@issuegraph/editor 0.4.0 → 0.22.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 (137) hide show
  1. package/README.md +65 -9
  2. package/dist/a11y/baseline.d.ts +182 -0
  3. package/dist/a11y/baseline.d.ts.map +1 -0
  4. package/dist/a11y/baseline.js +434 -0
  5. package/dist/a11y/baseline.js.map +1 -0
  6. package/dist/audit/findings.d.ts +107 -1
  7. package/dist/audit/findings.d.ts.map +1 -1
  8. package/dist/audit/findings.js +113 -6
  9. package/dist/audit/findings.js.map +1 -1
  10. package/dist/audit/panel.d.ts +277 -0
  11. package/dist/audit/panel.d.ts.map +1 -0
  12. package/dist/audit/panel.js +462 -0
  13. package/dist/audit/panel.js.map +1 -0
  14. package/dist/audit/refused.d.ts +99 -0
  15. package/dist/audit/refused.d.ts.map +1 -0
  16. package/dist/audit/refused.js +276 -0
  17. package/dist/audit/refused.js.map +1 -0
  18. package/dist/audit/styles.d.ts +1 -1
  19. package/dist/audit/styles.d.ts.map +1 -1
  20. package/dist/audit/styles.js +429 -1
  21. package/dist/audit/styles.js.map +1 -1
  22. package/dist/audit/surface.d.ts +54 -12
  23. package/dist/audit/surface.d.ts.map +1 -1
  24. package/dist/audit/surface.js +53 -13
  25. package/dist/audit/surface.js.map +1 -1
  26. package/dist/create/draft.d.ts +36 -0
  27. package/dist/create/draft.d.ts.map +1 -1
  28. package/dist/create/draft.js +40 -0
  29. package/dist/create/draft.js.map +1 -1
  30. package/dist/create/keys.d.ts +134 -9
  31. package/dist/create/keys.d.ts.map +1 -1
  32. package/dist/create/keys.js +119 -20
  33. package/dist/create/keys.js.map +1 -1
  34. package/dist/firstpass/candidates.d.ts +17 -1
  35. package/dist/firstpass/candidates.d.ts.map +1 -1
  36. package/dist/index.d.ts +42 -17
  37. package/dist/index.d.ts.map +1 -1
  38. package/dist/index.js +45 -14
  39. package/dist/index.js.map +1 -1
  40. package/dist/overlay/render.d.ts +11 -3
  41. package/dist/overlay/render.d.ts.map +1 -1
  42. package/dist/overlay/render.js +76 -20
  43. package/dist/overlay/render.js.map +1 -1
  44. package/dist/overlay/request.d.ts +71 -0
  45. package/dist/overlay/request.d.ts.map +1 -0
  46. package/dist/overlay/request.js +129 -0
  47. package/dist/overlay/request.js.map +1 -0
  48. package/dist/overlay/styles.d.ts +1 -1
  49. package/dist/overlay/styles.d.ts.map +1 -1
  50. package/dist/overlay/styles.js +78 -7
  51. package/dist/overlay/styles.js.map +1 -1
  52. package/dist/picker/render.d.ts +26 -16
  53. package/dist/picker/render.d.ts.map +1 -1
  54. package/dist/picker/render.js +25 -36
  55. package/dist/picker/render.js.map +1 -1
  56. package/dist/picker/styles.d.ts +7 -7
  57. package/dist/picker/styles.d.ts.map +1 -1
  58. package/dist/picker/styles.js +7 -36
  59. package/dist/picker/styles.js.map +1 -1
  60. package/dist/picker/view.d.ts +8 -4
  61. package/dist/picker/view.d.ts.map +1 -1
  62. package/dist/picker/view.js +4 -3
  63. package/dist/picker/view.js.map +1 -1
  64. package/dist/picker/words.d.ts +12 -10
  65. package/dist/picker/words.d.ts.map +1 -1
  66. package/dist/picker/words.js +12 -8
  67. package/dist/picker/words.js.map +1 -1
  68. package/dist/reevaluate/parts.d.ts +130 -0
  69. package/dist/reevaluate/parts.d.ts.map +1 -0
  70. package/dist/reevaluate/parts.js +260 -0
  71. package/dist/reevaluate/parts.js.map +1 -0
  72. package/dist/reevaluate/render.d.ts +18 -11
  73. package/dist/reevaluate/render.d.ts.map +1 -1
  74. package/dist/reevaluate/render.js +21 -135
  75. package/dist/reevaluate/render.js.map +1 -1
  76. package/dist/reevaluate/styles.d.ts +1 -1
  77. package/dist/reevaluate/styles.d.ts.map +1 -1
  78. package/dist/reevaluate/styles.js +26 -0
  79. package/dist/reevaluate/styles.js.map +1 -1
  80. package/dist/reevaluate/view.d.ts +16 -1
  81. package/dist/reevaluate/view.d.ts.map +1 -1
  82. package/dist/reevaluate/view.js +21 -1
  83. package/dist/reevaluate/view.js.map +1 -1
  84. package/dist/reevaluate/words.d.ts +7 -0
  85. package/dist/reevaluate/words.d.ts.map +1 -1
  86. package/dist/scale/ladder.d.ts +62 -6
  87. package/dist/scale/ladder.d.ts.map +1 -1
  88. package/dist/scale/ladder.js +129 -11
  89. package/dist/scale/ladder.js.map +1 -1
  90. package/dist/scale/render.d.ts +44 -0
  91. package/dist/scale/render.d.ts.map +1 -1
  92. package/dist/scale/render.js +206 -28
  93. package/dist/scale/render.js.map +1 -1
  94. package/dist/scale/styles.d.ts +11 -5
  95. package/dist/scale/styles.d.ts.map +1 -1
  96. package/dist/scale/styles.js +133 -4
  97. package/dist/scale/styles.js.map +1 -1
  98. package/dist/workspace/chrome.d.ts +1 -1
  99. package/dist/workspace/chrome.d.ts.map +1 -1
  100. package/dist/workspace/chrome.js +48 -0
  101. package/dist/workspace/chrome.js.map +1 -1
  102. package/dist/workspace/firstpass.d.ts +166 -0
  103. package/dist/workspace/firstpass.d.ts.map +1 -0
  104. package/dist/workspace/firstpass.js +151 -0
  105. package/dist/workspace/firstpass.js.map +1 -0
  106. package/dist/workspace/host.d.ts +283 -11
  107. package/dist/workspace/host.d.ts.map +1 -1
  108. package/dist/workspace/host.js +614 -43
  109. package/dist/workspace/host.js.map +1 -1
  110. package/dist/workspace/inspector.d.ts +72 -2
  111. package/dist/workspace/inspector.d.ts.map +1 -1
  112. package/dist/workspace/inspector.js +26 -0
  113. package/dist/workspace/inspector.js.map +1 -1
  114. package/dist/workspace/mount.d.ts +75 -6
  115. package/dist/workspace/mount.d.ts.map +1 -1
  116. package/dist/workspace/mount.js +1851 -60
  117. package/dist/workspace/mount.js.map +1 -1
  118. package/dist/workspace/rail.d.ts +5 -2
  119. package/dist/workspace/rail.d.ts.map +1 -1
  120. package/dist/workspace/rail.js.map +1 -1
  121. package/dist/workspace/recovery.d.ts +155 -0
  122. package/dist/workspace/recovery.d.ts.map +1 -0
  123. package/dist/workspace/recovery.js +156 -0
  124. package/dist/workspace/recovery.js.map +1 -0
  125. package/dist/workspace/render.d.ts +779 -2
  126. package/dist/workspace/render.d.ts.map +1 -1
  127. package/dist/workspace/render.js +1727 -61
  128. package/dist/workspace/render.js.map +1 -1
  129. package/dist/workspace/selection.d.ts +11 -0
  130. package/dist/workspace/selection.d.ts.map +1 -1
  131. package/dist/workspace/selection.js +10 -0
  132. package/dist/workspace/selection.js.map +1 -1
  133. package/dist/workspace/styles.d.ts +1 -1
  134. package/dist/workspace/styles.d.ts.map +1 -1
  135. package/dist/workspace/styles.js +1405 -42
  136. package/dist/workspace/styles.js.map +1 -1
  137. package/package.json +4 -4
@@ -9,7 +9,7 @@
9
9
  * |-----------|-------------------------------------------------------|
10
10
  * | header | `auditOverlay` + `renderAuditHeader` |
11
11
  * | rail | `renderViewer(…, { projection: 'linear' })`, windowed |
12
- * | canvas | `renderScaleLadder` |
12
+ * | canvas | `canvasToolbar` + `renderScaleLadder` |
13
13
  * | inspector | `inspectorView`, rendered here |
14
14
  *
15
15
  * ## The audit left-bar is applied to the rail's SPEC, never to its markup
@@ -48,15 +48,25 @@
48
48
  * forked token set here and no second palette: the theme is the viewer's, and
49
49
  * `styles.ts` writes structure against its custom properties.
50
50
  */
51
- import { KEY_ATTRIBUTE, element, normalizeDocument, renderMarkup, renderViewer, resolveTheme, themeCss, viewerStylesheet, } from '@issuegraph/viewer';
51
+ import { KEY_ATTRIBUTE, element, glyphAndLabel, hiddenGlyph, identity, labelFrom, normalizeDocument, renderMarkup, provenanceClause, renderViewer, resolveTheme, themeCss, treatmentFor, viewerStylesheet, } from '@issuegraph/viewer';
52
+ import { edgeIdentity } from '@issuegraph/core';
53
+ import { renderAuditPanel } from "../audit/panel.js";
54
+ import { renderEncodingRefusedBlock } from "../audit/refused.js";
52
55
  import { auditStylesheet } from "../audit/styles.js";
56
+ import { IDLE_CREATE_DRAFT } from "../create/draft.js";
57
+ import { KIND_KEYS, RELATE_KEY } from "../create/keys.js";
58
+ import { OVERLAY_TREATMENTS, treatmentForState } from "../overlay/grammar.js";
59
+ import { DELTA_ATTRIBUTE, chipSpec, deltaKind, summarySpec, textOf } from "../reevaluate/parts.js";
60
+ import { reevaluateStylesheet } from "../reevaluate/styles.js";
61
+ import { reevaluateView } from "../reevaluate/view.js";
53
62
  import { edgeOverlayStylesheet } from "../overlay/styles.js";
54
63
  import { AUDIT_SEVERITY_ATTRIBUTE, auditFilterKeeps, auditOverlay, heaviestRow, renderAuditHeader, } from "../audit/surface.js";
55
64
  import { INITIAL_SCALE_STATE } from "../scale/commands.js";
56
65
  import { renderScaleLadder } from "../scale/render.js";
57
66
  import { scaleLadderStylesheet } from "../scale/styles.js";
58
- import { inspectorView } from "./inspector.js";
67
+ import { inspectorView, } from "./inspector.js";
59
68
  import { railWindow } from "./rail.js";
69
+ import { diffIsEmpty, diffWithin } from "./recovery.js";
60
70
  import { INITIAL_SELECTION, selectedEdgeId, selectedKey, } from "./selection.js";
61
71
  import { workspaceStylesheet } from "./styles.js";
62
72
  /** The four fixed positions. A closed union, which is what keeps `zone` safe. */
@@ -71,6 +81,175 @@ export const ZONES = Object.freeze(['header', 'rail', 'canvas', 'inspector']);
71
81
  function zone(name, inner) {
72
82
  return `<section class="ig-zone" data-zone="${name}">${inner}</section>`;
73
83
  }
84
+ /**
85
+ * §17f's canvas caption — what this canvas is drawing, and out of how much.
86
+ *
87
+ * THE GAP IS THE `direct` TIER, AND ONLY IT. The zone is not silent in general:
88
+ * `renderScaleLadder` appends the ladder's own chrome after the graph, so a
89
+ * focused canvas already carries a "return to every component" button, a
90
+ * declining one already prints a counted refusal sentence, and the isolated
91
+ * chip and the search are already there. What none of them states is the RATIO
92
+ * on the one tier where the canvas succeeds — it narrows to a component, draws
93
+ * it, and says nothing about the rest — so a canvas showing 6 of 312 issues
94
+ * read exactly like a backlog that has 6.
95
+ *
96
+ * SO THE STATEMENT IS SCOPED TO THAT TIER, deliberately. Above the node budget
97
+ * `renderScaleLadder` draws NO graph at all — the canvas is `null` and the
98
+ * refusal takes its place — and a caption reading "1500 out of 2000 drawn here"
99
+ * over an empty canvas would state a thing the surface does not do. That is the
100
+ * defect {@link CanvasWords} refuses on the frame's own `1 hop`, and refusing it
101
+ * there while committing it here would be worse than not drawing the row.
102
+ *
103
+ * THE NUMBERS ARE THE LADDER'S AND THE DOCUMENT'S, ON ONE BASIS. `nodeCount` is
104
+ * "the nodes the canvas would draw", the same value the refusal is computed
105
+ * from, so the caption cannot disagree with the ladder beneath it. The total is
106
+ * the NORMALIZED document's — the one every zone in this function derives from —
107
+ * never the raw input, whose counts no zone drew, and never `ladder.canvas`,
108
+ * which would print `6 out of 6`.
109
+ *
110
+ * THE COUNT EXCLUDES ISSUES WITH NO RELATIONSHIP, and the surface says so
111
+ * elsewhere in its own words — at the FOOT OF THE RAIL where §17a puts it
112
+ * (`railFooter`), or, on a host that has not worded that row, in the ladder's
113
+ * own chip directly beneath this caption. This sentence read "the chip below"
114
+ * until the rail grew the footer, and the count is now in exactly one of those
115
+ * two places rather than reliably in the nearer one. Nothing else in this
116
+ * comment moves with it: the argument below is about the two NUMBERS, not about
117
+ * where the second one is drawn. UNFOCUSED the two are complementary and do sum to the total:
118
+ * `nodeCount` is every issue carrying a relationship and the chip is every issue
119
+ * carrying none. FOCUSED they do not, and an earlier draft of this comment said
120
+ * they did — `nodeCount` is then ONE component's members, so the shortfall is
121
+ * the chip's population PLUS every other component. What holds either way, and
122
+ * what the row actually relies on, is that the two count DISJOINT sets: they can
123
+ * never contradict, whatever they leave between them. So the row does not
124
+ * restate the chip, and no third number may be derived by subtracting one from
125
+ * the other.
126
+ */
127
+ function canvasToolbar(ladder, document, words) {
128
+ if (words === undefined)
129
+ return null;
130
+ const focused = ladder.focus;
131
+ const statement = ladder.tier !== 'direct'
132
+ ? null
133
+ : element('p', { class: 'ig-canvas-caption' }, [
134
+ // NO CLAUSE AT ALL WHEN NOTHING IS FOCUSED, rather than the label with
135
+ // an empty value after it. An unfocused canvas is drawing every
136
+ // component, which is a true thing the counts already say.
137
+ focused === null
138
+ ? null
139
+ : element('span', { class: 'ig-canvas-focus' }, [
140
+ element('span', {}, [words.focus]),
141
+ // THE PUNCTUATION IS THE PACKAGE'S, AND IT HAS TO ACTUALLY BE
142
+ // DRAWN. `CanvasWords.focus` tells a host to omit the colon
143
+ // because this draws it; an earlier revision made that promise
144
+ // and then emitted only a space, so a host that obeyed the
145
+ // contract got `focus #512`. The interpunct after the key is
146
+ // the frame's own typography for the same reason.
147
+ ': ',
148
+ // NOT `identity()`, AND NOT BECAUSE OF THE URL. That helper is
149
+ // right about who owns a tracker's link shape — but it renders
150
+ // an ANCHOR when the host supplied a url, and an anchor here is
151
+ // focusable inside a region that is replaced wholesale on every
152
+ // mounted redraw. `focusedKey()` and `commandFocusToken()` both
153
+ // read null for it, so the restore falls through to the first
154
+ // rail row and `WorkspaceHandle.update`'s promise to keep focus
155
+ // is broken by a caption. The caption is a READ-OUT: the canvas
156
+ // below it already draws this issue as a focusable node, and the
157
+ // rail already links it. So the key is drawn as text, which
158
+ // invents no URL and takes no focus.
159
+ element('span', { class: 'ig-id' }, [focused]),
160
+ ' · ',
161
+ ]),
162
+ element('span', { class: 'ig-canvas-shown' }, [
163
+ element('span', { class: 'ig-canvas-count' }, [String(ladder.nodeCount)]),
164
+ ' ',
165
+ element('span', {}, [words.of]),
166
+ ' ',
167
+ element('span', { class: 'ig-canvas-count' }, [String(document.issues.length)]),
168
+ ' ',
169
+ element('span', {}, [words.shown]),
170
+ ]),
171
+ ]);
172
+ // A STATE, NOT A CONTROL, AND NOT A ROLE. It carries text, so it is announced
173
+ // in reading order on its own; `role="img"` with an `aria-label` — the shape
174
+ // `station()` uses — is for an indicator with NO text, and giving one to this
175
+ // would replace the host's word with a copy of itself. A `button` would be
176
+ // the dead control `headerControls` refuses to draw.
177
+ const pill = words.editMode === undefined
178
+ ? null
179
+ : element('span', { class: 'ig-edit-mode' }, [words.editMode]);
180
+ // NOTHING TO SAY, NO ROW. Both halves are conditional and independently so,
181
+ // and an empty bordered band above the canvas is chrome that carries no fact.
182
+ if (statement === null && pill === null)
183
+ return null;
184
+ return element('div', { class: 'ig-canvas-toolbar' }, [statement, pill]);
185
+ }
186
+ /**
187
+ * §17a's rail footer — the isolated count and the control that opens it.
188
+ *
189
+ * THE FACT IS THE DOCUMENT'S, NOT THE CANVAS'S. `ladder.isolated` is
190
+ * `document.issues` minus everything in a component, so it is a property of the
191
+ * backlog rather than of how far the graph narrowed. Frame 17a draws it at the
192
+ * foot of the order rail and gives the canvas toolbar the projection toggle,
193
+ * the focus statement and the edit-mode pill instead — so this is where the
194
+ * count belongs, and `renderScaleLadder` is told not to draw its own.
195
+ *
196
+ * THE COUNT AND THE CONTROL ONLY — THE LIST IS DRAWN IN THE CANVAS. Two reasons
197
+ * it cannot live in this zone, and the second is the load-bearing one. The
198
+ * footer is sticky, and an element taller than its scrollport cannot stick: the
199
+ * browser clamps it, so a list inside the footer would lose the pin exactly as
200
+ * it grew. And the rail is VIRTUALIZED — `railRowAt` turns a scroll offset into
201
+ * a row index by `floor((scrollTop - chrome) / pitch)`, and `railSpacer` stands
202
+ * in for the rows outside the window on that same fixed pitch — so content of
203
+ * arbitrary height anywhere in this scroll track makes every offset beneath it
204
+ * name the wrong row, and the window is then re-cut against a geometry that no
205
+ * longer holds. `renderScaleLadder` keeps drawing the list in the canvas zone,
206
+ * which has no such arithmetic; only the control moves here.
207
+ *
208
+ * NOTHING TO SAY, NO ROW — the rule `canvasToolbar` follows and `isolatedSpec`
209
+ * follows before it. With no words there is no footer, and with no isolated
210
+ * issues the count is not a fact anyone asked for.
211
+ */
212
+ function railFooter(isolated, words, listId) {
213
+ if (words === undefined)
214
+ return null;
215
+ if (isolated.count === 0)
216
+ return null;
217
+ return element('div', { class: 'ig-rail-footer' }, [
218
+ // NO CLASS ON THE WRAPPER. It needs no rule of its own — the footer sets the
219
+ // muted body colour and `.ig-rail-count` lifts the number out of it — and a
220
+ // class with no rule is exactly what `styles.test.ts`'s second direction
221
+ // refuses.
222
+ element('span', {}, [
223
+ element('span', { class: 'ig-rail-count' }, [String(isolated.count)]),
224
+ ' ',
225
+ element('span', {}, [words.isolated]),
226
+ ]),
227
+ // A REAL BUTTON, NOT THE FRAME'S `cursor:pointer` SPAN. It is a control, and
228
+ // a span that looks pressable is the dead control `headerControls` refuses
229
+ // to draw; the frame's span is a mock's shorthand for one.
230
+ element('button', {
231
+ type: 'button',
232
+ class: 'ig-rail-isolated-toggle',
233
+ 'aria-expanded': isolated.open ? 'true' : 'false',
234
+ // NAMES WHAT IT OPENS, because what it opens is not beside it. This
235
+ // toggle is in the rail and its list is drawn by the ladder in the
236
+ // canvas, so `aria-expanded` alone tells a screen-reader user that
237
+ // something opened and nothing about where — the one thing the adjacent
238
+ // disclosure it replaced never had to say. `scrollIntoView` moves the
239
+ // visual viewport and not accessibility focus, so it does not answer
240
+ // this either.
241
+ //
242
+ // ONLY WHILE THE LIST EXISTS, AND ONLY WITH AN ID TO NAME. A shut
243
+ // disclosure renders no list, so naming one would be a dangling
244
+ // reference the a11y baseline refuses — and there would be nothing to
245
+ // reach. With no `surfaceId` there is no value that would be unique on
246
+ // the host's page, and a guessed one is the collision `searchSpec`
247
+ // avoids.
248
+ ...(isolated.open && listId !== undefined ? { 'aria-controls': listId } : {}),
249
+ 'data-ig-command': isolated.open ? 'close-isolated' : 'open-isolated',
250
+ }, [isolated.open ? words.hide : words.show]),
251
+ ]);
252
+ }
74
253
  /**
75
254
  * The height the rows outside the window would have taken.
76
255
  *
@@ -112,14 +291,7 @@ function severityForRow(overlay, members) {
112
291
  // the justification was the defect, not the loop.
113
292
  return overlay === null ? undefined : heaviestRow(overlay, members)?.severity;
114
293
  }
115
- /**
116
- * Add `data-ig-audit` to the rail's keyed rows.
117
- *
118
- * A total walk that rebuilds the tree rather than mutating it: `ElementSpec` is
119
- * `readonly` throughout, and a mutating walk would also be visible to the
120
- * caller's own copy of `scene.root`.
121
- */
122
- function markRail(root, severityOf) {
294
+ function markRail(root, severityOf, deltaOf) {
123
295
  // TYPED AS `ElementSpec -> ElementSpec` AT THE BOUNDARY, with the child walk
124
296
  // kept inside. A single function over `SpecChild` would hand `renderMarkup` a
125
297
  // union it does not take, and the obvious repair — casting the result back —
@@ -133,10 +305,56 @@ function markRail(root, severityOf) {
133
305
  // resolves to nothing today and to something the day a host names an issue
134
306
  // that. Narrowed rather than coerced.
135
307
  const severity = typeof key === 'string' ? severityOf(key) : undefined;
136
- const children = spec.children?.map(markChild);
137
- const attrs = severity === undefined
138
- ? spec.attrs
139
- : { ...spec.attrs, [AUDIT_SEVERITY_ATTRIBUTE]: severity };
308
+ const delta = typeof key === 'string' ? deltaOf(key) : undefined;
309
+ const walked = spec.children?.map(markChild);
310
+ // THE CHIP IS APPENDED TO THE ROW, WHICH IS WHY IT IS DRAWN HERE AND NOT
311
+ // IN A MOUNT. §17c puts the effect in place — "only affected rows carry a
312
+ // delta chip · unaffected rows are left completely alone" — and
313
+ // `reevaluate/render.ts` deferred that to "the change that assembles the
314
+ // workspace" on the ground that placing a chip needs the row's geometry.
315
+ // It does not: the row is a SPEC here, keyed and reachable, and this walk
316
+ // was already composing one decoration onto it. So the chip is composed
317
+ // like the audit's mark rather than positioned like an overlay, and no
318
+ // geometry is consulted at all.
319
+ //
320
+ // AN UNAFFECTED ROW IS RETURNED WITH ITS OWN `children` ARRAY, not a
321
+ // rebuilt one carrying an appended `null` — `deltaOf` answering `undefined`
322
+ // has to leave the row byte-identical, which is the design's rule made
323
+ // structural rather than asserted.
324
+ const chip = delta === undefined ? null : chipSpec(delta.chip, delta.words, { placed: true });
325
+ const children = chip === null ? walked : [...(walked ?? []), chip];
326
+ const marked = { ...spec.attrs };
327
+ if (severity !== undefined)
328
+ marked[AUDIT_SEVERITY_ATTRIBUTE] = severity;
329
+ // THE CHIP IS IN THE ROW'S NAME, OR IT IS SILENT.
330
+ //
331
+ // Layer 1 gives the row an `aria-label`, and an accessible name computed
332
+ // from `aria-label` WINS over descendant text — so a chip appended here is
333
+ // seen and not heard. The rail is the surface a reader arrows through, and
334
+ // "which row moved, and by how much" is exactly what §17c puts on the row;
335
+ // the summary above carries aggregate counts and cannot recover it.
336
+ //
337
+ // APPENDED TO THE NAME rather than hung off `aria-describedby`, and the
338
+ // reason is that the alternative costs more than it looks. A description
339
+ // needs an id, this package emits none anywhere, and `a11y/baseline.ts`
340
+ // records what ids cost here — a host-derived token reaching an attribute
341
+ // that other code resolves, which is the injection class the mount's focus
342
+ // token was written to avoid. A name needs no id and is announced as the
343
+ // reader arrives rather than after it.
344
+ //
345
+ // The separator is layer 1's own: `slotLabel` joins its three parts with
346
+ // the same em dash, so this reads as one more part rather than a second
347
+ // sentence in a different hand.
348
+ const label = marked['aria-label'];
349
+ if (chip !== null && typeof label === 'string') {
350
+ marked['aria-label'] = `${label} — ${textOf(chip)}`;
351
+ }
352
+ // OMITTED WHEN THE CHIP HAS NO DRAWABLE KIND, rather than stamped empty.
353
+ // `DELTA_ATTRIBUTE` records why: an attribute on every row with one value
354
+ // meaning "nothing" is marking the row, not leaving it alone.
355
+ if (delta?.kind !== undefined)
356
+ marked[DELTA_ATTRIBUTE] = delta.kind;
357
+ const attrs = severity === undefined && delta?.kind === undefined ? spec.attrs : marked;
140
358
  return {
141
359
  ...spec,
142
360
  ...(attrs === undefined ? {} : { attrs }),
@@ -145,6 +363,224 @@ function markRail(root, severityOf) {
145
363
  }
146
364
  return markSpec(root);
147
365
  }
366
+ /**
367
+ * A row's right-hand slot: exactly one of `selected`, a remove control, or the
368
+ * inbound marker.
369
+ *
370
+ * THE ORDER IS THE FRAME'S AND IT IS ALSO THE SAFE ONE. A selected row's SLOT
371
+ * says so and offers nothing else, which matters because `selected` is the
372
+ * state that FILTERS this panel: the reader is looking at one edge on purpose,
373
+ * and a destructive control is not what the slot is for at that moment.
374
+ *
375
+ * THE SLOT, NOT THE ROW — §17b's flip sits beside it on exactly that row. The
376
+ * reasoning above is specific to a DESTRUCTIVE control; a flip is the
377
+ * corrective act §17b asks for, so it is admitted where a `✕` is not.
378
+ * {@link relationshipSpec} draws it as the slot's peer and says why there.
379
+ *
380
+ * AN INBOUND ROW GETS NO REMOVE CONTROL, and that is a fact about the format
381
+ * rather than caution. An incoming edge's field is declared in the OTHER
382
+ * issue's body, so this panel's subject cannot declare it away; a `✕` there
383
+ * would publish an edit that either does nothing or edits a document the
384
+ * reader is not looking at. The reference implementation encodes the same rule
385
+ * as `removable: false`, and frame 17a puts the word `inbound` in the slot the
386
+ * other rows spend on `✕`.
387
+ *
388
+ * THE KEYBOARD'S `⌫` IS NOT A SECOND RULE. `create/keys.ts` acts on the
389
+ * SELECTED edge because a selection is the only edge a keyboard has named; that
390
+ * is this rule with the selection as the subject, not an exception to it.
391
+ *
392
+ * `selected` IS THE PACKAGE'S OWN WORD, taken from `treatmentForState` rather
393
+ * than from the words object. Write-state names are already authored here —
394
+ * `writing…`, `failed`, `invalid` — and a `selected` on `WorkspaceWords` would
395
+ * be a second spelling of one the overlay grammar publishes on the canvas.
396
+ */
397
+ function rowSlot(relationship, selected, words) {
398
+ if (relationship.edgeId === selected) {
399
+ return element('span', { class: 'ig-relationship-state', 'data-ig-state': 'selected' }, [treatmentForState('selected').label]);
400
+ }
401
+ if (relationship.direction === 'incoming') {
402
+ return element('span', { class: 'ig-relationship-inbound' }, [words.inbound]);
403
+ }
404
+ // THE GLYPH IS HIDDEN AND THE NAME IS AN ATTRIBUTE. `glyphAndLabel` draws a
405
+ // VISIBLE word beside its glyph, which is right for the kind and wrong here:
406
+ // the frame's slot is a bare `✕`. So the mark is layer 1's `hiddenGlyph` —
407
+ // the published half of that same pairing, rather than its class and its
408
+ // `aria-hidden` written out again here, which is exactly the local copy
409
+ // `glyphAndLabel`'s own header calls the failure it exists to prevent. `✕`
410
+ // announces as whatever a screen reader's character table calls it, which
411
+ // describes the mark and not the act, so the host's word becomes the
412
+ // button's accessible name. On the BUTTON, never on a span around it:
413
+ // `parts.ts` records that a plain span takes the generic role, on which ARIA
414
+ // prohibits naming, so the name would be dropped without a warning.
415
+ return element('button', {
416
+ type: 'button',
417
+ class: 'ig-relationship-remove',
418
+ 'data-ig-command': 'delete',
419
+ // ITS OWN ROW'S EDGE, WHICH IS THE WHOLE POINT OF THE ATTRIBUTE HERE.
420
+ // `reduceHost`'s delete arm read the selection and ignored the target
421
+ // while the only delete control lived inside `if (edgeId !== null)`; a
422
+ // control repeated per row makes that wrong in both directions — a no-op
423
+ // with an issue selected, and a delete of the WRONG edge with a
424
+ // different row's edge selected.
425
+ 'data-ig-target': relationship.edgeId,
426
+ 'aria-label': words.remove,
427
+ }, [hiddenGlyph('\u2715')]);
428
+ }
429
+ /**
430
+ * §17b's flip, on the selected row.
431
+ *
432
+ * ## It is drawn where the sentence is, and only where the reducer can act
433
+ *
434
+ * Frame 17b draws a directed relationship as a statement with `⇅ flip` at the
435
+ * end of the same line, and the row already IS that statement: with an edge
436
+ * selected the panel filters to one relationship and
437
+ * {@link relationshipDescription}'s no-subject arm words it forward with both
438
+ * references in stored order. So the control joins the row rather than opening
439
+ * a second card above it — a card would need a sentence of its own, and the
440
+ * panel would then state one relationship twice, one element apart, which is
441
+ * the encoding ambiguity §17b exists to remove.
442
+ *
443
+ * ONLY ON THE SELECTED ROW, and that is a fact about the REDUCER rather than
444
+ * restraint. `reduceHost`'s `flip` arm takes its edge from
445
+ * `selectedEdgeId(state.selection)` and ignores `data-ig-target` — deliberately,
446
+ * because a flip is one operation about the edge the reader is looking at. A
447
+ * control on an unselected row would publish a command the reducer would answer
448
+ * about a different edge, or, with an issue selected, about none; the panel's
449
+ * standing rule is that a control which cannot complete the act it advertises
450
+ * is not drawn.
451
+ *
452
+ * THAT GUARANTEE IS THE MOUNT'S, AND A STANDALONE HOST OWES THE SAME STEP. This
453
+ * surface is handed a `ViewerDocument` and the reducer a `GraphDocument`, so
454
+ * "the selected edge exists" is only true while the two agree. `mountWorkspace`
455
+ * makes them agree by running `reconcileHost` against the landed document
456
+ * before every render, which clears a selection naming an edge that is gone and
457
+ * moves one naming an edge hidden behind an unsettled write. A host wiring the
458
+ * published attributes itself must do the same, or it can draw a flip whose
459
+ * press the reducer answers with nothing.
460
+ *
461
+ * ## A symmetric kind gets no control, and the absence is the finding
462
+ *
463
+ * `serialize-with` and `together-with` state one fact whichever way round they
464
+ * are stored, so a control to reverse them claims something the format does not
465
+ * say — and the store would refuse the edit as `symmetric-edge` anyway.
466
+ * `picker/view.ts` already answers `null` there; this agrees with it rather
467
+ * than contradicting it from the next zone over.
468
+ *
469
+ * ONE ORACLE FOR THAT QUESTION. The directedness is read off the SAME
470
+ * {@link EdgeTreatment} `relationshipDescription` already reads to word the
471
+ * row, which is what `labelFrom` itself branches on. Importing
472
+ * `isSymmetricEdgeField` here instead would put two answers to one question at
473
+ * one call site, in a package whose objection everywhere else is "not a wrong
474
+ * answer, a second answer". `render.test.ts` pins the two to agree across the
475
+ * whole vocabulary, so the shorter reach is safe in both directions.
476
+ *
477
+ * ## It publishes and wires nothing
478
+ *
479
+ * `data-ig-command="flip"` and no target: the attribute the reducer does not
480
+ * read would advertise a per-row flip it does not implement. On a `button`,
481
+ * because a span has no tab stop and no native activation — the control §17b
482
+ * names most error-prone is the last one that should be pointer-only.
483
+ */
484
+ function flipControl(relationship, selected, words) {
485
+ if (relationship.edgeId !== selected)
486
+ return null;
487
+ if (treatmentFor(relationship.field).symmetric)
488
+ return null;
489
+ return element('button', { type: 'button', class: 'ig-relationship-flip', 'data-ig-command': 'flip' }, [words.flip]);
490
+ }
491
+ /**
492
+ * The host's sentence for a refusal, wherever the panel states one.
493
+ *
494
+ * SHARED FOR THE REASON THE HEAD BELOW IS. A refusal reaches the reader in two
495
+ * shapes — a capsule standing in for a relationship that does not exist, and a
496
+ * reason attached to one that does — and a second element built at the second
497
+ * site would be free to take a different class and lose the rule that styles
498
+ * it, on the half that ships later.
499
+ */
500
+ function refusalReason(code, words) {
501
+ return element('span', { class: 'ig-relationship-reason' }, [words.refusals[code]]);
502
+ }
503
+ /**
504
+ * What a relationship READS as: the kind's glyph and word, and the reference at
505
+ * the other end. No control, no wrapper — only the description.
506
+ *
507
+ * ONE DESCRIPTION FOR THE ROW AND FOR THE CAPSULE. They state the same thing
508
+ * about the same edge, and they briefly stated it twice — including deriving
509
+ * `outgoing` from the same two values in both places, which is the shape this
510
+ * package calls a second answer rather than a repetition.
511
+ *
512
+ * SPLIT FROM THE CONTROL THAT WRAPS IT, rather than parameterised with a
513
+ * "draw a button?" flag, because the two callers do not differ in a DETAIL of
514
+ * one element: one of them draws no control at all. A flag would leave the
515
+ * element that IS the control deciding whether to be one, and every attribute
516
+ * on it conditional on that. The description is the shared fact; who wraps it
517
+ * is the caller's, exactly as the slot beside it and the border around it
518
+ * already are.
519
+ *
520
+ * ## It draws the OTHER end, and words the kind from the subject's end
521
+ *
522
+ * The row used to draw three bare tokens — the field's machine name, then both
523
+ * endpoints — one of which is the issue whose panel this is. So the reader read
524
+ * their own subject back on every row and had to work out which of the two
525
+ * references was the other one, from a token that stated no direction at all
526
+ * while `data-direction` sat unread on the same element.
527
+ *
528
+ * WITH NO ISSUE SUBJECT THERE IS NO "OTHER END", and both are drawn. That is an
529
+ * edge selection: the panel is showing one relationship rather than one issue's
530
+ * relationships, so the head reads as the edge is stored, `from` before `to`,
531
+ * and the kind takes its plain forward wording. Picking one end there would
532
+ * mean picking arbitrarily and then wording the sentence around the choice.
533
+ */
534
+ function relationshipDescription(relationship, subject) {
535
+ const treatment = treatmentFor(relationship.field);
536
+ const outgoing = subject === null || relationship.from === subject;
537
+ // THE ROLE TRAVELS WITH THE REFERENCE, and it is not decoration. §17b's rule
538
+ // is that direction is STATED, never inferred — and with no issue subject
539
+ // both ends are drawn, so without a role the only thing saying which is which
540
+ // is their ORDER. That is inference, by the reader and by any host restyling
541
+ // the row. `directionSpec` published exactly this pair of roles before §17b's
542
+ // statement became the row; dropping them would have moved the statement and
543
+ // quietly lost the half that made it a statement.
544
+ //
545
+ // OMITTED, NOT FALSIFIED, WHERE THERE IS NO PAIR. With an issue subject the
546
+ // row draws one reference — the other end — and it is worded relative to the
547
+ // subject by `labelFrom`, so a `from`/`to` on it would name an end of the
548
+ // stored pair while the words name an end of the reader's sentence, and those
549
+ // are not always the same one.
550
+ const reference = (ref, role) => element('span', { class: 'ig-relationship-ref', 'data-ig-role': role }, [ref]);
551
+ return [
552
+ element('span', { class: 'ig-relationship-kind' },
553
+ // LAYER 1's PAIRING, not a second one. The glyph is one of the four
554
+ // channels the colour-blind-safety claim rests on and is
555
+ // `aria-hidden` because it announces as a character description;
556
+ // the word beside it is what a reader hears. A local copy of that
557
+ // arrangement is free to drop the second half.
558
+ glyphAndLabel(treatment.glyph, labelFrom(treatment, outgoing))),
559
+ ...(subject === null
560
+ ? [reference(relationship.from, 'from'), reference(relationship.to, 'to')]
561
+ : [reference(outgoing ? relationship.to : relationship.from)]),
562
+ ];
563
+ }
564
+ /**
565
+ * A relationship's head on a row that HAS one: the description, inside the
566
+ * control that selects the edge.
567
+ *
568
+ * ONLY WHERE THE EDGE IS IN THE DOCUMENT. `select-edge` reduces to a selection,
569
+ * and the next render's `reconcileHost` drops a selection naming an edge the
570
+ * landed document does not carry — so on a phantom this control would close the
571
+ * inspector instead of inspecting anything. That is the rule the panel's own
572
+ * header already states about `+ add`: a control that cannot complete the act
573
+ * it advertises is not drawn. {@link refusalCapsule} draws the description
574
+ * bare for exactly that reason.
575
+ */
576
+ function relationshipHead(relationship, subject) {
577
+ return element('button', {
578
+ type: 'button',
579
+ class: 'ig-relationship-select',
580
+ 'data-ig-command': 'select-edge',
581
+ 'data-ig-target': relationship.edgeId,
582
+ }, relationshipDescription(relationship, subject));
583
+ }
148
584
  /**
149
585
  * One relationship, as a row the reader can actually operate.
150
586
  *
@@ -153,65 +589,895 @@ function markRail(root, severityOf) {
153
589
  * reachable by pointer and by nothing else — and a host wiring the published
154
590
  * attributes cannot fix that without rebuilding the semantics this package
155
591
  * should have supplied. Every other command in the package is already on a
156
- * button; `refusalSpec`'s capsule is the same `li` + `button` shape.
592
+ * button; {@link refusalCapsule}'s capsule is the same `li` + `button` shape.
157
593
  *
158
594
  * The `li` keeps the hue and the direction, because those describe the
159
595
  * relationship rather than the action.
596
+ *
597
+ * ## A REFUSAL CAN LAND ON A ROW THAT STAYS
598
+ *
599
+ * `refused` is the code when the store refused an edit ABOUT this relationship
600
+ * and the relationship is still there — a create of an edge that already
601
+ * exists, a retype to the kind it already has, a flip of a symmetric kind. The
602
+ * reason joins the row and the row keeps its remove control, because the edge
603
+ * it names is real and removable; only a refusal about an edge that does not
604
+ * exist replaces the row, and {@link WorkspaceRefusal.phantom} is which. It is
605
+ * published as `data-ig-code` here for the same reason the capsule publishes
606
+ * it: a host styles or counts refusals off the store's vocabulary rather than
607
+ * by matching a sentence.
160
608
  */
161
- function relationshipSpec(relationship) {
609
+ function relationshipSpec(relationship, subject, selected, words, refused) {
162
610
  return element('li', {
163
611
  class: 'ig-relationship',
164
612
  'data-edge': relationship.field,
165
613
  // Omitted rather than falsified when the subject is not an issue: an edge
166
614
  // selection has no "my end", and `data-direction=""` would claim one.
167
615
  'data-direction': relationship.direction ?? undefined,
616
+ 'data-ig-code': refused,
168
617
  }, [
169
- element('button', {
170
- type: 'button',
171
- class: 'ig-relationship-select',
172
- 'data-ig-command': 'select-edge',
173
- 'data-ig-target': relationship.edgeId,
174
- }, [
175
- element('span', { class: 'ig-relationship-kind' }, [relationship.field]),
176
- element('span', { class: 'ig-relationship-ref' }, [relationship.from]),
177
- element('span', { class: 'ig-relationship-ref' }, [relationship.to]),
618
+ relationshipHead(relationship, subject),
619
+ refused === undefined ? null : refusalReason(refused, words),
620
+ rowSlot(relationship, selected, words),
621
+ // A PEER OF THE SLOT RATHER THAN A FOURTH OCCUPANT OF IT. The slot's
622
+ // three occupants are exclusive because two are statements and one is a
623
+ // destructive control, and a row is only ever one of those things; the
624
+ // flip is neither, and it coexists with the `selected` marker on the one
625
+ // row that draws both. Last, because frame 17b ends the statement's line
626
+ // with it.
627
+ flipControl(relationship, selected, words),
628
+ ]);
629
+ }
630
+ /**
631
+ * A refused relationship, drawn where the reader was building it.
632
+ *
633
+ * §17b's rule is that a refusal is shown IN PLACE and never snapped back, and
634
+ * until this the package had no surface that said so: the store marked the edge
635
+ * `invalid`, the canvas drew a ghost line, and the panel the reader had just
636
+ * used listed nothing about it. `would-cycle` is the case that matters — it is
637
+ * the one refusal this package family cannot detect for itself, so it arrives
638
+ * only after the reader has committed to the relationship.
639
+ *
640
+ * IT REPLACES THE ROW ONLY WHEN THERE IS NO RELATIONSHIP UNDER IT, and that
641
+ * qualification is the correction to an earlier "a refused edge is not a
642
+ * relationship: it is an edit that did not happen". Three of the store's codes
643
+ * refuse an edit ABOUT AN EDGE THAT EXISTS — `duplicate-edge`, `unchanged-kind`
644
+ * and `symmetric-edge` all mark a landed edge — so replacing the row for those
645
+ * deleted a real relationship from the panel, and with it the only control that
646
+ * could remove the edge the reader had just been told about.
647
+ * For an edge that genuinely does not exist the argument stands unchanged:
648
+ * listing it as an ordinary row would assert a relationship the document does
649
+ * not have, with a `select-edge` and a `✕` on it offering to remove something
650
+ * that was never added. {@link WorkspaceRefusal.phantom} is the question, and
651
+ * {@link relationshipEntries} is where it is asked. Either way the reader's
652
+ * POSITION is kept, because that is where they were working.
653
+ *
654
+ * THE SHAPE IS `scale/render.ts`'s CAPSULE — an `li` carrying a button and a
655
+ * stated reason — and the shape is all that is copied. That capsule's English
656
+ * is written inline in this package, which is a debt rather than a precedent;
657
+ * the reason here is the host's, keyed off the store's own code.
658
+ *
659
+ * IT NAMES THE EDGE AND OPERATES NOTHING. The capsule reused the row's head,
660
+ * which is a `select-edge` control — so a phantom that survived normalization
661
+ * (a `would-cycle` refusal reaches the panel as an edge, because the store
662
+ * draws a refused create precisely so a surface can mark it) published a live
663
+ * selector for an edge the LANDED document does not carry. Under
664
+ * `mountWorkspace` that click selected the phantom and the next render's
665
+ * `reconcileHost` dropped the selection again, so the one control on the
666
+ * capsule closed the inspector rather than inspecting anything. The
667
+ * description is drawn bare instead, on the rule
668
+ * {@link WorkspaceRefusal.phantom} already states in as many words: there is no
669
+ * relationship to operate. Its remove control was withheld for the same reason
670
+ * from the start; this is the half that was missed.
671
+ *
672
+ * `data-ig-code` IS THE STORE'S VOCABULARY VERBATIM, so a host that wants to
673
+ * style or count refusals reads the code rather than matching a sentence.
674
+ */
675
+ function refusalCapsule(relationship, code, subject, words) {
676
+ return element('li', {
677
+ class: 'ig-relationship-refused',
678
+ 'data-ig-code': code,
679
+ 'data-edge': relationship?.field,
680
+ }, [
681
+ // THE RELATIONSHIP IS NAMED WHEN IT CAN BE, AND THE CAPSULE STANDS WHEN
682
+ // IT CANNOT. The refused edge normally reaches the document as the
683
+ // store's phantom — `edgeChangeFor` draws a refused create so the refusal
684
+ // has something to be about — but a host that projects only landed edges
685
+ // gives us a code and nothing to word, and a refusal the reader cannot
686
+ // see is worse than one drawn without its subject.
687
+ relationship === undefined
688
+ ? null
689
+ : element('span', { class: 'ig-relationship-name' }, relationshipDescription(relationship, subject)),
690
+ refusalReason(code, words),
691
+ ]);
692
+ }
693
+ /**
694
+ * The numbered kind list, its heading, and the control that withdraws from it.
695
+ *
696
+ * DRAWN IN BOTH FRAMES, AND THIS FUNCTION ANSWERS TO BOTH. §17b fixes the
697
+ * keyboard loop the digits belong to; §17a draws the same list in the inspector
698
+ * with a heading over it and the digit as a chip at each row's end. Those two
699
+ * placements are §17a's, taken here under autnmy/issuegraph#147; the flip, the
700
+ * target search and delete/retype remain §17b's and are not drawn here.
701
+ *
702
+ * THE DIGITS ARE `create/keys.ts`'s OWN, through `KIND_KEYS`. Drawing
703
+ * `index + 1` over the kind order here would be a second construction of the
704
+ * keyboard's table — which is exactly what the mount's chooser did, agreeing
705
+ * with the key map only because both walked `EDGE_FIELDS`, with nothing
706
+ * pinning them to each other.
707
+ *
708
+ * THE WORD IS THE EDGE VOCABULARY'S, so an entry in this list and the row it
709
+ * will become read the same. A `kinds` record on `WorkspaceWords` would be a
710
+ * third spelling of a wording layer 1 already publishes twice over.
711
+ *
712
+ * IT IS NOT DRAWN AT THE TARGET STEP. `renderWorkspace` is markup-only and the
713
+ * target search is a live input over the reader's query, so that step stays the
714
+ * shell's. A host using this renderer without `mountWorkspace` can read,
715
+ * remove and BEGIN a relationship, and must supply its own target picker.
716
+ *
717
+ * `source` NAMES THE DRAFT WHEN IT IS NOT THIS PANEL'S OWN, and is `null` when
718
+ * it is — see {@link createStep} for why the list is drawn either way. Written
719
+ * as the reference beside the host's phrase, which is `whyRankSpec`'s own shape
720
+ * for the same problem: the package names the issue and the host writes the
721
+ * English around it.
722
+ */
723
+ function kindListSpec(words, source) {
724
+ return element('div', { class: 'ig-inspector-add' }, [
725
+ // §17a HEADS THE KINDS, in the caps treatment the stylesheet already
726
+ // declares for three headings and until now drew only two of. Without it
727
+ // the five numbered rows run straight on from the relationship rows above,
728
+ // and nothing on screen says the numbers choose a kind rather than name
729
+ // more relationships.
730
+ element('h3', { class: 'ig-inspector-heading' }, [words.addRelationshipHeading]),
731
+ source === null
732
+ ? null
733
+ : element('p', { class: 'ig-inspector-source' }, [
734
+ `${words.relatingFrom} `,
735
+ element('span', { class: 'ig-id' }, [source]),
736
+ ]),
737
+ element('ul', { class: 'ig-kind-list' }, KIND_KEYS.map((entry) => {
738
+ const treatment = treatmentFor(entry.edgeKind);
739
+ return element('li', {}, [
740
+ element('button', {
741
+ type: 'button',
742
+ class: 'ig-kind-option',
743
+ 'data-ig-command': 'kind',
744
+ 'data-ig-value': entry.edgeKind,
745
+ 'data-edge': entry.edgeKind,
746
+ }, [
747
+ // §17a PUTS THE DIGIT LAST, IN A CHIP. The kind is what the reader
748
+ // is choosing between, so it comes first in the reading order and
749
+ // the key follows it — "blocked-by, 1" rather than "1,
750
+ // blocked-by". The chip is what carries the "this is a key"
751
+ // signal a left-hand column used to carry by alignment alone.
752
+ ...glyphAndLabel(treatment.glyph, treatment.label),
753
+ element('span', { class: 'ig-kind-digit' }, [entry.key]),
754
+ ]),
755
+ ]);
756
+ })),
757
+ element('button', { type: 'button', class: 'ig-inspector-cancel', 'data-ig-command': 'cancel' }, [words.cancel]),
758
+ ]);
759
+ }
760
+ /**
761
+ * §17a's "why rank" block: the heading, and one sentence explaining the
762
+ * position.
763
+ *
764
+ * THE SYMPTOM THIS PACKAGE WAS FILED FOR. `#122` names it exactly: the frame
765
+ * gives the inspector a `WHY RANK 2` heading and a sentence, "the shipped
766
+ * inspector shows raw tokens and no sentence, even though the package already
767
+ * carries the function that composes provenance". It did, and this composes it.
768
+ *
769
+ * THE PROVENANCE CLAUSE IS LAYER 1's, AND THAT IS THE POINT. `provenanceClause`
770
+ * is the same function the §16 rail row's provenance line is built from, so the
771
+ * row and the panel state one fact one way. Switching on `RankProvenance` here
772
+ * would be a second wording, free to drift from the row's the moment either is
773
+ * edited — which is what the issue means by "composed from the existing
774
+ * provenance model rather than a second implementation of it".
775
+ *
776
+ * A HOLD'S REASON IS RENDERED VERBATIM, exactly as layer 1's `holdLine` renders
777
+ * it: `ViewerHold.reason` is host-authored, and rewording it here would put
778
+ * this package in the business of explaining a hold whose vocabulary belongs to
779
+ * the reader that produced it.
780
+ *
781
+ * A RANK AND A HOLD ARE NO LONGER EXCLUSIVE, which is what frame 17a always
782
+ * asked for: `#512` at rank 2 *and* "Held until #488 closes". `RULINGS.md` §1
783
+ * (Claude Design, 2026-09-20) settled it and `@issuegraph/derive` now ranks a
784
+ * held slot whose blocker is inside the previewed order, so the state the
785
+ * frame draws is representable and PR #126's em dash applies only to the other
786
+ * arm — a slot whose blocker is outside the order, which still has no number.
787
+ *
788
+ * SO THE TWO CHANNELS ARE READ FROM DIFFERENT FIELDS, AND NEITHER IS `rank`.
789
+ * `data-held` is READINESS — `ready` is the slot's own verdict and the only
790
+ * field that answers "may this start", exactly as layer 1's `slotPosition`
791
+ * now reads it for the row's accessible name. The HEADING is the rank: a
792
+ * number when the order stated a position, and otherwise a word for what is
793
+ * missing.
794
+ *
795
+ * THREE HEADINGS, NOT TWO, because `rank === null` and "held" came apart. A
796
+ * placed slot names its number, held or not. An unplaced HELD slot says so —
797
+ * PR #126's em dash, in words. An unplaced READY slot is the case the old
798
+ * one-field expression got flatly wrong: it was told "why held" while having
799
+ * no hold to show. `@issuegraph/derive` cannot emit one, but `ViewerDocument`
800
+ * is a public port and a host composes one by hand, so it is reachable from
801
+ * outside this repo — and leaving the inference here would ship the defect
802
+ * this change exists to retire in the package next door. It reuses the rank
803
+ * word with the rail's own em dash rather than taking a new `WorkspaceWords`
804
+ * entry: the fact is "there is no number", which is what the rank cell says
805
+ * with the same character, and a required word would break every host.
806
+ */
807
+ function whyRankSpec(why, words, holds) {
808
+ const held = !why.ready;
809
+ return element('div', { class: 'ig-why-rank', 'data-held': held ? 'true' : 'false' }, [
810
+ element('h3', { class: 'ig-why-rank-heading' }, [
811
+ why.rank !== null
812
+ ? `${words.whyRank} ${String(why.rank)}`
813
+ : held
814
+ ? words.whyHeld
815
+ : `${words.whyRank} —`,
816
+ ]),
817
+ element('p', { class: 'ig-why-rank-sentence' }, [
818
+ provenanceClause(why.provenance),
819
+ why.unitPartners.length === 0
820
+ ? null
821
+ : element('span', { class: 'ig-why-rank-unit' }, [
822
+ `${words.workedAsOneUnit} `,
823
+ element('span', { class: 'ig-id' }, [why.unitPartners.join(', ')]),
824
+ ]),
178
825
  ]),
826
+ // THE HOLDS BELONG TO THE EXPLANATION, so they live inside this block
827
+ // rather than beside it. They were briefly stated twice — once worded into
828
+ // this sentence and once in the list below it — which is the panel telling
829
+ // a reader the same cause in two voices.
830
+ //
831
+ // The LIST is what survived, because it is the half that carries the
832
+ // holder as a control: `holdRow` publishes `select-issue` on the blocker,
833
+ // under rules about when that control is withheld which a sentence could
834
+ // not express. A reason inline would have discarded them.
835
+ holds.length === 0 ? null : element('ul', { class: 'ig-inspector-holds' }, holds),
179
836
  ]);
180
837
  }
181
- function inspectorSpec(view, words, known, leadOf) {
838
+ /**
839
+ * One header member, rendered, or the empty string.
840
+ *
841
+ * The zone is assembled by concatenating already-rendered markup — the audit
842
+ * header arrives that way and owns its own `aria-pressed` — so a member that is
843
+ * a spec has to be rendered before it joins them, and an absent member has to
844
+ * join as nothing rather than as a gap.
845
+ */
846
+ function specMarkup(spec) {
847
+ return spec === null ? '' : renderMarkup(spec);
848
+ }
849
+ /**
850
+ * §17a's `312 open · 64 encoded` — how much of the backlog carries relationships.
851
+ *
852
+ * OMITTED WHEN THE HOST STATED NO NUMBERS, never defaulted, on this zone's
853
+ * standing rule: a zero the reader can trust and a zero nobody computed are
854
+ * different facts. `Adoption.counts` is optional precisely so a host that
855
+ * cannot count says nothing.
856
+ *
857
+ * THE TOTAL LEADS, WHICH IS THE FRAME'S ORDER AND NOT LAYER 1's. §16a words the
858
+ * same pair as `64 of 312 declare relationships`, subject first. §17a reads the
859
+ * backlog first and the encoded subset second, and the two surfaces are allowed
860
+ * to differ: this is the header of a grooming workspace, where "how much is
861
+ * left to encode" is the question, and that one is answered by the pair read in
862
+ * this direction.
863
+ */
864
+ function adoptionCountsSpec(adoption, words) {
865
+ const counts = adoption?.counts;
866
+ if (counts === undefined)
867
+ return null;
868
+ return element('span', { class: 'ig-workspace-counts' }, [
869
+ element('span', { class: 'ig-id' }, [String(counts.total)]),
870
+ ` ${words.open} · `,
871
+ element('span', { class: 'ig-id' }, [String(counts.declaring)]),
872
+ ` ${words.encoded}`,
873
+ ]);
874
+ }
875
+ /**
876
+ * §17a's `as of 14:32 ↻` — how fresh the read is, and the way to take another.
877
+ *
878
+ * THE CONTROL IS PUBLISHED AND NOT WIRED, the shape layer 1 already gives it:
879
+ * refreshing a mirror is fetching, which no layer here does, so the button
880
+ * carries `refresh` on the command attribute and the host that can listens. A
881
+ * real `button`, so the mount's own click and Enter handling treat it as a
882
+ * control rather than as a row.
883
+ *
884
+ * DRAWN ONLY WHEN THE HOST SUPPLIED A WORD FOR IT, again as layer 1 does: a
885
+ * control nobody named is one nobody wired.
886
+ *
887
+ * STALENESS IS AN ATTRIBUTE, NOT A WORD. See {@link WorkspaceWords.asOf}.
888
+ */
889
+ function freshnessSpec(freshness, words) {
890
+ if (freshness === undefined)
891
+ return null;
892
+ return element('span', {
893
+ class: 'ig-workspace-freshness',
894
+ 'data-stale': freshness.stale === true ? 'true' : 'false',
895
+ }, [
896
+ `${words.asOf} `,
897
+ element('span', { class: 'ig-id' }, [freshness.asOf]),
898
+ freshness.age === undefined || freshness.age === '' ? null : ` · ${freshness.age}`,
899
+ freshness.refresh === undefined || freshness.refresh === ''
900
+ ? null
901
+ : element('button', { type: 'button', class: 'ig-workspace-refresh', 'data-ig-command': 'refresh' }, [freshness.refresh]),
902
+ ]);
903
+ }
904
+ /**
905
+ * The host facts the RAIL is given: everything except the two §17a's header now
906
+ * draws.
907
+ *
908
+ * THIS IS THE WHOLE MECHANISM OF #135's MOVE, and it is a subtraction rather
909
+ * than a switch. Layer 1 draws a fact when it is given one and omits it when it
910
+ * is not — `hostHeader` returns `null` outright once nothing is left for it —
911
+ * so the zone that receives a fact is the zone that states it, and no option
912
+ * has to be invented to say so. `SceneOptions.chrome` is the switch that could
913
+ * not do this: it would also take the condition notice, the adoption NOTE and
914
+ * `data-ig-condition`, none of which §17a's header replaces.
915
+ *
916
+ * `adoption.note` SURVIVES WHILE `adoption.counts` DOES NOT, which is why this
917
+ * cannot drop the field whole. They are two independent members of one optional
918
+ * object — §16h's own note says the design draws the count where adoption is
919
+ * partial and the line where it is absent — and the line is the rail's panel
920
+ * footer, a place §17a's header is not.
921
+ *
922
+ * `running` SURVIVES BY BEING LEFT ALONE. The NOW row is not part of layer 1's
923
+ * panel header and never was gated with it; see {@link headerMarkup} for the
924
+ * premise that said otherwise.
925
+ */
926
+ function railHostFacts(host) {
927
+ const { freshness, adoption, ...rest } = host;
928
+ return adoption?.note === undefined ? rest : { ...rest, adoption: { note: adoption.note } };
929
+ }
930
+ /**
931
+ * §17a's workspace header: what backlog this is, how much of it is encoded,
932
+ * what is wrong with it, how fresh the read is, and the way into a first pass.
933
+ *
934
+ * IT IS UNCONDITIONAL NOW, AND THAT IS THE FIX. The zone used to be emitted
935
+ * only when an audit overlay existed — `overlay === null ? '' : zone('header',
936
+ * …)` — so the header WAS the audit header, and a workspace with no audit
937
+ * input had no header at all. §17a's header carries five facts and the audit
938
+ * count is one of them.
939
+ *
940
+ * WHAT IT DRAWS IS WHAT THE RAIL DOES NOT, and #135 settled the split. §17a
941
+ * hoists the identity, the adoption counts and the freshness stamp into a
942
+ * header spanning all three zones, so this carries five facts: what backlog
943
+ * this is, how much of it is encoded, what the audit found, how fresh the read
944
+ * is, and the way into a first pass.
945
+ *
946
+ * THE RULING, RECORDED HERE BECAUSE THIS IS THE SURFACE IT IS ABOUT:
947
+ *
948
+ * - `Adoption.counts` and the freshness stamp (with its refresh control) are
949
+ * drawn HERE and nowhere else. §17a draws both, and layer 1's panel header
950
+ * drew both until this change.
951
+ * - `OrderCounts` and `concurrencyCap` stay in the RAIL. They are a different
952
+ * pair from the adoption one — how the order splits, not how much of the
953
+ * backlog is encoded — and §17a's header does not draw them. The rail is
954
+ * the order, so the order's own tally belongs to its panel header.
955
+ * - The running-job NOW row stays in the rail, unchanged.
956
+ *
957
+ * ⚠️ #135's PREMISE WAS WRONG ABOUT THE NOW ROW, and correcting it is what made
958
+ * the ruling small. The issue records that `SceneOptions.chrome` takes the
959
+ * stamp, the refresh control, the count chips AND the NOW row together, so that
960
+ * flipping it would drop a row §17a's rail does not replace. It does not:
961
+ * `nowRows` is called unconditionally by all three projection roots, beside the
962
+ * `chrome`-gated header, so no setting of that switch has ever reached it.
963
+ *
964
+ * SO THE SWITCH IS NOT THE LEVER, and it is deliberately left alone. `chrome:
965
+ * false` also takes the condition notice, the adoption NOTE and the panel's
966
+ * `data-ig-condition` — three things §17a's header does not replace — so the
967
+ * all-or-nothing flip could only be made by dropping them. What moves a fact
968
+ * instead is WHICH ZONE IS GIVEN IT: {@link railHostFacts} hands the rail the
969
+ * host facts minus the two this header now draws, and layer 1 then omits them
970
+ * on its own existing "omitted when absent" rule rather than on a new option.
971
+ *
972
+ * EVERY FACT COMES FROM THE PORT THAT ALREADY CARRIES IT. `ViewerDocument.host`
973
+ * is commented "THE HOST-FACTS PORT", and #127 rejected deriving its numbers in
974
+ * terms: "Reporting is not deriving — the host's number is still what gets
975
+ * drawn." Counting the document here would give the header a second answer,
976
+ * free to disagree with the rail beside it.
977
+ *
978
+ * OMITTED WHEN ABSENT, NEVER DEFAULTED, for the reason the audit count already
979
+ * gives: a zero the reader can trust and a zero nobody computed are different
980
+ * facts, and a header that invents either is worse than one that says less.
981
+ */
982
+ function headerMarkup(host, auditHeader, words) {
983
+ // A STRING, not a spec, because one member of this zone already is one: the
984
+ // audit header comes from its own leaf rendered, and it owns the filter
985
+ // toggle's `aria-pressed` and the count's own omitted-when-absent rule. The
986
+ // file's standing idiom applies — everything carrying a dynamic value goes
987
+ // through `renderMarkup`, and only already-rendered markup is concatenated.
988
+ const parts = [
989
+ host?.identity === undefined || host.identity === ''
990
+ ? ''
991
+ : renderMarkup(element('span', { class: 'ig-workspace-identity' }, [host.identity])),
992
+ specMarkup(adoptionCountsSpec(host?.adoption, words)),
993
+ auditHeader,
994
+ specMarkup(freshnessSpec(host?.freshness, words)),
995
+ host?.firstPass === undefined || host.firstPass === ''
996
+ ? ''
997
+ : renderMarkup(element('button', { type: 'button', class: 'ig-workspace-firstpass', 'data-ig-command': 'first-pass' }, [host.firstPass])),
998
+ ];
999
+ return `<div class="ig-workspace-header">${parts.join('')}</div>`;
1000
+ }
1001
+ function panelScope(view) {
1002
+ const { subject } = view;
1003
+ switch (subject.kind) {
1004
+ case 'none':
1005
+ return { kind: 'none' };
1006
+ case 'issue':
1007
+ return {
1008
+ kind: 'issue',
1009
+ keys: new Set([subject.issue.key, ...(subject.whyRank?.unitPartners ?? [])]),
1010
+ };
1011
+ case 'edge':
1012
+ return { kind: 'edge', edgeId: subject.relationship.edgeId };
1013
+ }
1014
+ }
1015
+ /** Whether this panel is the one that states this record. */
1016
+ function statedHere(scope, record) {
1017
+ switch (scope.kind) {
1018
+ case 'none':
1019
+ return false;
1020
+ case 'issue':
1021
+ return record.carrier !== null && scope.keys.has(record.carrier);
1022
+ case 'edge':
1023
+ return record.edgeId === scope.edgeId;
1024
+ }
1025
+ }
1026
+ /**
1027
+ * The three affordances, mapped to the commands that carry them out.
1028
+ *
1029
+ * `satisfies Record<OverlayAffordance, string>` IS THE POINT. A fourth
1030
+ * affordance is a compile error here rather than a button that renders and does
1031
+ * nothing, and the vocabulary it is total over cannot spell `merge` — §17b's
1032
+ * one prohibition, encoded in a type rather than left to a reviewer.
1033
+ */
1034
+ const AFFORDANCE_COMMANDS = Object.freeze({
1035
+ 'view-diff': 'view-diff',
1036
+ retry: 'retry',
1037
+ 'discard-mine': 'discard',
1038
+ });
1039
+ /**
1040
+ * The region a card's `view-diff` control discloses.
1041
+ *
1042
+ * Derived from the write's own identity so `aria-controls` can name it, and
1043
+ * scoped by a prefix because a page may mount more than one workspace.
1044
+ */
1045
+ function diffRegionId(mutationId) {
1046
+ return `ig-recovery-diff-${mutationId}`;
1047
+ }
1048
+ /** One edge in a difference, named the way the relationship rows name one. */
1049
+ function diffEdgeSpec(edge) {
1050
+ return element('li', { class: 'ig-recovery-edge', 'data-edge': edge.kind }, [
1051
+ // OUTGOING, because a diff row names the edge as the document stores it —
1052
+ // `from` then `to` — rather than from the point of view of a subject. There
1053
+ // is no subject here: the list is about the document, not about one issue.
1054
+ element('span', { class: 'ig-relationship-kind' }, [labelFrom(treatmentFor(edge.kind), true)]),
1055
+ element('span', { class: 'ig-id' }, [edge.from]),
1056
+ element('span', { class: 'ig-id' }, [edge.to]),
1057
+ ]);
1058
+ }
1059
+ /** One side of a difference, or nothing when that side is empty. */
1060
+ function diffSideSpec(heading, edges) {
1061
+ return edges.length === 0
1062
+ ? null
1063
+ : element('div', { class: 'ig-recovery-side' }, [
1064
+ element('h5', { class: 'ig-recovery-side-name' }, [heading]),
1065
+ element('ul', { class: 'ig-recovery-edges' }, edges.map(diffEdgeSpec)),
1066
+ ]);
1067
+ }
1068
+ /**
1069
+ * The held difference, drawn.
1070
+ *
1071
+ * NOTHING HERE COMBINES THE TWO SIDES. They are separate lists under separate
1072
+ * headings, with no control that takes both — which is what "never auto-merge"
1073
+ * looks like in markup rather than in a comment.
1074
+ */
1075
+ function diffSpec(diff, words, id) {
1076
+ if (diffIsEmpty(diff)) {
1077
+ return element('p', { class: 'ig-recovery-diff-empty', id }, [words.diffEmpty]);
1078
+ }
1079
+ return element('div', { class: 'ig-recovery-diff', id }, [
1080
+ diffSideSpec(words.upstreamOnly, diff.upstreamOnly),
1081
+ diffSideSpec(words.mineOnly, diff.mineOnly),
1082
+ diffSideSpec(words.mineRemoved, diff.mineRemoved),
1083
+ diff.carrierReversed.length === 0
1084
+ ? null
1085
+ : element('div', { class: 'ig-recovery-side' }, [
1086
+ element('h5', { class: 'ig-recovery-side-name' }, [words.carrierReversed]),
1087
+ element('ul', { class: 'ig-recovery-edges' },
1088
+ // BOTH DIRECTIONS, SIDE BY SIDE. Which end declares a symmetric
1089
+ // relationship is the fact `edgeId` discards, so stating one
1090
+ // direction here would be this package picking a winner.
1091
+ diff.carrierReversed.flatMap((change) => [
1092
+ diffEdgeSpec(change.mine),
1093
+ diffEdgeSpec(change.upstream),
1094
+ ])),
1095
+ ]),
1096
+ diff.issuesChanged.length === 0
1097
+ ? null
1098
+ : element('div', { class: 'ig-recovery-side' }, [
1099
+ element('h5', { class: 'ig-recovery-side-name' }, [words.issuesChanged]),
1100
+ element('ul', { class: 'ig-recovery-issues' }, diff.issuesChanged.map((change) => element('li', { class: 'ig-recovery-issue' }, [
1101
+ element('span', { class: 'ig-id' }, [change.ref]),
1102
+ // BOTH TITLES, NEVER ONE RECONCILED ONE. A single line here
1103
+ // would be this package choosing a winner, which is the whole
1104
+ // of what §17b forbids.
1105
+ element('span', { class: 'ig-recovery-was' }, [change.mine?.title ?? '']),
1106
+ element('span', { class: 'ig-recovery-now' }, [change.upstream?.title ?? '']),
1107
+ ]))),
1108
+ ]),
1109
+ ]);
1110
+ }
1111
+ /**
1112
+ * The label for one affordance on one card.
1113
+ *
1114
+ * KEYED OFF THE CARD'S KIND, not off the affordance alone: `retry` is two
1115
+ * different operations and §17b gives them two different names.
1116
+ */
1117
+ function affordanceWord(affordance, kind, words) {
1118
+ switch (affordance) {
1119
+ case 'view-diff':
1120
+ return words.viewDiff;
1121
+ case 'retry':
1122
+ return kind === 'conflict' ? words.retryOnLatest : words.retry;
1123
+ case 'discard-mine':
1124
+ return words.discardMine;
1125
+ }
1126
+ }
1127
+ /**
1128
+ * One unsettled write the reader can act on.
1129
+ *
1130
+ * THE BUTTONS ARE THE GRAMMAR TABLE'S, NOT A LIST WRITTEN HERE.
1131
+ * `OVERLAY_TREATMENTS[kind].affordances` is what the line already draws its
1132
+ * state from, so the card and the edge cannot come to offer different things —
1133
+ * and because `OverlayAffordance` has no `merge` member, no entry in that table
1134
+ * can produce a merge button. That is what this construction buys, and it is
1135
+ * worth being exact about what it does NOT buy: the behavioural guarantee that
1136
+ * nothing merges lives one layer down, in a store that exposes no merge call
1137
+ * and a `HostEffect` with no merge arm.
1138
+ *
1139
+ * EVERY BUTTON CARRIES `data-ig-target`. The mount turns an attribute-borne
1140
+ * command into `{ kind: 'control', name, target }`, and the reducer's `retry`
1141
+ * and `discard` arms return NO EFFECT when `target` is undefined. Without the
1142
+ * attribute all three controls render, read correctly, and do nothing — while
1143
+ * every assertion about the command names stays green.
1144
+ */
1145
+ function recoveryCard(recovery, words, diffOpen, keys) {
1146
+ const treatment = OVERLAY_TREATMENTS[recovery.kind];
1147
+ const open = recovery.kind === 'conflict' && diffOpen === recovery.mutationId;
1148
+ return element('li', {
1149
+ class: 'ig-recovery',
1150
+ // THE STORE'S OWN VOCABULARY, so a host styles or counts these off the
1151
+ // state rather than by matching a sentence — `data-ig-code`'s rule.
1152
+ 'data-ig-state': recovery.kind,
1153
+ }, [
1154
+ element('h4', { class: 'ig-recovery-name' }, [
1155
+ recovery.kind === 'conflict' ? words.conflict : words.failed,
1156
+ ]),
1157
+ recovery.kind === 'failed'
1158
+ ? element('p', { class: 'ig-recovery-reason' }, [recovery.reason])
1159
+ : null,
1160
+ // THE REFRESH ERROR SITS ABOVE THE CONTROLS, because it is the reason the
1161
+ // control the reader last pressed did nothing.
1162
+ recovery.kind === 'conflict' && recovery.refreshError !== null
1163
+ ? element('p', { class: 'ig-recovery-refresh-error' }, [
1164
+ `${words.retryFailed} `,
1165
+ element('span', { class: 'ig-recovery-refresh-detail' }, [recovery.refreshError]),
1166
+ ])
1167
+ : null,
1168
+ element('div', { class: 'ig-recovery-actions' }, treatment.affordances.map((affordance) => element('button', {
1169
+ type: 'button',
1170
+ class: 'ig-recovery-action',
1171
+ 'data-ig-command': AFFORDANCE_COMMANDS[affordance],
1172
+ 'data-ig-target': recovery.mutationId,
1173
+ // A DISCLOSURE, NOT A TOGGLE BUTTON. `view-diff` shows and hides
1174
+ // a region, so `aria-expanded` (with `aria-controls` naming it)
1175
+ // is the pattern; `aria-pressed` announces a two-state button and
1176
+ // tells a screen-reader user nothing about the region that
1177
+ // appeared. The other two act once and carry neither.
1178
+ 'aria-expanded': affordance === 'view-diff' ? (open ? 'true' : 'false') : undefined,
1179
+ // ONLY WHILE THE REGION EXISTS. The difference is rendered only
1180
+ // when open, so naming it while shut pointed `aria-controls` at
1181
+ // no element at all — which the test for this control already
1182
+ // calls "worse than none at all", while checking only the open
1183
+ // case where it does resolve. `aria-expanded="false"` is
1184
+ // complete on its own; `aria-controls` is optional, and a
1185
+ // dangling IDREF is invisible on screen and total for a
1186
+ // screen-reader user. Found by the a11y baseline's
1187
+ // reference rule on its first run.
1188
+ 'aria-controls': affordance === 'view-diff' && open ? diffRegionId(recovery.mutationId) : undefined,
1189
+ }, [affordanceWord(affordance, recovery.kind, words)]))),
1190
+ // NARROWED BY THE PANEL'S KEY SET, not by the carrier alone. A together
1191
+ // unit's lead speaks for its partners, so a single key would drop a
1192
+ // partner's conflicted edge from the one panel entitled to state it.
1193
+ // `null` keys is the unplaced region, which is nobody's unit: nothing is
1194
+ // narrowed away there.
1195
+ open && recovery.kind === 'conflict'
1196
+ ? diffSpec(keys === null ? recovery.diff : diffWithin(recovery.diff, keys), words, diffRegionId(recovery.mutationId))
1197
+ : null,
1198
+ ]);
1199
+ }
1200
+ /** The cards a panel states, or nothing when it states none. */
1201
+ function recoveryListSpec(recoveries, words, diffOpen, keys, heading) {
1202
+ if (recoveries.length === 0)
1203
+ return null;
1204
+ return element('div', { class: 'ig-recovery-region' }, [
1205
+ heading === null ? null : element('h3', { class: 'ig-inspector-heading' }, [heading]),
1206
+ element('ul', { class: 'ig-recovery-list' }, recoveries.map((recovery) => recoveryCard(recovery, words, diffOpen, keys))),
1207
+ ]);
1208
+ }
1209
+ /**
1210
+ * The relationship list, with a refused edit drawn where the reader made it.
1211
+ *
1212
+ * ## A refusal about an edge that EXISTS keeps its row
1213
+ *
1214
+ * See {@link WorkspaceRefusal.phantom}. Four of the store's codes mark a landed
1215
+ * edge, so the capsule replaces the row only when there is no relationship
1216
+ * under it; otherwise the reason joins the row and the remove control stays.
1217
+ *
1218
+ * ## Whose refusal it is, is not a question asked here
1219
+ *
1220
+ * {@link statedHere} asks it, of the carrier the refusal arrived carrying —
1221
+ * and that is the whole of the change this function used to be the wrong place
1222
+ * for. `refusals` is one list for the whole surface, not one per panel, and
1223
+ * this function used to filter it by asking whether the refused edge's
1224
+ * IDENTITY named the panel's subject. That is a second derivation of the
1225
+ * edit's subject, run against a subject derived by a different rule, and every
1226
+ * way the two can come apart is a refusal drawn in the wrong place or in no
1227
+ * place: on every panel when nothing matched, on none when the panel had been
1228
+ * canonicalized onto a together unit's lead, on none again when the projection
1229
+ * had hidden the edge and left the panel with no subject at all. The refusal
1230
+ * names its own carrier now, so there is one derivation and nothing for a
1231
+ * second one to disagree with.
1232
+ *
1233
+ * ## The list is the ledger's, and the LAST refusal on an edge is the one
1234
+ *
1235
+ * One row states one reason, and the reader can be refused twice on one
1236
+ * relationship — `ProjectedEdge.writes` is a list because two edits touching
1237
+ * one edge compose in the order the reader made them. The last is the one they
1238
+ * just caused; the first is one they have read and moved past. That collapse
1239
+ * is `Map`'s repeated-key rule over `refusals` IN ORDER, which is why the
1240
+ * option is a list rather than a map a caller had already collapsed: a caller
1241
+ * assembling one from two passes loses the chronology at the join, and reports
1242
+ * a stale reason for an edge the reader has just been refused on again.
1243
+ *
1244
+ * ## A refusal with no row is still drawn
1245
+ *
1246
+ * The refused edge normally reaches the document — the store draws a refused
1247
+ * create as a phantom precisely so a surface can mark it — but that depends on
1248
+ * the host's projection, and a refusal that vanished because the host projects
1249
+ * only landed edges is the "never silently dropped" half of §17b's rule failing
1250
+ * quietly. So a refusal this panel states and no row carries is appended after
1251
+ * the rest, as a capsule.
1252
+ */
1253
+ function relationshipEntries(view, subject, selected, context) {
1254
+ const scope = panelScope(view);
1255
+ const stated = new Map();
1256
+ for (const refusal of context.refusals) {
1257
+ if (statedHere(scope, refusal))
1258
+ stated.set(refusal.edgeId, refusal);
1259
+ }
1260
+ const listed = new Set();
1261
+ const rows = view.relationships.map((relationship) => {
1262
+ const refusal = stated.get(relationship.edgeId);
1263
+ if (refusal === undefined) {
1264
+ return relationshipSpec(relationship, subject, selected, context.words, undefined);
1265
+ }
1266
+ listed.add(relationship.edgeId);
1267
+ return refusal.phantom
1268
+ ? refusalCapsule(relationship, refusal.code, subject, context.words)
1269
+ : relationshipSpec(relationship, subject, selected, context.words, refusal.code);
1270
+ });
1271
+ const orphaned = [...stated.values()]
1272
+ .filter((refusal) => !listed.has(refusal.edgeId))
1273
+ .map((refusal) => refusalCapsule(undefined, refusal.code, subject, context.words));
1274
+ return [...rows, ...orphaned];
1275
+ }
1276
+ /**
1277
+ * §17a's inspector panel.
1278
+ *
1279
+ * ## The heading and the clear control are the panel's, not the list's
1280
+ *
1281
+ * Both used to hang off the relationship list, and the clear control was drawn
1282
+ * only while an EDGE selection was narrowing it — so a reader who had selected
1283
+ * an issue had no way back to nothing selected, on a control whose own doc
1284
+ * says it "returns to nothing selected" and whose command is `clear`. The
1285
+ * condition it wanted was "is there a selection to clear", which is what it now
1286
+ * asks. `INITIAL_SELECTION` resolves to `none`, so the control is absent
1287
+ * exactly when pressing it would do nothing.
1288
+ *
1289
+ * ## The create step is drawn here, and the target step is not
1290
+ *
1291
+ * See {@link kindListSpec}. What the panel owns is `+ add` and the numbered
1292
+ * kind list; the target search stays in the shell because it is a live input
1293
+ * over the reader's query and this renderer returns markup.
1294
+ *
1295
+ * WITH AN EDGE SELECTED IT DRAWS NEITHER. That is not the create path being
1296
+ * unavailable — it is the panel filtered to one relationship, where "add a
1297
+ * relationship from the selected issue" has no subject: `reduceHost`'s `add`
1298
+ * reads `selectedKey`, which answers `null` for an edge selection. A control
1299
+ * that could not complete the act it advertises is the finding the audit
1300
+ * header already records paying for once.
1301
+ */
1302
+ function inspectorSpec(view, context) {
1303
+ const { words } = context;
182
1304
  const subject = view.subject;
1305
+ // THE CANONICAL SUBJECT, NOT THE SELECTION'S KEY. `inspectorView` folds a
1306
+ // together-unit member onto its slot's lead and lists the LEAD's
1307
+ // relationships, so a row worded against the key the reader clicked would
1308
+ // read every relationship from the wrong end on exactly the units where a
1309
+ // partner was selected. Read off the view, which is what the list was built
1310
+ // from.
1311
+ const key = subject.kind === 'issue' ? subject.issue.key : null;
1312
+ const selected = subject.kind === 'edge' ? subject.relationship.edgeId : null;
1313
+ const entries = relationshipEntries(view, key, selected, context);
1314
+ // THE SAME SCOPE THE RELATIONSHIP LIST USED, asked once here rather than
1315
+ // recomputed per region: two calls could not disagree today, but a panel that
1316
+ // decides its own reach twice is the shape `carrier` exists to have ended.
1317
+ const scope = panelScope(view);
1318
+ // ASKED ONCE, FOR THE SAME REASON `scope` IS. §17a draws the create path in
1319
+ // two zones — `+ add` on the relationships header, the numbered kinds under
1320
+ // their own heading — and a step derived separately per zone is two answers
1321
+ // to one question, free to disagree the day either guard moves.
1322
+ const step = createStep(context, key);
183
1323
  return element('div', { class: 'ig-inspector', 'data-subject': subject.kind }, [
1324
+ element('div', { class: 'ig-inspector-head' }, [
1325
+ element('h2', { class: 'ig-inspector-name' }, [words.inspector]),
1326
+ subject.kind === 'none'
1327
+ ? null
1328
+ : element('button', { type: 'button', class: 'ig-inspector-clear', 'data-ig-command': 'clear' }, [words.clearSelection]),
1329
+ ]),
184
1330
  subject.kind === 'none'
185
1331
  ? element('p', { class: 'ig-inspector-empty' }, [words.nothingSelected])
186
1332
  : null,
187
1333
  subject.kind === 'issue'
188
1334
  ? element('div', { class: 'ig-inspector-issue' }, [
189
- element('h2', { class: 'ig-inspector-title' }, [subject.issue.title]),
190
- element('span', { class: 'ig-inspector-key' }, [subject.issue.key]),
191
- subject.position === null
1335
+ element('h3', { class: 'ig-inspector-title' }, [subject.issue.title]),
1336
+ // LAYER 1's CHIP, not a second spelling of it. `identity` links the
1337
+ // qualified reference when the host gave a URL and prints it plain
1338
+ // when it did not — the rule for which is exactly the knowledge this
1339
+ // package must not carry a second copy of.
1340
+ identity(subject.issue),
1341
+ // THE RANK IS THE HEADING'S NOW, so there is no separate position
1342
+ // line: it printed the number, or the em dash for a held slot, which
1343
+ // is exactly what `WHY RANK n` and `WHY HELD` already say. Two
1344
+ // elements for one fact is how they come to disagree.
1345
+ subject.whyRank === null
192
1346
  ? null
193
- : element('p', {
194
- class: 'ig-inspector-position',
195
- 'data-ready': subject.position.ready ? 'true' : 'false',
196
- },
197
- // A HELD SLOT PRINTS THE VIEWER'S EM DASH, not a number. It has
198
- // no position in the sequence, and printing one would claim work
199
- // is queued that nothing can start.
200
- [subject.position.rank === null ? '—' : String(subject.position.rank)]),
201
- subject.position === null || subject.position.holds.length === 0
202
- ? null
203
- : element('ul', { class: 'ig-inspector-holds' }, subject.position.holds.map((hold) => holdRow(hold, known, (key) => leadOf.get(key) === leadOf.get(subject.issue.key)))),
1347
+ : whyRankSpec(subject.whyRank, words, subject.whyRank.holds.map((hold) => holdRow(hold, context.known, (subjectKey) => context.leadOf.get(subjectKey) === context.leadOf.get(subject.issue.key)))),
204
1348
  ])
205
1349
  : null,
1350
+ // ONE DERIVATION, READ TWICE. The step is asked once here and both
1351
+ // placements below read the answer, so the header and the body cannot
1352
+ // disagree about WHICH step is live. What each draws for a given arm is
1353
+ // still a discipline this file keeps rather than one the type checker
1354
+ // keeps — the single-return shape this replaces made drawing both
1355
+ // unrepresentable, and a value read at two sites does not. The pin that
1356
+ // stands where the return ordering used to is the one asserting no `add`
1357
+ // control exists anywhere in the panel at the kind step.
206
1358
  element('div', { class: 'ig-inspector-relationships', 'data-filtered': view.filtered ? 'true' : 'false' }, [
207
- element('h3', { class: 'ig-inspector-heading' }, [words.relationships]),
208
- view.filtered
209
- ? element('button', { type: 'button', class: 'ig-inspector-clear', 'data-ig-command': 'clear' }, [words.clearSelection])
1359
+ // §17a PUTS `+ add` ON THE HEADING'S OWN ROW, and the reason is the
1360
+ // list between them: it is unbounded, so a control after it drifts
1361
+ // down the panel as a subject gains relationships and is below the
1362
+ // fold on the busy issues a groomer most needs it for. On the header
1363
+ // row its position does not depend on the content.
1364
+ element('div', { class: 'ig-inspector-relationships-head' }, [
1365
+ element('h3', { class: 'ig-inspector-heading' }, [words.relationships]),
1366
+ step.kind === 'add' ? addControlSpec(words, step.subject) : null,
1367
+ ]),
1368
+ // A STATED EMPTY, AND ONLY WHERE THERE IS A SUBJECT TO STATE IT ABOUT.
1369
+ // With nothing selected the panel already says so once, in
1370
+ // `nothingSelected`; adding "no relationships" under it would be the
1371
+ // same absence reported twice, in two registers, on the render where
1372
+ // the reader has asked nothing yet.
1373
+ entries.length === 0 && subject.kind !== 'none'
1374
+ ? element('p', { class: 'ig-inspector-none' }, [words.noRelationships])
210
1375
  : null,
211
- view.relationships.length === 0
212
- ? null
213
- : element('ul', { class: 'ig-relationship-list' }, view.relationships.map((relationship) => relationshipSpec(relationship))),
1376
+ entries.length === 0 ? null : element('ul', { class: 'ig-relationship-list' }, entries),
1377
+ // THE STEPS ARE EXCLUSIVE, AND THE CHAIN IS THE SHELL'S OWN. `+ add`
1378
+ // begins a draft; the numbered list is what a live draft with no kind
1379
+ // yet looks like. Drawing `+ add` beside a live list would offer a
1380
+ // reader mid-draft a control that RESETS the draft they are in —
1381
+ // `create/draft.ts` makes `begin` clear the kind and the target
1382
+ // deliberately.
1383
+ step.kind === 'kinds' ? kindListSpec(words, step.source) : null,
214
1384
  ]),
1385
+ // STATED WHERE THE READER MADE THE EDIT, by the same rule the refusals use
1386
+ // — one `statedHere`, one carrier, no second derivation of whose panel this
1387
+ // is. No heading: the cards carry their own state names, and a heading over
1388
+ // a region that is usually absent is a heading a reader learns to skip.
1389
+ recoveryListSpec(context.recoveries.filter((recovery) => statedHere(scope, recovery)), words.recovery, context.diffOpen, scope.kind === 'issue' ? scope.keys : null, null),
1390
+ // THE WRITES WITH NOWHERE TO SIT, ON EVERY PANEL. A recovery whose carrier
1391
+ // is `null` is about issues this backlog does not hold, so no panel is
1392
+ // entitled to it — and the refusal path drops exactly these. Dropping a
1393
+ // REFUSAL costs a sentence the reader can live without; dropping a RECOVERY
1394
+ // takes away the only retry and discard they have, which is #137's
1395
+ // Done-when 1 ("offers all three resolutions from the editor's own
1396
+ // surface") failing on the state where it matters most. So they are drawn
1397
+ // here, under a heading that says why they are not on a relationship.
1398
+ // AND NEVER TWICE ON ONE PANEL. `statedHere`'s EDGE arm matches on
1399
+ // `edgeId` and ignores the carrier, so an edge panel narrowed to a
1400
+ // null-carrier recovery's own edge states it above AND here — two identical
1401
+ // cards, two tab stops, and two buttons carrying one write's id.
1402
+ recoveryListSpec(context.recoveries.filter((recovery) => recovery.carrier === null && !statedHere(scope, recovery)), words.recovery, context.diffOpen, null, words.recovery.unplaced),
1403
+ ]);
1404
+ }
1405
+ function createStep(context, subject) {
1406
+ const { draft, drop } = context;
1407
+ if (draft.source !== null && draft.kind === null && drop === null) {
1408
+ return { kind: 'kinds', source: draft.source === subject ? null : draft.source };
1409
+ }
1410
+ // `+ add` NEEDS A SUBJECT TO BEGIN FROM, and `reduceHost`'s `add` arm answers
1411
+ // `null` for an edge selection and for none — so drawing it there would
1412
+ // publish an act that cannot complete.
1413
+ if (subject !== null && draft.source !== subject)
1414
+ return { kind: 'add', subject };
1415
+ return { kind: 'none' };
1416
+ }
1417
+ /**
1418
+ * §17a's `+ add`, with the key that does the same thing beside it.
1419
+ *
1420
+ * THE HINT IS THE BINDING'S OWN LETTER, through {@link RELATE_KEY}. A literal
1421
+ * here would be a second spelling of a key the map already owns, free to go
1422
+ * stale the day the binding moves and with nothing failing to say so — the
1423
+ * drift `create/keys.ts`'s header rejects in terms, and the reason `KIND_KEYS`
1424
+ * exists for the digits one function below.
1425
+ *
1426
+ * IT IS `aria-hidden`, AND THE BUTTON KEEPS THE HOST'S WORD AS ITS NAME. An
1427
+ * accessible name is computed from descendant text, so an exposed hint appends
1428
+ * a bare letter to the act — "add relationship R" — which announces a shortcut
1429
+ * as though it were part of the label. Hidden, the name is the host's word
1430
+ * alone. This is the pairing rule `hiddenGlyph` already applies to every mark
1431
+ * beside a word in this package: the mark is seen, the word is heard.
1432
+ *
1433
+ * UPPERCASE IS THE STYLESHEET'S. The map stores the lowercase key a press
1434
+ * normalizes to; §17a draws `R`. `text-transform` renders it, as it already
1435
+ * does for every heading in this panel, so there is no second string.
1436
+ *
1437
+ * NOTE THE HINT AND THE CONTROL CAN BEGIN FROM DIFFERENT ISSUES. `keyIntent`'s
1438
+ * `relate` arm begins from `KeyboardContext.focused`, while this button
1439
+ * publishes the canonical `data-ig-target`; on a together unit's non-lead
1440
+ * member those are different issues, which {@link createStep} records. Both
1441
+ * begin a draft the reader can see and cancel, so the pairing is kept.
1442
+ *
1443
+ * AND THE KEY NOW WORKS WHILE THIS BUTTON ITSELF HOLDS FOCUS — it did not when
1444
+ * the hint was first drawn beside it, and `#173` is the repair. The hint had
1445
+ * been naming a key the reader genuinely has at the one focus position where it
1446
+ * was inert: `interaction()` answered `elsewhere` for a focused command control
1447
+ * and `create/keys.ts`'s `reaches()` refuses every binding there, while
1448
+ * `KeyboardContext.focused` was the rail's tab stop and so `null` then, leaving
1449
+ * the `relate` arm with nothing to relate FROM even had the interaction
1450
+ * reached. Both halves moved: `add-control` is a `CreateInteraction` admitting
1451
+ * `RELATE_KEY` alone, and `focusedAddSubject` supplies the source from the
1452
+ * attribute below.
1453
+ *
1454
+ * WHICH IS WHY THE ATTRIBUTE IS LOAD-BEARING FOR THE KEYBOARD TOO, and no
1455
+ * longer only for the pointer: `focusedAddSubject` reads exactly this
1456
+ * `data-ig-target`, narrowed to the `add` command, so the note above about the
1457
+ * hint and the control beginning from different issues is now the ONE case
1458
+ * where they still can — a together unit's non-lead member holding the rail's
1459
+ * tab stop, where `focusedKey` answers first and this fallback is not reached.
1460
+ */
1461
+ function addControlSpec(words, subject) {
1462
+ return element('button', {
1463
+ type: 'button',
1464
+ class: 'ig-inspector-addbutton',
1465
+ 'data-ig-command': 'add',
1466
+ // THE CANONICAL SUBJECT, PUBLISHED, for the reason the row's remove
1467
+ // control publishes its own edge. `reduceHost`'s `add` arm read
1468
+ // `selectedKey` — the RAW key — while everything around it is worded
1469
+ // against the slot LEAD that `inspectorView` canonicalizes to, so
1470
+ // selecting a together-unit PARTNER drew a panel titled with the lead
1471
+ // and began a relationship from the partner. The act names its
1472
+ // subject; the selection is the fallback for a control that names none.
1473
+ 'data-ig-target': subject,
1474
+ }, [
1475
+ // THE WORD IS A BARE CHILD, NOT A WRAPPED ONE. A span around it would need
1476
+ // a class, and a class with no rule is what `styles.test.ts` refuses —
1477
+ // correctly, since an element this sheet does not style is one a host
1478
+ // cannot theme.
1479
+ words.addRelationship,
1480
+ element('span', { class: 'ig-inspector-addkey', 'aria-hidden': 'true' }, [RELATE_KEY]),
215
1481
  ]);
216
1482
  }
217
1483
  /**
@@ -303,6 +1569,17 @@ export function renderWorkspace(input, options) {
303
1569
  const document = sound.document;
304
1570
  // The keys a hold's subject control may name — see `holdRow`.
305
1571
  const known = new Set(document.issues.map((issue) => issue.key));
1572
+ // THE EDGE IDS THIS SURFACE ACTUALLY DRAWS, for §17d's remedies to gate on.
1573
+ //
1574
+ // BUILT FROM THE DRAWN DOCUMENT, not from the audit's. The audit reads a
1575
+ // document that is not always the one on screen, and the two disagreeing is
1576
+ // the normal case rather than the exceptional one — a host audits everything
1577
+ // it holds and renders a page of it.
1578
+ //
1579
+ // `edgeIdentity` RATHER THAN A HAND-SPELLED KEY, because that function is
1580
+ // what mints the ids a finding carries and what the host looks one up by. A
1581
+ // second spelling here would agree with itself and disagree with both.
1582
+ const drawnEdges = new Set(document.edges.map((edge) => edgeIdentity(edge.field, edge.from, edge.to)));
306
1583
  // Which slot a key sits in, by lead — so a hold's subject that resolves to the
307
1584
  // inspected slot gets no control. The same canonicalization `inspectorView`
308
1585
  // applies, read off the same normalized slots.
@@ -316,18 +1593,79 @@ export function renderWorkspace(input, options) {
316
1593
  // members rather than the lead, for the reason the bar is: a finding can name
317
1594
  // a member that does not lead its unit.
318
1595
  const filtered = overlay !== null && options.auditFiltered === true;
1596
+ // AN OVERLAY WITH NOTHING BEHIND IT IS NOT OPEN. `overlay === null` means the
1597
+ // host wired no audit at all, and a host that leaves `auditOpen` true across
1598
+ // that change would otherwise ask for a panel over an audit that does not
1599
+ // exist.
1600
+ const auditOpen = overlay !== null && options.auditOpen === true;
1601
+ // THE HEADER'S TWO FACTS ARE WITHHELD FROM THE RAIL HERE, and this is the one
1602
+ // place they are: see `railHostFacts` for why a subtraction rather than
1603
+ // `SceneOptions.chrome`.
1604
+ const railHost = railHostFacts(document.host);
1605
+ const narrowedSlots = filtered
1606
+ ? document.order.slots.filter((slot) => slot.members.some((member) => auditFilterKeeps(overlay, member)))
1607
+ : document.order.slots;
1608
+ const narrowedExcluded = filtered
1609
+ ? document.order.excluded.filter((exclusion) => auditFilterKeeps(overlay, exclusion.key))
1610
+ : document.order.excluded;
1611
+ // A FILTER THAT MATCHES NOTHING IS NOT A ZERO, AND THE PROJECTION CANNOT KNOW
1612
+ // THAT FROM WHAT IT IS HANDED. It receives a document with no slots and says
1613
+ // the true thing about it — "Nothing is in the order right now" — which is a
1614
+ // FALSE thing about the backlog, because every rank the filter hid is still
1615
+ // there. `RULINGS.md` §2 names it: *"a false statement about the data, and it
1616
+ // is the one thing this panel exists not to make."*
1617
+ //
1618
+ // It is the defect `statesItsOwnCause` already describes one input over —
1619
+ // *"its own derived sentence is not a fallback explanation but a false one"* —
1620
+ // arrived at by narrowing instead of by a condition, so it takes the same
1621
+ // remedy: the host RECORDS what it did and the projection stops deriving.
1622
+ // Recording it here rather than teaching the viewer about filters keeps the
1623
+ // seam where it is: narrowing is the editor's act, and only the editor knows
1624
+ // the denominator, because only it still holds the unnarrowed order.
1625
+ const emptiedByFilter = filtered && narrowedSlots.length === 0 && narrowedExcluded.length === 0;
1626
+ // STATED WHENEVER THE FILTER IS ON, not only when it empties the rail — and
1627
+ // that is a consequence of §17d's entry point moving into the overlay.
1628
+ //
1629
+ // While the filter lived on the header count it was always on screen and
1630
+ // always clearable. `RULINGS.md` §3 makes that count the overlay's anchor and
1631
+ // puts the filter in the panel head, so the ONLY control for it now closes
1632
+ // with the panel — and a reader who narrowed the rail, shut the panel and
1633
+ // scrolled away would be left looking at a short list with nothing saying why
1634
+ // and nothing to press. §2 forbids exactly that: *"A filter that is visibly
1635
+ // on, states what it is hiding, and clears in one click is not a refusal."*
1636
+ // All three halves have to stay true with the panel shut.
1637
+ const railCondition = filtered
1638
+ ? {
1639
+ kind: 'filtered',
1640
+ headline: emptiedByFilter ? options.words.filteredEmpty : options.words.filteredActive,
1641
+ // THE HOST'S OWN RECORDED COUNT, NOT A SECOND DERIVATION OF IT. The
1642
+ // rail header already prints `8 ranked` from `counts.ranked`, and the
1643
+ // first version of this line counted `order.slots.length` instead —
1644
+ // which is 11 on the demo, because it includes the footer slots the
1645
+ // header does not call ranked. Two numbers for one fact, on one screen,
1646
+ // eight pixels apart.
1647
+ //
1648
+ // That is the defect this repository has already ruled on twice: two
1649
+ // capsules drawing one fact must not each hold their own sentence, or
1650
+ // they describe one document differently in one view. A denominator
1651
+ // that disagrees with the header is worse than the lie it replaced,
1652
+ // because it is a lie the reader can SEE is a lie and cannot resolve.
1653
+ //
1654
+ // Falls back to the unnarrowed slot count only when the host recorded
1655
+ // no counts at all — a number that may over-count is still better than
1656
+ // an empty sentence here, and the field is optional in the port.
1657
+ denominator: options.words.filteredDenominator(document.host.counts?.ranked ?? document.order.slots.length),
1658
+ action: { label: options.words.filteredClear },
1659
+ }
1660
+ : undefined;
1661
+ const railHostWithCause = railCondition === undefined ? railHost : { ...railHost, condition: railCondition };
319
1662
  const railInput = filtered
320
1663
  ? {
321
1664
  ...document,
322
- order: {
323
- // EXCLUSIONS ARE ROWS TOO, and filtering only the slots left the clean
324
- // ones on screen while the header said the filter was on — the toggle
325
- // narrowing part of the rail and claiming to have narrowed it.
326
- slots: document.order.slots.filter((slot) => slot.members.some((member) => auditFilterKeeps(overlay, member))),
327
- excluded: document.order.excluded.filter((exclusion) => auditFilterKeeps(overlay, exclusion.key)),
328
- },
1665
+ host: railHostWithCause,
1666
+ order: { slots: narrowedSlots, excluded: narrowedExcluded },
329
1667
  }
330
- : document;
1668
+ : { ...document, host: railHostWithCause };
331
1669
  const rail = railWindow(railInput, options.rail ?? {});
332
1670
  const railRender = renderViewer(rail.document, {
333
1671
  projection: 'linear',
@@ -335,6 +1673,15 @@ export function renderWorkspace(input, options) {
335
1673
  // The rail is where a selected ISSUE reads as current. An edge selection
336
1674
  // resolves to no key, which is `selectedKey`'s whole job.
337
1675
  selected: selectedKey(selection),
1676
+ // §16f. Passed WHOLE rather than windowed: the window moves, and a reader
1677
+ // who opened a row, scrolled past it and came back should find it open.
1678
+ // A key the window does not draw costs one `includes` miss and nothing else.
1679
+ ...(options.expanded === undefined ? {} : { expanded: options.expanded }),
1680
+ // §17j'S DENSITY ESCAPE HATCH, FORWARDED TO THE RAIL AND ONLY THE RAIL.
1681
+ // The canvas draws a second viewer in this surface, and the density block
1682
+ // is about the ORDER's rows — so handing both the same value would change a
1683
+ // projection the option says nothing about. See `ViewerOptions.density`.
1684
+ ...(options.railDensity === undefined ? {} : { density: options.railDensity }),
338
1685
  });
339
1686
  // Built once, over the window's rows, so a rail of 312 costs one pass rather
340
1687
  // than one scan of `overlay.rows` per drawn row.
@@ -358,9 +1705,31 @@ export function renderWorkspace(input, options) {
358
1705
  // scrolling position and says nothing about what surrounds the selected
359
1706
  // issue; handing the ladder a windowed document would make its budgets — and
360
1707
  // therefore its refusal — depend on where the reader had scrolled to.
1708
+ // THE LIST'S ID, DERIVED ONCE AND USED BY BOTH ZONES. Undefined unless the
1709
+ // caller supplied a surface value; see `WorkspaceOptions.surfaceId` for why
1710
+ // this renderer must not invent one.
1711
+ const isolatedListId = options.surfaceId === undefined ? undefined : `ig-isolated-list-${options.surfaceId}`;
361
1712
  const canvas = renderScaleLadder(document, {
362
1713
  state: options.scale ?? INITIAL_SCALE_STATE,
363
1714
  theme,
1715
+ isolatedListId,
1716
+ // ONE CONTROL, ONE ZONE — and the condition is WHETHER THIS SURFACE DRAWS
1717
+ // ONE, which is exactly what `words.rail` says. That is the contract
1718
+ // `ScaleLadderOptions.isolatedChip` states: suppress the ladder's chip only
1719
+ // if you draw the control yourself. §17a puts the count at the foot of the
1720
+ // rail and `railFooter` below draws it there. The LIST is unaffected and
1721
+ // stays in the canvas — see `railFooter` for why this zone cannot hold it.
1722
+ //
1723
+ // "THE WORDS EXIST" AND "THE FOOTER WAS DRAWN" ARE THE SAME CONDITION HERE,
1724
+ // and it is worth saying why rather than leaving a reader to check. The only
1725
+ // other reason `railFooter` returns null is an empty isolated set — and
1726
+ // `isolatedSpec` refuses that case too, so at count 0 neither draws and the
1727
+ // flag changes nothing. Gating on the words rather than on the returned spec
1728
+ // keeps this to ONE derivation of the ladder: the footer is built from
1729
+ // `canvas.ladder` below, which is the same value this call computes.
1730
+ // `rail-footer.test.ts` pins both halves — no configuration yields two
1731
+ // copies, and none yields none.
1732
+ isolatedChip: options.words.rail === undefined,
364
1733
  // THE SAME ONE VALUE THE RAIL READ. Without this the canvas drew the
365
1734
  // selected issue as ordinary while the rail marked it current, so the
366
1735
  // single selection this surface advertises disagreed with itself between
@@ -374,17 +1743,299 @@ export function renderWorkspace(input, options) {
374
1743
  selectedEdge: selectedEdgeId(selection),
375
1744
  projected: options.projected,
376
1745
  });
1746
+ const toolbar = canvasToolbar(canvas.ladder, document, options.words.canvas);
1747
+ // §17k'S STRIP CONTROL — the way out of the collapsed canvas below `1120`.
1748
+ //
1749
+ // ALWAYS RENDERED, HIDDEN BY THE SHEET AT THE OTHER TWO WIDTHS, and that
1750
+ // order matters: the alternative is rendering it only when narrow, which
1751
+ // would mean this function knowing how wide it is. It does not and must not —
1752
+ // §17k's implementation note — so the control is emitted once and the
1753
+ // container query decides where it is drawn. `display: none` is also what
1754
+ // keeps it out of the tab order where it has nothing to do, so a reader at
1755
+ // the wide layout never meets a button for a strip that is not there.
1756
+ //
1757
+ // `aria-expanded` RATHER THAN `aria-pressed`, on §17d's own distinction: this
1758
+ // discloses a region that is already in the markup rather than toggling a
1759
+ // mode. The label flips with it — see `WorkspaceWords.hideCanvas`.
1760
+ const canvasOpen = options.canvasOpen === true;
1761
+ const strip = element('button', {
1762
+ type: 'button',
1763
+ class: 'ig-canvas-strip',
1764
+ 'data-ig-command': 'canvas-strip',
1765
+ 'aria-expanded': canvasOpen ? 'true' : 'false',
1766
+ }, [canvasOpen ? options.words.hideCanvas : options.words.showCanvas]);
1767
+ // §17k'S MIDDLE ROW IS TWO RULES AND ONLY ONE OF THEM IS A STATE. *"Opened by
1768
+ // selection, Escape to dismiss"*: the first is a question about `selection`,
1769
+ // which this function already has, so the panel is lifted when something is
1770
+ // selected and the reader has not dismissed it since. Storing the openness
1771
+ // instead would be a second copy of the selection, free to disagree with it.
1772
+ const lifted = selection.kind !== 'none' && options.inspectorDismissed !== true;
1773
+ const narrowState = ` data-canvas="${canvasOpen ? 'open' : 'strip'}"` +
1774
+ ` data-inspector="${lifted ? 'lifted' : 'dismissed'}"`;
1775
+ // §17a'S RAIL FOOTER, FROM THE LADDER THE CANVAS ALREADY DERIVED. `isolated`
1776
+ // is a property of the whole document rather than of the tier the canvas
1777
+ // settled on, so the rail and the canvas cannot disagree about it: there is
1778
+ // one derivation and both zones read it.
1779
+ //
1780
+ // NAMED `railFooterSpec`, NOT `railFooter`, because `railFooter` is the
1781
+ // function and — more to the point — the bare identifier `rail` a few lines
1782
+ // below is the WINDOW (`options.rail`, a `RailWindowOptions`), which is a
1783
+ // different value from `options.words.rail`. Two things called rail in one
1784
+ // scope is a reading hazard worth one name, not one comment per use.
1785
+ const railFooterSpec = railFooter(canvas.ladder.isolated, options.words.rail, isolatedListId);
377
1786
  const inspector = inspectorView(document, selection);
1787
+ // §17c'S LOOP, OVER THE RAIL THE READER IS ACTUALLY LOOKING AT.
1788
+ // `reevaluateView` is handed the WINDOWED scene, so a chip whose row is
1789
+ // scrolled out of the window resolves to no row and draws nowhere — which is
1790
+ // right: a chip is a mark ON a row, and there is no row. The summary still
1791
+ // counts it, because the summary is about the ORDER and not about the window.
1792
+ //
1793
+ // NO WORDS, NO LOOP. `words.change` is optional and its absence is the whole
1794
+ // reason there is no default sentence here; see `ChangeWords`.
1795
+ const changeWords = options.words.change;
1796
+ const orderStatus = options.orderStatus ?? 'settled';
1797
+ // EVERY REF THE ORDER HAS, WINDOWED OR NOT — and taken from the UNFILTERED
1798
+ // document on purpose, so a row the audit filter is hiding counts as expected
1799
+ // for the same reason an off-window row does. The rail this render hands the
1800
+ // viewer is narrowed twice over; neither narrowing is a disagreement between
1801
+ // the projection and the change, and only this function holds the order both
1802
+ // narrowings started from.
1803
+ const inTheOrder = new Set();
1804
+ for (const slot of document.order.slots) {
1805
+ inTheOrder.add(slot.lead);
1806
+ for (const member of slot.members)
1807
+ inTheOrder.add(member);
1808
+ }
1809
+ for (const exclusion of document.order.excluded)
1810
+ inTheOrder.add(exclusion.key);
1811
+ const change = reevaluateView(changeWords === undefined ? null : options.change, railRender.scene, orderStatus, inTheOrder);
1812
+ const deltaByKey = new Map(changeWords === undefined
1813
+ ? []
1814
+ : change.chips.map((chip) => [
1815
+ chip.key,
1816
+ { chip, words: changeWords, kind: deltaKind(chip) },
1817
+ ]));
1818
+ // §17d'S PANEL IS AN OVERLAY OVER THE CANVAS, NOT A FOURTH ZONE.
1819
+ //
1820
+ // `RULINGS.md` §3: *"It is a transient overlay anchored to the header count,
1821
+ // floating over the canvas, 520px wide, dismissed on Escape."* It used to be
1822
+ // concatenated into the INSPECTOR zone, above the selection — §17a fixes
1823
+ // three zones and none of them is the audit's, so it had nowhere to go and
1824
+ // took a share of the one next to it. At six findings the inspector went
1825
+ // below the fold.
1826
+ //
1827
+ // OVER THE CANVAS AND NEVER THE RAIL, and the ruling gives the reason rather
1828
+ // than a preference: *"judging a finding is largely a question of what it
1829
+ // would do to the order, so the order has to stay readable while you
1830
+ // judge."* The canvas is also the zone §17f already designed to refuse.
1831
+ //
1832
+ // BUILT ONLY WHEN OPEN. "Transient" is the word, and a panel rendered hidden
1833
+ // is still in the accessibility tree and still in the tab order — a reader
1834
+ // on the keyboard would walk a list of findings they never opened.
1835
+ const auditPanel = !auditOpen
1836
+ ? ''
1837
+ : (() => {
1838
+ if (overlay === null)
1839
+ return '';
1840
+ const refused = renderEncodingRefusedBlock(options.audit?.encodingRefused ?? [], {
1841
+ words: options.words.audit,
1842
+ known,
1843
+ issueUrl: options.issueUrl,
1844
+ });
1845
+ // `filtered` REACHES THE PANEL, and the first draft forgot it. The chip drew
1846
+ // `aria-pressed="false"` while the rail beside it was visibly narrowed —
1847
+ // a control lying about its own state, which is the exact shape §2 wrote
1848
+ // its ruling against.
1849
+ const panel = renderAuditPanel(overlay, {
1850
+ words: options.words.audit,
1851
+ known,
1852
+ edges: drawnEdges,
1853
+ filtered,
1854
+ });
1855
+ // THE REFUSAL LEADS. It is the finding that says the other three cannot
1856
+ // be trusted for that issue — its edges were never read — so a reader
1857
+ // meeting the list first would be reading conclusions drawn from a
1858
+ // declaration nobody could parse.
1859
+ const members = [refused, panel].filter((spec) => spec !== null);
1860
+ // NO EMPTY REGION. A wrapper drawn around nothing is a heading-less box
1861
+ // taking a declared share of a column whose space belongs to the
1862
+ // selection — the same call `renderAuditPanel` makes about a list of
1863
+ // nothing, one level up.
1864
+ if (members.length === 0)
1865
+ return '';
1866
+ return renderMarkup(element('div', { class: 'ig-audit-overlay' }, members));
1867
+ })();
378
1868
  const markup = [
379
- `<div class="ig-workspace">`,
380
- overlay === null ? '' : zone('header', renderAuditHeader(overlay, { filtered })),
1869
+ // THE ORDER'S STATUS, ON THE SURFACE ROOT. `mount.ts` already publishes the
1870
+ // same value on the mounted element for a host to read; this puts it where
1871
+ // the stylesheet can reach it in the UNMOUNTED rendering too, so §17c's
1872
+ // "greyed one step, and labelled" is drawn by whoever renders rather than
1873
+ // only by whoever mounts. Same vocabulary, verbatim, so the two agree.
1874
+ // STAMPED ONLY WHERE THE LABEL CAN BE DRAWN, and the two really are one
1875
+ // treatment. §17c's held state is a greyed rail AND the word saying why —
1876
+ // "a stale-but-labelled order beats a half-computed one" — and half of that
1877
+ // is worse than neither: a dimmed order with no explanation is exactly the
1878
+ // defect the label exists to prevent. `mountWorkspace` passes the store's
1879
+ // status unconditionally, so a host that has not supplied `words.change`
1880
+ // would otherwise get the greying with the label suppressed one branch
1881
+ // below, on the very path the optional vocabulary exists to keep working.
1882
+ //
1883
+ // THE FACT IS NOT LOST TO A HOST. `mount.ts` publishes the same status on
1884
+ // the element the host holds, which is the reader this attribute was never
1885
+ // for: this one is the stylesheet's hook, so it is absent exactly when the
1886
+ // stylesheet must not act.
1887
+ //
1888
+ // §17k'S TWO READER DECISIONS RIDE THE SAME ROOT, and for the same reason
1889
+ // `data-order` does: the stylesheet is the only party that knows which of
1890
+ // the three layouts is drawn, so the surface publishes what the reader
1891
+ // asked for and each container block reads the one it has a use for.
1892
+ // Neither attribute means anything at the width where its zone is a zone.
1893
+ //
1894
+ // BOTH ARE ALWAYS STAMPED, unlike `data-order`. That one is withheld where
1895
+ // its label cannot be drawn — a greyed rail with no word is worse than
1896
+ // neither — and these two have no such pair: the value is the full state,
1897
+ // and an absent attribute would read as the strip being open and the
1898
+ // inspector being lifted, which is the opposite of both defaults.
1899
+ changeWords === undefined
1900
+ ? `<div class="ig-workspace"${narrowState}>`
1901
+ : `<div class="ig-workspace" data-order="${orderStatus}"${narrowState}>`,
1902
+ zone('header', [
1903
+ headerMarkup(document.host, overlay === null ? '' : renderAuditHeader(overlay, { open: auditOpen }), options.words),
1904
+ // §17c'S SUMMARY LIVES IN THE HEADER, NOT AT THE TOP OF THE RAIL, and
1905
+ // the frame draws them adjacent so this is a real departure worth the
1906
+ // sentence. The rail zone is the SCROLLER — `mount.ts` preserves
1907
+ // `railBefore.scrollTop` across every redraw — so a summary placed
1908
+ // inside it scrolls away from the reader, and §17c's rule is that the
1909
+ // summary "persists until the next edit or an explicit dismiss". A
1910
+ // summary that survives a dismiss but not a scroll is not persistent.
1911
+ //
1912
+ // The header is where #135 settled that a workspace-wide fact is
1913
+ // stated, and it is stated in exactly one zone. The chips carry the
1914
+ // adjacency instead: cause in the header, effect on the row.
1915
+ // "A STALE-BUT-LABELLED ORDER BEATS A HALF-COMPUTED ONE." §17c greys the
1916
+ // held rail and says WHY beside it, and the greying without the label
1917
+ // is the half of that pair which communicates nothing.
1918
+ //
1919
+ // THE LABEL IS `summarySpec`'S, INSIDE ITS LIVE REGION, and it was a
1920
+ // sibling of that region until a reader pointed out this made it
1921
+ // silent. The store clears `lastChange` when a write goes PENDING, so
1922
+ // the region is empty in exactly the state the label exists for — a
1923
+ // sighted reader got the greying and the sentence, and a screen-reader
1924
+ // user was told nothing about the ranks having gone stale.
1925
+ changeWords === undefined
1926
+ ? ''
1927
+ : renderMarkup(summarySpec(change.summary, changeWords, { held: change.held })),
1928
+ ].join('')),
381
1929
  zone('rail', [
382
1930
  railSpacer(rail.before, 'before'),
383
- renderMarkup(markRail(railRender.scene.root, (key) => severityByKey.get(key))),
1931
+ renderMarkup(markRail(railRender.scene.root, (key) => severityByKey.get(key), (key) => deltaByKey.get(key))),
384
1932
  railSpacer(rail.after, 'after'),
1933
+ railFooterSpec === null ? '' : renderMarkup(railFooterSpec),
385
1934
  ].join('')),
386
- zone('canvas', canvas.markup),
387
- zone('inspector', renderMarkup(inspectorSpec(inspector, options.words, known, leadOf))),
1935
+ zone('canvas',
1936
+ // THE CAPTION LEADS THE ZONE, above the graph, exactly as frame 17a
1937
+ // draws it — and it is assembled HERE rather than inside the ladder.
1938
+ // `renderScaleLadder` is also used standalone, where `chrome: false` is
1939
+ // the deliberate answer, and the words this row needs are the
1940
+ // workspace's. Both halves are already-rendered markup, which is the
1941
+ // rule `zone` exists under: it writes the only hand-authored tag in this
1942
+ // package and takes no caller value.
1943
+ //
1944
+ // THE STRIP CONTROL LEADS THE ZONE, above the caption, because below
1945
+ // `1120` it is the ONLY thing in the zone the reader can reach: the
1946
+ // sheet hides every sibling while the canvas is collapsed. Drawn last it
1947
+ // would be the last stop in the zone's tab order at the wide layout,
1948
+ // where it is `display: none` and therefore no stop at all — so the
1949
+ // position is chosen for the width where it is drawn.
1950
+ //
1951
+ // AND §17d'S PANEL IS LAST so it paints over the graph rather than under
1952
+ // it, and it is INSIDE this zone so `workspace/styles.ts` can position it
1953
+ // against the zone's box — which is what keeps it off the rail without a
1954
+ // magic number. The strip's hiding rule names it as an exception, or a
1955
+ // collapsed canvas would take the audit panel down with it.
1956
+ [strip, toolbar]
1957
+ .map((spec) => (spec === null ? '' : renderMarkup(spec)))
1958
+ .join('') +
1959
+ canvas.markup +
1960
+ auditPanel),
1961
+ zone('inspector',
1962
+ // §17d'S AUDIT IS THE SELECTION'S SIBLING, AND THEY SHARE ONE SCROLL
1963
+ // TRACK. Sibling rather than child so each surface keeps its own padding
1964
+ // and reads as a peer of the selection rather than part of it — their
1965
+ // headings are `h2`s beside the inspector's for the same reason.
1966
+ //
1967
+ // THE AUDIT REGION TAKES A SHARE; THE OTHER TWO SIBLINGS DO NOT. #177
1968
+ // settled this. The zone is still ONE scroll track, and inside it
1969
+ // `workspace/styles.ts` bounds the region alone at half the column,
1970
+ // scrolling itself past that. So a long audit can no longer push the
1971
+ // selection detail further than half the column down, whatever the
1972
+ // finding count — the detail's head is always above the fold, which is
1973
+ // the bound, not a promise that all of it fits. `.ig-inspector` and the
1974
+ // `.ig-chrome` the mount appends keep the track's own scrolling and
1975
+ // cannot be clipped at any height.
1976
+ //
1977
+ // THE SHARE IS `workspace/styles.ts`', NOT `audit/styles.ts`'. An earlier
1978
+ // revision of this comment said otherwise; that file states in as many
1979
+ // words that its leaves declare no size of their own, and the
1980
+ // misattribution is what nearly let §17d's second surface arrive with no
1981
+ // bound at all.
1982
+ //
1983
+ // WHY THIS ONE. Being drawn FIRST is half the reason — it is what puts
1984
+ // its length in front of the detail — and being the zone's only GLOBAL
1985
+ // member is the other. The two below it are about what the reader is
1986
+ // doing; this is about the document.
1987
+ //
1988
+ // A two-pane version was built and reverted before that: it clipped
1989
+ // `.ig-chrome`, a THIRD sibling. Bounding one member inside the single
1990
+ // track is what avoids needing panes at all.
1991
+ //
1992
+ // BOTH SIDES ARE ALREADY-RENDERED MARKUP, which is the rule `zone` exists
1993
+ // under: it writes the only hand-authored tag in this package and takes
1994
+ // no caller value, and everything with a dynamic value in it went through
1995
+ // `renderMarkup`.
1996
+ //
1997
+ // `known` IS THE DRAWN DOCUMENT'S KEYS, the same set `holdRow` withholds
1998
+ // its subject control on, and NOT the audit's: a host audits what it
1999
+ // holds and draws a page of it, so a ref can be audited and still have no
2000
+ // row here. The panel cannot see that difference and is told.
2001
+ // §17d IS ONE REGION HOLDING TWO SURFACES, AND THAT IS WHY THEY ARE
2002
+ // WRAPPED RATHER THAN CONCATENATED. `audit/refused.ts` draws the fourth
2003
+ // class outside the findings list — SPEC surfaces a refusal "on the issue
2004
+ // itself" — so the zone would otherwise gain a FOURTH sibling, and #177's
2005
+ // answer does not survive that arithmetic: two siblings each bounded at
2006
+ // half the column can take all of it between them and push the selection
2007
+ // detail entirely below the fold, which is worse than the state #177
2008
+ // fixed rather than a smaller version of it.
2009
+ //
2010
+ // SO THE BOUND MOVES UP ONE LEVEL, and #177's rule is kept rather than
2011
+ // restated: exactly one sibling of this zone is bounded, and it is now
2012
+ // the REGION. The block and the panel are unbounded inside it and share
2013
+ // its half between them, which is also what the artboard draws — §17d
2014
+ // puts the findings list and the refusal side by side as one section.
2015
+ // `workspace/styles.ts` carries the share; neither leaf declares a size.
2016
+ renderMarkup(inspectorSpec(inspector, {
2017
+ words: options.words,
2018
+ audit: overlay,
2019
+ known,
2020
+ leadOf,
2021
+ draft: options.draft ?? IDLE_CREATE_DRAFT,
2022
+ drop: options.drop ?? null,
2023
+ // FORWARDED IN THE ORDER IT ARRIVED, and turned into an answer per
2024
+ // edge inside `relationshipEntries` — after the panel has decided
2025
+ // which of them are its to state. Collapsing here instead put the
2026
+ // "one reason per row" rule one layer away from the filter that
2027
+ // decides which reasons there are, and made the ORDER of this array
2028
+ // load-bearing at a distance: a caller building it from two passes
2029
+ // lost the ledger's chronology at the join, and the panel reported a
2030
+ // refusal the reader had already read instead of the one they had
2031
+ // just caused.
2032
+ refusals: options.refusals ?? [],
2033
+ // FORWARDED IN THE LEDGER'S ORDER too, for the reason above: the
2034
+ // cards are drawn in the order the writes were made, so the one the
2035
+ // reader just caused is the one nearest what they were doing.
2036
+ recoveries: options.recoveries ?? [],
2037
+ diffOpen: options.diffOpen ?? null,
2038
+ }))),
388
2039
  `</div>`,
389
2040
  ].join('');
390
2041
  return {
@@ -409,6 +2060,11 @@ export function renderWorkspace(input, options) {
409
2060
  // it.
410
2061
  edgeOverlayStylesheet,
411
2062
  ...(overlay === null ? [] : [auditStylesheet]),
2063
+ // UNCONDITIONAL, for the reason `edgeOverlayStylesheet` above records:
2064
+ // the summary and the chips arrive with the NEXT snapshot after an edit,
2065
+ // so a sheet installed only once something has already changed is missing
2066
+ // on exactly the render that first draws one.
2067
+ reevaluateStylesheet,
412
2068
  workspaceStylesheet,
413
2069
  ].join('\n'),
414
2070
  // CONCATENATED, NOT DEDUPED — and the dedupe that used to sit here is worth
@@ -426,7 +2082,17 @@ export function renderWorkspace(input, options) {
426
2082
  // Nothing replaces it, because nothing needs to: the zones now receive an
427
2083
  // already-sound document, so they contribute nothing to this list at all.
428
2084
  // `reports every occurrence, and the zones add nothing` pins both halves.
429
- diagnostics: [...sound.diagnostics, ...railRender.diagnostics, ...canvas.diagnostics],
2085
+ // THE PLACEMENT DIAGNOSTICS ARE THE CHANGE'S, and they are only worth
2086
+ // reporting because `expected` above makes them mean what they say. Dropped
2087
+ // entirely, a host got no signal that its projection and its change
2088
+ // disagree; forwarded unfiltered, every scroll past a changed row would
2089
+ // have reported one.
2090
+ diagnostics: [
2091
+ ...sound.diagnostics,
2092
+ ...railRender.diagnostics,
2093
+ ...canvas.diagnostics,
2094
+ ...change.diagnostics,
2095
+ ],
430
2096
  };
431
2097
  }
432
2098
  //# sourceMappingURL=render.js.map