@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
@@ -46,17 +46,24 @@
46
46
  * document rather than on a window.
47
47
  */
48
48
  import { edgeIdentity } from '@issuegraph/core';
49
+ import { nextDocument } from '@issuegraph/store';
49
50
  import { navigate, renderViewer, resolveTheme, } from '@issuegraph/viewer';
50
- import { keyIntent } from "../create/keys.js";
51
+ import { CONTROL_ATTRIBUTES } from "../a11y/baseline.js";
52
+ import { isChoosingKind, isDraftLive } from "../create/draft.js";
53
+ import { KIND_KEYS, keyIntent } from "../create/keys.js";
51
54
  import { pickerPlacement } from "../create/placement.js";
55
+ import { firstPassIntent } from "../firstpass/keys.js";
56
+ import { ANSWER_ATTRIBUTE, renderFirstPass } from "../firstpass/render.js";
57
+ import { firstPassStylesheet } from "../firstpass/styles.js";
52
58
  import { STATE_ATTRIBUTE, overlayFor } from "../overlay/grammar.js";
53
59
  import { overlaysFor } from "../overlay/projected.js";
54
60
  import { renderPicker } from "../picker/render.js";
55
61
  import { pickerStylesheet } from "../picker/styles.js";
56
62
  import { scaleLadder } from "../scale/ladder.js";
57
63
  import { mountStylesheet } from "./chrome.js";
58
- import { INITIAL_HOST_STATE, KINDS, railRowAt, railSlackFor, railWindowTarget, reconcileHost, reduceHost, targetMatches, } from "./host.js";
59
- import { renderWorkspace } from "./render.js";
64
+ import { INITIAL_HOST_STATE, editCarrier, narrowOverlayOpen, railRowAt, railSlackFor, railWindowTarget, reconcileHost, reduceHost, targetMatches, } from "./host.js";
65
+ import { conflictDiff } from "./recovery.js";
66
+ import { renderWorkspace, } from "./render.js";
60
67
  import { selectedEdgeId, selectedKey } from "./selection.js";
61
68
  /** What the canvas zone draws: the editor's scale ladder, or the viewer's tree projection. */
62
69
  export const CANVAS_MODES = Object.freeze(['neighbourhood', 'tree']);
@@ -67,8 +74,65 @@ const CHOOSER_SIZE = { width: 280, height: 220 };
67
74
  /** The drag threshold, in CSS pixels: a press that moves less is a click. */
68
75
  const DRAG_THRESHOLD = 6;
69
76
  const KEY_ATTRIBUTE = 'data-ig-key';
77
+ /**
78
+ * §17c's change region, which is the one node this surface carries ACROSS a
79
+ * redraw rather than replacing.
80
+ *
81
+ * Matched on the role as well as the class: the class is a styling hook, and
82
+ * what has to be preserved is the live region specifically.
83
+ */
84
+ const LIVE_REGION = '.ig-change-line[role="status"]';
70
85
  const GROUP_ATTRIBUTE = 'data-ig-group';
71
86
  const COMMAND_ATTRIBUTE = 'data-ig-command';
87
+ /**
88
+ * How many surfaces this module has mounted, so each gets an id nothing else
89
+ * on the page carries.
90
+ *
91
+ * MODULE STATE, DELIBERATELY, and it is the smallest honest way to do this. The
92
+ * renderer is pure and must stay so — `scale/render.ts`'s `searchSpec` records
93
+ * what a fixed id costs when a host draws two documents side by side — and the
94
+ * value has to be stable across every redraw of the SAME mount, so it cannot be
95
+ * derived per render. A counter in the module that does the mounting satisfies
96
+ * both: one value per `mountWorkspace` call, fixed for that surface's life.
97
+ *
98
+ * Not a random value: a deterministic sequence keeps the markup reproducible
99
+ * for anything that snapshots it.
100
+ */
101
+ let surfaces = 0;
102
+ /**
103
+ * Which subject a command acts on, when one command names several.
104
+ *
105
+ * Named here because this file now READS it in two places rather than one —
106
+ * `controlAnswer` turns it into the dispatch's `target`, and the focus token
107
+ * below uses it to tell one `select-edge` from the next. A third bare literal for an
108
+ * attribute the renderer writes on every relationship row and every recovery
109
+ * button is how a typo becomes a control that silently stops matching.
110
+ */
111
+ const TARGET_ATTRIBUTE = 'data-ig-target';
112
+ /**
113
+ * Commands that name ONE control in two states.
114
+ *
115
+ * The isolated-issues chip redraws the SAME button with its command flipped
116
+ * (`scale/render.ts`), so a focus token comparing the raw command rejects the
117
+ * replacement and sends the reader to the rail — and that control, like the
118
+ * conflict disclosure, is one you press a second time to undo. It is this
119
+ * pull request's own defect wearing a different attribute value, which is
120
+ * why it is worth a table rather than a special case: the next toggle gets
121
+ * one line here instead of a new bug.
122
+ *
123
+ * A TABLE, NOT A PREFIX RULE. `open-` / `close-` looks like a pattern and is
124
+ * not one — `first-pass` and `first-pass-close` are two different controls in
125
+ * two different places, and a rule that folded them would restore focus onto
126
+ * the wrong one.
127
+ */
128
+ const TOGGLE_IDENTITY = Object.freeze({
129
+ 'open-isolated': 'isolated',
130
+ 'close-isolated': 'isolated',
131
+ });
132
+ /** What a control is, rather than which of its states is showing. */
133
+ function controlIdentity(control) {
134
+ return TOGGLE_IDENTITY[control] ?? control;
135
+ }
72
136
  /**
73
137
  * An element, recognised by what it can do.
74
138
  *
@@ -82,6 +146,21 @@ function isElement(target) {
82
146
  function isFocusable(node) {
83
147
  return node !== null && node !== undefined && 'focus' in node;
84
148
  }
149
+ /** The three refusals, allocated once: they carry no per-press data. */
150
+ const REFUSED_INPUT = Object.freeze({ kind: 'refused', reason: 'input' });
151
+ const REFUSED_INERT = Object.freeze({ kind: 'refused', reason: 'inert' });
152
+ /** A control whose activation the BROWSER performs. See {@link isNavigating}. */
153
+ const REFUSED_NAVIGATION = Object.freeze({ kind: 'refused', reason: 'navigation' });
154
+ /**
155
+ * Whether this control is a link the browser will follow by itself.
156
+ *
157
+ * THE `href` IS PART OF THE TEST. A bare `<a>` with no `href` is not a link —
158
+ * it has no activation behaviour of its own — so one carrying a command is an
159
+ * ordinary control and must still be dispatched.
160
+ */
161
+ function isNavigating(node) {
162
+ return node?.tagName.toLowerCase() === 'a' && node.getAttribute('href') !== null;
163
+ }
85
164
  function isInput(node) {
86
165
  return node !== null && node !== undefined && node.tagName === 'INPUT';
87
166
  }
@@ -101,11 +180,26 @@ function withKey(scope, key) {
101
180
  * canvas renders the whole document and so drew a second header and a second
102
181
  * refresh control in the same workspace. One place strips it for both.
103
182
  */
104
- function withoutHost(document) {
183
+ /**
184
+ * The host facts a SECONDARY view keeps: what is true, never what is drawn.
185
+ *
186
+ * The rail owns this workspace's one header, NOW row and freshness stamp, and
187
+ * carrying them onto a second view would draw them twice — that is why the
188
+ * facts are stripped here at all, and it stays true.
189
+ *
190
+ * THE CONDITION IS NOT ONE OF THEM. It is the host's account of WHY the panel
191
+ * is short, and the viewer uses it to refuse to invent a different account:
192
+ * without it, a canvas beside an explained rail falls back to "no issue in this
193
+ * document declares a relationship", which under a stated `error` is a claim
194
+ * about something else and contradicts the rail. It draws nothing on its own
195
+ * under `chrome: false`, so keeping it costs no chrome — it only stops a second
196
+ * view explaining the same emptiness a different, false way.
197
+ */
198
+ function withoutChrome(document) {
105
199
  if (document.host === undefined)
106
200
  return document;
107
- const { host: _host, ...rest } = document;
108
- return rest;
201
+ const { host, ...rest } = document;
202
+ return host.condition === undefined ? rest : { ...rest, host: { condition: host.condition } };
109
203
  }
110
204
  /**
111
205
  * Mount the workspace into an element.
@@ -117,6 +211,10 @@ export function mountWorkspace(element, options) {
117
211
  const doc = element.ownerDocument;
118
212
  const { store } = options;
119
213
  let current = options;
214
+ // ONE PER MOUNT, FIXED FOR ITS LIFE. See `surfaces` for why it is taken here
215
+ // rather than per render.
216
+ surfaces += 1;
217
+ const surfaceId = `w${String(surfaces)}`;
120
218
  const styles = doc.createElement('style');
121
219
  const surface = doc.createElement('div');
122
220
  surface.className = 'ig-mount';
@@ -129,10 +227,110 @@ export function mountWorkspace(element, options) {
129
227
  let drawn = null;
130
228
  // A rail row to focus once the window has been re-cut around it.
131
229
  let pendingFocus = null;
230
+ /**
231
+ * Set when a command opened the isolated list, cleared by the redraw that
232
+ * reveals it.
233
+ *
234
+ * THE LIST IS NOT WHERE ITS CONTROL IS, and that is what makes this
235
+ * necessary rather than tidy. §17a puts the count and the toggle at the foot
236
+ * of the ORDER RAIL; the list itself is drawn by the ladder in the CANVAS,
237
+ * because the rail is virtualized on a fixed row pitch (`railRowAt`,
238
+ * `railSpacer`) and content of arbitrary height inside that scroll track
239
+ * makes every offset beneath it name the wrong row. So pressing the toggle
240
+ * flips a control in one zone and grows a list in another, below whatever
241
+ * that zone was already scrolled to — and the reader is told the list is open
242
+ * while seeing nothing change.
243
+ */
244
+ let revealIsolated = false;
245
+ // The keys currently holding a control they activated. See `onKeydown`'s first
246
+ // arm for why this is a fact about the PRESS and not a question asked of
247
+ // whatever holds focus by the time the repeats arrive.
248
+ //
249
+ // A SET RATHER THAN THE LATEST KEY. Two can be down at once — hold `Space` on
250
+ // one control and press `Enter` on another — and a scalar answers that by
251
+ // forgetting the first, so its repeats stop being recognised as its own and go
252
+ // wherever focus has since moved. That is not a case to guard; it is a case
253
+ // the representation should not be able to express, which is what this is.
254
+ const activating = new Set();
132
255
  let pressed = null;
133
256
  // Whether the last render drew the target search, so focus moves into it on
134
257
  // the render that OPENS it and not on every render while it stays open.
135
258
  let searchWasOpen = false;
259
+ // The phase the last render drew, so the overlay is entered once rather than
260
+ // on every redraw, and left exactly once.
261
+ let firstPassWas = 'closed';
262
+ // Where focus was when the overlay went up, so it can be given back when the
263
+ // overlay comes down. `mount.ts` already learned this lesson on the create
264
+ // flow: a surface removed with focus inside it leaves `activeElement` on the
265
+ // body, and the keydown listener is on the element — so the whole workspace
266
+ // goes keyboard-dead until the reader clicks something.
267
+ let focusBeforeFirstPass = null;
268
+ /** The scanner the live lifecycle belongs to, so a swap can end it. */
269
+ let firstPassSource = options.firstPass?.source ?? null;
270
+ /**
271
+ * Which write each applied candidate created.
272
+ *
273
+ * THE ONLY HANDLE THAT IDENTIFIES A WRITE, and it exists nowhere else: the
274
+ * `MutationId` is minted by `store.propose` and seen only here, while the
275
+ * create's own `kind`/`from`/`to` does NOT identify it — `candidates.ts` mints
276
+ * a distinct id per finding precisely so two detectors proposing the same pair
277
+ * stay two questions, and an older failed write can carry the same triple. An
278
+ * earlier revision matched structurally and could have discarded the wrong
279
+ * record. Cleared when the queue closes.
280
+ */
281
+ const appliedWrites = new Map();
282
+ /**
283
+ * The issue each unsettled write is about, answered once and then remembered.
284
+ *
285
+ * THE ANSWER GOES STALE, WHICH IS WHY IT IS KEPT RATHER THAN RE-ASKED. A
286
+ * refusal reaches the panel through the ledger, long after the act that
287
+ * caused it, and `editCarrier` reads the LANDED document — so between the two
288
+ * moments a sibling write can land and take the relationship away, leaving
289
+ * that call with nothing but the edge's identity. An identity records which
290
+ * two issues a relationship was between, and for a SYMMETRIC field it does not
291
+ * record which of them declared it: `edgeIdentity` sorts those endpoints, so
292
+ * the declaring end is not in the string at all. Re-asking therefore answered a
293
+ * different issue from the one the edit went out under, for exactly the pairs
294
+ * whose stored `from` sorted after their `to` — and `renderWorkspace` draws a
295
+ * refusal only on the panel it names, so it was drawn where the reader was
296
+ * not. Asked once, there is nothing for a second answer to disagree with.
297
+ *
298
+ * TWO WAYS IN, AND THE EARLIER ONE WINS. `reduceHost` decides the carrier as
299
+ * it emits the edit — before `store.propose` is even called, from the
300
+ * document as it stood when the reader acted — and `perform` records it under
301
+ * the identity the store mints. A write this mount did not emit (a host
302
+ * proposing on the same store) has no such moment, so it is answered the first
303
+ * time this render sees it in the ledger, which is the earliest moment there
304
+ * is. Neither is ever recomputed.
305
+ *
306
+ * ITS LIFETIME IS THE LEDGER'S, exactly. A record that lands is REMOVED from
307
+ * `snapshot.writes` — that is the store's own contract — and a refused or
308
+ * failed one stays until the reader discards it, which is precisely as long as
309
+ * it can still be drawn. So the map is pruned to the ledger's own keys on
310
+ * every render and needs no rule of its own. Clearing it beside
311
+ * `appliedWrites` was the alternative and is wrong in both directions: those
312
+ * clears are the first pass's lifecycle, not the write ledger's, so a queue
313
+ * that closes over a still-refused write would forget where to state it, and
314
+ * a write settling with no queue in sight would never be forgotten at all.
315
+ */
316
+ const writeCarriers = new Map();
317
+ /**
318
+ * The conflict whose `retry on latest` is the last one the reader pressed.
319
+ *
320
+ * WITHOUT IT, `hydrationError` IS THE WRONG FACT TO DRAW. The store sets that
321
+ * field from ANY failed read — a host's own `refresh()`, a background
322
+ * rehydrate, a failed first load — and attributes it to no mutation. Painted
323
+ * on every conflict card it says "could not read the newest version" on cards
324
+ * whose button nobody pressed, and on all of them at once when two conflicts
325
+ * stand. The card's claim is about a control the reader operated, so the
326
+ * shell records which control that was; the store cannot answer it.
327
+ *
328
+ * CLEARED WHEN THE READ SUCCEEDS, which the ledger cannot express: a resolve
329
+ * that reads fine and is then refused leaves an `invalid` record and no
330
+ * hydration error, and a stale flag here would keep explaining a failure that
331
+ * did not happen.
332
+ */
333
+ let refreshFailedFor = null;
136
334
  const railCount = () => current.railCount ?? MOUNT_RAIL_COUNT;
137
335
  const theme = () => resolveTheme(current.theme);
138
336
  const el = (tag, attributes = {}, children = []) => {
@@ -165,7 +363,10 @@ export function mountWorkspace(element, options) {
165
363
  const perform = (effect) => {
166
364
  switch (effect.kind) {
167
365
  case 'propose':
168
- void store.propose(effect.proposal);
366
+ // THE CARRIER IS RECORDED, NOT RE-DERIVED. See `writeCarriers`: the
367
+ // reducer decided it from the document the reader acted on, and the
368
+ // `MutationId` that binds the two exists only here.
369
+ writeCarriers.set(store.propose(effect.proposal).mutationId, effect.carrier);
169
370
  return;
170
371
  case 'retry': {
171
372
  // A conflict retries against the LATEST document. The store owns that
@@ -173,6 +374,10 @@ export function mountWorkspace(element, options) {
173
374
  // operation, so there is nothing for the mount to sequence.
174
375
  const record = store.getSnapshot().writes.find((each) => each.mutationId === effect.mutationId);
175
376
  if (record?.state === 'conflict') {
377
+ // WHOSE READ THIS IS, recorded as the press happens. See
378
+ // `refreshFailedFor`: after the fact there is nothing that ties the
379
+ // store's hydration error to the edit it was read for.
380
+ refreshFailedFor = effect.mutationId;
176
381
  void store.retryOnLatest(effect.mutationId);
177
382
  }
178
383
  else {
@@ -186,30 +391,205 @@ export function mountWorkspace(element, options) {
186
391
  case 'dismiss-change':
187
392
  store.dismissChange();
188
393
  return;
394
+ case 'find-candidates': {
395
+ const option = current.firstPass;
396
+ // UNREACHABLE WITHOUT A BUNDLE, because the command is withheld at the
397
+ // listener — but written as a guard rather than an assertion, because
398
+ // `update()` can take the bundle away between the click and this call.
399
+ if (option === undefined) {
400
+ // CLOSED, NOT FAILED. `failed` is a state this surface DRAWS, in the
401
+ // host's own words — and with no bundle there are no words and no
402
+ // overlay, so a failed phase here is an invisible one that blocks the
403
+ // workspace's keyboard with no control to close it. The DOM guard
404
+ // withholds this command, but `handle.dispatch` is public and reaches
405
+ // the reducer directly, so the shell answers for that route too.
406
+ dispatch({ kind: 'first-pass', command: { kind: 'close' } });
407
+ return;
408
+ }
409
+ // THE OPEN IS REAL, SO THE DRAFT GOES NOW. Only the shell knows a scan
410
+ // will actually run — the reducer cannot tell this open from one about
411
+ // to be refused for want of a source, and clearing on both destroyed a
412
+ // draft for a queue that never appeared. Both routes into the queue, the
413
+ // attribute and `handle.dispatch`, arrive here.
414
+ dispatch({ kind: 'control', name: 'cancel' });
415
+ const { scan } = effect;
416
+ // STARTED FROM A CALLBACK, so a host whose `findCandidates` throws
417
+ // BEFORE returning its promise — reading its own state, building a
418
+ // request — reaches the same failed scan as one whose promise rejects.
419
+ // Called directly, that throw escapes the `.catch` entirely and unwinds
420
+ // through the click handler, leaving the phase `scanning` for good.
421
+ void Promise.resolve()
422
+ .then(() => option.source.findCandidates())
423
+ .then((candidates) => {
424
+ dispatch({ kind: 'first-pass', command: { kind: 'candidates', scan, candidates } });
425
+ })
426
+ .catch(() => {
427
+ // THE REASON IS THE HOST'S AND IS NOT READ. A failed scan is a phase
428
+ // this surface draws in the host's own words; adopting the rejection
429
+ // value would be this package writing a sentence about someone
430
+ // else's tracker.
431
+ dispatch({ kind: 'first-pass', command: { kind: 'scan-failed', scan } });
432
+ });
433
+ return;
434
+ }
435
+ case 'first-pass-apply': {
436
+ // THIS CANDIDATE'S WRITE MAY ALREADY BE OUT THERE. `⌫` on an answer whose
437
+ // create is still `pending` steps the queue back but takes nothing away,
438
+ // because `discardMine` declines a pending record — so answering `Y`
439
+ // again would propose the SAME create a second time, and when the first
440
+ // lands the second turns `invalid` and shows the reader an error about a
441
+ // relationship that now exists. One consent, one write: the handle is
442
+ // kept and nothing new is proposed.
443
+ if (pendingWriteFor(effect.candidateId) !== undefined)
444
+ return;
445
+ // AND THE RELATIONSHIP ITSELF MAY ALREADY BE THERE. The check above is
446
+ // per candidate, and a candidate is not a relationship: two findings
447
+ // with different ids may propose the same pair (`candidates.ts` keeps
448
+ // them apart on purpose), and a close-and-reopen starts a fresh queue
449
+ // over a document the earlier answer has since written to. Either way
450
+ // the store refuses the second create as a duplicate — correctly — and
451
+ // the reader is left an `invalid` record about a relationship that
452
+ // exists.
453
+ // LANDED OR ON ITS WAY, AND NOTHING ELSE. Asked of the two records that
454
+ // answer it directly: `landed` says the relationship is there, and a
455
+ // `pending-write` state says one is being created right now. An edge
456
+ // whose only write FAILED is precisely NOT there, so a second consent is
457
+ // free to try again.
458
+ // ASKED OF `landed`, NOT INFERRED FROM "no unsettled write". An earlier
459
+ // revision read a landed edge as one with an empty `writes` — which a
460
+ // landed edge carrying any settled overlay, a failed DELETE among them,
461
+ // is not. It then proposed a create for a relationship that was still
462
+ // there, and the store recorded the invalid duplicate.
463
+ const { proposal } = effect;
464
+ if (proposal.op === 'create') {
465
+ const id = edgeIdentity(proposal.kind, proposal.from, proposal.to);
466
+ const snapshot = store.getSnapshot();
467
+ // ASKED OF THE STORE'S OWN RULE, not enumerated here. `nextDocument`
468
+ // is the store's answer to "what would this edit produce if it landed
469
+ // exactly as proposed", and it answers for every operation the store
470
+ // has: a create adds the edge, a delete removes it, and a retype or a
471
+ // flip replaces it with a different identity — which removes the one
472
+ // the reader is being asked about just as surely as a delete does.
473
+ // FOUR EARLIER VERSIONS RECONSTRUCTED THAT ANSWER and each was wrong
474
+ // in a way only the store's internals reveal — a failed write still
475
+ // projects, a landed edge can carry a settled overlay, a pending
476
+ // delete is marked `pending-write` too, and a retype removes an edge
477
+ // without being a delete. Every one of them suppressed a consent that
478
+ // then wrote nothing, showed nothing, and was already decided. So the
479
+ // enumeration is the store's, and a fifth operation is covered the day
480
+ // the store learns about one. See autnmy/issuegraph#142.
481
+ const landedNow = { issues: snapshot.issues, edges: snapshot.landed };
482
+ const isThere = (document___) => document___.edges.some((edge) => edge.id === id);
483
+ const already = isThere(landedNow);
484
+ let coming = false;
485
+ let going = false;
486
+ for (const write of snapshot.writes) {
487
+ if (write.state !== 'pending')
488
+ continue;
489
+ const after = isThere(nextDocument(landedNow, write.mutation));
490
+ if (after && !already)
491
+ coming = true;
492
+ if (!after && already)
493
+ going = true;
494
+ }
495
+ // THERE, AND NOT ON ITS WAY OUT. A landed edge under a pending edit
496
+ // that would remove it is a relationship the reader may be about to
497
+ // lose, so their consent goes to the store and is adjudicated there —
498
+ // visible either way, which is the difference this guard exists to
499
+ // preserve.
500
+ if ((already && !going) || coming)
501
+ return;
502
+ }
503
+ const handle = store.propose(effect.proposal);
504
+ appliedWrites.set(effect.candidateId, handle.mutationId);
505
+ // AND THE SAME RECORDING AS THE PLAIN ROUTE, because this is the same
506
+ // kind of event: a write going out with a panel it belongs to. The
507
+ // effect carries one now — it used to be built without going through
508
+ // the reducer's emit funnel at all, so a refused first-pass answer had
509
+ // no panel to be stated on.
510
+ writeCarriers.set(handle.mutationId, effect.carrier);
511
+ return;
512
+ }
513
+ case 'first-pass-withdraw': {
514
+ // THE STORE'S OWN UNDO, AND NOTHING ELSE. `discardMine` is the whole of
515
+ // what a host may take back: it declines a `pending` record itself, and
516
+ // a create that LANDED has no record left at all — so in practice the
517
+ // withdrawable set is `invalid`, `failed` and `conflict`, and the
518
+ // reader's `⌫` on anything else steps the queue back and leaves the
519
+ // write alone.
520
+ // THAT REFUSAL IS NOT RESTATED HERE. An earlier revision guarded on
521
+ // `state !== 'pending'` before calling, which is a second copy of a rule
522
+ // the store already enforces — and a mutation test proved it: removing
523
+ // the guard changed no observable behaviour, because `discardMine` was
524
+ // refusing anyway. What is NOT done here is the thing that would be
525
+ // wrong: proposing a compensating `delete`, which is the second
526
+ // retraction path `firstpass/queue.ts` refuses in terms — "free to
527
+ // disagree with [the store] about what undoing a create means."
528
+ // BY THE WRITE'S OWN IDENTITY. See `appliedWrites`: a structural match on
529
+ // the create's fields cannot tell this answer's write from another
530
+ // candidate proposing the same pair, or from an older failed one.
531
+ const mutationId = appliedWrites.get(effect.candidateId);
532
+ if (mutationId === undefined)
533
+ return;
534
+ store.discardMine(mutationId);
535
+ // THE HANDLE OUTLIVES A REFUSED DISCARD. `discardMine` leaves a `pending`
536
+ // record exactly where it was, so forgetting the id here would lose the
537
+ // only thing that can find that write again — see the apply arm.
538
+ if (!recordedWriteFor(effect.candidateId)) {
539
+ appliedWrites.delete(effect.candidateId);
540
+ }
541
+ return;
542
+ }
189
543
  }
190
544
  };
191
- const dispatch = (command) => {
192
- if (destroyed)
193
- return;
194
- const result = reduceHost(state, command, landed());
545
+ /**
546
+ * Take a reduction: keep its state, perform its effects, draw.
547
+ *
548
+ * SPLIT FROM `dispatch` SO ONE CALLER CAN LOOK BEFORE IT LEAPS. The keyboard
549
+ * arm has to know whether this reducer owns a command BEFORE it does anything
550
+ * at all, and `schedule()` is not nothing: it redraws, which replaces
551
+ * `surface.innerHTML` and with it the very button the reader is holding. A
552
+ * `<button>`'s `Space` activation lands on KEYUP, so destroying it first takes
553
+ * the host's own control off the keyboard just as surely as cancelling the
554
+ * press did. `reduceHost` is pure, so asking costs nothing and answers safely.
555
+ */
556
+ const applyResult = (result) => {
195
557
  state = result.state;
196
558
  for (const effect of result.effects)
197
559
  perform(effect);
198
560
  schedule();
199
561
  };
562
+ const dispatch = (command) => {
563
+ if (destroyed)
564
+ return;
565
+ // ON THE COMMAND, NOT ON THE RESULTING STATE. `open-isolated` is what the
566
+ // reader just asked for; `scale.isolatedOpen` is also true on every redraw
567
+ // that follows for any other reason, and revealing it again there would
568
+ // yank the canvas back under a reader who had scrolled away.
569
+ if (command.kind === 'control' && command.name === 'open-isolated')
570
+ revealIsolated = true;
571
+ applyResult(reduceHost(state, command, landed()));
572
+ };
200
573
  const zone = (name) => surface.querySelector(`.ig-zone[data-zone="${name}"]`);
201
574
  const pitch = () => theme().metrics['--ig-row-height'] + theme().metrics['--ig-space-tight'];
202
575
  const kindChooser = (source, target) => {
203
576
  const { words } = current;
204
577
  const chooser = el('div', { class: 'ig-chrome-chooser', role: 'group', 'aria-label': words.picker.heading });
205
578
  chooser.append(el('p', { class: 'ig-chrome-sentence' }, [`#${source} … ${target === null ? words.chooseKind : `#${target}`}`]));
579
+ // THE DIGITS ARE THE KEYBOARD'S OWN, READ OFF `KIND_KEYS`. They used to be
580
+ // computed here as `index + 1` over a `KINDS` alias in `host.ts` — a second
581
+ // construction of `create/keys.ts`'s table: the two agreed only because
582
+ // both walked `EDGE_FIELDS`, and nothing pinned them, so a chooser could
583
+ // have told the reader to press a key the key map resolved to a different
584
+ // kind. That alias is gone with its last caller, so this is now the only
585
+ // numbering in the package.
206
586
  const list = el('div', { class: 'ig-chrome-kinds' });
207
- KINDS.forEach((kind, index) => {
208
- list.append(button(`${String(index + 1)} ${words.picker.kinds[kind]}`, 'kind', {
209
- 'data-ig-value': kind,
210
- 'data-edge': kind,
587
+ for (const entry of KIND_KEYS) {
588
+ list.append(button(`${entry.key} ${words.picker.kinds[entry.edgeKind]}`, 'kind', {
589
+ 'data-ig-value': entry.edgeKind,
590
+ 'data-edge': entry.edgeKind,
211
591
  }));
212
- });
592
+ }
213
593
  chooser.append(list, button(words.cancel, 'cancel', { class: 'ig-chrome-button ig-chrome-quiet' }));
214
594
  return chooser;
215
595
  };
@@ -232,7 +612,7 @@ export function mountWorkspace(element, options) {
232
612
  for (const match of matches) {
233
613
  list.append(el('li', {}, [
234
614
  button(`#${match.ref} ${match.title}`, 'target', {
235
- 'data-ig-target': match.ref,
615
+ [TARGET_ATTRIBUTE]: match.ref,
236
616
  class: 'ig-chrome-button ig-chrome-match',
237
617
  }),
238
618
  ]));
@@ -247,24 +627,31 @@ export function mountWorkspace(element, options) {
247
627
  const { words } = current;
248
628
  const panel = el('div', { class: 'ig-chrome', 'data-chrome': 'inspector' });
249
629
  const edgeId = selectedEdgeId(state.selection);
250
- const issue = selectedKey(state.selection);
251
630
  const { draft } = state;
631
+ // WHAT IS LEFT HERE IS WHAT NEEDS A DOM. `+ add`, the numbered kind list
632
+ // and §17b's flip are `renderWorkspace`'s now — the flip because the
633
+ // statement it reverses is the selected relationship's ROW, and a picker
634
+ // composed into this same panel drew a second statement and a second flip
635
+ // one element from the first. What is left below draws one control, not two.
636
+ // The older half of this note stands: `+ add` and the numbered kind list
637
+ // are `renderWorkspace`'s now — they are markup, and a host rendering the
638
+ // package without mounting had a panel it could only read. The retype
639
+ // picker and the target search stay: the first is another package's
640
+ // renderer composed as markup, and the second is a live input over
641
+ // `state.targetQuery` with a caret to preserve, which a markup-only
642
+ // renderer cannot be.
643
+ //
644
+ // THE CHAIN'S EXCLUSIVITY IS NOW SPLIT ACROSS TWO LAYERS, and that is why
645
+ // `renderWorkspace` is told about the draft and the drop: it re-states the
646
+ // same guards for the steps it draws, rather than the two surfaces each
647
+ // drawing whatever they can see.
252
648
  if (edgeId !== null) {
253
649
  const picker = el('div', { class: 'ig-chrome-picker' });
254
650
  // Package-rendered markup, escaped by the package.
255
651
  picker.innerHTML = renderPicker(document, edgeId, { words: words.picker, theme: theme() }).markup;
256
652
  panel.append(picker, button(words.deleteRelationship, 'delete', { class: 'ig-chrome-button ig-chrome-danger' }));
257
653
  }
258
- else if (draft.source === null) {
259
- if (issue !== null)
260
- panel.append(button(words.addRelationship, 'add'));
261
- }
262
- else if (draft.kind === null) {
263
- // Placed at the drop point when a canvas drag got here; inline otherwise.
264
- if (state.drop === null)
265
- panel.append(kindChooser(draft.source, draft.target));
266
- }
267
- else if (draft.target === null) {
654
+ else if (draft.source !== null && draft.kind !== null && draft.target === null) {
268
655
  panel.append(targetSearch(document, draft.source, draft.kind));
269
656
  }
270
657
  if (words.keys !== undefined)
@@ -288,6 +675,225 @@ export function mountWorkspace(element, options) {
288
675
  chooser.style.top = `${String(placed.y)}px`;
289
676
  return chooser;
290
677
  };
678
+ /**
679
+ * The first-pass surface, when one is up.
680
+ *
681
+ * A TAKEOVER RATHER THAN A FOURTH ZONE: `ZONES` is a closed union the
682
+ * workspace's grid is laid out from, and §17e's queue occupies the surface
683
+ * rather than a column. It is built and appended the way the floating chooser
684
+ * is — after `surface.innerHTML`, inside the workspace root, which is the box
685
+ * `chrome.ts` positions against and the element a host's theme is scoped to.
686
+ *
687
+ * ## It is a modal, and says so
688
+ *
689
+ * The rail and the canvas are still in the DOM underneath, still focusable and
690
+ * still in the accessibility tree. Without `aria-modal` and `inert` on the
691
+ * zones, one Tab lands the reader on a row they cannot see, where every key is
692
+ * inert because the queue owns the keyboard — and a screen reader reads the
693
+ * whole covered workspace as though nothing were over it.
694
+ *
695
+ * ## `aria-live`, because focus deliberately does not move
696
+ *
697
+ * Answering swaps the question in place while focus stays on this wrapper, so
698
+ * without a live region a reader answering sixty questions at speed hears
699
+ * nothing after the first. Focus stays here rather than moving onto a control
700
+ * because the first control is `apply` — the one irreversible answer — and a
701
+ * `Space` on a focus move nobody made is precisely the un-consented apply
702
+ * §17e forbids.
703
+ */
704
+ const firstPassOverlay = () => {
705
+ const option = current.firstPass;
706
+ const { phase } = state.firstPass;
707
+ if (option === undefined || phase.kind === 'closed')
708
+ return null;
709
+ const wrapper = el('div', {
710
+ class: 'ig-firstpass-overlay',
711
+ role: 'dialog',
712
+ 'aria-modal': 'true',
713
+ // THE DIALOG IS NAMED, not just its child. `renderFirstPass` puts the
714
+ // host's label on the `<section>` it draws, which names that landmark and
715
+ // says nothing about its dialog ancestor — so assistive technology met an
716
+ // unnamed modal. The same word, on the element that is the modal.
717
+ 'aria-label': option.words.label,
718
+ 'aria-live': 'polite',
719
+ 'data-ig-firstpass': phase.kind,
720
+ tabindex: '-1',
721
+ });
722
+ if (phase.kind === 'open') {
723
+ const panel = el('div', { class: 'ig-firstpass-panel' });
724
+ // Package-rendered markup, escaped by the package — the file's standing
725
+ // idiom, the same one the inspector's picker uses.
726
+ panel.innerHTML = renderFirstPass(phase.queue, { words: option.words, theme: theme() }).markup;
727
+ wrapper.append(panel);
728
+ }
729
+ else {
730
+ // THE HOST'S SENTENCE, PLACED. A scan that is out and a scan that failed
731
+ // are different facts and get different words; neither is worded here.
732
+ wrapper.append(el('p', { class: 'ig-firstpass-note' }, [
733
+ phase.kind === 'scanning' ? option.scanning : option.scanFailed,
734
+ ]));
735
+ }
736
+ wrapper.append(button(option.exit, 'first-pass-close', { class: 'ig-chrome-button ig-chrome-quiet' }));
737
+ return wrapper;
738
+ };
739
+ /**
740
+ * This candidate's write, if one is still IN FLIGHT.
741
+ *
742
+ * `pending` and nothing else, because the question it answers is "is this
743
+ * create already on its way?" — and a record that has FAILED is not on its way
744
+ * and never landed, so the relationship it stood for does not exist. Reading
745
+ * "a record exists" as "a write is out there" suppressed the reader's second
746
+ * consent after a failure: nothing was proposed, and the queue nevertheless
747
+ * marked the candidate decided, so a relationship they believed they had
748
+ * recorded was silently absent and never asked about again.
749
+ */
750
+ const pendingWriteFor = (candidateId) => {
751
+ const mutationId = appliedWrites.get(candidateId);
752
+ if (mutationId === undefined)
753
+ return undefined;
754
+ return store
755
+ .getSnapshot()
756
+ .writes.some((write) => write.mutationId === mutationId && write.state === 'pending')
757
+ ? mutationId
758
+ : undefined;
759
+ };
760
+ /** Whether the store still holds a record for this candidate's write, in any state. */
761
+ const recordedWriteFor = (candidateId) => {
762
+ const mutationId = appliedWrites.get(candidateId);
763
+ return (mutationId !== undefined &&
764
+ store.getSnapshot().writes.some((write) => write.mutationId === mutationId));
765
+ };
766
+ /**
767
+ * Which control inside the overlay owns focus, as a SELECTOR a redraw can find
768
+ * again — or `''` for the wrapper itself, or `null` when focus is elsewhere.
769
+ *
770
+ * A selector rather than the node, because the node does not survive the
771
+ * redraw: `surface.innerHTML` is reassigned and the overlay is rebuilt, so
772
+ * what has to be carried across is a way to name the control, not a reference
773
+ * to the one that was destroyed.
774
+ */
775
+ const overlayFocusToken = () => {
776
+ const active = doc.activeElement;
777
+ if (!isElement(active))
778
+ return null;
779
+ const overlay = active.closest('.ig-firstpass-overlay');
780
+ if (overlay === null || !surface.contains(overlay))
781
+ return null;
782
+ const answer = active.getAttribute(ANSWER_ATTRIBUTE);
783
+ if (answer !== null)
784
+ return `[${ANSWER_ATTRIBUTE}="${answer}"]`;
785
+ const command = active.getAttribute(COMMAND_ATTRIBUTE);
786
+ if (command !== null)
787
+ return `[${COMMAND_ATTRIBUTE}="${command}"]`;
788
+ return '';
789
+ };
790
+ /**
791
+ * The command control that owns focus, as facts a redraw can match again.
792
+ *
793
+ * `overlayFocusToken` above answers the same question for the first-pass
794
+ * overlay and only for it, so every command control OUTSIDE that overlay had
795
+ * no restore path at all: the arms below cover the overlay, an `input`'s
796
+ * caret, the target search on the render that opens it, and a pending rail
797
+ * jump — and then fall through to restoring by RAIL ROW, which needs a
798
+ * `[data-ig-key]` ancestor. An inspector button has none, so focus fell to
799
+ * the body. The keydown listener is on the mount's element, so that killed
800
+ * the whole keyboard loop until the reader clicked something else.
801
+ *
802
+ * FACTS, NOT A SELECTOR STRING. `overlayFocusToken` interpolates its value
803
+ * into `[attr="value"]`, which is safe only because `data-ig-answer` and the
804
+ * overlay's own commands come from closed sets. A general token carries
805
+ * {@link TARGET_ATTRIBUTE} — edge identities and issue keys straight out of
806
+ * a host's tracker — and a `"` in one would break the selector or match the
807
+ * wrong control. Comparing attribute values during a walk removes the class
808
+ * instead of escaping it.
809
+ *
810
+ * THE ZONE IS PART OF THE IDENTITY. One command name is published by many
811
+ * controls — every relationship row carries `select-edge` — and an issue is
812
+ * commonly drawn in the rail and again on the canvas, so a token without its
813
+ * zone would restore focus to a namesake in a zone the reader was not in.
814
+ */
815
+ const commandFocusToken = () => {
816
+ const active = doc.activeElement;
817
+ if (!isElement(active) || !surface.contains(active))
818
+ return null;
819
+ // ALL THREE CHANNELS, because the defect is not the command channel's. The
820
+ // audit header's toggle publishes on `data-ig-audit-filter` alone and the
821
+ // first pass's answers on `data-ig-answer` — `controlAnswer` resolves each
822
+ // in its own branch — so a token reading only `data-ig-command` left the audit
823
+ // toggle dropping focus to the body exactly as before. #149 says "any
824
+ // command control", and this is the list that makes that true.
825
+ for (const channel of CONTROL_ATTRIBUTES) {
826
+ const control = active.getAttribute(channel);
827
+ if (control === null)
828
+ continue;
829
+ const partial = {
830
+ channel,
831
+ zone: active.closest('.ig-zone')?.getAttribute('data-zone') ?? null,
832
+ control,
833
+ target: active.getAttribute(TARGET_ATTRIBUTE),
834
+ // THE SAME TWO SPELLINGS `controlAnswer` READS, and in its order: the picker
835
+ // publishes its kind as `data-ig-kind`, the mount's own chrome as
836
+ // `data-ig-value`.
837
+ value: active.getAttribute('data-ig-value') ?? active.getAttribute('data-ig-kind'),
838
+ };
839
+ // `findIndex` RATHER THAN `indexOf`, because `active` is narrowed to
840
+ // `Element` and the siblings are `HTMLElement`. Identity comparison is
841
+ // the same question either way, and the alternative — widening the list
842
+ // or asserting the narrower type — would be a cast this repository bans.
843
+ const ordinal = siblingsOf(partial).findIndex((node) => node === active);
844
+ // A CONTROL THAT DOES NOT FIND ITSELF is one this scope cannot name, so
845
+ // there is no token to carry. `indexOf` answering -1 would otherwise
846
+ // become an ordinal that matches nothing on the way back.
847
+ return ordinal < 0 ? null : { ...partial, ordinal };
848
+ }
849
+ return null;
850
+ };
851
+ /**
852
+ * Every control this token could name, in document order.
853
+ *
854
+ * THE ATTRIBUTE NAME IS A CONSTANT AND THE VALUES ARE COMPARED, so nothing a
855
+ * host's tracker can spell reaches a selector. See the token above.
856
+ *
857
+ * Shared by the token and the restore so the ordinal means the same thing on
858
+ * both sides: a list computed two ways is a list that can disagree with
859
+ * itself, and the ordinal would then point at a different control than the
860
+ * one it was counted against.
861
+ */
862
+ const siblingsOf = (token) => {
863
+ const scope = token.zone === null ? surface : (zone(token.zone) ?? surface);
864
+ return [...scope.querySelectorAll(`[${token.channel}]`)].filter((node) => {
865
+ const control = node.getAttribute(token.channel);
866
+ // BY IDENTITY, so a toggle that redraws itself with the other command is
867
+ // still the control the reader was on. See {@link TOGGLE_IDENTITY}.
868
+ if (control === null || controlIdentity(control) !== controlIdentity(token.control))
869
+ return false;
870
+ if (node.getAttribute(TARGET_ATTRIBUTE) !== token.target)
871
+ return false;
872
+ const value = node.getAttribute('data-ig-value') ?? node.getAttribute('data-ig-kind');
873
+ if (value !== token.value)
874
+ return false;
875
+ // A ZONELESS TOKEN MUST NOT MATCH A ZONED CONTROL. The floating chooser
876
+ // and the first-pass overlay are appended OUTSIDE the four zones, so
877
+ // their tokens carry `zone: null` and scope to the whole surface — and
878
+ // without this the chooser's `cancel` restored onto the inspector's.
879
+ // The two directions are asymmetric on purpose: a zoned token is already
880
+ // confined by `scope`.
881
+ return !(token.zone === null && node.closest('.ig-zone') !== null);
882
+ });
883
+ };
884
+ /** Give focus back to the control a {@link CommandFocus} names. `false` when it is gone. */
885
+ const refocusCommand = (token) => {
886
+ const siblings = siblingsOf(token);
887
+ // THE SAME ONE, THEN THE FIRST. A list that shrank under the reader — a
888
+ // match that stopped matching, a capsule that collapsed — has no nth
889
+ // member, and landing on the first sibling keeps the keyboard alive where
890
+ // returning `false` would drop to the rail.
891
+ const again = siblings[token.ordinal] ?? siblings[0];
892
+ if (again === undefined)
893
+ return false;
894
+ again.focus({ preventScroll: true });
895
+ return true;
896
+ };
291
897
  /** Focus the element carrying `key`, inside one zone when named, without scrolling the page. */
292
898
  const focusIn = (zoneName, key) => {
293
899
  const scope = zoneName === null ? surface : (zone(zoneName) ?? surface);
@@ -331,9 +937,11 @@ export function mountWorkspace(element, options) {
331
937
  if (zoneName !== 'canvas')
332
938
  return null;
333
939
  if (current.canvas === 'tree')
334
- return renderViewer(withoutHost(drawn.viewer), { projection: 'tree', theme: theme() }).scene;
940
+ return renderViewer(withoutChrome(drawn.viewer), { projection: 'tree', theme: theme(), chrome: false }).scene;
335
941
  const ladder = scaleLadder(drawn.viewer, state.scale);
336
- return ladder.tier === 'direct' ? renderViewer(ladder.canvas, { projection: 'graph', theme: theme() }).scene : null;
942
+ return ladder.tier === 'direct'
943
+ ? renderViewer(ladder.canvas, { projection: 'graph', theme: theme(), chrome: false }).scene
944
+ : null;
337
945
  };
338
946
  /** The row or node that owns keyboard focus, if focus is on one at all. */
339
947
  const focusedKey = () => {
@@ -341,13 +949,53 @@ export function mountWorkspace(element, options) {
341
949
  const keyed = isElement(active) ? active.closest(`[${KEY_ATTRIBUTE}]`) : null;
342
950
  return keyed !== null && surface.contains(keyed) ? keyed.getAttribute(KEY_ATTRIBUTE) : null;
343
951
  };
952
+ /**
953
+ * The subject `+ add` publishes, when that control itself owns focus.
954
+ *
955
+ * THE SECOND SOURCE FOR `KeyboardContext.focused`, and it exists because the
956
+ * first one cannot answer here: `focusedKey` reads the rail's roving tab stop,
957
+ * and a focused button has no keyed ancestor — every command control this
958
+ * package draws sits in chrome beside the keyed rows rather than inside one.
959
+ * So `R` on `+ add` reached a `relate` arm whose subject was `null`, which
960
+ * answers `none` ("nothing focused is nothing to relate FROM"). `#173`.
961
+ *
962
+ * IT READS THE CONTROL'S OWN ATTRIBUTE, NOT THE SELECTION, for the reason
963
+ * `reduceHost`'s `add` arm already reads it: `inspectorView` canonicalizes a
964
+ * selection naming a together unit's member onto the slot's LEAD, and the
965
+ * panel is worded from that, so the control publishes the canonical subject
966
+ * and the selection is only a fallback for a control that names none. Taking
967
+ * the selection here would begin the keyboard's draft from a different issue
968
+ * than the pointer's, on one screen, from one control.
969
+ *
970
+ * NARROWED TO THE `add` COMMAND, and that is a correctness bound rather than
971
+ * tidiness: {@link TARGET_ATTRIBUTE} is carried by the row remove control
972
+ * (an edge id), by a recovery card (a mutation id) and by a hold's subject
973
+ * button. Reading it off whichever control happens to hold focus would hand
974
+ * `relate` an edge id as an issue ref.
975
+ */
976
+ const focusedAddSubject = () => {
977
+ const active = doc.activeElement;
978
+ if (!isElement(active) || !surface.contains(active))
979
+ return null;
980
+ if (active.getAttribute(COMMAND_ATTRIBUTE) !== 'add')
981
+ return null;
982
+ return active.getAttribute(TARGET_ATTRIBUTE);
983
+ };
344
984
  const render = () => {
345
985
  if (destroyed)
346
986
  return;
347
987
  const snapshot = store.getSnapshot();
348
988
  const document_ = landed();
349
- // A landed write can retire what the state names; agree with the document first.
350
- state = reconcileHost(state, document_);
989
+ // WHAT THE STORE IS NOT SHOWING, computed before the reconcile rather than
990
+ // beside the edges below, because the reconcile is the first reader of it:
991
+ // an unsettled retype or flip hides the edge it replaces, and the landed
992
+ // document the reconcile is otherwise handed still carries it. Both halves
993
+ // of "the reader can no longer see this" reach `reconcileHost` together.
994
+ const shown = new Set(snapshot.projected.map((edge) => edge.id));
995
+ const hidden = new Set(snapshot.landed.map((edge) => edge.id).filter((id) => !shown.has(id)));
996
+ // A landed write can retire what the state names, and an unsettled one can
997
+ // hide it; agree with what is on show first.
998
+ state = reconcileHost(state, document_, hidden);
351
999
  const projected = current.project(snapshot);
352
1000
  // WHAT TO DRAW IS THE STORE'S PROJECTION, by the store's own contract:
353
1001
  // `projected` is "landed plus every unsettled edit, each carrying its
@@ -362,8 +1010,6 @@ export function mountWorkspace(element, options) {
362
1010
  // projection hides the old one until the write settles; the host's
363
1011
  // document still carries it, so without this the old line and the new
364
1012
  // dashed one were drawn together for the life of the write.
365
- const shown = new Set(snapshot.projected.map((edge) => edge.id));
366
- const hidden = new Set(snapshot.landed.map((edge) => edge.id).filter((id) => !shown.has(id)));
367
1013
  const kept = projected.viewer.edges.filter((edge) => !hidden.has(edgeIdentity(edge.field, edge.from, edge.to)));
368
1014
  const drawn_ = new Set(kept.map((edge) => edgeIdentity(edge.field, edge.from, edge.to)));
369
1015
  const unsettled = snapshot.projected
@@ -379,13 +1025,199 @@ export function mountWorkspace(element, options) {
379
1025
  // through `store.select()` would draw a second halo the inspector does not
380
1026
  // reflect, so it is stripped before the projection reaches a canvas.
381
1027
  const writeStates = snapshot.projected.map((edge) => edge.states.includes('selected') ? { ...edge, states: edge.states.filter((state) => state !== 'selected') } : edge);
1028
+ // THE WRITE LEDGER, TURNED INTO WHAT THE PANEL DRAWS — in ONE pass, in the
1029
+ // ledger's own order.
1030
+ //
1031
+ // IT WAS TWO PASSES AND A CONCATENATION: one walk over the projection for
1032
+ // the refusals it had an edge for, then one over the ledger for the ones it
1033
+ // did not, appended after. Both halves were right and the JOIN was not.
1034
+ // The renderer collapses repeated edges last-wins, so the array's order is
1035
+ // the chronology — and appending every stranded record after every
1036
+ // projected one is not the order the reader made them in. An older
1037
+ // `unknown-edge` naming an edge a later refused create then projects
1038
+ // reported the older reason. There is nothing to get wrong now: the ledger
1039
+ // is walked once, and its order is the order.
1040
+ //
1041
+ // WHICH EDGE A REFUSAL MARKS IS THE PROJECTION'S OWN ANSWER.
1042
+ // `ProjectedEdge.writes` is its record of which mutations it is speaking
1043
+ // for, and `validity.ts` has already decided which edge each one marks — a
1044
+ // create marks the edge it would have made, a retype the one it would have
1045
+ // become. Rebuilding that here would be a second answer, free to disagree
1046
+ // with the line the canvas draws the ghost on; asked of the projection, the
1047
+ // record that produced one edge cannot also be counted against another,
1048
+ // which is the double-draw this join used to be able to produce.
1049
+ const markedBy = new Map(snapshot.projected.flatMap((edge) => edge.writes.map((write) => [write, edge.id])));
1050
+ // WHETHER THE MARKED EDGE IS REAL. Three codes refuse an edit ABOUT a
1051
+ // landed relationship — `duplicate-edge`, `unchanged-kind`,
1052
+ // `symmetric-edge` — and the panel keeps the row for those rather than
1053
+ // replacing it with a capsule; see `WorkspaceRefusal.phantom`.
1054
+ //
1055
+ // ASKED OF `landed`, NEVER OF THE CODE. A list of codes here would be a
1056
+ // second copy of `validity.ts`'s decision about which edge each refusal
1057
+ // marks, and it would already be wrong: `cardinality` reads like a fourth
1058
+ // member of that set and marks a PHANTOM through both of its routes. This
1059
+ // is the store's own answer to "what does the document actually carry".
1060
+ const landedIds = new Set(snapshot.landed.map((edge) => edge.id));
1061
+ const landedNow = { issues: snapshot.issues, edges: snapshot.landed };
1062
+ // THE CARRIER MAP, BROUGHT LEVEL WITH THE LEDGER. See `writeCarriers` for
1063
+ // why the answer is kept rather than re-asked, and why the ledger's own
1064
+ // membership is the whole of its lifetime.
1065
+ const ledger = new Set(snapshot.writes.map((record) => record.mutationId));
1066
+ // CLEARED WHEN THE RECORD GOES, AND NOT BEFORE.
1067
+ //
1068
+ // Clearing it on `hydrationError === undefined` as well was the obvious
1069
+ // rule and it was WRONG BY ONE RENDER: `retryOnLatest` reserves its record
1070
+ // synchronously and publishes, so the very next render happens BEFORE the
1071
+ // read has had a chance to fail — the error is still undefined, the flag
1072
+ // was dropped, and when the failure did arrive there was nothing left to
1073
+ // attribute it to. The card then stayed silent on exactly the failure this
1074
+ // field exists to explain. Measured: the pin below went red.
1075
+ //
1076
+ // So the flag's lifetime is the RECORD's, and whether there is anything to
1077
+ // draw is asked of the snapshot at draw time instead.
1078
+ if (refreshFailedFor !== null && !ledger.has(refreshFailedFor)) {
1079
+ refreshFailedFor = null;
1080
+ }
1081
+ for (const mutationId of writeCarriers.keys()) {
1082
+ if (!ledger.has(mutationId))
1083
+ writeCarriers.delete(mutationId);
1084
+ }
1085
+ for (const record of snapshot.writes) {
1086
+ // FIRST SIGHT IS THE EARLIEST MOMENT THERE IS for a write this mount did
1087
+ // not emit. Its own edits are already recorded by `perform`, from the
1088
+ // document the reader acted on, so this fills in only what a host
1089
+ // proposed on the same store — and never a second time.
1090
+ if (!writeCarriers.has(record.mutationId)) {
1091
+ writeCarriers.set(record.mutationId, editCarrier(landedNow, record.mutation));
1092
+ }
1093
+ }
1094
+ const refusals = snapshot.writes.flatMap((record) => {
1095
+ if (record.state !== 'invalid')
1096
+ return [];
1097
+ // WHOSE PANEL STATES IT, READ BACK RATHER THAN WORKED OUT. The panel used
1098
+ // to derive this from the refused edge's endpoints, against a subject it
1099
+ // had derived by an unrelated rule; then `editCarrier` was called here
1100
+ // instead, which is one rule but still asked at the wrong TIME — a
1101
+ // sibling write can remove the relationship between the act and the
1102
+ // refusal, and what is left says which end declared it only for the
1103
+ // directed fields — `edgeIdentity` sorts the symmetric ones. The answer
1104
+ // was taken when the edit was made; this only looks it up. The two loops
1105
+ // above run over this same `snapshot.writes`, so the key is always
1106
+ // present, and `null` means the document held neither end — there is no
1107
+ // panel a reader could be standing on to read it.
1108
+ const carrier = writeCarriers.get(record.mutationId) ?? null;
1109
+ if (carrier === null)
1110
+ return [];
1111
+ // THE PROJECTION'S EDGE, AND THE EDIT'S OWN AS THE FALLBACK. A refusal
1112
+ // the projection has nothing to hang on is the whole `unknown-edge`
1113
+ // class — a retype, flip or delete of an edge the document no longer
1114
+ // carries produces `{ hidden: [], drawn: [], marked: [] }` — and the
1115
+ // mutation names the edge it was about, so that id is what the capsule
1116
+ // is keyed on.
1117
+ const edgeId = markedBy.get(record.mutationId) ??
1118
+ (record.mutation.op === 'create'
1119
+ ? edgeIdentity(record.mutation.kind, record.mutation.from, record.mutation.to)
1120
+ : record.mutation.edgeId);
1121
+ return [{ edgeId, code: record.reason.code, carrier, phantom: !landedIds.has(edgeId) }];
1122
+ });
1123
+ // §17b's RECOVERY CARDS, BUILT FROM THE SAME LEDGER PASS AS THE REFUSALS.
1124
+ // One walk of `snapshot.writes` keeps the chronology the panel relies on,
1125
+ // and reuses the `edgeId` and `carrier` answers already worked out above
1126
+ // rather than asking either question a second time.
1127
+ //
1128
+ // THE CARRIER IS CARRIED EVEN WHEN IT IS `null`, and that is the one place
1129
+ // this deliberately parts company with the refusal derivation above, which
1130
+ // drops those. A refusal dropped costs the reader a sentence. A RECOVERY
1131
+ // dropped costs them the only retry and discard they have, on a write that
1132
+ // is still theirs to resolve — so it travels, and the panel draws it in the
1133
+ // unplaced region instead of nowhere.
1134
+ // ANNOTATED RATHER THAN INFERRED. `flatMap` takes its element type from the
1135
+ // first arm it sees, which here is the `failed` one — so an unannotated
1136
+ // callback types the whole list as failures and rejects the conflict arm
1137
+ // for want of a `reason`. The repair this repository bans is a cast; the
1138
+ // repair it wants is saying what the function returns.
1139
+ const recoveries = snapshot.writes.flatMap((record) => {
1140
+ if (record.state !== 'failed' && record.state !== 'conflict')
1141
+ return [];
1142
+ const carrier = writeCarriers.get(record.mutationId) ?? null;
1143
+ const edgeId = markedBy.get(record.mutationId) ??
1144
+ (record.mutation.op === 'create'
1145
+ ? edgeIdentity(record.mutation.kind, record.mutation.from, record.mutation.to)
1146
+ : record.mutation.edgeId);
1147
+ if (record.state === 'failed') {
1148
+ return [{ kind: 'failed', mutationId: record.mutationId, edgeId, carrier, reason: record.reason }];
1149
+ }
1150
+ return [
1151
+ {
1152
+ kind: 'conflict',
1153
+ mutationId: record.mutationId,
1154
+ edgeId,
1155
+ carrier,
1156
+ // AGAINST `landedNow`, WHICH CANNOT HOLD THE EDIT — that is the
1157
+ // point. `conflictDiff` reconstructs the reader's own side from the
1158
+ // mutation, so the card is guaranteed to name the relationship it is
1159
+ // about rather than depending on a document that by contract omits it.
1160
+ diff: conflictDiff(landedNow, record.upstream, record.mutation),
1161
+ // THE READ'S OWN FAILURE, WHICH IS NOT THE RECORD'S. A `retry on
1162
+ // latest` whose refresh fails restores this record verbatim and
1163
+ // dispatches nothing; the reason lands on the snapshot instead. Read
1164
+ // from there, the card can say why the button appeared to do nothing.
1165
+ // ONLY ON THE CARD WHOSE READ FAILED. Both halves are required: a
1166
+ // hydration error with no press behind it belongs to no card, and a
1167
+ // press whose read succeeded leaves no error to draw.
1168
+ refreshError: refreshFailedFor === record.mutationId ? (snapshot.hydrationError ?? null) : null,
1169
+ },
1170
+ ];
1171
+ });
1172
+ // ITS LIFETIME IS THE CONFLICT'S, NOT THE LEDGER'S, and the distinction is
1173
+ // load-bearing: `retryOnLatest` reserves its record as `pending`
1174
+ // synchronously, and a resolve whose re-check refuses leaves it `invalid`.
1175
+ // In both the record is still in the ledger and its held document is gone,
1176
+ // so "still in the ledger" would leave a difference region open over
1177
+ // nothing. Asked of the state, it closes on every route out of `conflict`.
1178
+ if (state.diffOpen !== null &&
1179
+ !snapshot.writes.some((record) => record.mutationId === state.diffOpen && record.state === 'conflict')) {
1180
+ state = { ...state, diffOpen: null };
1181
+ }
382
1182
  const result = renderWorkspace(viewer, {
383
1183
  words: current.words,
1184
+ // §17c's CAUSE AND EFFECT, STRAIGHT OFF THE SNAPSHOT. The store is the
1185
+ // single source of truth for both — `lastChange` persists until the next
1186
+ // edit or an explicit dismissal, and `dismissChange()` is already the
1187
+ // only thing besides the next edit that clears it — so nothing about
1188
+ // "has this been dismissed" is held out here. A second copy of that is
1189
+ // exactly the drift the command grammar exists to avoid.
1190
+ //
1191
+ // `orderStatus` is the SAME value stamped on `surface` below. They cannot
1192
+ // disagree because they are one read of one snapshot, and each is for a
1193
+ // different reader: the attribute on `surface` is the host's, on the
1194
+ // element the host holds; the option is the stylesheet's, on the root the
1195
+ // renderer owns, so an unmounted rendering greys the held rail too.
1196
+ change: snapshot.lastChange ?? null,
1197
+ orderStatus: snapshot.order.status,
384
1198
  selection: state.selection,
385
1199
  scale: state.scale,
386
1200
  rail: { start: state.railStart, count: railCount() },
1201
+ // §16f's open rows. Host state, because the viewer is re-rendered from
1202
+ // scratch and cannot keep any of its own.
1203
+ expanded: state.expanded,
1204
+ // UNIQUE TO THIS MOUNT, so §17a's rail footer can name the isolated list
1205
+ // the canvas draws without two mounted workspaces on one page naming the
1206
+ // same element. See `WorkspaceOptions.surfaceId`; the counter is why a
1207
+ // second mount gets a second value.
1208
+ surfaceId,
387
1209
  audit,
388
1210
  auditFiltered: state.auditFiltered,
1211
+ // §17k's two reader decisions. Host state, for the reason the filter
1212
+ // beside it is: the renderer keeps nothing across a redraw, and a strip
1213
+ // that reopened itself on every landed write would be unusable.
1214
+ canvasOpen: state.canvasOpen,
1215
+ inspectorDismissed: state.inspectorDismissed,
1216
+ // §17d's overlay. Host state, for the same reason the filter beside it
1217
+ // is: the renderer keeps nothing across a redraw.
1218
+ auditOpen: state.auditOpen,
1219
+ issueUrl: current.issueUrl,
1220
+ railDensity: current.railDensity,
389
1221
  theme: resolved,
390
1222
  themeSelector: current.themeSelector,
391
1223
  // THE WRITE STATES ONLY. The workspace holds the one selection, and the
@@ -393,14 +1225,43 @@ export function mountWorkspace(element, options) {
393
1225
  // through `store.select()` would draw a second halo the inspector does
394
1226
  // not reflect, so it is stripped before the projection reaches the canvas.
395
1227
  projected: writeStates,
1228
+ // THE SAME TWO VALUES THE CHROME BELOW READS. The create path's steps are
1229
+ // split across the two surfaces now, so both have to be told which step is
1230
+ // live — and a drop in flight suppresses the panel's list for the reason
1231
+ // `WorkspaceOptions.drop` records: the floating chooser is already open
1232
+ // at the pointer, and two choosers writing to one draft is what the
1233
+ // shell's own `if (state.drop === null)` was preventing while it drew
1234
+ // both of them.
1235
+ draft: state.draft,
1236
+ drop: state.drop,
1237
+ refusals,
1238
+ recoveries,
1239
+ diffOpen: state.diffOpen,
396
1240
  });
397
- const sheet = [result.styles, pickerStylesheet, mountStylesheet].join('\n');
1241
+ // THE FIRST PASS'S OWN SHEET, IMPORTED — not `renderFirstPass(...).styles`,
1242
+ // which carries a second copy of the theme block written just above it. The
1243
+ // picker is installed the same way and for the same reason.
1244
+ const sheet = [result.styles, pickerStylesheet, firstPassStylesheet, mountStylesheet].join('\n');
398
1245
  if (styles.textContent !== sheet)
399
1246
  styles.textContent = sheet;
400
1247
  // What the reader was doing survives the redraw: the rail's scroll offset,
401
- // and the caret in whichever search box they were typing into.
1248
+ // the audit panel's, and the caret in whichever search box they were typing
1249
+ // into.
402
1250
  const railBefore = zone('rail');
403
1251
  const scrollTop = railBefore?.scrollTop ?? 0;
1252
+ // THE AUDIT SCROLLS TOO, SINCE IT TOOK A SHARE OF ITS COLUMN. Before it had
1253
+ // a bound it grew to its content and only the ZONE scrolled, so there was no
1254
+ // second offset to keep. Now a long audit is read inside its own scroller,
1255
+ // and every store update and unrelated command runs this redraw — so without
1256
+ // this a reader deep in the findings is returned to the first one by
1257
+ // something they did somewhere else entirely.
1258
+ //
1259
+ // THE REGION, NOT THE PANEL. #177 put the bound on `.ig-audit-panel`, and
1260
+ // §17d's second surface moved it up to the region holding both — so reading
1261
+ // the panel's offset now reads a number that is always zero, and restoring
1262
+ // it is a no-op a reader feels as the audit jumping back on every redraw.
1263
+ const auditBefore = surface.querySelector('.ig-audit-overlay');
1264
+ const auditScrollTop = auditBefore?.scrollTop ?? 0;
404
1265
  const active = doc.activeElement;
405
1266
  const activeInput = isElement(active) && isInput(active) && surface.contains(active) ? active : null;
406
1267
  const activeCommand = activeInput?.getAttribute(COMMAND_ATTRIBUTE) ?? null;
@@ -410,13 +1271,73 @@ export function mountWorkspace(element, options) {
410
1271
  ? { start: activeInput.selectionStart, end: activeInput.selectionEnd, direction: activeInput.selectionDirection }
411
1272
  : null;
412
1273
  const focused = focusedKey();
1274
+ const overlayToken = overlayFocusToken();
1275
+ // CAPTURED BEFORE THE REDRAW, like every other fact here: the node itself
1276
+ // does not survive `surface.innerHTML`, so what crosses is a way to name
1277
+ // the control rather than a reference to the one about to be destroyed.
1278
+ const commandToken = commandFocusToken();
1279
+ // WAS THE FOCUS OURS TO RESTORE? A reader whose focus is on the host's own
1280
+ // chrome must not have it dragged into the workspace by a redraw the
1281
+ // workspace happened to do — the search arm below already records paying
1282
+ // for that once. The last-resort arm needs this because it fires on
1283
+ // "nothing inside the surface holds focus", which is equally true of a
1284
+ // redraw that destroyed the focused control and of a reader who simply is
1285
+ // not here.
1286
+ const heldFocus = isElement(active) && surface.contains(active);
1287
+ // AND WHETHER IT WAS ONE OF THE AUDIT'S OWN TAB STOPS, which are the stops
1288
+ // on this surface no token can name. Neither has a keyed ancestor or a
1289
+ // command attribute, so `focusedKey()` and `commandFocusToken()` both answer
1290
+ // nothing for them — and the last-resort arm below fires on exactly that,
1291
+ // sending a reader who was reading a long audit to the first rail row, in
1292
+ // another zone.
1293
+ //
1294
+ // TWO OF THEM SINCE §17d GAINED ITS SECOND SURFACE, and the refused block is
1295
+ // the one that needs this MOST: both of its controls are conditional, so a
1296
+ // block whose refusals all name unloaded issues has no focusable descendant
1297
+ // at all and the section itself is the only stop a reader can hold.
1298
+ //
1299
+ // A CLASS RATHER THAN THE NODE THE OFFSET CAME FROM. That identity test was
1300
+ // right while the scrolling element and the tab stop were one element; they
1301
+ // are not any more — the region scrolls and its members carry the stops —
1302
+ // so the answer has to name which stop, and it is restored by the same name.
1303
+ const AUDIT_STOPS = ['.ig-audit-refused', '.ig-audit-panel'];
1304
+ const auditStop = isElement(active)
1305
+ ? (AUDIT_STOPS.find((one) => active.matches(one)) ?? null)
1306
+ : null;
413
1307
  // The ZONE too: an issue is commonly drawn in the rail and on the canvas,
414
1308
  // and restoring "the first element with this key" would move focus from
415
1309
  // a canvas node into the rail on every redraw.
416
1310
  const focusedZone = isElement(active) ? (active.closest('.ig-zone')?.getAttribute('data-zone') ?? null) : null;
417
1311
  drawn = { viewer, rail: result.view.rail };
1312
+ // THE LIVE REGION HAS TO SURVIVE THE REDRAW, or it announces nothing.
1313
+ //
1314
+ // `summarySpec` mounts §17c's `role="status"` region ALWAYS and leaves it
1315
+ // EMPTY until there is something to say, precisely because a status node
1316
+ // that is created already carrying its text is not reliably announced —
1317
+ // the region has to exist first and then have its contents change. This
1318
+ // function replaces the whole subtree on every redraw, so rendering that
1319
+ // region into the markup and stopping there would destroy it and insert a
1320
+ // new, already-populated one every time: the affordance present, correct
1321
+ // in the markup, and inert. Worse than absent, because it looks done.
1322
+ //
1323
+ // So the ELEMENT is carried across: re-attached still holding the previous
1324
+ // summary, and only then given the new one. The insertion says nothing and
1325
+ // the mutation is what announces, which is the sequence the region was
1326
+ // designed around.
1327
+ const liveBefore = surface.querySelector(LIVE_REGION);
418
1328
  // Package-rendered markup, escaped by the package.
419
1329
  surface.innerHTML = result.markup;
1330
+ const liveAfter = surface.querySelector(LIVE_REGION);
1331
+ if (liveBefore !== null && liveAfter !== null) {
1332
+ const next = liveAfter.innerHTML;
1333
+ liveAfter.replaceWith(liveBefore);
1334
+ // GUARDED, so a redraw that did not touch the summary is not a change to
1335
+ // announce. Every keystroke in the ladder's search box redraws this
1336
+ // surface, and re-assigning identical content would re-announce the last
1337
+ // edit's result on each one.
1338
+ if (liveBefore.innerHTML !== next)
1339
+ liveBefore.innerHTML = next;
1340
+ }
420
1341
  // THE ORDER'S STATUS, PUBLISHED AS DATA. A write in flight holds the order
421
1342
  // — it does not move until the edit lands — and a host that wants to say
422
1343
  // so has nowhere to read it once the snapshot is consumed here. The
@@ -433,7 +1354,63 @@ export function mountWorkspace(element, options) {
433
1354
  // as data, the same `data-ig-state` the ladder's line carries, so a
434
1355
  // pending edge is not drawn as a settled one and a host styles or reads
435
1356
  // it the same way in both modes. The merge is the ladder's own.
436
- canvas.innerHTML = renderViewer(withoutHost(viewer), { projection: 'tree', theme: resolved, selected: selectedKey(state.selection) }).markup;
1357
+ // §17f'S ROW SURVIVES THIS ASSIGNMENT, AND ITS CAPTION DOES NOT.
1358
+ // `innerHTML` replaces every child, and `renderWorkspace` puts the
1359
+ // toolbar in this zone as its FIRST child — so the row, the pill
1360
+ // included, was silently deleted the moment a reader switched to the
1361
+ // tree. The pill is a property of the SURFACE, and a mounted surface is
1362
+ // exactly where a host is told to supply it, so losing it here took the
1363
+ // control away in the one state it is documented to exist in.
1364
+ //
1365
+ // THE CAPTION IS DIFFERENT, and is dropped on purpose rather than by
1366
+ // accident. It states what the LADDER's canvas draws — one connected
1367
+ // component out of the whole backlog — and this branch does not draw the
1368
+ // ladder's canvas: it draws `viewer`, the whole document, as a tree. So
1369
+ // "6 out of 312 drawn here" is simply false over a tree of 312, on the
1370
+ // same rule that keeps the caption off the refusing tiers.
1371
+ const toolbar = canvas.querySelector('.ig-canvas-toolbar');
1372
+ toolbar?.querySelector('.ig-canvas-caption')?.remove();
1373
+ // THE ISOLATED BLOCK SURVIVES THIS ASSIGNMENT TOO, and for a sharper
1374
+ // reason than the row above. §17a moves the isolated CONTROL to the
1375
+ // foot of the order rail, which this branch does not touch — so the
1376
+ // control survives the switch to the tree whatever happens here, while
1377
+ // the list it opens is drawn in THIS zone and would not. The reader
1378
+ // presses it, the label turns to "hide" and `aria-expanded` to `true`,
1379
+ // and nothing appears: a control that reports a state the surface is
1380
+ // not in.
1381
+ //
1382
+ // WORSE THAN WHAT IT REPLACED, WHICH IS WHY IT IS FIXED HERE RATHER
1383
+ // THAN NOTED. Before that move the chip lived in this zone beside its
1384
+ // list, so the tree deleted BOTH and the affordance was merely absent —
1385
+ // wrong, but not lying. Keeping the list is what makes the control it
1386
+ // is now separated from honest.
1387
+ //
1388
+ // UNCONDITIONAL, unlike the toolbar's `childElementCount` guard. This
1389
+ // element is not assembled from optional parts: `isolatedSpec` returns
1390
+ // null rather than an empty husk, so anything found here has content.
1391
+ const isolated = canvas.querySelector('.ig-ladder-isolated');
1392
+ canvas.innerHTML = renderViewer(withoutChrome(viewer), {
1393
+ projection: 'tree',
1394
+ theme: resolved,
1395
+ selected: selectedKey(state.selection),
1396
+ // The rail beside this canvas draws the panel's one header.
1397
+ chrome: false,
1398
+ }).markup;
1399
+ // Re-inserted rather than re-rendered: the row is already assembled,
1400
+ // and rebuilding it here would be a second place that decides what it
1401
+ // says.
1402
+ //
1403
+ // ONLY IF IT STILL HAS CONTENT, which is `canvasToolbar`'s own rule —
1404
+ // "nothing to say, no row" — and removing the caption above is exactly
1405
+ // what can empty it. A host that words the caption and not the pill
1406
+ // gets a caption-only row on the direct tier, and prepending its
1407
+ // emptied husk here left a sticky padded band with a border and no
1408
+ // content over the tree. Reading the element rather than re-deriving
1409
+ // the condition keeps the rule in one place.
1410
+ if (isolated !== null)
1411
+ canvas.append(isolated);
1412
+ if (toolbar !== null && toolbar.childElementCount > 0)
1413
+ canvas.prepend(toolbar);
437
1414
  const states = new Map(overlaysFor(viewer.edges, writeStates, selectedEdgeId(state.selection)).map((edge) => [edge.id, overlayFor(edge).attribute]));
438
1415
  for (const badge of canvas.querySelectorAll(`[${GROUP_ATTRIBUTE}]`)) {
439
1416
  const attribute = states.get(badge.getAttribute(GROUP_ATTRIBUTE) ?? '');
@@ -449,9 +1426,37 @@ export function mountWorkspace(element, options) {
449
1426
  const floating = floatingChooser();
450
1427
  if (floating !== null)
451
1428
  (surface.firstElementChild ?? surface).append(floating);
1429
+ const overlay = firstPassOverlay();
1430
+ if (overlay !== null) {
1431
+ (surface.firstElementChild ?? surface).append(overlay);
1432
+ // THE ZONES GO INERT UNDER IT. Set here rather than in the stylesheet
1433
+ // because it is a behaviour — focus and hit-testing — not a look, and the
1434
+ // markup is rebuilt every render so it cannot be left behind on close.
1435
+ for (const covered of surface.querySelectorAll('.ig-zone'))
1436
+ covered.setAttribute('inert', '');
1437
+ }
452
1438
  const rail = zone('rail');
453
1439
  if (rail !== null)
454
1440
  rail.scrollTop = scrollTop;
1441
+ // RE-QUERIED, NEVER REUSED: `surface.innerHTML` above replaced the subtree,
1442
+ // so the element captured from is gone. A region that shrank clamps this on
1443
+ // assignment, and one the redraw removed — the audit going clean — is
1444
+ // simply absent, which is why nothing is restored rather than zeroed.
1445
+ const auditRegion = surface.querySelector('.ig-audit-overlay');
1446
+ if (auditRegion !== null)
1447
+ auditRegion.scrollTop = auditScrollTop;
1448
+ // THE LIST THE READER JUST OPENED IS BROUGHT INTO VIEW. See
1449
+ // `revealIsolated` for why the control and the list are in different zones.
1450
+ // `block: 'nearest'` so a list already visible is not scrolled at all, and
1451
+ // guarded on the method because the same guard is used for the rail's own
1452
+ // jumps — this module renders into whatever document a host hands it.
1453
+ if (revealIsolated) {
1454
+ revealIsolated = false;
1455
+ const list = surface.querySelector('.ig-isolated-list');
1456
+ if (list !== null && typeof list.scrollIntoView === 'function') {
1457
+ list.scrollIntoView({ block: 'nearest' });
1458
+ }
1459
+ }
455
1460
  // FOCUS SURVIVES THE REDRAW, and it moves to the target search when that
456
1461
  // step opens. The keyboard path is R -> kind -> search -> Enter, and every
457
1462
  // step redraws: without this, R destroyed the focused row, the next press
@@ -463,7 +1468,39 @@ export function mountWorkspace(element, options) {
463
1468
  return;
464
1469
  focusIn(within, key);
465
1470
  };
466
- if (activeCommand !== null) {
1471
+ if (overlay !== null) {
1472
+ // FOCUS IS TAKEN BACK ON EVERY RENDER WHILE THE OVERLAY IS UP, not only on
1473
+ // the one that opens it. Every answer dispatches, every dispatch redraws,
1474
+ // and the redraw destroys the element focus was on — so a once-only rule
1475
+ // gave the reader exactly one keystroke before `activeElement` fell to the
1476
+ // body, `interaction` read `elsewhere` and every later key returned
1477
+ // `none`. This is the same arm the target search already earns below.
1478
+ if (firstPassWas === 'closed')
1479
+ focusBeforeFirstPass = focused;
1480
+ const again = overlayToken === null || overlayToken === ''
1481
+ ? null
1482
+ : overlay.querySelector(overlayToken);
1483
+ // THE WRAPPER IS THE FALLBACK, INCLUDING ON THE RENDER THAT OPENS IT. The
1484
+ // first control is `apply`, and landing focus there would put the one
1485
+ // irreversible answer under the reader's next Space.
1486
+ (again ?? overlay).focus({ preventScroll: true });
1487
+ }
1488
+ else if (auditStop !== null && surface.querySelector(auditStop) !== null) {
1489
+ // THE READER WAS IN THE AUDIT, SO THEY STAY THERE — and on the same one of
1490
+ // its two surfaces, which is why the stop is restored by the name it was
1491
+ // captured under rather than by whichever the redraw drew first.
1492
+ //
1493
+ // Beside the offset restored above rather than instead of it: the offset
1494
+ // says where the region is scrolled to and this says who is reading it,
1495
+ // and a redraw that kept one without the other still moves the reader out
1496
+ // of the zone.
1497
+ //
1498
+ // `preventScroll`, because the offset was already put back — focusing
1499
+ // normally would scroll the column to bring the element into view and
1500
+ // undo it.
1501
+ surface.querySelector(auditStop)?.focus({ preventScroll: true });
1502
+ }
1503
+ else if (activeCommand !== null) {
467
1504
  const again = surface.querySelector(`input[${COMMAND_ATTRIBUTE}="${activeCommand}"]`);
468
1505
  if (again !== null) {
469
1506
  again.focus();
@@ -516,32 +1553,290 @@ export function mountWorkspace(element, options) {
516
1553
  }
517
1554
  }
518
1555
  else {
519
- focusRow(focused);
1556
+ // THE COMMAND CONTROL, WHERE THE RAIL CANNOT ANSWER. Strictly additive:
1557
+ // it is reached only when `focusedKey()` found nothing, which is exactly
1558
+ // the case `focusRow(null)` returned from without moving focus at all —
1559
+ // so no control that has a keyed ancestor changes behaviour, and the
1560
+ // rail's roving tab stop is untouched.
1561
+ //
1562
+ // The control can legitimately be gone: the edit it named landed, the
1563
+ // relationship it belonged to was deleted, the recovery card it sat in
1564
+ // resolved. `refocusCommand` says so rather than guessing, and the rail
1565
+ // fallback below is then the same one that ran before.
1566
+ const restored = focused === null && commandToken !== null && refocusCommand(commandToken);
1567
+ if (!restored)
1568
+ focusRow(focused);
1569
+ // A LAST RESORT, because half of these controls RESOLVE THEMSELVES. A
1570
+ // picker option closes the picker; `retry`, `discard`, `cancel` and
1571
+ // `dismiss-change` each remove the card they sit in — so `refocusCommand`
1572
+ // correctly answers "gone", `focusRow(null)` moves nothing, and the
1573
+ // keyboard loop died anyway. That is the whole-loop half of the defect,
1574
+ // and leaving it would have fixed the disclosure while every one-shot
1575
+ // control kept failing the same way.
1576
+ //
1577
+ // The first drawn row, on the same reasoning the first-pass close arm
1578
+ // gives for returning to §17a's entry: somewhere inside the surface that
1579
+ // reaches the listener beats the body, and the rail is the zone the
1580
+ // reader can navigate out of.
1581
+ // THE TEST IS "INSIDE THE SURFACE", NOT "FOCUSABLE". `isFocusable` asks
1582
+ // whether a node has `focus`, and `<body>` does — so it answers true for
1583
+ // exactly the state this arm exists to repair. What the keydown listener
1584
+ // needs is a focus owner it can receive an event from.
1585
+ const adrift = !isElement(doc.activeElement) || !surface.contains(doc.activeElement);
1586
+ if (!restored && focused === null && heldFocus && adrift) {
1587
+ // THROUGH `focusIn`, NOT `focus()`. The rail renders one row at
1588
+ // `tabindex="0"` and the rest at `-1`, and focusing a row directly
1589
+ // leaves the STOP on whichever row had it — so Tab out and back
1590
+ // returns to a different row than the one the reader is on.
1591
+ // `focusIn` moves the stop with the focus, which is why every other
1592
+ // row-focus path in this file goes through it.
1593
+ const first = zone('rail')?.querySelector(`[${KEY_ATTRIBUTE}][tabindex]`);
1594
+ const key = first?.getAttribute(KEY_ATTRIBUTE);
1595
+ if (key !== null && key !== undefined)
1596
+ focusIn('rail', key);
1597
+ }
1598
+ // AND A RESORT THAT CANNOT ITSELF FAIL. The rail is not always there to
1599
+ // fall back to: with the audit filter on and nothing flagged it draws no
1600
+ // rows at all, while the inspector stays perfectly usable — so pressing a
1601
+ // self-removing control there left focus on the body and killed the
1602
+ // keyboard loop exactly as before. A fallback with a precondition is not
1603
+ // a last resort.
1604
+ //
1605
+ // The surface itself always exists, and the keydown listener is on it, so
1606
+ // focus landing here is by definition focus the loop can hear. `tabindex`
1607
+ // is -1: this is somewhere to PUT focus, never a stop Tab should find.
1608
+ if (heldFocus &&
1609
+ (!isElement(doc.activeElement) || !surface.contains(doc.activeElement))) {
1610
+ // A CONTROL, BEFORE THE SURFACE ITSELF. Most of these do not merely
1611
+ // vanish — they REPLACE themselves with the step they opened, and `add`
1612
+ // becoming the kind list is the ordinary case. Landing on the bare
1613
+ // surface there put the press back in reach of the listener and no
1614
+ // further: `interaction()` answers `canvas` only for a focused ROW, so
1615
+ // on the surface every create binding — the kind digits, Escape —
1616
+ // returns `none`. Reachable and inoperable is not the loop this is about.
1617
+ //
1618
+ // THE ZONE THE READER WAS IN FIRST, then anywhere. Which control
1619
+ // replaced which is not knowable from here — the chooser is sometimes
1620
+ // floating and sometimes the inspector's own list — so this asks the
1621
+ // weaker, answerable question: what can the reader act on, nearest to
1622
+ // where they were.
1623
+ const zoneName = commandToken?.zone ?? null;
1624
+ const near = zoneName === null ? null : zone(zoneName);
1625
+ const opened = near?.querySelector(`[${COMMAND_ATTRIBUTE}]`) ??
1626
+ surface.querySelector(`[${COMMAND_ATTRIBUTE}]`);
1627
+ if (opened !== null) {
1628
+ opened.focus({ preventScroll: true });
1629
+ }
1630
+ else {
1631
+ // AND THE SURFACE LAST, which is the resort that cannot itself fail.
1632
+ // It leaves the reader without a binding to press, but it keeps the
1633
+ // listener reachable so Tab moves them somewhere useful — strictly
1634
+ // better than the body, which reaches nothing at all.
1635
+ surface.setAttribute('tabindex', '-1');
1636
+ surface.focus({ preventScroll: true });
1637
+ }
1638
+ }
1639
+ }
1640
+ // THE KEYBOARD IS GIVEN BACK. The overlay is removed with focus inside it,
1641
+ // so without this `activeElement` is the body — and the keydown listener is
1642
+ // on the mount's element, so no press reaches it and the whole workspace is
1643
+ // dead until the reader clicks. Restores what held focus when it opened.
1644
+ if (overlay === null && firstPassWas !== 'closed') {
1645
+ const back = focusBeforeFirstPass;
1646
+ focusBeforeFirstPass = null;
1647
+ appliedWrites.clear();
1648
+ if (back !== null) {
1649
+ focusIn(null, back);
1650
+ }
1651
+ else {
1652
+ // NOTHING KEYED HELD FOCUS, WHICH IS THE ORDINARY CASE. §17a's entry is
1653
+ // a button and carries no `data-ig-key`, so a reader who tabbed to it
1654
+ // and pressed it leaves nothing for `focusIn` to find — and focus on
1655
+ // the document body reaches no listener at all, because the keydown
1656
+ // listener is on the mount's element. The entry is the control they
1657
+ // came in through, so it is where they come back to.
1658
+ // ONE RECORDED ANSWER, NOT TWO: an earlier revision also recorded WHICH
1659
+ // control held focus, which a mutation test showed could never differ —
1660
+ // the queue opens from this one control and no other.
1661
+ // THE ENTRY CAN BE GONE BY NOW, THOUGH. Whether a backlog has a first
1662
+ // pass to run is the host's answer and the host may change it: the
1663
+ // sandbox draws the entry only while its detector finds candidates, so
1664
+ // a completed queue whose writes land removes the very control this
1665
+ // would return to. So the order of resort ends inside the ORDER, which
1666
+ // is the one part of the workspace that is always there.
1667
+ const back_ = surface.querySelector(`[${COMMAND_ATTRIBUTE}="first-pass"]`) ??
1668
+ surface.querySelector(`[${KEY_ATTRIBUTE}][tabindex]`);
1669
+ back_?.focus({ preventScroll: true });
1670
+ }
520
1671
  }
1672
+ firstPassWas = state.firstPass.phase.kind;
521
1673
  searchWasOpen = search !== null;
522
1674
  };
523
- // --- listeners ---
524
- const onClick = (event) => {
525
- const target = isElement(event.target) ? event.target : null;
526
- if (target === null)
1675
+ /**
1676
+ * Take the option's scanner, and end the lifecycle the old one was scanning for.
1677
+ *
1678
+ * Two failures, one rule. Removing the bundle stops the overlay being drawn
1679
+ * while the phase stays open — and the phase is what hands every key to a
1680
+ * queue that is no longer on screen, with no control left to close it.
1681
+ * REPLACING the source is worse than it looks: the old scan's promise still
1682
+ * resolves under the current generation, so the queue would be drawn in the
1683
+ * new bundle's words and populated by the superseded scanner, and a `Y` there
1684
+ * writes a relationship the configured source never proposed.
1685
+ *
1686
+ * RESET RATHER THAN CLOSE, and the difference is the decided set: a
1687
+ * `CandidateId` is the HOST's and opaque, so one scanner's ids say nothing
1688
+ * about another's — carried across, a collision silently drops the new
1689
+ * detector's question, and a question nobody was asked is indistinguishable
1690
+ * from one already answered.
1691
+ *
1692
+ * CALLED FROM `update()`, AT THE MOMENT THE OPTION CHANGES, not from the
1693
+ * render that observes it later. Renders are coalesced on a microtask, so a
1694
+ * host that calls `update()` and then dispatches an `open` in the same task
1695
+ * had its brand-new lifecycle reset by a render still holding the previous
1696
+ * scanner — and the new scan's answer then arrived on a closed phase and was
1697
+ * dropped, so that supported sequence never opened a queue at all.
1698
+ *
1699
+ * Keyed on the SOURCE rather than the bundle, because a host that rebuilds an
1700
+ * equivalent options object every render has changed nothing.
1701
+ */
1702
+ const adoptSource = () => {
1703
+ const source = current.firstPass?.source ?? null;
1704
+ if (source === firstPassSource)
527
1705
  return;
1706
+ firstPassSource = source;
1707
+ if (state.firstPass.phase.kind !== 'closed' || state.firstPass.decided.length > 0) {
1708
+ dispatch({ kind: 'first-pass', command: { kind: 'reset' } });
1709
+ }
1710
+ appliedWrites.clear();
1711
+ };
1712
+ // --- listeners ---
1713
+ /**
1714
+ * What a control press means, resolved once for both listeners.
1715
+ *
1716
+ * ONE CONSTRUCTION, TWO CALLERS. A pointer press and a key press perform the
1717
+ * same act, and until `#158` only the pointer could perform it — so the
1718
+ * keyboard's half was written as a second reading of the same attributes,
1719
+ * free to disagree with this one about which act a press is. Extracting the
1720
+ * answer is what makes "the click path and the key path do the same thing" a
1721
+ * fact rather than a claim two code paths happen to agree on today.
1722
+ *
1723
+ * WHY A REFUSAL IS A STATE AND NOT A `null`. The two callers want opposite
1724
+ * things from a control that declines, so collapsing the two would be wrong
1725
+ * for one of them whichever way it collapsed:
1726
+ *
1727
+ * - `onClick` must STOP on a refusal. Fall through and a press on an inert
1728
+ * `First pass →` reaches the identity branch below and selects whatever row
1729
+ * or group happens to sit under it — an act the reader never asked for.
1730
+ * - `onKeydown` must hand an `input` refusal BACK to the platform and hold an
1731
+ * `inert` one. Hence the reason travels with the refusal: see the arm's own
1732
+ * comment for the space character that is lost when it does not.
1733
+ *
1734
+ * The alternative considered and rejected: `HostCommand | null` plus an
1735
+ * `isControl(target)` predicate for the callers to gate on. The predicate has
1736
+ * to re-walk these same three `closest` calls, which is a second construction
1737
+ * of the very thing this function exists to make singular.
1738
+ *
1739
+ * THREE `closest` WALKS RATHER THAN ONE COMBINED SELECTOR, on purpose. A
1740
+ * single `closest('[a],[b],[c]')` answers with the NEAREST of the three,
1741
+ * which is not the same question: these channels are asked in a fixed
1742
+ * priority — a command wins over an answer, an answer over the audit filter —
1743
+ * and where two nest, the combined selector would answer with the inner one
1744
+ * whatever the priority says. The saving is two ancestor walks on a press,
1745
+ * which is a human keystroke rather than a loop, and the risk is changing
1746
+ * which act a press performs.
1747
+ */
1748
+ const controlAnswer = (target) => {
528
1749
  const control = target.closest(`[${COMMAND_ATTRIBUTE}]`);
529
1750
  if (control !== null && surface.contains(control)) {
530
1751
  const name = control.getAttribute(COMMAND_ATTRIBUTE) ?? '';
531
1752
  if (isInput(control))
532
- return; // the `input` listener owns these
533
- dispatch({
534
- kind: 'control',
535
- name,
536
- target: control.getAttribute('data-ig-target') ?? undefined,
537
- // The picker publishes its kind as `data-ig-kind`; the mount's chrome
538
- // publishes `data-ig-value`. One command channel, two spellings.
539
- value: control.getAttribute('data-ig-value') ?? control.getAttribute('data-ig-kind') ?? undefined,
540
- });
541
- return;
1753
+ return REFUSED_INPUT; // the `input` listener owns these
1754
+ // AND THE BROWSER OWNS A LINK, which is the same refusal one element over.
1755
+ // §17d's `Open in GitHub` is an ANCHOR — it leaves the document, and a
1756
+ // control that navigates away must not announce itself as a button — and
1757
+ // it carries a command name only so `a11y/baseline.ts` can see it: that
1758
+ // artifact records a control through the attribute channels it knows, and
1759
+ // an anchor bearing nothing but `href` is invisible to it.
1760
+ //
1761
+ // DISPATCHING IT WOULD BE A REDRAW UNDER THE READER'S OWN CLICK. The
1762
+ // reducer has no arm for the name, so the command comes back unclaimed —
1763
+ // but `applyResult` still schedules, and the redraw replaces the surface's
1764
+ // markup, detaching the anchor mid-activation. An `<a>` is exempt from the
1765
+ // "cannot navigate" connectedness check so the navigation still happens,
1766
+ // which is precisely why this would have gone unnoticed: a full re-render
1767
+ // per click, paid for a command nothing handles.
1768
+ if (isNavigating(control))
1769
+ return REFUSED_NAVIGATION;
1770
+ // THE ENTRY IS INERT WITHOUT A BUNDLE, and it is withheld HERE rather
1771
+ // than in the reducer. `renderWorkspace` draws §17a's `First pass →`
1772
+ // inside this surface, so a host cannot intercept it from outside; and
1773
+ // the reducer's `first-pass` arm would move to `scanning` with no source
1774
+ // to call and no way back. See `MountWorkspaceOptions.firstPass`.
1775
+ if (name === 'first-pass' && current.firstPass === undefined)
1776
+ return REFUSED_INERT;
1777
+ return {
1778
+ kind: 'dispatch',
1779
+ command: {
1780
+ kind: 'control',
1781
+ name,
1782
+ target: control.getAttribute(TARGET_ATTRIBUTE) ?? undefined,
1783
+ // The picker publishes its kind as `data-ig-kind`; the mount's chrome
1784
+ // publishes `data-ig-value`. One command channel, two spellings.
1785
+ value: control.getAttribute('data-ig-value') ?? control.getAttribute('data-ig-kind') ?? undefined,
1786
+ },
1787
+ };
1788
+ }
1789
+ // AN ANSWER IS ITS OWN ATTRIBUTE, so it falls through the command branch
1790
+ // above and is read here — before the identity branch in `onClick`, which
1791
+ // would otherwise answer a click inside the overlay with whatever key or
1792
+ // group is underneath it.
1793
+ //
1794
+ // REACHED BY THE POINTER ONLY, and deliberately. `onKeydown` returns while
1795
+ // the first-pass phase is open, and this attribute is published only by the
1796
+ // overlay that is drawn while it is open — so the key arm can never see one.
1797
+ // The overlay's own `y`/`n`/`s` map owns those presses, which makes this
1798
+ // half a designed vocabulary rather than a gap.
1799
+ //
1800
+ // WHAT IS A GAP, said plainly because the first draft of this note got it
1801
+ // wrong: the same early return also hides `first-pass-close`, and THAT is a
1802
+ // `data-ig-command` control — the very channel `#158` is about, not one
1803
+ // over. Whether it is activated from `firstPassKeydown` or from a binding in
1804
+ // `firstpass/keys.ts` is a decision about who owns the queue's keyboard, so
1805
+ // it is filed as `#161` rather than settled from inside this function.
1806
+ const answered = target.closest(`[${ANSWER_ATTRIBUTE}]`);
1807
+ if (answered !== null && surface.contains(answered)) {
1808
+ return {
1809
+ kind: 'dispatch',
1810
+ command: {
1811
+ kind: 'control',
1812
+ name: 'first-pass-answer',
1813
+ value: answered.getAttribute(ANSWER_ATTRIBUTE) ?? undefined,
1814
+ },
1815
+ };
542
1816
  }
543
1817
  if (target.closest('[data-ig-audit-filter]') !== null) {
544
- dispatch({ kind: 'control', name: 'audit-filter' });
1818
+ return { kind: 'dispatch', command: { kind: 'control', name: 'audit-filter' } };
1819
+ }
1820
+ // §17d's HEADER COUNT. Checked AFTER the filter, and the order is not
1821
+ // arbitrary: the filter chip lives inside the panel the count opens, so a
1822
+ // click on it is inside neither the other's subtree today — but if the two
1823
+ // ever nest, the inner control is the one the reader aimed at.
1824
+ if (target.closest('[data-ig-audit-panel]') !== null) {
1825
+ return { kind: 'dispatch', command: { kind: 'control', name: 'audit-panel' } };
1826
+ }
1827
+ return null;
1828
+ };
1829
+ const onClick = (event) => {
1830
+ const target = isElement(event.target) ? event.target : null;
1831
+ if (target === null)
1832
+ return;
1833
+ const answer = controlAnswer(target);
1834
+ if (answer !== null) {
1835
+ // A REFUSAL STILL STOPS THE CLICK, which is what the three `return`s this
1836
+ // branch replaced did. See {@link controlAnswer} for why falling through
1837
+ // would select something underneath the control instead.
1838
+ if (answer.kind === 'dispatch')
1839
+ dispatch(answer.command);
545
1840
  return;
546
1841
  }
547
1842
  // THE NEARER IDENTITY WINS. A relationship badge inside a row carries
@@ -616,15 +1911,149 @@ export function mountWorkspace(element, options) {
616
1911
  * filter or the delete button is a control with its own Enter and Space, and
617
1912
  * a Backspace there must not delete the selected edge; the key map names
618
1913
  * `elsewhere` as "everything that is not our own search box", and a button is
619
- * that. The target search keeps its two bindings, and everything else is
620
- * someone else's key.
1914
+ * that. The target search keeps its two bindings.
1915
+ *
1916
+ * ## The TWO controls that are not `elsewhere`, and the test they pass
1917
+ *
1918
+ * The rule above is right for every control it names and wrong for these two,
1919
+ * and the difference is namable rather than a taste: the audit filter and the
1920
+ * delete button share no vocabulary with the key map, while these two ARE the
1921
+ * key map rendered. The kind chooser's digits are `KIND_KEYS`, the same table
1922
+ * `keyIntent` resolves the press against, and `+ add` draws `RELATE_KEY`
1923
+ * itself as its hint. A control whose labels tell the reader to press `2`
1924
+ * cannot be a control the reader's `2` is refused by, and `#173` is the same
1925
+ * sentence about `R`: the hint named a key the reader genuinely has, at the
1926
+ * one focus position where it was inert.
1927
+ *
1928
+ * THE TEST IS "DOES THIS CONTROL DRAW A KEY THIS MAP READS", and it is what
1929
+ * stops the list growing on taste. It admits exactly two controls today; a
1930
+ * host's own widget, a filter, a remove button and every control that draws
1931
+ * no key stay `elsewhere`. `create/keys.ts` carries the matching half — the
1932
+ * new state admits the ONE binding it renders and no other.
1933
+ *
1934
+ * ## Asked of the DRAFT, not of where focus happens to be
1935
+ *
1936
+ * `#152`, and the reason three consecutive rounds of `#150` each fixed a real
1937
+ * bug and surfaced the next one: every one of them moved focus somewhere else,
1938
+ * and this predicate never read the draft, so the next place focus landed
1939
+ * classified `elsewhere` again. Focus is not the fact. A draft with a source
1940
+ * and no kind IS the reader being at the kind step, wherever the mount's
1941
+ * restore happened to put them — and it puts them on the inspector's `clear`,
1942
+ * because the last-resort arm takes the zone's FIRST command control and
1943
+ * `clear` is drawn above the kind list.
1944
+ *
1945
+ * A CONJUNCT ON THE CHOOSER'S OWN MARKUP WAS TRIED ON PAPER AND WOULD HAVE
1946
+ * SHIPPED THE BUG: `closest('<the chooser>')` is false from `clear`, so the
1947
+ * digits would still have died while the change typechecked and its own table
1948
+ * test passed.
1949
+ *
1950
+ * ## `add-control` reads focus, and that is not a relapse
1951
+ *
1952
+ * `#173`'s arm asks where focus is, which is the shape this section rejects —
1953
+ * so it has to survive the argument rather than repeat what it rejected, and
1954
+ * the difference is WHICH FACT IS MISSING. At the kind step the fact is the
1955
+ * DRAFT: a source with no kind IS the reader at that step, wherever a restore
1956
+ * put them, so reading focus there answers a question the draft had already
1957
+ * answered better. At the begin step there is no draft yet — beginning one is
1958
+ * the act — and the fact is the SUBJECT to begin from. The control publishes
1959
+ * it on `data-ig-target`, so what this arm reads is a control's own claim
1960
+ * about what it is for, and focus is what says the reader is standing on it.
1961
+ *
1962
+ * The `#150` failure mode is therefore absent by construction: it was focus
1963
+ * MOVING AWAY from the control the draft belonged to, and the arm here has no
1964
+ * "away" — it answers about the control still holding focus, and once the
1965
+ * draft begins the panel replaces this control with the kind list, at which
1966
+ * point the draft is the fact again and `kind-chooser` answers. That ordering
1967
+ * is written below rather than left to be inferred.
1968
+ *
1969
+ * ## `canvas` is asked FIRST, and that ordering is the safety property
1970
+ *
1971
+ * The only answers that move are ones that read `elsewhere` today — where
1972
+ * nothing reaches, so nothing can regress. A reader who leaves the chooser
1973
+ * open and tabs back to a row keeps the whole canvas vocabulary exactly as
1974
+ * before. Note that the order is NOT needed to keep a chooser out of `canvas`:
1975
+ * `focusedKey` walks DOM ancestry, the floating chooser is appended to the
1976
+ * workspace root beside the zones rather than inside the canvas, and the panel
1977
+ * list sits in an inspector that carries no key — so it answers null from
1978
+ * inside either one however this is ordered.
1979
+ */
1980
+ /**
1981
+ * Whether Escape has nothing of the CREATE PATH's to cancel.
1982
+ *
1983
+ * ONE PREDICATE FOR WHAT WAS TWO. §17d's overlay and §17k's transients both
1984
+ * take Escape, and both must yield to a live draft — a reader mid-relationship
1985
+ * means "not this edge", not "close the panel behind it". Written out at each
1986
+ * site, that rule is one a later edit can move at one of them.
1987
+ *
1988
+ * FIVE FIELDS, NOT THREE. `isDraftLive` answers for the draft itself, and the
1989
+ * target search and the drop point are the create path's too: a reader who
1990
+ * typed into the target box with no draft open still has something for Escape
1991
+ * to clear, and taking the press for a panel would leave their query sitting
1992
+ * there. `cancel` clears all five, which is why all five are asked.
1993
+ */
1994
+ /**
1995
+ * Whether §17k's lifted inspector is actually ON SCREEN.
1996
+ *
1997
+ * `narrowOverlayOpen` answers about STATE, and state is not visibility here:
1998
+ * the panel is lifted between `1120` and `1359` and is an ordinary column
1999
+ * either side of that, so at the wide layout a selection makes the reducer's
2000
+ * predicate true with nothing overlaid for Escape to dismiss. The press was
2001
+ * being spent on a change the reader could not see.
2002
+ *
2003
+ * THE STYLESHEET KNOWS AND THE DOM SHOWS ITS ANSWER, which is why this is a
2004
+ * measurement rather than a width. The lifted panel is placed in the CANVAS's
2005
+ * grid area, so it overlaps the canvas's box; as a column it sits beside it
2006
+ * and cannot. That is the same fact the sheet encodes, read back rather than
2007
+ * re-derived — and it is not the window, which §17k rules out.
2008
+ *
2009
+ * A SURFACE WITH NO LAYOUT CANNOT ANSWER, and says so by yielding to the
2010
+ * state. Every box is zero-sized before layout, and under `node --test` there
2011
+ * is no layout at all; a strict reading would disable the key there and take
2012
+ * a tested behaviour away from every consumer that renders without a browser.
621
2013
  */
2014
+ const inspectorIsLifted = () => {
2015
+ const inspector = surface.querySelector('.ig-zone[data-zone="inspector"]');
2016
+ const canvas = surface.querySelector('.ig-zone[data-zone="canvas"]');
2017
+ if (inspector === null || canvas === null)
2018
+ return false;
2019
+ const panel = inspector.getBoundingClientRect();
2020
+ const zone = canvas.getBoundingClientRect();
2021
+ if (panel.width === 0 && zone.width === 0)
2022
+ return true;
2023
+ return panel.left < zone.right && panel.right > zone.left;
2024
+ };
2025
+ const nothingToCancel = () => !isDraftLive(state.draft) && state.targetQuery === '' && state.drop === null;
622
2026
  const interaction = () => {
623
2027
  const active = doc.activeElement;
624
2028
  if (isElement(active) && isInput(active) && active.getAttribute(COMMAND_ATTRIBUTE) === 'target-query') {
625
2029
  return 'target-search';
626
2030
  }
627
- return focusedKey() !== null ? 'canvas' : 'elsewhere';
2031
+ if (focusedKey() !== null)
2032
+ return 'canvas';
2033
+ // A FOCUSED TEXT BOX IS NEVER THE CHOOSER, whatever the draft is doing.
2034
+ // `scale/render.ts` draws the search-to-focus input, and it stays usable
2035
+ // while a draft is open — so without this a reader who clicked it and typed
2036
+ // `1` would have the digit taken from their query and spent on a
2037
+ // relationship kind, `preventDefault()` included. That is the failure
2038
+ // `create/keys.ts` withholds the digits from `target-search` to avoid, and
2039
+ // this arm would have reintroduced it one control over.
2040
+ //
2041
+ // NARROWING THE NEW ARM ONLY, deliberately: an input that is not the target
2042
+ // search already read `elsewhere` before this predicate existed, so this
2043
+ // hands those presses back exactly as they were rather than reclassifying
2044
+ // anything that works today. A focused input INSIDE a keyed node still
2045
+ // answers `canvas` above, which is a pre-existing question and not this
2046
+ // change's to settle.
2047
+ if (isInput(active))
2048
+ return 'elsewhere';
2049
+ // THE DRAFT IS ASKED BEFORE THE CONTROL, so the section above stays true of
2050
+ // the state it was written for. The two cannot both be live in practice —
2051
+ // `createStep` withholds `+ add` for the whole of the kind step — but a rule
2052
+ // that depends on another module's rendering condition is one that changes
2053
+ // silently the day that condition does.
2054
+ if (isChoosingKind(state.draft))
2055
+ return 'kind-chooser';
2056
+ return focusedAddSubject() === null ? 'elsewhere' : 'add-control';
628
2057
  };
629
2058
  /**
630
2059
  * The rail's window edge, decided BEFORE the viewer is asked.
@@ -720,28 +2149,386 @@ export function mountWorkspace(element, options) {
720
2149
  focusIn(zoneName, result.command.key);
721
2150
  return true;
722
2151
  }
2152
+ // §16f's `→ expands provenance` on a list row. The viewer cannot expand
2153
+ // anything itself, so it publishes `expand:<key>` and this is where the
2154
+ // host takes it — split into the name/target shape every other control
2155
+ // here already uses, rather than teaching the reducer a second spelling.
2156
+ //
2157
+ // THE COLON IS LOAD-BEARING. `expand` with no key is §16a's panel-size
2158
+ // control and means something else entirely; only the qualified form is a
2159
+ // row. A press that names no key is handed back rather than swallowed.
2160
+ if (result.command.kind === 'command') {
2161
+ const [name, ...rest] = result.command.command.split(':');
2162
+ // REJOINED, NOT `rest[0]`. A qualified key is `owner/repo#12`, which
2163
+ // carries no colon today — but the split is over the whole command and
2164
+ // taking only the first segment would silently truncate any key that
2165
+ // ever does.
2166
+ const row = rest.join(':');
2167
+ if ((name === 'expand' || name === 'collapse') && row !== '') {
2168
+ dispatch({ kind: 'control', name, target: row });
2169
+ return true;
2170
+ }
2171
+ }
723
2172
  return false;
724
2173
  };
2174
+ /**
2175
+ * The queue's keys, while the queue is up.
2176
+ *
2177
+ * Returns whether the press was consumed. The queue OWNS the keyboard while it
2178
+ * is drawn — the create map and the viewer's navigation are not consulted,
2179
+ * because every one of their targets is behind an inert overlay — but a press
2180
+ * it does not recognise is handed back rather than swallowed, which is the
2181
+ * contract `firstpass/keys.ts` states in terms: `none` exists "so the host does
2182
+ * not `preventDefault()` a key it did not consume".
2183
+ */
2184
+ const firstPassKeydown = (event) => {
2185
+ const { phase } = state.firstPass;
2186
+ if (phase.kind === 'closed')
2187
+ return false;
2188
+ // ESCAPE IS THE MOUNT'S OWN, and it has to be: `firstpass/keys.ts` binds
2189
+ // `y`/`n`/`s` and both spellings of the delete key and nothing else, and its
2190
+ // own tests pin `Escape` to `none`. The exit CONTROL is the mount's, so the
2191
+ // exit KEY is too — without it §17e's "exit anytime" is reachable only with
2192
+ // a pointer, on the one surface that advertises a pointer-free loop.
2193
+ if (event.key === 'Escape') {
2194
+ dispatch({ kind: 'first-pass', command: { kind: 'close' } });
2195
+ return true;
2196
+ }
2197
+ // TAB STAYS INSIDE. `inert` covers the workspace's own zones and nothing
2198
+ // else, so a mount sitting beside other page chrome let Tab — and Shift-Tab
2199
+ // from the wrapper focus lands on — walk straight out of a dialog that
2200
+ // asserts `aria-modal`. The keydown listener is on the mount's element, so
2201
+ // once focus is outside, `Y`/`N`/`S` and Escape all stop working while the
2202
+ // overlay is still up: a keyboard reader stranded with no way back.
2203
+ if (event.key === 'Tab') {
2204
+ const overlay = surface.querySelector('.ig-firstpass-overlay');
2205
+ if (overlay === null)
2206
+ return false;
2207
+ const stops = [...overlay.querySelectorAll('button, [tabindex]:not([tabindex="-1"])')];
2208
+ const first = stops[0];
2209
+ const last = stops[stops.length - 1];
2210
+ if (first === undefined || last === undefined)
2211
+ return false;
2212
+ const active = doc.activeElement;
2213
+ const at = isElement(active) ? stops.indexOf(active) : -1;
2214
+ // WRAPPING AT BOTH ENDS, and treating "focus is on the wrapper" as before
2215
+ // the first stop — which is where it sits on open and after every answer.
2216
+ const next = event.shiftKey ? (at <= 0 ? last : stops[at - 1]) : at === stops.length - 1 ? first : stops[at + 1];
2217
+ (next ?? first).focus({ preventScroll: true });
2218
+ return true;
2219
+ }
2220
+ if (phase.kind !== 'open')
2221
+ return false;
2222
+ const progress = phase.queue;
2223
+ const context = {
2224
+ // `queue` WHENEVER THE OVERLAY HOLDS FOCUS. The surface has no text input
2225
+ // of its own, so the only question is whether the reader is inside it.
2226
+ interaction: isElement(doc.activeElement) &&
2227
+ doc.activeElement.closest('.ig-firstpass-overlay') !== null &&
2228
+ !isInput(doc.activeElement)
2229
+ ? 'queue'
2230
+ : 'elsewhere',
2231
+ hasCandidate: progress.cursor < progress.candidates.length,
2232
+ canUndo: progress.answers.length > 0,
2233
+ };
2234
+ const intent = firstPassIntent(event, context);
2235
+ if (intent.kind === 'none')
2236
+ return false;
2237
+ dispatch({ kind: 'first-pass', command: { kind: 'queue', command: intent.command } });
2238
+ return true;
2239
+ };
725
2240
  const onKeydown = (event) => {
2241
+ // A HELD KEY BELONGS TO THE CONTROL IT ACTIVATED, wherever focus went next.
2242
+ //
2243
+ // The arm below already refuses to dispatch twice for one held key, and that
2244
+ // is not enough, because ACTIVATING A CONTROL OFTEN MOVES FOCUS: pressing a
2245
+ // kind option redraws and focuses `target-query`, and a self-removing
2246
+ // control falls back to a rail row. So the repeats of that same press arrive
2247
+ // at a DIFFERENT element, where the arm's question — "is what holds focus
2248
+ // now a control?" — answers about the wrong thing entirely. Held `Space` on
2249
+ // a kind option would type spaces into the query the reader has not started;
2250
+ // held `Space` on `add` would walk the rail. Each is the platform doing
2251
+ // exactly the right thing with a press this package took halfway.
2252
+ //
2253
+ // Recorded as a FACT ABOUT THE PRESS rather than re-derived from focus,
2254
+ // which is the same shape as the rest of this file's fixes for this class:
2255
+ // where focus is is not what the question was about. It ends at `keyup`, and
2256
+ // a fresh non-repeat press of the same key clears it too — so a `keyup` lost
2257
+ // to a window blur costs one held key rather than every later one.
2258
+ if (event.repeat && activating.has(event.key)) {
2259
+ event.preventDefault();
2260
+ return;
2261
+ }
2262
+ // A FRESH PRESS ENDS ONLY ITS OWN KEY'S RECORD, and with a set that falls
2263
+ // out rather than being arranged: every key's record is its own, so no other
2264
+ // key's press can reach it. A reader holding `Space` on a control and
2265
+ // touching `Shift` used to have the whole record dropped, and the next
2266
+ // `Space` repeat landed wherever the activation had sent focus.
2267
+ if (!event.repeat)
2268
+ activating.delete(event.key);
2269
+ if (firstPassKeydown(event)) {
2270
+ event.preventDefault();
2271
+ return;
2272
+ }
2273
+ // THE QUEUE STILL OWNS THE SURFACE even for a key it did not consume: every
2274
+ // target the create map and the viewer's navigation could reach is behind
2275
+ // the overlay and inert, so consulting them would act on something the
2276
+ // reader cannot see.
2277
+ if (state.firstPass.phase.kind !== 'closed')
2278
+ return;
726
2279
  const document_ = landed();
727
2280
  const match = targetMatches(document_.issues, state.targetQuery, state.draft.source)[0]?.ref ?? null;
728
2281
  const context = {
729
2282
  // The FOCUSED row, not the selection: on a fresh page a row can own
730
2283
  // focus while nothing is selected, and after focus moves on, a stale
731
2284
  // selection must not become the source of a keyboard-started draft.
732
- focused: focusedKey(),
2285
+ // AND `+ add`'S OWN SUBJECT WHERE THERE IS NO ROW, which is still that
2286
+ // rule rather than an exception to it — the fallback is a control's
2287
+ // published claim about what it acts on, not the selection this line
2288
+ // refuses. It moves nothing that worked: the second term is consulted
2289
+ // only where the first is `null`, and where it answers, `interaction()`
2290
+ // answers `add-control`, which admits `relate` alone.
2291
+ // AND IT DOES NOT OPEN THE NAVIGATION ARM BELOW, which is the one place a
2292
+ // wider `focused` could have leaked. That arm is guarded on this field,
2293
+ // but `navigateFocus` re-derives the key from a `[data-ig-key]` ANCESTOR
2294
+ // and answers `false` without one — and no command control has one, since
2295
+ // every one this package draws sits in chrome beside the keyed rows. So
2296
+ // the guard now admits a press the arm still declines, rather than
2297
+ // stepping the rail from a focused button.
2298
+ focused: focusedKey() ?? focusedAddSubject(),
733
2299
  match,
734
2300
  selectedEdge: selectedEdgeId(state.selection),
735
2301
  interaction: interaction(),
736
2302
  };
2303
+ // §17k'S TRANSIENT SURFACES ARE DISMISSED ON ESCAPE — the ruling's own
2304
+ // words for the lifted inspector, and the strip takes the same key because
2305
+ // a canvas opened over the inspector is the same kind of thing.
2306
+ //
2307
+ // BEFORE `keyIntent`, AND THE OBVIOUS PLACEMENT IS THE WRONG ONE. Putting
2308
+ // it after reads as the safe order — let the create loop have the press
2309
+ // first — and it makes this arm DEAD. `create/keys.ts` binds `escape` to
2310
+ // `cancel` with the comment "ALWAYS AVAILABLE", and `reaches` admits it
2311
+ // whenever the interaction is `canvas`, which `interaction()` answers for
2312
+ // any focused `[data-ig-key]`. So with focus on a rail row — where it sits
2313
+ // after almost every click on this surface — `keyIntent` claims Escape for
2314
+ // a draft that does not exist and returns, and nothing below it is reached.
2315
+ // MEASURED IN A BROWSER, not reasoned about: the panel stayed open, and the
2316
+ // `dataInspector` attribute stayed `lifted` across the press.
2317
+ //
2318
+ // THE DRAFT STILL WINS, WHICH IS WHAT THE OTHER ORDER WAS FOR. A reader
2319
+ // mid-relationship pressing Escape means "not this edge", not "close the
2320
+ // panel behind it" — so the guard is `nothingToCancel`, which asks about
2321
+ // the create path rather than about the position in this function. An idle
2322
+ // one is the only state `keyIntent` would have spent the press on, and
2323
+ // cancelling an idle draft is a no-op.
2324
+ //
2325
+ // GUARDED ALSO ON `narrowOverlayOpen`, WHICH IS THE REDUCER'S OWN PREDICATE
2326
+ // rather than a second reading of the same two fields, so the two cannot
2327
+ // drift apart — `reduceHost` answers `unclaimed` for exactly the same case.
2328
+ //
2329
+ // WHAT THAT DOES NOT DO IS LET THE PRESS THROUGH. A press this arm declines
2330
+ // falls to `keyIntent`, which binds Escape to a cancel that is "ALWAYS
2331
+ // AVAILABLE" and cancels it there — so on a focused rail row Escape is
2332
+ // consumed by this surface whether or not anything was dismissed. That
2333
+ // predates this branch and is not changed here; it is written down because
2334
+ // the obvious reading of `unclaimed` is that the key reaches the host, and
2335
+ // on this surface it does not.
2336
+ //
2337
+ // THE REDUCER CANNOT ASK WHICH OF THE TWO THE READER CAN SEE, and it must
2338
+ // not: the width is the stylesheet's to know, per §17k's note that a
2339
+ // package reading the window cannot be dropped into someone else's page.
2340
+ // So it closes both and the sheet draws whichever was on screen.
2341
+ //
2342
+ // THE SHELL CAN ASK, THOUGH, AND HAS TO. An earlier revision left the guard
2343
+ // at the reducer's predicate alone and accepted "a press spent invisibly at
2344
+ // the wide layout" as a cost. It is not a cost worth taking: a key consumed
2345
+ // for a surface nobody can see is indistinguishable from a broken key. See
2346
+ // `inspectorIsLifted`, which reads the sheet's own answer off the DOM
2347
+ // rather than re-deriving it from a width.
2348
+ //
2349
+ // TWO TRANSIENTS SHARE THIS KEY NOW, AND §17d'S GOES FIRST. The audit
2350
+ // overlay is the one the reader pressed a control to open, and it is drawn
2351
+ // at EVERY width; §17k's lifted inspector is opened implicitly by a
2352
+ // selection and exists only between `1120` and `1359`. So a press with the
2353
+ // audit panel up closes the audit panel, at any width — without this order
2354
+ // a reader at the wide layout, where nothing of §17k's is drawn, would
2355
+ // press Escape and watch the panel stay put.
2356
+ if (event.key === 'Escape' && nothingToCancel()) {
2357
+ if (state.auditOpen) {
2358
+ event.preventDefault();
2359
+ dispatch({ kind: 'control', name: 'audit-close' });
2360
+ return;
2361
+ }
2362
+ if (narrowOverlayOpen(state) && (state.canvasOpen || inspectorIsLifted())) {
2363
+ event.preventDefault();
2364
+ dispatch({ kind: 'control', name: 'narrow-dismiss' });
2365
+ return;
2366
+ }
2367
+ }
737
2368
  const intent = keyIntent(event, context);
738
2369
  if (intent.kind !== 'none') {
739
2370
  event.preventDefault();
740
2371
  dispatch({ kind: 'intent', intent });
741
2372
  return;
742
2373
  }
743
- if (context.focused !== null && navigateFocus(event))
2374
+ if (context.focused !== null && navigateFocus(event)) {
744
2375
  event.preventDefault();
2376
+ return;
2377
+ }
2378
+ // --- activating a control from the keyboard ---
2379
+ //
2380
+ // ## Why this is last, and why that is the safety property
2381
+ //
2382
+ // The same argument `interaction()` makes for its own ordering: the only
2383
+ // answers that move are ones nothing reaches today, so nothing can regress.
2384
+ // Two facts make it true here rather than merely plausible.
2385
+ //
2386
+ // `interaction()` answers `elsewhere` for a focused command control that is
2387
+ // neither an input, nor the kind step, nor `+ add`, so `keyIntent` returns
2388
+ // `none` for every key there. At the KIND STEP it answers `kind-chooser` —
2389
+ // the predicate is asked of the DRAFT, not of where focus is — and there
2390
+ // `enter` does not survive and `' '` is unbound, so `keyIntent` is silent
2391
+ // again. On `+ add` it answers `add-control`, which admits `RELATE_KEY`
2392
+ // alone (`#173`) and neither of these two, so this arm still owns them
2393
+ // there. So this arm does claim `Enter` and `Space` on the inspector's
2394
+ // `clear`, which is where the mount's focus restore lands after `begin`.
2395
+ // That is a control doing what a control does, and it is new behaviour on
2396
+ // a step the create loop passes through every time — said out loud because
2397
+ // it is the one place this arm changes an interaction that already worked.
2398
+ //
2399
+ // And `navigateFocus` is gated on a focused `[data-ig-key]` ancestor, which
2400
+ // no control has: every command control this package draws sits in chrome —
2401
+ // the inspector, the header, the ladder's refusal list and search — beside
2402
+ // the keyed rows rather than inside one.
2403
+ //
2404
+ // ## Why cancelling is mandatory here, not tidy
2405
+ //
2406
+ // On a real `<button>` the browser's ACTIVATION BEHAVIOUR is this keydown's
2407
+ // default action, and `onClick` sees the click it synthesises. Cancelling
2408
+ // the press is therefore what stops this arm and the browser from both
2409
+ // dispatching — the double fire that was, correctly, the reason this was
2410
+ // never built. Every path below that resolves a control reaches
2411
+ // `preventDefault()` for that reason first and the page's scroll second.
2412
+ //
2413
+ // ## …and why an `input` refusal is handed back anyway
2414
+ //
2415
+ // Both search boxes are `<input>` elements carrying `data-ig-command` — the
2416
+ // chrome's `target-query` and the ladder's `search`. `' '` is unbound in
2417
+ // `create/keys.ts` and neither input has a keyed ancestor, so a space
2418
+ // pressed in either arrives HERE. Cancel it and the reader cannot type a
2419
+ // space into the target query, which is step three of the very
2420
+ // `R → digit → search → ⏎` loop this arm exists to complete. The `input`
2421
+ // listener and the platform's text editing own those characters.
2422
+ //
2423
+ // An `inert` refusal keeps the press for the OTHER two reasons this block
2424
+ // gives: `Space` would scroll the page, and this package has answered. It is
2425
+ // NOT that handing it back would let a native button activate the entry —
2426
+ // that was the first draft of this comment and it is false, because the
2427
+ // click a handed-back press synthesizes reaches `onClick`, which asks the
2428
+ // same `controlAnswer` and gets the same refusal. Recorded wrong-then-right
2429
+ // because the argument, not the behaviour, is what the next reader inherits.
2430
+ //
2431
+ // `' '` is the modern `KeyboardEvent.key` value for the space bar; legacy
2432
+ // `'Spacebar'` is not bound. `viewer/navigation.ts` is the package's one
2433
+ // other key reader that binds it and spells it the same way. (`create/keys.ts`
2434
+ // is NOT a precedent either way: it binds no space key, and lowercases the
2435
+ // names it does bind.)
2436
+ if (event.key !== 'Enter' && event.key !== ' ')
2437
+ return;
2438
+ // TWO OF THE REFUSALS `create/keys.ts` ARGUES FOR ITS OWN BINDINGS, and for
2439
+ // the same reasons — a chord is the platform's shortcut, and `Enter` while
2440
+ // an input method is composing confirms its candidate. Read off the event
2441
+ // rather than imported: those predicates are that module's private
2442
+ // business, and this is the shell's half. Its third, `repeat`, is answered
2443
+ // below rather than here, and the difference is not cosmetic — see there.
2444
+ if (event.ctrlKey || event.metaKey || event.altKey)
2445
+ return;
2446
+ if (event.isComposing)
2447
+ return;
2448
+ // THE FOCUSED ELEMENT. `onClick` reads `event.target` because a click's
2449
+ // target IS where the pointer landed; a keydown's target is the focused
2450
+ // element only under the browser's own dispatch, so a press delegated from a
2451
+ // container would name the container instead. `interaction()` and
2452
+ // `navigateFocus` both read `activeElement` for that reason, and an arm in
2453
+ // the same handler answering a different question would be the outlier.
2454
+ const active = doc.activeElement;
2455
+ if (!isElement(active))
2456
+ return;
2457
+ const answer = controlAnswer(active);
2458
+ if (answer === null)
2459
+ return;
2460
+ if (answer.kind === 'refused' && answer.reason === 'input')
2461
+ return;
2462
+ // A REPEAT IS SWALLOWED, NOT HANDED BACK, and it has to be resolved first to
2463
+ // know that it is ours to swallow. Handing it back looked like the modest
2464
+ // choice and is the one that breaks: `Space` activates a `<button>` on
2465
+ // KEYUP, so a held `Space` whose first keydown this arm cancels and whose
2466
+ // second it releases lets the platform mark the button active — and the
2467
+ // keyup then synthesizes a click that dispatches the act a SECOND time. One
2468
+ // held key, two edits; on a toggle the two cancel and the control reads
2469
+ // dead. So the reader holding a key is one decision, which is what
2470
+ // `create/keys.ts` says a repeat is, and cancelling is how it stays one.
2471
+ // (`Enter` repeats natively either way; only `Space` carries the asymmetry,
2472
+ // and a rule that split them would be a rule about keyboards rather than
2473
+ // about acts.)
2474
+ // A REFUSAL IS THIS PACKAGE'S OWN ANSWER, so it keeps the press: `Space`
2475
+ // would otherwise scroll the page under a reader who has just pressed
2476
+ // something, and there is nothing else waiting for this key.
2477
+ //
2478
+ // EXCEPT A NAVIGATION REFUSAL, WHICH MEANS THE OPPOSITE. `input` and `inert`
2479
+ // say "this press does nothing, and nothing else wants it"; `navigation`
2480
+ // says the BROWSER performs this control's activation, so there is something
2481
+ // else waiting for the key and cancelling is how it never arrives. An
2482
+ // anchor's `Enter` activation is a DEFAULT ACTION — cancel the keydown and
2483
+ // no click is synthesized and the link never opens, which would leave §17d's
2484
+ // outward link working for a pointer and dead for a keyboard. The click path
2485
+ // still refuses to DISPATCH it; the two answers differ because the acts do.
2486
+ if (answer.kind === 'refused') {
2487
+ if (answer.reason !== 'navigation')
2488
+ event.preventDefault();
2489
+ return;
2490
+ }
2491
+ // DISPATCH FIRST, THEN DECIDE WHETHER THE PRESS WAS OURS TO KEEP — and that
2492
+ // order is the whole of this arm's remaining subtlety.
2493
+ //
2494
+ // `data-ig-command` IS A SHARED NAMESPACE. `reduceHost`'s `default` arm says
2495
+ // so in terms: a host's own chrome publishes on the same attribute, and
2496
+ // layer 1 already does — the freshness `refresh`, `retry:index`,
2497
+ // `review-pick-order`, `dismiss:adoption` are all drawn inside this surface
2498
+ // and all answered by the host's own `click` listener, never by this
2499
+ // reducer. Cancelling their keydown would suppress the native click that
2500
+ // listener is waiting for, and every one of those controls would stop
2501
+ // answering the keyboard — a regression this arm introduced and could not
2502
+ // see, because from out here a command the reducer ignored and one it
2503
+ // handled without changing anything look identical. So the reducer is asked.
2504
+ //
2505
+ // Unclaimed means HANDS OFF, ALL THE WAY OFF — and "all the way" is the
2506
+ // part that needed a second try. Not cancelling is not enough: dispatching
2507
+ // an unclaimed command still ends in `schedule()`, and the redraw replaces
2508
+ // `surface.innerHTML` — including the button the reader is holding. Since
2509
+ // `Space` activates a `<button>` on KEYUP, destroying it before then loses
2510
+ // the activation just as completely as cancelling the press would. So the
2511
+ // reduction is computed, read, and only THEN applied. No `preventDefault`,
2512
+ // no redraw, no `activating`: the platform's own activation is that
2513
+ // control's route, repeats and all, exactly as before this arm existed.
2514
+ if (destroyed)
2515
+ return;
2516
+ const result = reduceHost(state, answer.command, landed());
2517
+ if (!result.claimed)
2518
+ return;
2519
+ applyResult(result);
2520
+ event.preventDefault();
2521
+ // THE PRESS IS NOW RECORDED AS OURS, so its repeats are swallowed at the top
2522
+ // of this handler wherever the dispatch has since sent focus. Added beside
2523
+ // any other key already held rather than replacing it.
2524
+ activating.add(event.key);
2525
+ };
2526
+ // THE HELD KEY IS LET GO. Registered beside the keydown listener rather than on
2527
+ // the document, so a mount that is torn down takes it with it — and a `keyup`
2528
+ // that never arrives because focus left the window is survivable, which is why
2529
+ // `onKeydown` clears the flag on a fresh press too.
2530
+ const onKeyup = (event) => {
2531
+ activating.delete(event.key);
745
2532
  };
746
2533
  const onPointerDown = (event) => {
747
2534
  // ONE PRIMARY MAIN-BUTTON PRESS AT A TIME. A second pointer during a drag
@@ -834,6 +2621,7 @@ export function mountWorkspace(element, options) {
834
2621
  element.addEventListener('compositionend', onCompositionEnd);
835
2622
  element.addEventListener('scroll', onScroll, true);
836
2623
  element.addEventListener('keydown', onKeydown);
2624
+ element.addEventListener('keyup', onKeyup);
837
2625
  element.addEventListener('pointerdown', onPointerDown);
838
2626
  element.addEventListener('pointermove', onPointerMove);
839
2627
  element.addEventListener('pointerup', onPointerUp);
@@ -850,6 +2638,7 @@ export function mountWorkspace(element, options) {
850
2638
  element.removeEventListener('compositionend', onCompositionEnd);
851
2639
  element.removeEventListener('scroll', onScroll, true);
852
2640
  element.removeEventListener('keydown', onKeydown);
2641
+ element.removeEventListener('keyup', onKeyup);
853
2642
  element.removeEventListener('pointerdown', onPointerDown);
854
2643
  element.removeEventListener('pointermove', onPointerMove);
855
2644
  element.removeEventListener('pointerup', onPointerUp);
@@ -890,8 +2679,10 @@ export function mountWorkspace(element, options) {
890
2679
  update(next) {
891
2680
  if (destroyed)
892
2681
  return;
893
- if (next !== undefined)
2682
+ if (next !== undefined) {
894
2683
  current = { ...current, ...next, store };
2684
+ adoptSource();
2685
+ }
895
2686
  schedule();
896
2687
  },
897
2688
  dispatch,