@issuegraph/editor 0.10.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 (106) hide show
  1. package/README.md +38 -7
  2. package/dist/a11y/baseline.d.ts.map +1 -1
  3. package/dist/a11y/baseline.js +4 -0
  4. package/dist/a11y/baseline.js.map +1 -1
  5. package/dist/audit/findings.d.ts +107 -1
  6. package/dist/audit/findings.d.ts.map +1 -1
  7. package/dist/audit/findings.js +113 -6
  8. package/dist/audit/findings.js.map +1 -1
  9. package/dist/audit/panel.d.ts +277 -0
  10. package/dist/audit/panel.d.ts.map +1 -0
  11. package/dist/audit/panel.js +462 -0
  12. package/dist/audit/panel.js.map +1 -0
  13. package/dist/audit/refused.d.ts +99 -0
  14. package/dist/audit/refused.d.ts.map +1 -0
  15. package/dist/audit/refused.js +276 -0
  16. package/dist/audit/refused.js.map +1 -0
  17. package/dist/audit/styles.d.ts +1 -1
  18. package/dist/audit/styles.d.ts.map +1 -1
  19. package/dist/audit/styles.js +428 -0
  20. package/dist/audit/styles.js.map +1 -1
  21. package/dist/audit/surface.d.ts +54 -12
  22. package/dist/audit/surface.d.ts.map +1 -1
  23. package/dist/audit/surface.js +53 -13
  24. package/dist/audit/surface.js.map +1 -1
  25. package/dist/create/draft.d.ts +19 -0
  26. package/dist/create/draft.d.ts.map +1 -1
  27. package/dist/create/draft.js +21 -0
  28. package/dist/create/draft.js.map +1 -1
  29. package/dist/create/keys.d.ts +68 -8
  30. package/dist/create/keys.d.ts.map +1 -1
  31. package/dist/create/keys.js +43 -5
  32. package/dist/create/keys.js.map +1 -1
  33. package/dist/index.d.ts +38 -15
  34. package/dist/index.d.ts.map +1 -1
  35. package/dist/index.js +34 -13
  36. package/dist/index.js.map +1 -1
  37. package/dist/overlay/render.d.ts +11 -3
  38. package/dist/overlay/render.d.ts.map +1 -1
  39. package/dist/overlay/render.js +22 -6
  40. package/dist/overlay/render.js.map +1 -1
  41. package/dist/overlay/request.d.ts +71 -0
  42. package/dist/overlay/request.d.ts.map +1 -0
  43. package/dist/overlay/request.js +129 -0
  44. package/dist/overlay/request.js.map +1 -0
  45. package/dist/overlay/styles.d.ts +1 -1
  46. package/dist/overlay/styles.d.ts.map +1 -1
  47. package/dist/overlay/styles.js +38 -7
  48. package/dist/overlay/styles.js.map +1 -1
  49. package/dist/reevaluate/parts.d.ts +130 -0
  50. package/dist/reevaluate/parts.d.ts.map +1 -0
  51. package/dist/reevaluate/parts.js +260 -0
  52. package/dist/reevaluate/parts.js.map +1 -0
  53. package/dist/reevaluate/render.d.ts +18 -11
  54. package/dist/reevaluate/render.d.ts.map +1 -1
  55. package/dist/reevaluate/render.js +21 -135
  56. package/dist/reevaluate/render.js.map +1 -1
  57. package/dist/reevaluate/styles.d.ts +1 -1
  58. package/dist/reevaluate/styles.d.ts.map +1 -1
  59. package/dist/reevaluate/styles.js +26 -0
  60. package/dist/reevaluate/styles.js.map +1 -1
  61. package/dist/reevaluate/view.d.ts +16 -1
  62. package/dist/reevaluate/view.d.ts.map +1 -1
  63. package/dist/reevaluate/view.js +21 -1
  64. package/dist/reevaluate/view.js.map +1 -1
  65. package/dist/reevaluate/words.d.ts +7 -0
  66. package/dist/reevaluate/words.d.ts.map +1 -1
  67. package/dist/scale/ladder.d.ts +62 -6
  68. package/dist/scale/ladder.d.ts.map +1 -1
  69. package/dist/scale/ladder.js +119 -8
  70. package/dist/scale/ladder.js.map +1 -1
  71. package/dist/scale/render.d.ts +44 -0
  72. package/dist/scale/render.d.ts.map +1 -1
  73. package/dist/scale/render.js +197 -27
  74. package/dist/scale/render.js.map +1 -1
  75. package/dist/scale/styles.d.ts +11 -5
  76. package/dist/scale/styles.d.ts.map +1 -1
  77. package/dist/scale/styles.js +133 -4
  78. package/dist/scale/styles.js.map +1 -1
  79. package/dist/workspace/host.d.ts +83 -1
  80. package/dist/workspace/host.d.ts.map +1 -1
  81. package/dist/workspace/host.js +197 -4
  82. package/dist/workspace/host.js.map +1 -1
  83. package/dist/workspace/inspector.d.ts +33 -7
  84. package/dist/workspace/inspector.d.ts.map +1 -1
  85. package/dist/workspace/inspector.js +15 -0
  86. package/dist/workspace/inspector.js.map +1 -1
  87. package/dist/workspace/mount.d.ts +23 -1
  88. package/dist/workspace/mount.d.ts.map +1 -1
  89. package/dist/workspace/mount.js +500 -17
  90. package/dist/workspace/mount.js.map +1 -1
  91. package/dist/workspace/rail.d.ts +5 -2
  92. package/dist/workspace/rail.d.ts.map +1 -1
  93. package/dist/workspace/rail.js.map +1 -1
  94. package/dist/workspace/render.d.ts +379 -3
  95. package/dist/workspace/render.d.ts.map +1 -1
  96. package/dist/workspace/render.js +893 -133
  97. package/dist/workspace/render.js.map +1 -1
  98. package/dist/workspace/selection.d.ts +11 -0
  99. package/dist/workspace/selection.d.ts.map +1 -1
  100. package/dist/workspace/selection.js +10 -0
  101. package/dist/workspace/selection.js.map +1 -1
  102. package/dist/workspace/styles.d.ts +1 -1
  103. package/dist/workspace/styles.d.ts.map +1 -1
  104. package/dist/workspace/styles.js +868 -18
  105. package/dist/workspace/styles.js.map +1 -1
  106. package/package.json +3 -3
@@ -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
@@ -49,10 +49,16 @@
49
49
  * `styles.ts` writes structure against its custom properties.
50
50
  */
51
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";
53
56
  import { IDLE_CREATE_DRAFT } from "../create/draft.js";
54
- import { KIND_KEYS } from "../create/keys.js";
57
+ import { KIND_KEYS, RELATE_KEY } from "../create/keys.js";
55
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";
56
62
  import { edgeOverlayStylesheet } from "../overlay/styles.js";
57
63
  import { AUDIT_SEVERITY_ATTRIBUTE, auditFilterKeeps, auditOverlay, heaviestRow, renderAuditHeader, } from "../audit/surface.js";
58
64
  import { INITIAL_SCALE_STATE } from "../scale/commands.js";
@@ -75,6 +81,175 @@ export const ZONES = Object.freeze(['header', 'rail', 'canvas', 'inspector']);
75
81
  function zone(name, inner) {
76
82
  return `<section class="ig-zone" data-zone="${name}">${inner}</section>`;
77
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
+ }
78
253
  /**
79
254
  * The height the rows outside the window would have taken.
80
255
  *
@@ -116,14 +291,7 @@ function severityForRow(overlay, members) {
116
291
  // the justification was the defect, not the loop.
117
292
  return overlay === null ? undefined : heaviestRow(overlay, members)?.severity;
118
293
  }
119
- /**
120
- * Add `data-ig-audit` to the rail's keyed rows.
121
- *
122
- * A total walk that rebuilds the tree rather than mutating it: `ElementSpec` is
123
- * `readonly` throughout, and a mutating walk would also be visible to the
124
- * caller's own copy of `scene.root`.
125
- */
126
- function markRail(root, severityOf) {
294
+ function markRail(root, severityOf, deltaOf) {
127
295
  // TYPED AS `ElementSpec -> ElementSpec` AT THE BOUNDARY, with the child walk
128
296
  // kept inside. A single function over `SpecChild` would hand `renderMarkup` a
129
297
  // union it does not take, and the obvious repair — casting the result back —
@@ -137,10 +305,56 @@ function markRail(root, severityOf) {
137
305
  // resolves to nothing today and to something the day a host names an issue
138
306
  // that. Narrowed rather than coerced.
139
307
  const severity = typeof key === 'string' ? severityOf(key) : undefined;
140
- const children = spec.children?.map(markChild);
141
- const attrs = severity === undefined
142
- ? spec.attrs
143
- : { ...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;
144
358
  return {
145
359
  ...spec,
146
360
  ...(attrs === undefined ? {} : { attrs }),
@@ -477,7 +691,13 @@ function refusalCapsule(relationship, code, subject, words) {
477
691
  ]);
478
692
  }
479
693
  /**
480
- * Frame 17b's numbered kind list, and the control that withdraws from it.
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.
481
701
  *
482
702
  * THE DIGITS ARE `create/keys.ts`'s OWN, through `KIND_KEYS`. Drawing
483
703
  * `index + 1` over the kind order here would be a second construction of the
@@ -495,13 +715,19 @@ function refusalCapsule(relationship, code, subject, words) {
495
715
  * remove and BEGIN a relationship, and must supply its own target picker.
496
716
  *
497
717
  * `source` NAMES THE DRAFT WHEN IT IS NOT THIS PANEL'S OWN, and is `null` when
498
- * it is — see {@link addStepSpec} for why the list is drawn either way. Written
718
+ * it is — see {@link createStep} for why the list is drawn either way. Written
499
719
  * as the reference beside the host's phrase, which is `whyRankSpec`'s own shape
500
720
  * for the same problem: the package names the issue and the host writes the
501
721
  * English around it.
502
722
  */
503
723
  function kindListSpec(words, source) {
504
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]),
505
731
  source === null
506
732
  ? null
507
733
  : element('p', { class: 'ig-inspector-source' }, [
@@ -518,8 +744,13 @@ function kindListSpec(words, source) {
518
744
  'data-ig-value': entry.edgeKind,
519
745
  'data-edge': entry.edgeKind,
520
746
  }, [
521
- element('span', { class: 'ig-kind-digit' }, [entry.key]),
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.
522
752
  ...glyphAndLabel(treatment.glyph, treatment.label),
753
+ element('span', { class: 'ig-kind-digit' }, [entry.key]),
523
754
  ]),
524
755
  ]);
525
756
  })),
@@ -547,17 +778,41 @@ function kindListSpec(words, source) {
547
778
  * this package in the business of explaining a hold whose vocabulary belongs to
548
779
  * the reader that produced it.
549
780
  *
550
- * WHAT DOES NOT APPEAR: a rank on a held slot. `@issuegraph/derive` assigns
551
- * `ready ? (rank += 1) : null`, so the two are exclusive and the heading says
552
- * which one it is. Frame 17a draws `#512` at rank 2 *and* "Held until #488
553
- * closes"; that state is unrepresentable, and PR #126 already ruled for §16
554
- * that the model wins and the em dash stands.
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.
555
806
  */
556
807
  function whyRankSpec(why, words, holds) {
557
- const held = why.rank === null;
808
+ const held = !why.ready;
558
809
  return element('div', { class: 'ig-why-rank', 'data-held': held ? 'true' : 'false' }, [
559
810
  element('h3', { class: 'ig-why-rank-heading' }, [
560
- held ? words.whyHeld : `${words.whyRank} ${String(why.rank)}`,
811
+ why.rank !== null
812
+ ? `${words.whyRank} ${String(why.rank)}`
813
+ : held
814
+ ? words.whyHeld
815
+ : `${words.whyRank} —`,
561
816
  ]),
562
817
  element('p', { class: 'ig-why-rank-sentence' }, [
563
818
  provenanceClause(why.provenance),
@@ -580,6 +835,98 @@ function whyRankSpec(why, words, holds) {
580
835
  holds.length === 0 ? null : element('ul', { class: 'ig-inspector-holds' }, holds),
581
836
  ]);
582
837
  }
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
+ }
583
930
  /**
584
931
  * §17a's workspace header: what backlog this is, how much of it is encoded,
585
932
  * what is wrong with it, how fresh the read is, and the way into a first pass.
@@ -590,18 +937,37 @@ function whyRankSpec(why, words, holds) {
590
937
  * input had no header at all. §17a's header carries five facts and the audit
591
938
  * count is one of them.
592
939
  *
593
- * WHAT IT DRAWS IS WHAT THE RAIL DOES NOT. §17a hoists the identity, the
594
- * counts and the freshness stamp into a header spanning all three zones, and
595
- * the rail below still draws the last two as layer 1's panel header — so this
596
- * carries the three facts that appear NOWHERE else today: what backlog this
597
- * is, what the audit found, and the way into a first pass.
598
- *
599
- * MOVING THE OTHER TWO IS ITS OWN CHANGE, not an omission here. Layer 1's panel
600
- * header is one element carrying the stamp, the refresh control, the count
601
- * chips AND the running-job NOW row; `SceneOptions.chrome` takes all of them or
602
- * none. §17a's header replaces some and its rail does not obviously replace the
603
- * NOW row, so which of them survives is a design ruling, and a half-made one
604
- * would either state a fact twice or drop a control on the way past.
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.
605
971
  *
606
972
  * EVERY FACT COMES FROM THE PORT THAT ALREADY CARRIES IT. `ViewerDocument.host`
607
973
  * is commented "THE HOST-FACTS PORT", and #127 rejected deriving its numbers in
@@ -613,7 +979,7 @@ function whyRankSpec(why, words, holds) {
613
979
  * gives: a zero the reader can trust and a zero nobody computed are different
614
980
  * facts, and a header that invents either is worse than one that says less.
615
981
  */
616
- function headerMarkup(host, auditHeader) {
982
+ function headerMarkup(host, auditHeader, words) {
617
983
  // A STRING, not a spec, because one member of this zone already is one: the
618
984
  // audit header comes from its own leaf rendered, and it owns the filter
619
985
  // toggle's `aria-pressed` and the count's own omitted-when-absent rule. The
@@ -623,7 +989,9 @@ function headerMarkup(host, auditHeader) {
623
989
  host?.identity === undefined || host.identity === ''
624
990
  ? ''
625
991
  : renderMarkup(element('span', { class: 'ig-workspace-identity' }, [host.identity])),
992
+ specMarkup(adoptionCountsSpec(host?.adoption, words)),
626
993
  auditHeader,
994
+ specMarkup(freshnessSpec(host?.freshness, words)),
627
995
  host?.firstPass === undefined || host.firstPass === ''
628
996
  ? ''
629
997
  : renderMarkup(element('button', { type: 'button', class: 'ig-workspace-firstpass', 'data-ig-command': 'first-pass' }, [host.firstPass])),
@@ -947,6 +1315,11 @@ function inspectorSpec(view, context) {
947
1315
  // recomputed per region: two calls could not disagree today, but a panel that
948
1316
  // decides its own reach twice is the shape `carrier` exists to have ended.
949
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);
950
1323
  return element('div', { class: 'ig-inspector', 'data-subject': subject.kind }, [
951
1324
  element('div', { class: 'ig-inspector-head' }, [
952
1325
  element('h2', { class: 'ig-inspector-name' }, [words.inspector]),
@@ -974,8 +1347,24 @@ function inspectorSpec(view, context) {
974
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)))),
975
1348
  ])
976
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.
977
1358
  element('div', { class: 'ig-inspector-relationships', 'data-filtered': view.filtered ? 'true' : 'false' }, [
978
- element('h3', { class: 'ig-inspector-heading' }, [words.relationships]),
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
+ ]),
979
1368
  // A STATED EMPTY, AND ONLY WHERE THERE IS A SUBJECT TO STATE IT ABOUT.
980
1369
  // With nothing selected the panel already says so once, in
981
1370
  // `nothingSelected`; adding "no relationships" under it would be the
@@ -991,13 +1380,7 @@ function inspectorSpec(view, context) {
991
1380
  // reader mid-draft a control that RESETS the draft they are in —
992
1381
  // `create/draft.ts` makes `begin` clear the kind and the target
993
1382
  // deliberately.
994
- // ASKED UNCONDITIONALLY, because the step belongs to the DRAFT and only
995
- // `+ add` belongs to the subject. `subject.kind !== 'issue' ? null : …`
996
- // was one guard doing both jobs, and it dropped a live draft's controls
997
- // on a panel with no issue subject — which `reconcileHost` reaches by
998
- // clearing a selection whose issue a write removed while leaving a
999
- // draft begun from another issue standing.
1000
- addStepSpec(context, subject.kind === 'issue' ? subject.issue.key : null),
1383
+ step.kind === 'kinds' ? kindListSpec(words, step.source) : null,
1001
1384
  ]),
1002
1385
  // STATED WHERE THE READER MADE THE EDIT, by the same rule the refusals use
1003
1386
  // — one `statedHere`, one carrier, no second derivation of whose panel this
@@ -1019,85 +1402,83 @@ function inspectorSpec(view, context) {
1019
1402
  recoveryListSpec(context.recoveries.filter((recovery) => recovery.carrier === null && !statedHere(scope, recovery)), words.recovery, context.diffOpen, null, words.recovery.unplaced),
1020
1403
  ]);
1021
1404
  }
1022
- /**
1023
- * Which step of the create path the panel is showing.
1024
- *
1025
- * THE STEP FOLLOWS THE DRAFT, NOT THE SELECTION, and the draft's source is
1026
- * NAMED when it is not this panel's subject. The two can diverge: `pointed`
1027
- * diverts a click to the draft only once a kind has been chosen, so at the kind
1028
- * step a click anywhere else moves the selection and leaves the draft's source
1029
- * behind — and `R` on a together unit's non-lead member begins a draft from
1030
- * that member while `inspectorView` canonicalizes the panel onto the slot's
1031
- * LEAD, so the two disagree from the first keystroke. Unnamed, the panel showed
1032
- * "choose a kind" beneath the heading of one issue while the write would go out
1033
- * from another. Named, it says whose draft it is.
1034
- *
1035
- * WITHHOLDING THE LIST WAS THE FIRST ANSWER AND IT WAS WORSE. It left the
1036
- * reader a LIVE draft with no pointer route to it at all: the numbered choices
1037
- * were gone, the cancel that sits with them was gone, and the shell draws no
1038
- * chooser of its own at this step — `mount.ts`'s floating one is drawn only
1039
- * under a canvas drop, and its target search only once a kind is chosen. So the
1040
- * one control still on screen was `+ add`, which does not cancel a draft, it
1041
- * RESETS one, silently moving the source to whatever the reader happened to be
1042
- * looking at. "The shell's cancel still reaches it" was the reasoning, and the
1043
- * shell has no cancel to reach it with. A draft the reader cannot abandon with
1044
- * a pointer is the failure `create/keys.ts` names for the keyboard, and the
1045
- * cost of the alternative — one phrase naming an issue that is not the panel's
1046
- * subject — is the cost the shell's own chooser always paid, in the sentence it
1047
- * printed above its kinds.
1048
- *
1049
- * `+ add` IS NOT DRAWN BESIDE IT, on either panel. Two entries into one draft is
1050
- * the rule {@link WorkspaceOptions.drop} already states for the floating
1051
- * chooser, and here the second entry is the destructive one: `create/draft.ts`
1052
- * makes `begin` clear the kind and the target deliberately, so a reader
1053
- * mid-draft offered `+ add` is offered a reset wearing the label of a start.
1054
- * They restart by cancelling first, which is now a control they can see.
1055
- *
1056
- * IT IS ASKED FOR EVERY SUBJECT, INCLUDING NONE. `+ add` is the half that needs
1057
- * one — `reduceHost`'s `add` arm answers `null` for an edge selection and for
1058
- * none, so a control there could not complete — and the step is not. One guard
1059
- * over both dropped a live draft's controls on a panel with no issue subject,
1060
- * which `reconcileHost` reaches: it clears a selection whose issue a landed
1061
- * write removed while leaving a draft begun from a DIFFERENT issue standing,
1062
- * because that draft's own references are all still in the document.
1063
- *
1064
- * `null` AT THE TARGET STEP AND UNDER A LIVE DROP, which are two more different
1065
- * silences and both deliberate. The target step belongs to the shell, which
1066
- * owns the live input — and draws its own cancel beside it, naming the source
1067
- * in the same sentence. A drop means a chooser is already open at the pointer,
1068
- * with a cancel of its own, and a second copy of the same step in the column
1069
- * beside it would be two controls writing to one draft.
1070
- */
1071
- function addStepSpec(context, subject) {
1072
- const { draft, drop, words } = context;
1073
- // THE ONE LIVE KIND STEP, WHEREVER IT WAS BEGUN. Asked of the draft alone, so
1074
- // there is exactly one panel state for it rather than one per subject — and
1075
- // so a panel with no issue subject at all still carries the cancel.
1405
+ function createStep(context, subject) {
1406
+ const { draft, drop } = context;
1076
1407
  if (draft.source !== null && draft.kind === null && drop === null) {
1077
- return kindListSpec(words, draft.source === subject ? null : draft.source);
1408
+ return { kind: 'kinds', source: draft.source === subject ? null : draft.source };
1078
1409
  }
1079
1410
  // `+ add` NEEDS A SUBJECT TO BEGIN FROM, and `reduceHost`'s `add` arm answers
1080
1411
  // `null` for an edge selection and for none — so drawing it there would
1081
1412
  // publish an act that cannot complete.
1082
- if (subject !== null && draft.source !== subject) {
1083
- return element('div', { class: 'ig-inspector-add' }, [
1084
- element('button', {
1085
- type: 'button',
1086
- class: 'ig-inspector-addbutton',
1087
- 'data-ig-command': 'add',
1088
- // THE CANONICAL SUBJECT, PUBLISHED, for the reason the row's remove
1089
- // control publishes its own edge. `reduceHost`'s `add` arm read
1090
- // `selectedKey` — the RAW key — while everything around it is worded
1091
- // against the slot LEAD that `inspectorView` canonicalizes to, so
1092
- // selecting a together-unit PARTNER drew a panel titled with the lead
1093
- // and began a relationship from the partner. The act names its
1094
- // subject; the selection is the fallback for a control that names
1095
- // none.
1096
- 'data-ig-target': subject,
1097
- }, [words.addRelationship]),
1098
- ]);
1099
- }
1100
- return null;
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]),
1481
+ ]);
1101
1482
  }
1102
1483
  /**
1103
1484
  * One hold in the inspector, with its cause and its subject on the markup.
@@ -1188,6 +1569,17 @@ export function renderWorkspace(input, options) {
1188
1569
  const document = sound.document;
1189
1570
  // The keys a hold's subject control may name — see `holdRow`.
1190
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)));
1191
1583
  // Which slot a key sits in, by lead — so a hold's subject that resolves to the
1192
1584
  // inspected slot gets no control. The same canonicalization `inspectorView`
1193
1585
  // applies, read off the same normalized slots.
@@ -1201,18 +1593,79 @@ export function renderWorkspace(input, options) {
1201
1593
  // members rather than the lead, for the reason the bar is: a finding can name
1202
1594
  // a member that does not lead its unit.
1203
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 };
1204
1662
  const railInput = filtered
1205
1663
  ? {
1206
1664
  ...document,
1207
- order: {
1208
- // EXCLUSIONS ARE ROWS TOO, and filtering only the slots left the clean
1209
- // ones on screen while the header said the filter was on — the toggle
1210
- // narrowing part of the rail and claiming to have narrowed it.
1211
- slots: document.order.slots.filter((slot) => slot.members.some((member) => auditFilterKeeps(overlay, member))),
1212
- excluded: document.order.excluded.filter((exclusion) => auditFilterKeeps(overlay, exclusion.key)),
1213
- },
1665
+ host: railHostWithCause,
1666
+ order: { slots: narrowedSlots, excluded: narrowedExcluded },
1214
1667
  }
1215
- : document;
1668
+ : { ...document, host: railHostWithCause };
1216
1669
  const rail = railWindow(railInput, options.rail ?? {});
1217
1670
  const railRender = renderViewer(rail.document, {
1218
1671
  projection: 'linear',
@@ -1220,6 +1673,15 @@ export function renderWorkspace(input, options) {
1220
1673
  // The rail is where a selected ISSUE reads as current. An edge selection
1221
1674
  // resolves to no key, which is `selectedKey`'s whole job.
1222
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 }),
1223
1685
  });
1224
1686
  // Built once, over the window's rows, so a rail of 312 costs one pass rather
1225
1687
  // than one scan of `overlay.rows` per drawn row.
@@ -1243,9 +1705,31 @@ export function renderWorkspace(input, options) {
1243
1705
  // scrolling position and says nothing about what surrounds the selected
1244
1706
  // issue; handing the ladder a windowed document would make its budgets — and
1245
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}`;
1246
1712
  const canvas = renderScaleLadder(document, {
1247
1713
  state: options.scale ?? INITIAL_SCALE_STATE,
1248
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,
1249
1733
  // THE SAME ONE VALUE THE RAIL READ. Without this the canvas drew the
1250
1734
  // selected issue as ordinary while the rail marked it current, so the
1251
1735
  // single selection this surface advertises disagreed with itself between
@@ -1259,18 +1743,279 @@ export function renderWorkspace(input, options) {
1259
1743
  selectedEdge: selectedEdgeId(selection),
1260
1744
  projected: options.projected,
1261
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);
1262
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
+ })();
1263
1868
  const markup = [
1264
- `<div class="ig-workspace">`,
1265
- zone('header', headerMarkup(document.host, overlay === null ? '' : 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('')),
1266
1929
  zone('rail', [
1267
1930
  railSpacer(rail.before, 'before'),
1268
- renderMarkup(markRail(railRender.scene.root, (key) => severityByKey.get(key))),
1931
+ renderMarkup(markRail(railRender.scene.root, (key) => severityByKey.get(key), (key) => deltaByKey.get(key))),
1269
1932
  railSpacer(rail.after, 'after'),
1933
+ railFooterSpec === null ? '' : renderMarkup(railFooterSpec),
1270
1934
  ].join('')),
1271
- zone('canvas', canvas.markup),
1272
- zone('inspector', renderMarkup(inspectorSpec(inspector, {
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, {
1273
2017
  words: options.words,
2018
+ audit: overlay,
1274
2019
  known,
1275
2020
  leadOf,
1276
2021
  draft: options.draft ?? IDLE_CREATE_DRAFT,
@@ -1315,6 +2060,11 @@ export function renderWorkspace(input, options) {
1315
2060
  // it.
1316
2061
  edgeOverlayStylesheet,
1317
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,
1318
2068
  workspaceStylesheet,
1319
2069
  ].join('\n'),
1320
2070
  // CONCATENATED, NOT DEDUPED — and the dedupe that used to sit here is worth
@@ -1332,7 +2082,17 @@ export function renderWorkspace(input, options) {
1332
2082
  // Nothing replaces it, because nothing needs to: the zones now receive an
1333
2083
  // already-sound document, so they contribute nothing to this list at all.
1334
2084
  // `reports every occurrence, and the zones add nothing` pins both halves.
1335
- 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
+ ],
1336
2096
  };
1337
2097
  }
1338
2098
  //# sourceMappingURL=render.js.map