@issuegraph/editor 0.4.0 → 0.10.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (84) hide show
  1. package/README.md +31 -6
  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 +430 -0
  5. package/dist/a11y/baseline.js.map +1 -0
  6. package/dist/audit/styles.d.ts +1 -1
  7. package/dist/audit/styles.d.ts.map +1 -1
  8. package/dist/audit/styles.js +1 -1
  9. package/dist/create/draft.d.ts +17 -0
  10. package/dist/create/draft.d.ts.map +1 -1
  11. package/dist/create/draft.js +19 -0
  12. package/dist/create/draft.js.map +1 -1
  13. package/dist/create/keys.d.ts +70 -5
  14. package/dist/create/keys.d.ts.map +1 -1
  15. package/dist/create/keys.js +78 -17
  16. package/dist/create/keys.js.map +1 -1
  17. package/dist/firstpass/candidates.d.ts +17 -1
  18. package/dist/firstpass/candidates.d.ts.map +1 -1
  19. package/dist/index.d.ts +6 -4
  20. package/dist/index.d.ts.map +1 -1
  21. package/dist/index.js +12 -2
  22. package/dist/index.js.map +1 -1
  23. package/dist/overlay/render.d.ts.map +1 -1
  24. package/dist/overlay/render.js +54 -14
  25. package/dist/overlay/render.js.map +1 -1
  26. package/dist/overlay/styles.d.ts +1 -1
  27. package/dist/overlay/styles.d.ts.map +1 -1
  28. package/dist/overlay/styles.js +40 -0
  29. package/dist/overlay/styles.js.map +1 -1
  30. package/dist/picker/render.d.ts +26 -16
  31. package/dist/picker/render.d.ts.map +1 -1
  32. package/dist/picker/render.js +25 -36
  33. package/dist/picker/render.js.map +1 -1
  34. package/dist/picker/styles.d.ts +7 -7
  35. package/dist/picker/styles.d.ts.map +1 -1
  36. package/dist/picker/styles.js +7 -36
  37. package/dist/picker/styles.js.map +1 -1
  38. package/dist/picker/view.d.ts +8 -4
  39. package/dist/picker/view.d.ts.map +1 -1
  40. package/dist/picker/view.js +4 -3
  41. package/dist/picker/view.js.map +1 -1
  42. package/dist/picker/words.d.ts +12 -10
  43. package/dist/picker/words.d.ts.map +1 -1
  44. package/dist/picker/words.js +12 -8
  45. package/dist/picker/words.js.map +1 -1
  46. package/dist/scale/ladder.d.ts.map +1 -1
  47. package/dist/scale/ladder.js +10 -3
  48. package/dist/scale/ladder.js.map +1 -1
  49. package/dist/scale/render.d.ts.map +1 -1
  50. package/dist/scale/render.js +9 -1
  51. package/dist/scale/render.js.map +1 -1
  52. package/dist/workspace/chrome.d.ts +1 -1
  53. package/dist/workspace/chrome.d.ts.map +1 -1
  54. package/dist/workspace/chrome.js +48 -0
  55. package/dist/workspace/chrome.js.map +1 -1
  56. package/dist/workspace/firstpass.d.ts +166 -0
  57. package/dist/workspace/firstpass.d.ts.map +1 -0
  58. package/dist/workspace/firstpass.js +151 -0
  59. package/dist/workspace/firstpass.js.map +1 -0
  60. package/dist/workspace/host.d.ts +200 -10
  61. package/dist/workspace/host.d.ts.map +1 -1
  62. package/dist/workspace/host.js +420 -42
  63. package/dist/workspace/host.js.map +1 -1
  64. package/dist/workspace/inspector.d.ts +45 -1
  65. package/dist/workspace/inspector.d.ts.map +1 -1
  66. package/dist/workspace/inspector.js +11 -0
  67. package/dist/workspace/inspector.js.map +1 -1
  68. package/dist/workspace/mount.d.ts +52 -5
  69. package/dist/workspace/mount.d.ts.map +1 -1
  70. package/dist/workspace/mount.js +1366 -58
  71. package/dist/workspace/mount.js.map +1 -1
  72. package/dist/workspace/recovery.d.ts +155 -0
  73. package/dist/workspace/recovery.d.ts.map +1 -0
  74. package/dist/workspace/recovery.js +156 -0
  75. package/dist/workspace/recovery.js.map +1 -0
  76. package/dist/workspace/render.d.ts +402 -1
  77. package/dist/workspace/render.d.ts.map +1 -1
  78. package/dist/workspace/render.js +941 -35
  79. package/dist/workspace/render.js.map +1 -1
  80. package/dist/workspace/styles.d.ts +1 -1
  81. package/dist/workspace/styles.d.ts.map +1 -1
  82. package/dist/workspace/styles.js +554 -41
  83. package/dist/workspace/styles.js.map +1 -1
  84. package/package.json +3 -3
@@ -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 } 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, 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']);
@@ -69,6 +76,40 @@ const DRAG_THRESHOLD = 6;
69
76
  const KEY_ATTRIBUTE = 'data-ig-key';
70
77
  const GROUP_ATTRIBUTE = 'data-ig-group';
71
78
  const COMMAND_ATTRIBUTE = 'data-ig-command';
79
+ /**
80
+ * Which subject a command acts on, when one command names several.
81
+ *
82
+ * Named here because this file now READS it in two places rather than one —
83
+ * `controlAnswer` turns it into the dispatch's `target`, and the focus token
84
+ * below uses it to tell one `select-edge` from the next. A third bare literal for an
85
+ * attribute the renderer writes on every relationship row and every recovery
86
+ * button is how a typo becomes a control that silently stops matching.
87
+ */
88
+ const TARGET_ATTRIBUTE = 'data-ig-target';
89
+ /**
90
+ * Commands that name ONE control in two states.
91
+ *
92
+ * The isolated-issues chip redraws the SAME button with its command flipped
93
+ * (`scale/render.ts`), so a focus token comparing the raw command rejects the
94
+ * replacement and sends the reader to the rail — and that control, like the
95
+ * conflict disclosure, is one you press a second time to undo. It is this
96
+ * pull request's own defect wearing a different attribute value, which is
97
+ * why it is worth a table rather than a special case: the next toggle gets
98
+ * one line here instead of a new bug.
99
+ *
100
+ * A TABLE, NOT A PREFIX RULE. `open-` / `close-` looks like a pattern and is
101
+ * not one — `first-pass` and `first-pass-close` are two different controls in
102
+ * two different places, and a rule that folded them would restore focus onto
103
+ * the wrong one.
104
+ */
105
+ const TOGGLE_IDENTITY = Object.freeze({
106
+ 'open-isolated': 'isolated',
107
+ 'close-isolated': 'isolated',
108
+ });
109
+ /** What a control is, rather than which of its states is showing. */
110
+ function controlIdentity(control) {
111
+ return TOGGLE_IDENTITY[control] ?? control;
112
+ }
72
113
  /**
73
114
  * An element, recognised by what it can do.
74
115
  *
@@ -82,6 +123,9 @@ function isElement(target) {
82
123
  function isFocusable(node) {
83
124
  return node !== null && node !== undefined && 'focus' in node;
84
125
  }
126
+ /** The two refusals, allocated once: they carry no per-press data. */
127
+ const REFUSED_INPUT = Object.freeze({ kind: 'refused', reason: 'input' });
128
+ const REFUSED_INERT = Object.freeze({ kind: 'refused', reason: 'inert' });
85
129
  function isInput(node) {
86
130
  return node !== null && node !== undefined && node.tagName === 'INPUT';
87
131
  }
@@ -101,11 +145,26 @@ function withKey(scope, key) {
101
145
  * canvas renders the whole document and so drew a second header and a second
102
146
  * refresh control in the same workspace. One place strips it for both.
103
147
  */
104
- function withoutHost(document) {
148
+ /**
149
+ * The host facts a SECONDARY view keeps: what is true, never what is drawn.
150
+ *
151
+ * The rail owns this workspace's one header, NOW row and freshness stamp, and
152
+ * carrying them onto a second view would draw them twice — that is why the
153
+ * facts are stripped here at all, and it stays true.
154
+ *
155
+ * THE CONDITION IS NOT ONE OF THEM. It is the host's account of WHY the panel
156
+ * is short, and the viewer uses it to refuse to invent a different account:
157
+ * without it, a canvas beside an explained rail falls back to "no issue in this
158
+ * document declares a relationship", which under a stated `error` is a claim
159
+ * about something else and contradicts the rail. It draws nothing on its own
160
+ * under `chrome: false`, so keeping it costs no chrome — it only stops a second
161
+ * view explaining the same emptiness a different, false way.
162
+ */
163
+ function withoutChrome(document) {
105
164
  if (document.host === undefined)
106
165
  return document;
107
- const { host: _host, ...rest } = document;
108
- return rest;
166
+ const { host, ...rest } = document;
167
+ return host.condition === undefined ? rest : { ...rest, host: { condition: host.condition } };
109
168
  }
110
169
  /**
111
170
  * Mount the workspace into an element.
@@ -129,10 +188,95 @@ export function mountWorkspace(element, options) {
129
188
  let drawn = null;
130
189
  // A rail row to focus once the window has been re-cut around it.
131
190
  let pendingFocus = null;
191
+ // The keys currently holding a control they activated. See `onKeydown`'s first
192
+ // arm for why this is a fact about the PRESS and not a question asked of
193
+ // whatever holds focus by the time the repeats arrive.
194
+ //
195
+ // A SET RATHER THAN THE LATEST KEY. Two can be down at once — hold `Space` on
196
+ // one control and press `Enter` on another — and a scalar answers that by
197
+ // forgetting the first, so its repeats stop being recognised as its own and go
198
+ // wherever focus has since moved. That is not a case to guard; it is a case
199
+ // the representation should not be able to express, which is what this is.
200
+ const activating = new Set();
132
201
  let pressed = null;
133
202
  // Whether the last render drew the target search, so focus moves into it on
134
203
  // the render that OPENS it and not on every render while it stays open.
135
204
  let searchWasOpen = false;
205
+ // The phase the last render drew, so the overlay is entered once rather than
206
+ // on every redraw, and left exactly once.
207
+ let firstPassWas = 'closed';
208
+ // Where focus was when the overlay went up, so it can be given back when the
209
+ // overlay comes down. `mount.ts` already learned this lesson on the create
210
+ // flow: a surface removed with focus inside it leaves `activeElement` on the
211
+ // body, and the keydown listener is on the element — so the whole workspace
212
+ // goes keyboard-dead until the reader clicks something.
213
+ let focusBeforeFirstPass = null;
214
+ /** The scanner the live lifecycle belongs to, so a swap can end it. */
215
+ let firstPassSource = options.firstPass?.source ?? null;
216
+ /**
217
+ * Which write each applied candidate created.
218
+ *
219
+ * THE ONLY HANDLE THAT IDENTIFIES A WRITE, and it exists nowhere else: the
220
+ * `MutationId` is minted by `store.propose` and seen only here, while the
221
+ * create's own `kind`/`from`/`to` does NOT identify it — `candidates.ts` mints
222
+ * a distinct id per finding precisely so two detectors proposing the same pair
223
+ * stay two questions, and an older failed write can carry the same triple. An
224
+ * earlier revision matched structurally and could have discarded the wrong
225
+ * record. Cleared when the queue closes.
226
+ */
227
+ const appliedWrites = new Map();
228
+ /**
229
+ * The issue each unsettled write is about, answered once and then remembered.
230
+ *
231
+ * THE ANSWER GOES STALE, WHICH IS WHY IT IS KEPT RATHER THAN RE-ASKED. A
232
+ * refusal reaches the panel through the ledger, long after the act that
233
+ * caused it, and `editCarrier` reads the LANDED document — so between the two
234
+ * moments a sibling write can land and take the relationship away, leaving
235
+ * that call with nothing but the edge's identity. An identity records which
236
+ * two issues a relationship was between, and for a SYMMETRIC field it does not
237
+ * record which of them declared it: `edgeIdentity` sorts those endpoints, so
238
+ * the declaring end is not in the string at all. Re-asking therefore answered a
239
+ * different issue from the one the edit went out under, for exactly the pairs
240
+ * whose stored `from` sorted after their `to` — and `renderWorkspace` draws a
241
+ * refusal only on the panel it names, so it was drawn where the reader was
242
+ * not. Asked once, there is nothing for a second answer to disagree with.
243
+ *
244
+ * TWO WAYS IN, AND THE EARLIER ONE WINS. `reduceHost` decides the carrier as
245
+ * it emits the edit — before `store.propose` is even called, from the
246
+ * document as it stood when the reader acted — and `perform` records it under
247
+ * the identity the store mints. A write this mount did not emit (a host
248
+ * proposing on the same store) has no such moment, so it is answered the first
249
+ * time this render sees it in the ledger, which is the earliest moment there
250
+ * is. Neither is ever recomputed.
251
+ *
252
+ * ITS LIFETIME IS THE LEDGER'S, exactly. A record that lands is REMOVED from
253
+ * `snapshot.writes` — that is the store's own contract — and a refused or
254
+ * failed one stays until the reader discards it, which is precisely as long as
255
+ * it can still be drawn. So the map is pruned to the ledger's own keys on
256
+ * every render and needs no rule of its own. Clearing it beside
257
+ * `appliedWrites` was the alternative and is wrong in both directions: those
258
+ * clears are the first pass's lifecycle, not the write ledger's, so a queue
259
+ * that closes over a still-refused write would forget where to state it, and
260
+ * a write settling with no queue in sight would never be forgotten at all.
261
+ */
262
+ const writeCarriers = new Map();
263
+ /**
264
+ * The conflict whose `retry on latest` is the last one the reader pressed.
265
+ *
266
+ * WITHOUT IT, `hydrationError` IS THE WRONG FACT TO DRAW. The store sets that
267
+ * field from ANY failed read — a host's own `refresh()`, a background
268
+ * rehydrate, a failed first load — and attributes it to no mutation. Painted
269
+ * on every conflict card it says "could not read the newest version" on cards
270
+ * whose button nobody pressed, and on all of them at once when two conflicts
271
+ * stand. The card's claim is about a control the reader operated, so the
272
+ * shell records which control that was; the store cannot answer it.
273
+ *
274
+ * CLEARED WHEN THE READ SUCCEEDS, which the ledger cannot express: a resolve
275
+ * that reads fine and is then refused leaves an `invalid` record and no
276
+ * hydration error, and a stale flag here would keep explaining a failure that
277
+ * did not happen.
278
+ */
279
+ let refreshFailedFor = null;
136
280
  const railCount = () => current.railCount ?? MOUNT_RAIL_COUNT;
137
281
  const theme = () => resolveTheme(current.theme);
138
282
  const el = (tag, attributes = {}, children = []) => {
@@ -165,7 +309,10 @@ export function mountWorkspace(element, options) {
165
309
  const perform = (effect) => {
166
310
  switch (effect.kind) {
167
311
  case 'propose':
168
- void store.propose(effect.proposal);
312
+ // THE CARRIER IS RECORDED, NOT RE-DERIVED. See `writeCarriers`: the
313
+ // reducer decided it from the document the reader acted on, and the
314
+ // `MutationId` that binds the two exists only here.
315
+ writeCarriers.set(store.propose(effect.proposal).mutationId, effect.carrier);
169
316
  return;
170
317
  case 'retry': {
171
318
  // A conflict retries against the LATEST document. The store owns that
@@ -173,6 +320,10 @@ export function mountWorkspace(element, options) {
173
320
  // operation, so there is nothing for the mount to sequence.
174
321
  const record = store.getSnapshot().writes.find((each) => each.mutationId === effect.mutationId);
175
322
  if (record?.state === 'conflict') {
323
+ // WHOSE READ THIS IS, recorded as the press happens. See
324
+ // `refreshFailedFor`: after the fact there is nothing that ties the
325
+ // store's hydration error to the edit it was read for.
326
+ refreshFailedFor = effect.mutationId;
176
327
  void store.retryOnLatest(effect.mutationId);
177
328
  }
178
329
  else {
@@ -186,30 +337,199 @@ export function mountWorkspace(element, options) {
186
337
  case 'dismiss-change':
187
338
  store.dismissChange();
188
339
  return;
340
+ case 'find-candidates': {
341
+ const option = current.firstPass;
342
+ // UNREACHABLE WITHOUT A BUNDLE, because the command is withheld at the
343
+ // listener — but written as a guard rather than an assertion, because
344
+ // `update()` can take the bundle away between the click and this call.
345
+ if (option === undefined) {
346
+ // CLOSED, NOT FAILED. `failed` is a state this surface DRAWS, in the
347
+ // host's own words — and with no bundle there are no words and no
348
+ // overlay, so a failed phase here is an invisible one that blocks the
349
+ // workspace's keyboard with no control to close it. The DOM guard
350
+ // withholds this command, but `handle.dispatch` is public and reaches
351
+ // the reducer directly, so the shell answers for that route too.
352
+ dispatch({ kind: 'first-pass', command: { kind: 'close' } });
353
+ return;
354
+ }
355
+ // THE OPEN IS REAL, SO THE DRAFT GOES NOW. Only the shell knows a scan
356
+ // will actually run — the reducer cannot tell this open from one about
357
+ // to be refused for want of a source, and clearing on both destroyed a
358
+ // draft for a queue that never appeared. Both routes into the queue, the
359
+ // attribute and `handle.dispatch`, arrive here.
360
+ dispatch({ kind: 'control', name: 'cancel' });
361
+ const { scan } = effect;
362
+ // STARTED FROM A CALLBACK, so a host whose `findCandidates` throws
363
+ // BEFORE returning its promise — reading its own state, building a
364
+ // request — reaches the same failed scan as one whose promise rejects.
365
+ // Called directly, that throw escapes the `.catch` entirely and unwinds
366
+ // through the click handler, leaving the phase `scanning` for good.
367
+ void Promise.resolve()
368
+ .then(() => option.source.findCandidates())
369
+ .then((candidates) => {
370
+ dispatch({ kind: 'first-pass', command: { kind: 'candidates', scan, candidates } });
371
+ })
372
+ .catch(() => {
373
+ // THE REASON IS THE HOST'S AND IS NOT READ. A failed scan is a phase
374
+ // this surface draws in the host's own words; adopting the rejection
375
+ // value would be this package writing a sentence about someone
376
+ // else's tracker.
377
+ dispatch({ kind: 'first-pass', command: { kind: 'scan-failed', scan } });
378
+ });
379
+ return;
380
+ }
381
+ case 'first-pass-apply': {
382
+ // THIS CANDIDATE'S WRITE MAY ALREADY BE OUT THERE. `⌫` on an answer whose
383
+ // create is still `pending` steps the queue back but takes nothing away,
384
+ // because `discardMine` declines a pending record — so answering `Y`
385
+ // again would propose the SAME create a second time, and when the first
386
+ // lands the second turns `invalid` and shows the reader an error about a
387
+ // relationship that now exists. One consent, one write: the handle is
388
+ // kept and nothing new is proposed.
389
+ if (pendingWriteFor(effect.candidateId) !== undefined)
390
+ return;
391
+ // AND THE RELATIONSHIP ITSELF MAY ALREADY BE THERE. The check above is
392
+ // per candidate, and a candidate is not a relationship: two findings
393
+ // with different ids may propose the same pair (`candidates.ts` keeps
394
+ // them apart on purpose), and a close-and-reopen starts a fresh queue
395
+ // over a document the earlier answer has since written to. Either way
396
+ // the store refuses the second create as a duplicate — correctly — and
397
+ // the reader is left an `invalid` record about a relationship that
398
+ // exists.
399
+ // LANDED OR ON ITS WAY, AND NOTHING ELSE. Asked of the two records that
400
+ // answer it directly: `landed` says the relationship is there, and a
401
+ // `pending-write` state says one is being created right now. An edge
402
+ // whose only write FAILED is precisely NOT there, so a second consent is
403
+ // free to try again.
404
+ // ASKED OF `landed`, NOT INFERRED FROM "no unsettled write". An earlier
405
+ // revision read a landed edge as one with an empty `writes` — which a
406
+ // landed edge carrying any settled overlay, a failed DELETE among them,
407
+ // is not. It then proposed a create for a relationship that was still
408
+ // there, and the store recorded the invalid duplicate.
409
+ const { proposal } = effect;
410
+ if (proposal.op === 'create') {
411
+ const id = edgeIdentity(proposal.kind, proposal.from, proposal.to);
412
+ const snapshot = store.getSnapshot();
413
+ // ASKED OF THE STORE'S OWN RULE, not enumerated here. `nextDocument`
414
+ // is the store's answer to "what would this edit produce if it landed
415
+ // exactly as proposed", and it answers for every operation the store
416
+ // has: a create adds the edge, a delete removes it, and a retype or a
417
+ // flip replaces it with a different identity — which removes the one
418
+ // the reader is being asked about just as surely as a delete does.
419
+ // FOUR EARLIER VERSIONS RECONSTRUCTED THAT ANSWER and each was wrong
420
+ // in a way only the store's internals reveal — a failed write still
421
+ // projects, a landed edge can carry a settled overlay, a pending
422
+ // delete is marked `pending-write` too, and a retype removes an edge
423
+ // without being a delete. Every one of them suppressed a consent that
424
+ // then wrote nothing, showed nothing, and was already decided. So the
425
+ // enumeration is the store's, and a fifth operation is covered the day
426
+ // the store learns about one. See autnmy/issuegraph#142.
427
+ const landedNow = { issues: snapshot.issues, edges: snapshot.landed };
428
+ const isThere = (document___) => document___.edges.some((edge) => edge.id === id);
429
+ const already = isThere(landedNow);
430
+ let coming = false;
431
+ let going = false;
432
+ for (const write of snapshot.writes) {
433
+ if (write.state !== 'pending')
434
+ continue;
435
+ const after = isThere(nextDocument(landedNow, write.mutation));
436
+ if (after && !already)
437
+ coming = true;
438
+ if (!after && already)
439
+ going = true;
440
+ }
441
+ // THERE, AND NOT ON ITS WAY OUT. A landed edge under a pending edit
442
+ // that would remove it is a relationship the reader may be about to
443
+ // lose, so their consent goes to the store and is adjudicated there —
444
+ // visible either way, which is the difference this guard exists to
445
+ // preserve.
446
+ if ((already && !going) || coming)
447
+ return;
448
+ }
449
+ const handle = store.propose(effect.proposal);
450
+ appliedWrites.set(effect.candidateId, handle.mutationId);
451
+ // AND THE SAME RECORDING AS THE PLAIN ROUTE, because this is the same
452
+ // kind of event: a write going out with a panel it belongs to. The
453
+ // effect carries one now — it used to be built without going through
454
+ // the reducer's emit funnel at all, so a refused first-pass answer had
455
+ // no panel to be stated on.
456
+ writeCarriers.set(handle.mutationId, effect.carrier);
457
+ return;
458
+ }
459
+ case 'first-pass-withdraw': {
460
+ // THE STORE'S OWN UNDO, AND NOTHING ELSE. `discardMine` is the whole of
461
+ // what a host may take back: it declines a `pending` record itself, and
462
+ // a create that LANDED has no record left at all — so in practice the
463
+ // withdrawable set is `invalid`, `failed` and `conflict`, and the
464
+ // reader's `⌫` on anything else steps the queue back and leaves the
465
+ // write alone.
466
+ // THAT REFUSAL IS NOT RESTATED HERE. An earlier revision guarded on
467
+ // `state !== 'pending'` before calling, which is a second copy of a rule
468
+ // the store already enforces — and a mutation test proved it: removing
469
+ // the guard changed no observable behaviour, because `discardMine` was
470
+ // refusing anyway. What is NOT done here is the thing that would be
471
+ // wrong: proposing a compensating `delete`, which is the second
472
+ // retraction path `firstpass/queue.ts` refuses in terms — "free to
473
+ // disagree with [the store] about what undoing a create means."
474
+ // BY THE WRITE'S OWN IDENTITY. See `appliedWrites`: a structural match on
475
+ // the create's fields cannot tell this answer's write from another
476
+ // candidate proposing the same pair, or from an older failed one.
477
+ const mutationId = appliedWrites.get(effect.candidateId);
478
+ if (mutationId === undefined)
479
+ return;
480
+ store.discardMine(mutationId);
481
+ // THE HANDLE OUTLIVES A REFUSED DISCARD. `discardMine` leaves a `pending`
482
+ // record exactly where it was, so forgetting the id here would lose the
483
+ // only thing that can find that write again — see the apply arm.
484
+ if (!recordedWriteFor(effect.candidateId)) {
485
+ appliedWrites.delete(effect.candidateId);
486
+ }
487
+ return;
488
+ }
189
489
  }
190
490
  };
191
- const dispatch = (command) => {
192
- if (destroyed)
193
- return;
194
- const result = reduceHost(state, command, landed());
491
+ /**
492
+ * Take a reduction: keep its state, perform its effects, draw.
493
+ *
494
+ * SPLIT FROM `dispatch` SO ONE CALLER CAN LOOK BEFORE IT LEAPS. The keyboard
495
+ * arm has to know whether this reducer owns a command BEFORE it does anything
496
+ * at all, and `schedule()` is not nothing: it redraws, which replaces
497
+ * `surface.innerHTML` and with it the very button the reader is holding. A
498
+ * `<button>`'s `Space` activation lands on KEYUP, so destroying it first takes
499
+ * the host's own control off the keyboard just as surely as cancelling the
500
+ * press did. `reduceHost` is pure, so asking costs nothing and answers safely.
501
+ */
502
+ const applyResult = (result) => {
195
503
  state = result.state;
196
504
  for (const effect of result.effects)
197
505
  perform(effect);
198
506
  schedule();
199
507
  };
508
+ const dispatch = (command) => {
509
+ if (destroyed)
510
+ return;
511
+ applyResult(reduceHost(state, command, landed()));
512
+ };
200
513
  const zone = (name) => surface.querySelector(`.ig-zone[data-zone="${name}"]`);
201
514
  const pitch = () => theme().metrics['--ig-row-height'] + theme().metrics['--ig-space-tight'];
202
515
  const kindChooser = (source, target) => {
203
516
  const { words } = current;
204
517
  const chooser = el('div', { class: 'ig-chrome-chooser', role: 'group', 'aria-label': words.picker.heading });
205
518
  chooser.append(el('p', { class: 'ig-chrome-sentence' }, [`#${source} … ${target === null ? words.chooseKind : `#${target}`}`]));
519
+ // THE DIGITS ARE THE KEYBOARD'S OWN, READ OFF `KIND_KEYS`. They used to be
520
+ // computed here as `index + 1` over a `KINDS` alias in `host.ts` — a second
521
+ // construction of `create/keys.ts`'s table: the two agreed only because
522
+ // both walked `EDGE_FIELDS`, and nothing pinned them, so a chooser could
523
+ // have told the reader to press a key the key map resolved to a different
524
+ // kind. That alias is gone with its last caller, so this is now the only
525
+ // numbering in the package.
206
526
  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,
527
+ for (const entry of KIND_KEYS) {
528
+ list.append(button(`${entry.key} ${words.picker.kinds[entry.edgeKind]}`, 'kind', {
529
+ 'data-ig-value': entry.edgeKind,
530
+ 'data-edge': entry.edgeKind,
211
531
  }));
212
- });
532
+ }
213
533
  chooser.append(list, button(words.cancel, 'cancel', { class: 'ig-chrome-button ig-chrome-quiet' }));
214
534
  return chooser;
215
535
  };
@@ -232,7 +552,7 @@ export function mountWorkspace(element, options) {
232
552
  for (const match of matches) {
233
553
  list.append(el('li', {}, [
234
554
  button(`#${match.ref} ${match.title}`, 'target', {
235
- 'data-ig-target': match.ref,
555
+ [TARGET_ATTRIBUTE]: match.ref,
236
556
  class: 'ig-chrome-button ig-chrome-match',
237
557
  }),
238
558
  ]));
@@ -247,24 +567,31 @@ export function mountWorkspace(element, options) {
247
567
  const { words } = current;
248
568
  const panel = el('div', { class: 'ig-chrome', 'data-chrome': 'inspector' });
249
569
  const edgeId = selectedEdgeId(state.selection);
250
- const issue = selectedKey(state.selection);
251
570
  const { draft } = state;
571
+ // WHAT IS LEFT HERE IS WHAT NEEDS A DOM. `+ add`, the numbered kind list
572
+ // and §17b's flip are `renderWorkspace`'s now — the flip because the
573
+ // statement it reverses is the selected relationship's ROW, and a picker
574
+ // composed into this same panel drew a second statement and a second flip
575
+ // one element from the first. What is left below draws one control, not two.
576
+ // The older half of this note stands: `+ add` and the numbered kind list
577
+ // are `renderWorkspace`'s now — they are markup, and a host rendering the
578
+ // package without mounting had a panel it could only read. The retype
579
+ // picker and the target search stay: the first is another package's
580
+ // renderer composed as markup, and the second is a live input over
581
+ // `state.targetQuery` with a caret to preserve, which a markup-only
582
+ // renderer cannot be.
583
+ //
584
+ // THE CHAIN'S EXCLUSIVITY IS NOW SPLIT ACROSS TWO LAYERS, and that is why
585
+ // `renderWorkspace` is told about the draft and the drop: it re-states the
586
+ // same guards for the steps it draws, rather than the two surfaces each
587
+ // drawing whatever they can see.
252
588
  if (edgeId !== null) {
253
589
  const picker = el('div', { class: 'ig-chrome-picker' });
254
590
  // Package-rendered markup, escaped by the package.
255
591
  picker.innerHTML = renderPicker(document, edgeId, { words: words.picker, theme: theme() }).markup;
256
592
  panel.append(picker, button(words.deleteRelationship, 'delete', { class: 'ig-chrome-button ig-chrome-danger' }));
257
593
  }
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) {
594
+ else if (draft.source !== null && draft.kind !== null && draft.target === null) {
268
595
  panel.append(targetSearch(document, draft.source, draft.kind));
269
596
  }
270
597
  if (words.keys !== undefined)
@@ -288,6 +615,225 @@ export function mountWorkspace(element, options) {
288
615
  chooser.style.top = `${String(placed.y)}px`;
289
616
  return chooser;
290
617
  };
618
+ /**
619
+ * The first-pass surface, when one is up.
620
+ *
621
+ * A TAKEOVER RATHER THAN A FOURTH ZONE: `ZONES` is a closed union the
622
+ * workspace's grid is laid out from, and §17e's queue occupies the surface
623
+ * rather than a column. It is built and appended the way the floating chooser
624
+ * is — after `surface.innerHTML`, inside the workspace root, which is the box
625
+ * `chrome.ts` positions against and the element a host's theme is scoped to.
626
+ *
627
+ * ## It is a modal, and says so
628
+ *
629
+ * The rail and the canvas are still in the DOM underneath, still focusable and
630
+ * still in the accessibility tree. Without `aria-modal` and `inert` on the
631
+ * zones, one Tab lands the reader on a row they cannot see, where every key is
632
+ * inert because the queue owns the keyboard — and a screen reader reads the
633
+ * whole covered workspace as though nothing were over it.
634
+ *
635
+ * ## `aria-live`, because focus deliberately does not move
636
+ *
637
+ * Answering swaps the question in place while focus stays on this wrapper, so
638
+ * without a live region a reader answering sixty questions at speed hears
639
+ * nothing after the first. Focus stays here rather than moving onto a control
640
+ * because the first control is `apply` — the one irreversible answer — and a
641
+ * `Space` on a focus move nobody made is precisely the un-consented apply
642
+ * §17e forbids.
643
+ */
644
+ const firstPassOverlay = () => {
645
+ const option = current.firstPass;
646
+ const { phase } = state.firstPass;
647
+ if (option === undefined || phase.kind === 'closed')
648
+ return null;
649
+ const wrapper = el('div', {
650
+ class: 'ig-firstpass-overlay',
651
+ role: 'dialog',
652
+ 'aria-modal': 'true',
653
+ // THE DIALOG IS NAMED, not just its child. `renderFirstPass` puts the
654
+ // host's label on the `<section>` it draws, which names that landmark and
655
+ // says nothing about its dialog ancestor — so assistive technology met an
656
+ // unnamed modal. The same word, on the element that is the modal.
657
+ 'aria-label': option.words.label,
658
+ 'aria-live': 'polite',
659
+ 'data-ig-firstpass': phase.kind,
660
+ tabindex: '-1',
661
+ });
662
+ if (phase.kind === 'open') {
663
+ const panel = el('div', { class: 'ig-firstpass-panel' });
664
+ // Package-rendered markup, escaped by the package — the file's standing
665
+ // idiom, the same one the inspector's picker uses.
666
+ panel.innerHTML = renderFirstPass(phase.queue, { words: option.words, theme: theme() }).markup;
667
+ wrapper.append(panel);
668
+ }
669
+ else {
670
+ // THE HOST'S SENTENCE, PLACED. A scan that is out and a scan that failed
671
+ // are different facts and get different words; neither is worded here.
672
+ wrapper.append(el('p', { class: 'ig-firstpass-note' }, [
673
+ phase.kind === 'scanning' ? option.scanning : option.scanFailed,
674
+ ]));
675
+ }
676
+ wrapper.append(button(option.exit, 'first-pass-close', { class: 'ig-chrome-button ig-chrome-quiet' }));
677
+ return wrapper;
678
+ };
679
+ /**
680
+ * This candidate's write, if one is still IN FLIGHT.
681
+ *
682
+ * `pending` and nothing else, because the question it answers is "is this
683
+ * create already on its way?" — and a record that has FAILED is not on its way
684
+ * and never landed, so the relationship it stood for does not exist. Reading
685
+ * "a record exists" as "a write is out there" suppressed the reader's second
686
+ * consent after a failure: nothing was proposed, and the queue nevertheless
687
+ * marked the candidate decided, so a relationship they believed they had
688
+ * recorded was silently absent and never asked about again.
689
+ */
690
+ const pendingWriteFor = (candidateId) => {
691
+ const mutationId = appliedWrites.get(candidateId);
692
+ if (mutationId === undefined)
693
+ return undefined;
694
+ return store
695
+ .getSnapshot()
696
+ .writes.some((write) => write.mutationId === mutationId && write.state === 'pending')
697
+ ? mutationId
698
+ : undefined;
699
+ };
700
+ /** Whether the store still holds a record for this candidate's write, in any state. */
701
+ const recordedWriteFor = (candidateId) => {
702
+ const mutationId = appliedWrites.get(candidateId);
703
+ return (mutationId !== undefined &&
704
+ store.getSnapshot().writes.some((write) => write.mutationId === mutationId));
705
+ };
706
+ /**
707
+ * Which control inside the overlay owns focus, as a SELECTOR a redraw can find
708
+ * again — or `''` for the wrapper itself, or `null` when focus is elsewhere.
709
+ *
710
+ * A selector rather than the node, because the node does not survive the
711
+ * redraw: `surface.innerHTML` is reassigned and the overlay is rebuilt, so
712
+ * what has to be carried across is a way to name the control, not a reference
713
+ * to the one that was destroyed.
714
+ */
715
+ const overlayFocusToken = () => {
716
+ const active = doc.activeElement;
717
+ if (!isElement(active))
718
+ return null;
719
+ const overlay = active.closest('.ig-firstpass-overlay');
720
+ if (overlay === null || !surface.contains(overlay))
721
+ return null;
722
+ const answer = active.getAttribute(ANSWER_ATTRIBUTE);
723
+ if (answer !== null)
724
+ return `[${ANSWER_ATTRIBUTE}="${answer}"]`;
725
+ const command = active.getAttribute(COMMAND_ATTRIBUTE);
726
+ if (command !== null)
727
+ return `[${COMMAND_ATTRIBUTE}="${command}"]`;
728
+ return '';
729
+ };
730
+ /**
731
+ * The command control that owns focus, as facts a redraw can match again.
732
+ *
733
+ * `overlayFocusToken` above answers the same question for the first-pass
734
+ * overlay and only for it, so every command control OUTSIDE that overlay had
735
+ * no restore path at all: the arms below cover the overlay, an `input`'s
736
+ * caret, the target search on the render that opens it, and a pending rail
737
+ * jump — and then fall through to restoring by RAIL ROW, which needs a
738
+ * `[data-ig-key]` ancestor. An inspector button has none, so focus fell to
739
+ * the body. The keydown listener is on the mount's element, so that killed
740
+ * the whole keyboard loop until the reader clicked something else.
741
+ *
742
+ * FACTS, NOT A SELECTOR STRING. `overlayFocusToken` interpolates its value
743
+ * into `[attr="value"]`, which is safe only because `data-ig-answer` and the
744
+ * overlay's own commands come from closed sets. A general token carries
745
+ * {@link TARGET_ATTRIBUTE} — edge identities and issue keys straight out of
746
+ * a host's tracker — and a `"` in one would break the selector or match the
747
+ * wrong control. Comparing attribute values during a walk removes the class
748
+ * instead of escaping it.
749
+ *
750
+ * THE ZONE IS PART OF THE IDENTITY. One command name is published by many
751
+ * controls — every relationship row carries `select-edge` — and an issue is
752
+ * commonly drawn in the rail and again on the canvas, so a token without its
753
+ * zone would restore focus to a namesake in a zone the reader was not in.
754
+ */
755
+ const commandFocusToken = () => {
756
+ const active = doc.activeElement;
757
+ if (!isElement(active) || !surface.contains(active))
758
+ return null;
759
+ // ALL THREE CHANNELS, because the defect is not the command channel's. The
760
+ // audit header's toggle publishes on `data-ig-audit-filter` alone and the
761
+ // first pass's answers on `data-ig-answer` — `controlAnswer` resolves each
762
+ // in its own branch — so a token reading only `data-ig-command` left the audit
763
+ // toggle dropping focus to the body exactly as before. #149 says "any
764
+ // command control", and this is the list that makes that true.
765
+ for (const channel of CONTROL_ATTRIBUTES) {
766
+ const control = active.getAttribute(channel);
767
+ if (control === null)
768
+ continue;
769
+ const partial = {
770
+ channel,
771
+ zone: active.closest('.ig-zone')?.getAttribute('data-zone') ?? null,
772
+ control,
773
+ target: active.getAttribute(TARGET_ATTRIBUTE),
774
+ // THE SAME TWO SPELLINGS `controlAnswer` READS, and in its order: the picker
775
+ // publishes its kind as `data-ig-kind`, the mount's own chrome as
776
+ // `data-ig-value`.
777
+ value: active.getAttribute('data-ig-value') ?? active.getAttribute('data-ig-kind'),
778
+ };
779
+ // `findIndex` RATHER THAN `indexOf`, because `active` is narrowed to
780
+ // `Element` and the siblings are `HTMLElement`. Identity comparison is
781
+ // the same question either way, and the alternative — widening the list
782
+ // or asserting the narrower type — would be a cast this repository bans.
783
+ const ordinal = siblingsOf(partial).findIndex((node) => node === active);
784
+ // A CONTROL THAT DOES NOT FIND ITSELF is one this scope cannot name, so
785
+ // there is no token to carry. `indexOf` answering -1 would otherwise
786
+ // become an ordinal that matches nothing on the way back.
787
+ return ordinal < 0 ? null : { ...partial, ordinal };
788
+ }
789
+ return null;
790
+ };
791
+ /**
792
+ * Every control this token could name, in document order.
793
+ *
794
+ * THE ATTRIBUTE NAME IS A CONSTANT AND THE VALUES ARE COMPARED, so nothing a
795
+ * host's tracker can spell reaches a selector. See the token above.
796
+ *
797
+ * Shared by the token and the restore so the ordinal means the same thing on
798
+ * both sides: a list computed two ways is a list that can disagree with
799
+ * itself, and the ordinal would then point at a different control than the
800
+ * one it was counted against.
801
+ */
802
+ const siblingsOf = (token) => {
803
+ const scope = token.zone === null ? surface : (zone(token.zone) ?? surface);
804
+ return [...scope.querySelectorAll(`[${token.channel}]`)].filter((node) => {
805
+ const control = node.getAttribute(token.channel);
806
+ // BY IDENTITY, so a toggle that redraws itself with the other command is
807
+ // still the control the reader was on. See {@link TOGGLE_IDENTITY}.
808
+ if (control === null || controlIdentity(control) !== controlIdentity(token.control))
809
+ return false;
810
+ if (node.getAttribute(TARGET_ATTRIBUTE) !== token.target)
811
+ return false;
812
+ const value = node.getAttribute('data-ig-value') ?? node.getAttribute('data-ig-kind');
813
+ if (value !== token.value)
814
+ return false;
815
+ // A ZONELESS TOKEN MUST NOT MATCH A ZONED CONTROL. The floating chooser
816
+ // and the first-pass overlay are appended OUTSIDE the four zones, so
817
+ // their tokens carry `zone: null` and scope to the whole surface — and
818
+ // without this the chooser's `cancel` restored onto the inspector's.
819
+ // The two directions are asymmetric on purpose: a zoned token is already
820
+ // confined by `scope`.
821
+ return !(token.zone === null && node.closest('.ig-zone') !== null);
822
+ });
823
+ };
824
+ /** Give focus back to the control a {@link CommandFocus} names. `false` when it is gone. */
825
+ const refocusCommand = (token) => {
826
+ const siblings = siblingsOf(token);
827
+ // THE SAME ONE, THEN THE FIRST. A list that shrank under the reader — a
828
+ // match that stopped matching, a capsule that collapsed — has no nth
829
+ // member, and landing on the first sibling keeps the keyboard alive where
830
+ // returning `false` would drop to the rail.
831
+ const again = siblings[token.ordinal] ?? siblings[0];
832
+ if (again === undefined)
833
+ return false;
834
+ again.focus({ preventScroll: true });
835
+ return true;
836
+ };
291
837
  /** Focus the element carrying `key`, inside one zone when named, without scrolling the page. */
292
838
  const focusIn = (zoneName, key) => {
293
839
  const scope = zoneName === null ? surface : (zone(zoneName) ?? surface);
@@ -331,9 +877,11 @@ export function mountWorkspace(element, options) {
331
877
  if (zoneName !== 'canvas')
332
878
  return null;
333
879
  if (current.canvas === 'tree')
334
- return renderViewer(withoutHost(drawn.viewer), { projection: 'tree', theme: theme() }).scene;
880
+ return renderViewer(withoutChrome(drawn.viewer), { projection: 'tree', theme: theme(), chrome: false }).scene;
335
881
  const ladder = scaleLadder(drawn.viewer, state.scale);
336
- return ladder.tier === 'direct' ? renderViewer(ladder.canvas, { projection: 'graph', theme: theme() }).scene : null;
882
+ return ladder.tier === 'direct'
883
+ ? renderViewer(ladder.canvas, { projection: 'graph', theme: theme(), chrome: false }).scene
884
+ : null;
337
885
  };
338
886
  /** The row or node that owns keyboard focus, if focus is on one at all. */
339
887
  const focusedKey = () => {
@@ -346,8 +894,16 @@ export function mountWorkspace(element, options) {
346
894
  return;
347
895
  const snapshot = store.getSnapshot();
348
896
  const document_ = landed();
349
- // A landed write can retire what the state names; agree with the document first.
350
- state = reconcileHost(state, document_);
897
+ // WHAT THE STORE IS NOT SHOWING, computed before the reconcile rather than
898
+ // beside the edges below, because the reconcile is the first reader of it:
899
+ // an unsettled retype or flip hides the edge it replaces, and the landed
900
+ // document the reconcile is otherwise handed still carries it. Both halves
901
+ // of "the reader can no longer see this" reach `reconcileHost` together.
902
+ const shown = new Set(snapshot.projected.map((edge) => edge.id));
903
+ const hidden = new Set(snapshot.landed.map((edge) => edge.id).filter((id) => !shown.has(id)));
904
+ // A landed write can retire what the state names, and an unsettled one can
905
+ // hide it; agree with what is on show first.
906
+ state = reconcileHost(state, document_, hidden);
351
907
  const projected = current.project(snapshot);
352
908
  // WHAT TO DRAW IS THE STORE'S PROJECTION, by the store's own contract:
353
909
  // `projected` is "landed plus every unsettled edit, each carrying its
@@ -362,8 +918,6 @@ export function mountWorkspace(element, options) {
362
918
  // projection hides the old one until the write settles; the host's
363
919
  // document still carries it, so without this the old line and the new
364
920
  // 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
921
  const kept = projected.viewer.edges.filter((edge) => !hidden.has(edgeIdentity(edge.field, edge.from, edge.to)));
368
922
  const drawn_ = new Set(kept.map((edge) => edgeIdentity(edge.field, edge.from, edge.to)));
369
923
  const unsettled = snapshot.projected
@@ -379,6 +933,160 @@ export function mountWorkspace(element, options) {
379
933
  // through `store.select()` would draw a second halo the inspector does not
380
934
  // reflect, so it is stripped before the projection reaches a canvas.
381
935
  const writeStates = snapshot.projected.map((edge) => edge.states.includes('selected') ? { ...edge, states: edge.states.filter((state) => state !== 'selected') } : edge);
936
+ // THE WRITE LEDGER, TURNED INTO WHAT THE PANEL DRAWS — in ONE pass, in the
937
+ // ledger's own order.
938
+ //
939
+ // IT WAS TWO PASSES AND A CONCATENATION: one walk over the projection for
940
+ // the refusals it had an edge for, then one over the ledger for the ones it
941
+ // did not, appended after. Both halves were right and the JOIN was not.
942
+ // The renderer collapses repeated edges last-wins, so the array's order is
943
+ // the chronology — and appending every stranded record after every
944
+ // projected one is not the order the reader made them in. An older
945
+ // `unknown-edge` naming an edge a later refused create then projects
946
+ // reported the older reason. There is nothing to get wrong now: the ledger
947
+ // is walked once, and its order is the order.
948
+ //
949
+ // WHICH EDGE A REFUSAL MARKS IS THE PROJECTION'S OWN ANSWER.
950
+ // `ProjectedEdge.writes` is its record of which mutations it is speaking
951
+ // for, and `validity.ts` has already decided which edge each one marks — a
952
+ // create marks the edge it would have made, a retype the one it would have
953
+ // become. Rebuilding that here would be a second answer, free to disagree
954
+ // with the line the canvas draws the ghost on; asked of the projection, the
955
+ // record that produced one edge cannot also be counted against another,
956
+ // which is the double-draw this join used to be able to produce.
957
+ const markedBy = new Map(snapshot.projected.flatMap((edge) => edge.writes.map((write) => [write, edge.id])));
958
+ // WHETHER THE MARKED EDGE IS REAL. Three codes refuse an edit ABOUT a
959
+ // landed relationship — `duplicate-edge`, `unchanged-kind`,
960
+ // `symmetric-edge` — and the panel keeps the row for those rather than
961
+ // replacing it with a capsule; see `WorkspaceRefusal.phantom`.
962
+ //
963
+ // ASKED OF `landed`, NEVER OF THE CODE. A list of codes here would be a
964
+ // second copy of `validity.ts`'s decision about which edge each refusal
965
+ // marks, and it would already be wrong: `cardinality` reads like a fourth
966
+ // member of that set and marks a PHANTOM through both of its routes. This
967
+ // is the store's own answer to "what does the document actually carry".
968
+ const landedIds = new Set(snapshot.landed.map((edge) => edge.id));
969
+ const landedNow = { issues: snapshot.issues, edges: snapshot.landed };
970
+ // THE CARRIER MAP, BROUGHT LEVEL WITH THE LEDGER. See `writeCarriers` for
971
+ // why the answer is kept rather than re-asked, and why the ledger's own
972
+ // membership is the whole of its lifetime.
973
+ const ledger = new Set(snapshot.writes.map((record) => record.mutationId));
974
+ // CLEARED WHEN THE RECORD GOES, AND NOT BEFORE.
975
+ //
976
+ // Clearing it on `hydrationError === undefined` as well was the obvious
977
+ // rule and it was WRONG BY ONE RENDER: `retryOnLatest` reserves its record
978
+ // synchronously and publishes, so the very next render happens BEFORE the
979
+ // read has had a chance to fail — the error is still undefined, the flag
980
+ // was dropped, and when the failure did arrive there was nothing left to
981
+ // attribute it to. The card then stayed silent on exactly the failure this
982
+ // field exists to explain. Measured: the pin below went red.
983
+ //
984
+ // So the flag's lifetime is the RECORD's, and whether there is anything to
985
+ // draw is asked of the snapshot at draw time instead.
986
+ if (refreshFailedFor !== null && !ledger.has(refreshFailedFor)) {
987
+ refreshFailedFor = null;
988
+ }
989
+ for (const mutationId of writeCarriers.keys()) {
990
+ if (!ledger.has(mutationId))
991
+ writeCarriers.delete(mutationId);
992
+ }
993
+ for (const record of snapshot.writes) {
994
+ // FIRST SIGHT IS THE EARLIEST MOMENT THERE IS for a write this mount did
995
+ // not emit. Its own edits are already recorded by `perform`, from the
996
+ // document the reader acted on, so this fills in only what a host
997
+ // proposed on the same store — and never a second time.
998
+ if (!writeCarriers.has(record.mutationId)) {
999
+ writeCarriers.set(record.mutationId, editCarrier(landedNow, record.mutation));
1000
+ }
1001
+ }
1002
+ const refusals = snapshot.writes.flatMap((record) => {
1003
+ if (record.state !== 'invalid')
1004
+ return [];
1005
+ // WHOSE PANEL STATES IT, READ BACK RATHER THAN WORKED OUT. The panel used
1006
+ // to derive this from the refused edge's endpoints, against a subject it
1007
+ // had derived by an unrelated rule; then `editCarrier` was called here
1008
+ // instead, which is one rule but still asked at the wrong TIME — a
1009
+ // sibling write can remove the relationship between the act and the
1010
+ // refusal, and what is left says which end declared it only for the
1011
+ // directed fields — `edgeIdentity` sorts the symmetric ones. The answer
1012
+ // was taken when the edit was made; this only looks it up. The two loops
1013
+ // above run over this same `snapshot.writes`, so the key is always
1014
+ // present, and `null` means the document held neither end — there is no
1015
+ // panel a reader could be standing on to read it.
1016
+ const carrier = writeCarriers.get(record.mutationId) ?? null;
1017
+ if (carrier === null)
1018
+ return [];
1019
+ // THE PROJECTION'S EDGE, AND THE EDIT'S OWN AS THE FALLBACK. A refusal
1020
+ // the projection has nothing to hang on is the whole `unknown-edge`
1021
+ // class — a retype, flip or delete of an edge the document no longer
1022
+ // carries produces `{ hidden: [], drawn: [], marked: [] }` — and the
1023
+ // mutation names the edge it was about, so that id is what the capsule
1024
+ // is keyed on.
1025
+ const edgeId = markedBy.get(record.mutationId) ??
1026
+ (record.mutation.op === 'create'
1027
+ ? edgeIdentity(record.mutation.kind, record.mutation.from, record.mutation.to)
1028
+ : record.mutation.edgeId);
1029
+ return [{ edgeId, code: record.reason.code, carrier, phantom: !landedIds.has(edgeId) }];
1030
+ });
1031
+ // §17b's RECOVERY CARDS, BUILT FROM THE SAME LEDGER PASS AS THE REFUSALS.
1032
+ // One walk of `snapshot.writes` keeps the chronology the panel relies on,
1033
+ // and reuses the `edgeId` and `carrier` answers already worked out above
1034
+ // rather than asking either question a second time.
1035
+ //
1036
+ // THE CARRIER IS CARRIED EVEN WHEN IT IS `null`, and that is the one place
1037
+ // this deliberately parts company with the refusal derivation above, which
1038
+ // drops those. A refusal dropped costs the reader a sentence. A RECOVERY
1039
+ // dropped costs them the only retry and discard they have, on a write that
1040
+ // is still theirs to resolve — so it travels, and the panel draws it in the
1041
+ // unplaced region instead of nowhere.
1042
+ // ANNOTATED RATHER THAN INFERRED. `flatMap` takes its element type from the
1043
+ // first arm it sees, which here is the `failed` one — so an unannotated
1044
+ // callback types the whole list as failures and rejects the conflict arm
1045
+ // for want of a `reason`. The repair this repository bans is a cast; the
1046
+ // repair it wants is saying what the function returns.
1047
+ const recoveries = snapshot.writes.flatMap((record) => {
1048
+ if (record.state !== 'failed' && record.state !== 'conflict')
1049
+ return [];
1050
+ const carrier = writeCarriers.get(record.mutationId) ?? null;
1051
+ const edgeId = markedBy.get(record.mutationId) ??
1052
+ (record.mutation.op === 'create'
1053
+ ? edgeIdentity(record.mutation.kind, record.mutation.from, record.mutation.to)
1054
+ : record.mutation.edgeId);
1055
+ if (record.state === 'failed') {
1056
+ return [{ kind: 'failed', mutationId: record.mutationId, edgeId, carrier, reason: record.reason }];
1057
+ }
1058
+ return [
1059
+ {
1060
+ kind: 'conflict',
1061
+ mutationId: record.mutationId,
1062
+ edgeId,
1063
+ carrier,
1064
+ // AGAINST `landedNow`, WHICH CANNOT HOLD THE EDIT — that is the
1065
+ // point. `conflictDiff` reconstructs the reader's own side from the
1066
+ // mutation, so the card is guaranteed to name the relationship it is
1067
+ // about rather than depending on a document that by contract omits it.
1068
+ diff: conflictDiff(landedNow, record.upstream, record.mutation),
1069
+ // THE READ'S OWN FAILURE, WHICH IS NOT THE RECORD'S. A `retry on
1070
+ // latest` whose refresh fails restores this record verbatim and
1071
+ // dispatches nothing; the reason lands on the snapshot instead. Read
1072
+ // from there, the card can say why the button appeared to do nothing.
1073
+ // ONLY ON THE CARD WHOSE READ FAILED. Both halves are required: a
1074
+ // hydration error with no press behind it belongs to no card, and a
1075
+ // press whose read succeeded leaves no error to draw.
1076
+ refreshError: refreshFailedFor === record.mutationId ? (snapshot.hydrationError ?? null) : null,
1077
+ },
1078
+ ];
1079
+ });
1080
+ // ITS LIFETIME IS THE CONFLICT'S, NOT THE LEDGER'S, and the distinction is
1081
+ // load-bearing: `retryOnLatest` reserves its record as `pending`
1082
+ // synchronously, and a resolve whose re-check refuses leaves it `invalid`.
1083
+ // In both the record is still in the ledger and its held document is gone,
1084
+ // so "still in the ledger" would leave a difference region open over
1085
+ // nothing. Asked of the state, it closes on every route out of `conflict`.
1086
+ if (state.diffOpen !== null &&
1087
+ !snapshot.writes.some((record) => record.mutationId === state.diffOpen && record.state === 'conflict')) {
1088
+ state = { ...state, diffOpen: null };
1089
+ }
382
1090
  const result = renderWorkspace(viewer, {
383
1091
  words: current.words,
384
1092
  selection: state.selection,
@@ -393,8 +1101,23 @@ export function mountWorkspace(element, options) {
393
1101
  // through `store.select()` would draw a second halo the inspector does
394
1102
  // not reflect, so it is stripped before the projection reaches the canvas.
395
1103
  projected: writeStates,
1104
+ // THE SAME TWO VALUES THE CHROME BELOW READS. The create path's steps are
1105
+ // split across the two surfaces now, so both have to be told which step is
1106
+ // live — and a drop in flight suppresses the panel's list for the reason
1107
+ // `WorkspaceOptions.drop` records: the floating chooser is already open
1108
+ // at the pointer, and two choosers writing to one draft is what the
1109
+ // shell's own `if (state.drop === null)` was preventing while it drew
1110
+ // both of them.
1111
+ draft: state.draft,
1112
+ drop: state.drop,
1113
+ refusals,
1114
+ recoveries,
1115
+ diffOpen: state.diffOpen,
396
1116
  });
397
- const sheet = [result.styles, pickerStylesheet, mountStylesheet].join('\n');
1117
+ // THE FIRST PASS'S OWN SHEET, IMPORTED — not `renderFirstPass(...).styles`,
1118
+ // which carries a second copy of the theme block written just above it. The
1119
+ // picker is installed the same way and for the same reason.
1120
+ const sheet = [result.styles, pickerStylesheet, firstPassStylesheet, mountStylesheet].join('\n');
398
1121
  if (styles.textContent !== sheet)
399
1122
  styles.textContent = sheet;
400
1123
  // What the reader was doing survives the redraw: the rail's scroll offset,
@@ -410,6 +1133,19 @@ export function mountWorkspace(element, options) {
410
1133
  ? { start: activeInput.selectionStart, end: activeInput.selectionEnd, direction: activeInput.selectionDirection }
411
1134
  : null;
412
1135
  const focused = focusedKey();
1136
+ const overlayToken = overlayFocusToken();
1137
+ // CAPTURED BEFORE THE REDRAW, like every other fact here: the node itself
1138
+ // does not survive `surface.innerHTML`, so what crosses is a way to name
1139
+ // the control rather than a reference to the one about to be destroyed.
1140
+ const commandToken = commandFocusToken();
1141
+ // WAS THE FOCUS OURS TO RESTORE? A reader whose focus is on the host's own
1142
+ // chrome must not have it dragged into the workspace by a redraw the
1143
+ // workspace happened to do — the search arm below already records paying
1144
+ // for that once. The last-resort arm needs this because it fires on
1145
+ // "nothing inside the surface holds focus", which is equally true of a
1146
+ // redraw that destroyed the focused control and of a reader who simply is
1147
+ // not here.
1148
+ const heldFocus = isElement(active) && surface.contains(active);
413
1149
  // The ZONE too: an issue is commonly drawn in the rail and on the canvas,
414
1150
  // and restoring "the first element with this key" would move focus from
415
1151
  // a canvas node into the rail on every redraw.
@@ -433,7 +1169,13 @@ export function mountWorkspace(element, options) {
433
1169
  // as data, the same `data-ig-state` the ladder's line carries, so a
434
1170
  // pending edge is not drawn as a settled one and a host styles or reads
435
1171
  // 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;
1172
+ canvas.innerHTML = renderViewer(withoutChrome(viewer), {
1173
+ projection: 'tree',
1174
+ theme: resolved,
1175
+ selected: selectedKey(state.selection),
1176
+ // The rail beside this canvas draws the panel's one header.
1177
+ chrome: false,
1178
+ }).markup;
437
1179
  const states = new Map(overlaysFor(viewer.edges, writeStates, selectedEdgeId(state.selection)).map((edge) => [edge.id, overlayFor(edge).attribute]));
438
1180
  for (const badge of canvas.querySelectorAll(`[${GROUP_ATTRIBUTE}]`)) {
439
1181
  const attribute = states.get(badge.getAttribute(GROUP_ATTRIBUTE) ?? '');
@@ -449,6 +1191,15 @@ export function mountWorkspace(element, options) {
449
1191
  const floating = floatingChooser();
450
1192
  if (floating !== null)
451
1193
  (surface.firstElementChild ?? surface).append(floating);
1194
+ const overlay = firstPassOverlay();
1195
+ if (overlay !== null) {
1196
+ (surface.firstElementChild ?? surface).append(overlay);
1197
+ // THE ZONES GO INERT UNDER IT. Set here rather than in the stylesheet
1198
+ // because it is a behaviour — focus and hit-testing — not a look, and the
1199
+ // markup is rebuilt every render so it cannot be left behind on close.
1200
+ for (const covered of surface.querySelectorAll('.ig-zone'))
1201
+ covered.setAttribute('inert', '');
1202
+ }
452
1203
  const rail = zone('rail');
453
1204
  if (rail !== null)
454
1205
  rail.scrollTop = scrollTop;
@@ -463,7 +1214,24 @@ export function mountWorkspace(element, options) {
463
1214
  return;
464
1215
  focusIn(within, key);
465
1216
  };
466
- if (activeCommand !== null) {
1217
+ if (overlay !== null) {
1218
+ // FOCUS IS TAKEN BACK ON EVERY RENDER WHILE THE OVERLAY IS UP, not only on
1219
+ // the one that opens it. Every answer dispatches, every dispatch redraws,
1220
+ // and the redraw destroys the element focus was on — so a once-only rule
1221
+ // gave the reader exactly one keystroke before `activeElement` fell to the
1222
+ // body, `interaction` read `elsewhere` and every later key returned
1223
+ // `none`. This is the same arm the target search already earns below.
1224
+ if (firstPassWas === 'closed')
1225
+ focusBeforeFirstPass = focused;
1226
+ const again = overlayToken === null || overlayToken === ''
1227
+ ? null
1228
+ : overlay.querySelector(overlayToken);
1229
+ // THE WRAPPER IS THE FALLBACK, INCLUDING ON THE RENDER THAT OPENS IT. The
1230
+ // first control is `apply`, and landing focus there would put the one
1231
+ // irreversible answer under the reader's next Space.
1232
+ (again ?? overlay).focus({ preventScroll: true });
1233
+ }
1234
+ else if (activeCommand !== null) {
467
1235
  const again = surface.querySelector(`input[${COMMAND_ATTRIBUTE}="${activeCommand}"]`);
468
1236
  if (again !== null) {
469
1237
  again.focus();
@@ -516,32 +1284,267 @@ export function mountWorkspace(element, options) {
516
1284
  }
517
1285
  }
518
1286
  else {
519
- focusRow(focused);
1287
+ // THE COMMAND CONTROL, WHERE THE RAIL CANNOT ANSWER. Strictly additive:
1288
+ // it is reached only when `focusedKey()` found nothing, which is exactly
1289
+ // the case `focusRow(null)` returned from without moving focus at all —
1290
+ // so no control that has a keyed ancestor changes behaviour, and the
1291
+ // rail's roving tab stop is untouched.
1292
+ //
1293
+ // The control can legitimately be gone: the edit it named landed, the
1294
+ // relationship it belonged to was deleted, the recovery card it sat in
1295
+ // resolved. `refocusCommand` says so rather than guessing, and the rail
1296
+ // fallback below is then the same one that ran before.
1297
+ const restored = focused === null && commandToken !== null && refocusCommand(commandToken);
1298
+ if (!restored)
1299
+ focusRow(focused);
1300
+ // A LAST RESORT, because half of these controls RESOLVE THEMSELVES. A
1301
+ // picker option closes the picker; `retry`, `discard`, `cancel` and
1302
+ // `dismiss-change` each remove the card they sit in — so `refocusCommand`
1303
+ // correctly answers "gone", `focusRow(null)` moves nothing, and the
1304
+ // keyboard loop died anyway. That is the whole-loop half of the defect,
1305
+ // and leaving it would have fixed the disclosure while every one-shot
1306
+ // control kept failing the same way.
1307
+ //
1308
+ // The first drawn row, on the same reasoning the first-pass close arm
1309
+ // gives for returning to §17a's entry: somewhere inside the surface that
1310
+ // reaches the listener beats the body, and the rail is the zone the
1311
+ // reader can navigate out of.
1312
+ // THE TEST IS "INSIDE THE SURFACE", NOT "FOCUSABLE". `isFocusable` asks
1313
+ // whether a node has `focus`, and `<body>` does — so it answers true for
1314
+ // exactly the state this arm exists to repair. What the keydown listener
1315
+ // needs is a focus owner it can receive an event from.
1316
+ const adrift = !isElement(doc.activeElement) || !surface.contains(doc.activeElement);
1317
+ if (!restored && focused === null && heldFocus && adrift) {
1318
+ // THROUGH `focusIn`, NOT `focus()`. The rail renders one row at
1319
+ // `tabindex="0"` and the rest at `-1`, and focusing a row directly
1320
+ // leaves the STOP on whichever row had it — so Tab out and back
1321
+ // returns to a different row than the one the reader is on.
1322
+ // `focusIn` moves the stop with the focus, which is why every other
1323
+ // row-focus path in this file goes through it.
1324
+ const first = zone('rail')?.querySelector(`[${KEY_ATTRIBUTE}][tabindex]`);
1325
+ const key = first?.getAttribute(KEY_ATTRIBUTE);
1326
+ if (key !== null && key !== undefined)
1327
+ focusIn('rail', key);
1328
+ }
1329
+ // AND A RESORT THAT CANNOT ITSELF FAIL. The rail is not always there to
1330
+ // fall back to: with the audit filter on and nothing flagged it draws no
1331
+ // rows at all, while the inspector stays perfectly usable — so pressing a
1332
+ // self-removing control there left focus on the body and killed the
1333
+ // keyboard loop exactly as before. A fallback with a precondition is not
1334
+ // a last resort.
1335
+ //
1336
+ // The surface itself always exists, and the keydown listener is on it, so
1337
+ // focus landing here is by definition focus the loop can hear. `tabindex`
1338
+ // is -1: this is somewhere to PUT focus, never a stop Tab should find.
1339
+ if (heldFocus &&
1340
+ (!isElement(doc.activeElement) || !surface.contains(doc.activeElement))) {
1341
+ // A CONTROL, BEFORE THE SURFACE ITSELF. Most of these do not merely
1342
+ // vanish — they REPLACE themselves with the step they opened, and `add`
1343
+ // becoming the kind list is the ordinary case. Landing on the bare
1344
+ // surface there put the press back in reach of the listener and no
1345
+ // further: `interaction()` answers `canvas` only for a focused ROW, so
1346
+ // on the surface every create binding — the kind digits, Escape —
1347
+ // returns `none`. Reachable and inoperable is not the loop this is about.
1348
+ //
1349
+ // THE ZONE THE READER WAS IN FIRST, then anywhere. Which control
1350
+ // replaced which is not knowable from here — the chooser is sometimes
1351
+ // floating and sometimes the inspector's own list — so this asks the
1352
+ // weaker, answerable question: what can the reader act on, nearest to
1353
+ // where they were.
1354
+ const zoneName = commandToken?.zone ?? null;
1355
+ const near = zoneName === null ? null : zone(zoneName);
1356
+ const opened = near?.querySelector(`[${COMMAND_ATTRIBUTE}]`) ??
1357
+ surface.querySelector(`[${COMMAND_ATTRIBUTE}]`);
1358
+ if (opened !== null) {
1359
+ opened.focus({ preventScroll: true });
1360
+ }
1361
+ else {
1362
+ // AND THE SURFACE LAST, which is the resort that cannot itself fail.
1363
+ // It leaves the reader without a binding to press, but it keeps the
1364
+ // listener reachable so Tab moves them somewhere useful — strictly
1365
+ // better than the body, which reaches nothing at all.
1366
+ surface.setAttribute('tabindex', '-1');
1367
+ surface.focus({ preventScroll: true });
1368
+ }
1369
+ }
1370
+ }
1371
+ // THE KEYBOARD IS GIVEN BACK. The overlay is removed with focus inside it,
1372
+ // so without this `activeElement` is the body — and the keydown listener is
1373
+ // on the mount's element, so no press reaches it and the whole workspace is
1374
+ // dead until the reader clicks. Restores what held focus when it opened.
1375
+ if (overlay === null && firstPassWas !== 'closed') {
1376
+ const back = focusBeforeFirstPass;
1377
+ focusBeforeFirstPass = null;
1378
+ appliedWrites.clear();
1379
+ if (back !== null) {
1380
+ focusIn(null, back);
1381
+ }
1382
+ else {
1383
+ // NOTHING KEYED HELD FOCUS, WHICH IS THE ORDINARY CASE. §17a's entry is
1384
+ // a button and carries no `data-ig-key`, so a reader who tabbed to it
1385
+ // and pressed it leaves nothing for `focusIn` to find — and focus on
1386
+ // the document body reaches no listener at all, because the keydown
1387
+ // listener is on the mount's element. The entry is the control they
1388
+ // came in through, so it is where they come back to.
1389
+ // ONE RECORDED ANSWER, NOT TWO: an earlier revision also recorded WHICH
1390
+ // control held focus, which a mutation test showed could never differ —
1391
+ // the queue opens from this one control and no other.
1392
+ // THE ENTRY CAN BE GONE BY NOW, THOUGH. Whether a backlog has a first
1393
+ // pass to run is the host's answer and the host may change it: the
1394
+ // sandbox draws the entry only while its detector finds candidates, so
1395
+ // a completed queue whose writes land removes the very control this
1396
+ // would return to. So the order of resort ends inside the ORDER, which
1397
+ // is the one part of the workspace that is always there.
1398
+ const back_ = surface.querySelector(`[${COMMAND_ATTRIBUTE}="first-pass"]`) ??
1399
+ surface.querySelector(`[${KEY_ATTRIBUTE}][tabindex]`);
1400
+ back_?.focus({ preventScroll: true });
1401
+ }
520
1402
  }
1403
+ firstPassWas = state.firstPass.phase.kind;
521
1404
  searchWasOpen = search !== null;
522
1405
  };
523
- // --- listeners ---
524
- const onClick = (event) => {
525
- const target = isElement(event.target) ? event.target : null;
526
- if (target === null)
1406
+ /**
1407
+ * Take the option's scanner, and end the lifecycle the old one was scanning for.
1408
+ *
1409
+ * Two failures, one rule. Removing the bundle stops the overlay being drawn
1410
+ * while the phase stays open — and the phase is what hands every key to a
1411
+ * queue that is no longer on screen, with no control left to close it.
1412
+ * REPLACING the source is worse than it looks: the old scan's promise still
1413
+ * resolves under the current generation, so the queue would be drawn in the
1414
+ * new bundle's words and populated by the superseded scanner, and a `Y` there
1415
+ * writes a relationship the configured source never proposed.
1416
+ *
1417
+ * RESET RATHER THAN CLOSE, and the difference is the decided set: a
1418
+ * `CandidateId` is the HOST's and opaque, so one scanner's ids say nothing
1419
+ * about another's — carried across, a collision silently drops the new
1420
+ * detector's question, and a question nobody was asked is indistinguishable
1421
+ * from one already answered.
1422
+ *
1423
+ * CALLED FROM `update()`, AT THE MOMENT THE OPTION CHANGES, not from the
1424
+ * render that observes it later. Renders are coalesced on a microtask, so a
1425
+ * host that calls `update()` and then dispatches an `open` in the same task
1426
+ * had its brand-new lifecycle reset by a render still holding the previous
1427
+ * scanner — and the new scan's answer then arrived on a closed phase and was
1428
+ * dropped, so that supported sequence never opened a queue at all.
1429
+ *
1430
+ * Keyed on the SOURCE rather than the bundle, because a host that rebuilds an
1431
+ * equivalent options object every render has changed nothing.
1432
+ */
1433
+ const adoptSource = () => {
1434
+ const source = current.firstPass?.source ?? null;
1435
+ if (source === firstPassSource)
527
1436
  return;
1437
+ firstPassSource = source;
1438
+ if (state.firstPass.phase.kind !== 'closed' || state.firstPass.decided.length > 0) {
1439
+ dispatch({ kind: 'first-pass', command: { kind: 'reset' } });
1440
+ }
1441
+ appliedWrites.clear();
1442
+ };
1443
+ // --- listeners ---
1444
+ /**
1445
+ * What a control press means, resolved once for both listeners.
1446
+ *
1447
+ * ONE CONSTRUCTION, TWO CALLERS. A pointer press and a key press perform the
1448
+ * same act, and until `#158` only the pointer could perform it — so the
1449
+ * keyboard's half was written as a second reading of the same attributes,
1450
+ * free to disagree with this one about which act a press is. Extracting the
1451
+ * answer is what makes "the click path and the key path do the same thing" a
1452
+ * fact rather than a claim two code paths happen to agree on today.
1453
+ *
1454
+ * WHY A REFUSAL IS A STATE AND NOT A `null`. The two callers want opposite
1455
+ * things from a control that declines, so collapsing the two would be wrong
1456
+ * for one of them whichever way it collapsed:
1457
+ *
1458
+ * - `onClick` must STOP on a refusal. Fall through and a press on an inert
1459
+ * `First pass →` reaches the identity branch below and selects whatever row
1460
+ * or group happens to sit under it — an act the reader never asked for.
1461
+ * - `onKeydown` must hand an `input` refusal BACK to the platform and hold an
1462
+ * `inert` one. Hence the reason travels with the refusal: see the arm's own
1463
+ * comment for the space character that is lost when it does not.
1464
+ *
1465
+ * The alternative considered and rejected: `HostCommand | null` plus an
1466
+ * `isControl(target)` predicate for the callers to gate on. The predicate has
1467
+ * to re-walk these same three `closest` calls, which is a second construction
1468
+ * of the very thing this function exists to make singular.
1469
+ *
1470
+ * THREE `closest` WALKS RATHER THAN ONE COMBINED SELECTOR, on purpose. A
1471
+ * single `closest('[a],[b],[c]')` answers with the NEAREST of the three,
1472
+ * which is not the same question: these channels are asked in a fixed
1473
+ * priority — a command wins over an answer, an answer over the audit filter —
1474
+ * and where two nest, the combined selector would answer with the inner one
1475
+ * whatever the priority says. The saving is two ancestor walks on a press,
1476
+ * which is a human keystroke rather than a loop, and the risk is changing
1477
+ * which act a press performs.
1478
+ */
1479
+ const controlAnswer = (target) => {
528
1480
  const control = target.closest(`[${COMMAND_ATTRIBUTE}]`);
529
1481
  if (control !== null && surface.contains(control)) {
530
1482
  const name = control.getAttribute(COMMAND_ATTRIBUTE) ?? '';
531
1483
  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;
1484
+ return REFUSED_INPUT; // the `input` listener owns these
1485
+ // THE ENTRY IS INERT WITHOUT A BUNDLE, and it is withheld HERE rather
1486
+ // than in the reducer. `renderWorkspace` draws §17a's `First pass →`
1487
+ // inside this surface, so a host cannot intercept it from outside; and
1488
+ // the reducer's `first-pass` arm would move to `scanning` with no source
1489
+ // to call and no way back. See `MountWorkspaceOptions.firstPass`.
1490
+ if (name === 'first-pass' && current.firstPass === undefined)
1491
+ return REFUSED_INERT;
1492
+ return {
1493
+ kind: 'dispatch',
1494
+ command: {
1495
+ kind: 'control',
1496
+ name,
1497
+ target: control.getAttribute(TARGET_ATTRIBUTE) ?? undefined,
1498
+ // The picker publishes its kind as `data-ig-kind`; the mount's chrome
1499
+ // publishes `data-ig-value`. One command channel, two spellings.
1500
+ value: control.getAttribute('data-ig-value') ?? control.getAttribute('data-ig-kind') ?? undefined,
1501
+ },
1502
+ };
1503
+ }
1504
+ // AN ANSWER IS ITS OWN ATTRIBUTE, so it falls through the command branch
1505
+ // above and is read here — before the identity branch in `onClick`, which
1506
+ // would otherwise answer a click inside the overlay with whatever key or
1507
+ // group is underneath it.
1508
+ //
1509
+ // REACHED BY THE POINTER ONLY, and deliberately. `onKeydown` returns while
1510
+ // the first-pass phase is open, and this attribute is published only by the
1511
+ // overlay that is drawn while it is open — so the key arm can never see one.
1512
+ // The overlay's own `y`/`n`/`s` map owns those presses, which makes this
1513
+ // half a designed vocabulary rather than a gap.
1514
+ //
1515
+ // WHAT IS A GAP, said plainly because the first draft of this note got it
1516
+ // wrong: the same early return also hides `first-pass-close`, and THAT is a
1517
+ // `data-ig-command` control — the very channel `#158` is about, not one
1518
+ // over. Whether it is activated from `firstPassKeydown` or from a binding in
1519
+ // `firstpass/keys.ts` is a decision about who owns the queue's keyboard, so
1520
+ // it is filed as `#161` rather than settled from inside this function.
1521
+ const answered = target.closest(`[${ANSWER_ATTRIBUTE}]`);
1522
+ if (answered !== null && surface.contains(answered)) {
1523
+ return {
1524
+ kind: 'dispatch',
1525
+ command: {
1526
+ kind: 'control',
1527
+ name: 'first-pass-answer',
1528
+ value: answered.getAttribute(ANSWER_ATTRIBUTE) ?? undefined,
1529
+ },
1530
+ };
542
1531
  }
543
1532
  if (target.closest('[data-ig-audit-filter]') !== null) {
544
- dispatch({ kind: 'control', name: 'audit-filter' });
1533
+ return { kind: 'dispatch', command: { kind: 'control', name: 'audit-filter' } };
1534
+ }
1535
+ return null;
1536
+ };
1537
+ const onClick = (event) => {
1538
+ const target = isElement(event.target) ? event.target : null;
1539
+ if (target === null)
1540
+ return;
1541
+ const answer = controlAnswer(target);
1542
+ if (answer !== null) {
1543
+ // A REFUSAL STILL STOPS THE CLICK, which is what the three `return`s this
1544
+ // branch replaced did. See {@link controlAnswer} for why falling through
1545
+ // would select something underneath the control instead.
1546
+ if (answer.kind === 'dispatch')
1547
+ dispatch(answer.command);
545
1548
  return;
546
1549
  }
547
1550
  // THE NEARER IDENTITY WINS. A relationship badge inside a row carries
@@ -616,15 +1619,68 @@ export function mountWorkspace(element, options) {
616
1619
  * filter or the delete button is a control with its own Enter and Space, and
617
1620
  * a Backspace there must not delete the selected edge; the key map names
618
1621
  * `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.
1622
+ * that. The target search keeps its two bindings.
1623
+ *
1624
+ * ## The kind chooser is the ONE button group that is not `elsewhere`
1625
+ *
1626
+ * The rule above is right for every control it names and wrong for this one,
1627
+ * and the difference is namable rather than a taste: the audit filter and the
1628
+ * delete button share no vocabulary with the key map, while the chooser IS the
1629
+ * key map rendered — its digits are `KIND_KEYS`, the same table `keyIntent`
1630
+ * resolves the press against. A group whose labels tell the reader to press
1631
+ * `2` cannot be a group the reader's `2` is refused by.
1632
+ *
1633
+ * ## Asked of the DRAFT, not of where focus happens to be
1634
+ *
1635
+ * `#152`, and the reason three consecutive rounds of `#150` each fixed a real
1636
+ * bug and surfaced the next one: every one of them moved focus somewhere else,
1637
+ * and this predicate never read the draft, so the next place focus landed
1638
+ * classified `elsewhere` again. Focus is not the fact. A draft with a source
1639
+ * and no kind IS the reader being at the kind step, wherever the mount's
1640
+ * restore happened to put them — and it puts them on the inspector's `clear`,
1641
+ * because the last-resort arm takes the zone's FIRST command control and
1642
+ * `clear` is drawn above the kind list.
1643
+ *
1644
+ * A CONJUNCT ON THE CHOOSER'S OWN MARKUP WAS TRIED ON PAPER AND WOULD HAVE
1645
+ * SHIPPED THE BUG: `closest('<the chooser>')` is false from `clear`, so the
1646
+ * digits would still have died while the change typechecked and its own table
1647
+ * test passed.
1648
+ *
1649
+ * ## `canvas` is asked FIRST, and that ordering is the safety property
1650
+ *
1651
+ * The only answers that move are ones that read `elsewhere` today — where
1652
+ * nothing reaches, so nothing can regress. A reader who leaves the chooser
1653
+ * open and tabs back to a row keeps the whole canvas vocabulary exactly as
1654
+ * before. Note that the order is NOT needed to keep a chooser out of `canvas`:
1655
+ * `focusedKey` walks DOM ancestry, the floating chooser is appended to the
1656
+ * workspace root beside the zones rather than inside the canvas, and the panel
1657
+ * list sits in an inspector that carries no key — so it answers null from
1658
+ * inside either one however this is ordered.
621
1659
  */
622
1660
  const interaction = () => {
623
1661
  const active = doc.activeElement;
624
1662
  if (isElement(active) && isInput(active) && active.getAttribute(COMMAND_ATTRIBUTE) === 'target-query') {
625
1663
  return 'target-search';
626
1664
  }
627
- return focusedKey() !== null ? 'canvas' : 'elsewhere';
1665
+ if (focusedKey() !== null)
1666
+ return 'canvas';
1667
+ // A FOCUSED TEXT BOX IS NEVER THE CHOOSER, whatever the draft is doing.
1668
+ // `scale/render.ts` draws the search-to-focus input, and it stays usable
1669
+ // while a draft is open — so without this a reader who clicked it and typed
1670
+ // `1` would have the digit taken from their query and spent on a
1671
+ // relationship kind, `preventDefault()` included. That is the failure
1672
+ // `create/keys.ts` withholds the digits from `target-search` to avoid, and
1673
+ // this arm would have reintroduced it one control over.
1674
+ //
1675
+ // NARROWING THE NEW ARM ONLY, deliberately: an input that is not the target
1676
+ // search already read `elsewhere` before this predicate existed, so this
1677
+ // hands those presses back exactly as they were rather than reclassifying
1678
+ // anything that works today. A focused input INSIDE a keyed node still
1679
+ // answers `canvas` above, which is a pre-existing question and not this
1680
+ // change's to settle.
1681
+ if (isInput(active))
1682
+ return 'elsewhere';
1683
+ return isChoosingKind(state.draft) ? 'kind-chooser' : 'elsewhere';
628
1684
  };
629
1685
  /**
630
1686
  * The rail's window edge, decided BEFORE the viewer is asked.
@@ -722,7 +1778,111 @@ export function mountWorkspace(element, options) {
722
1778
  }
723
1779
  return false;
724
1780
  };
1781
+ /**
1782
+ * The queue's keys, while the queue is up.
1783
+ *
1784
+ * Returns whether the press was consumed. The queue OWNS the keyboard while it
1785
+ * is drawn — the create map and the viewer's navigation are not consulted,
1786
+ * because every one of their targets is behind an inert overlay — but a press
1787
+ * it does not recognise is handed back rather than swallowed, which is the
1788
+ * contract `firstpass/keys.ts` states in terms: `none` exists "so the host does
1789
+ * not `preventDefault()` a key it did not consume".
1790
+ */
1791
+ const firstPassKeydown = (event) => {
1792
+ const { phase } = state.firstPass;
1793
+ if (phase.kind === 'closed')
1794
+ return false;
1795
+ // ESCAPE IS THE MOUNT'S OWN, and it has to be: `firstpass/keys.ts` binds
1796
+ // `y`/`n`/`s` and both spellings of the delete key and nothing else, and its
1797
+ // own tests pin `Escape` to `none`. The exit CONTROL is the mount's, so the
1798
+ // exit KEY is too — without it §17e's "exit anytime" is reachable only with
1799
+ // a pointer, on the one surface that advertises a pointer-free loop.
1800
+ if (event.key === 'Escape') {
1801
+ dispatch({ kind: 'first-pass', command: { kind: 'close' } });
1802
+ return true;
1803
+ }
1804
+ // TAB STAYS INSIDE. `inert` covers the workspace's own zones and nothing
1805
+ // else, so a mount sitting beside other page chrome let Tab — and Shift-Tab
1806
+ // from the wrapper focus lands on — walk straight out of a dialog that
1807
+ // asserts `aria-modal`. The keydown listener is on the mount's element, so
1808
+ // once focus is outside, `Y`/`N`/`S` and Escape all stop working while the
1809
+ // overlay is still up: a keyboard reader stranded with no way back.
1810
+ if (event.key === 'Tab') {
1811
+ const overlay = surface.querySelector('.ig-firstpass-overlay');
1812
+ if (overlay === null)
1813
+ return false;
1814
+ const stops = [...overlay.querySelectorAll('button, [tabindex]:not([tabindex="-1"])')];
1815
+ const first = stops[0];
1816
+ const last = stops[stops.length - 1];
1817
+ if (first === undefined || last === undefined)
1818
+ return false;
1819
+ const active = doc.activeElement;
1820
+ const at = isElement(active) ? stops.indexOf(active) : -1;
1821
+ // WRAPPING AT BOTH ENDS, and treating "focus is on the wrapper" as before
1822
+ // the first stop — which is where it sits on open and after every answer.
1823
+ const next = event.shiftKey ? (at <= 0 ? last : stops[at - 1]) : at === stops.length - 1 ? first : stops[at + 1];
1824
+ (next ?? first).focus({ preventScroll: true });
1825
+ return true;
1826
+ }
1827
+ if (phase.kind !== 'open')
1828
+ return false;
1829
+ const progress = phase.queue;
1830
+ const context = {
1831
+ // `queue` WHENEVER THE OVERLAY HOLDS FOCUS. The surface has no text input
1832
+ // of its own, so the only question is whether the reader is inside it.
1833
+ interaction: isElement(doc.activeElement) &&
1834
+ doc.activeElement.closest('.ig-firstpass-overlay') !== null &&
1835
+ !isInput(doc.activeElement)
1836
+ ? 'queue'
1837
+ : 'elsewhere',
1838
+ hasCandidate: progress.cursor < progress.candidates.length,
1839
+ canUndo: progress.answers.length > 0,
1840
+ };
1841
+ const intent = firstPassIntent(event, context);
1842
+ if (intent.kind === 'none')
1843
+ return false;
1844
+ dispatch({ kind: 'first-pass', command: { kind: 'queue', command: intent.command } });
1845
+ return true;
1846
+ };
725
1847
  const onKeydown = (event) => {
1848
+ // A HELD KEY BELONGS TO THE CONTROL IT ACTIVATED, wherever focus went next.
1849
+ //
1850
+ // The arm below already refuses to dispatch twice for one held key, and that
1851
+ // is not enough, because ACTIVATING A CONTROL OFTEN MOVES FOCUS: pressing a
1852
+ // kind option redraws and focuses `target-query`, and a self-removing
1853
+ // control falls back to a rail row. So the repeats of that same press arrive
1854
+ // at a DIFFERENT element, where the arm's question — "is what holds focus
1855
+ // now a control?" — answers about the wrong thing entirely. Held `Space` on
1856
+ // a kind option would type spaces into the query the reader has not started;
1857
+ // held `Space` on `add` would walk the rail. Each is the platform doing
1858
+ // exactly the right thing with a press this package took halfway.
1859
+ //
1860
+ // Recorded as a FACT ABOUT THE PRESS rather than re-derived from focus,
1861
+ // which is the same shape as the rest of this file's fixes for this class:
1862
+ // where focus is is not what the question was about. It ends at `keyup`, and
1863
+ // a fresh non-repeat press of the same key clears it too — so a `keyup` lost
1864
+ // to a window blur costs one held key rather than every later one.
1865
+ if (event.repeat && activating.has(event.key)) {
1866
+ event.preventDefault();
1867
+ return;
1868
+ }
1869
+ // A FRESH PRESS ENDS ONLY ITS OWN KEY'S RECORD, and with a set that falls
1870
+ // out rather than being arranged: every key's record is its own, so no other
1871
+ // key's press can reach it. A reader holding `Space` on a control and
1872
+ // touching `Shift` used to have the whole record dropped, and the next
1873
+ // `Space` repeat landed wherever the activation had sent focus.
1874
+ if (!event.repeat)
1875
+ activating.delete(event.key);
1876
+ if (firstPassKeydown(event)) {
1877
+ event.preventDefault();
1878
+ return;
1879
+ }
1880
+ // THE QUEUE STILL OWNS THE SURFACE even for a key it did not consume: every
1881
+ // target the create map and the viewer's navigation could reach is behind
1882
+ // the overlay and inert, so consulting them would act on something the
1883
+ // reader cannot see.
1884
+ if (state.firstPass.phase.kind !== 'closed')
1885
+ return;
726
1886
  const document_ = landed();
727
1887
  const match = targetMatches(document_.issues, state.targetQuery, state.draft.source)[0]?.ref ?? null;
728
1888
  const context = {
@@ -740,8 +1900,152 @@ export function mountWorkspace(element, options) {
740
1900
  dispatch({ kind: 'intent', intent });
741
1901
  return;
742
1902
  }
743
- if (context.focused !== null && navigateFocus(event))
1903
+ if (context.focused !== null && navigateFocus(event)) {
1904
+ event.preventDefault();
1905
+ return;
1906
+ }
1907
+ // --- activating a control from the keyboard ---
1908
+ //
1909
+ // ## Why this is last, and why that is the safety property
1910
+ //
1911
+ // The same argument `interaction()` makes for its own ordering: the only
1912
+ // answers that move are ones nothing reaches today, so nothing can regress.
1913
+ // Two facts make it true here rather than merely plausible.
1914
+ //
1915
+ // `interaction()` answers `elsewhere` for a focused command control that is
1916
+ // neither an input nor the kind step, so `keyIntent` returns `none` for
1917
+ // every key there. At the KIND STEP it answers `kind-chooser` — the
1918
+ // predicate is asked of the DRAFT, not of where focus is — and there
1919
+ // `enter` does not survive and `' '` is unbound, so `keyIntent` is silent
1920
+ // again. So this arm does claim `Enter` and `Space` on the inspector's
1921
+ // `clear`, which is where the mount's focus restore lands after `begin`.
1922
+ // That is a control doing what a control does, and it is new behaviour on
1923
+ // a step the create loop passes through every time — said out loud because
1924
+ // it is the one place this arm changes an interaction that already worked.
1925
+ //
1926
+ // And `navigateFocus` is gated on a focused `[data-ig-key]` ancestor, which
1927
+ // no control has: every command control this package draws sits in chrome —
1928
+ // the inspector, the header, the ladder's refusal list and search — beside
1929
+ // the keyed rows rather than inside one.
1930
+ //
1931
+ // ## Why cancelling is mandatory here, not tidy
1932
+ //
1933
+ // On a real `<button>` the browser's ACTIVATION BEHAVIOUR is this keydown's
1934
+ // default action, and `onClick` sees the click it synthesises. Cancelling
1935
+ // the press is therefore what stops this arm and the browser from both
1936
+ // dispatching — the double fire that was, correctly, the reason this was
1937
+ // never built. Every path below that resolves a control reaches
1938
+ // `preventDefault()` for that reason first and the page's scroll second.
1939
+ //
1940
+ // ## …and why an `input` refusal is handed back anyway
1941
+ //
1942
+ // Both search boxes are `<input>` elements carrying `data-ig-command` — the
1943
+ // chrome's `target-query` and the ladder's `search`. `' '` is unbound in
1944
+ // `create/keys.ts` and neither input has a keyed ancestor, so a space
1945
+ // pressed in either arrives HERE. Cancel it and the reader cannot type a
1946
+ // space into the target query, which is step three of the very
1947
+ // `R → digit → search → ⏎` loop this arm exists to complete. The `input`
1948
+ // listener and the platform's text editing own those characters.
1949
+ //
1950
+ // An `inert` refusal keeps the press for the OTHER two reasons this block
1951
+ // gives: `Space` would scroll the page, and this package has answered. It is
1952
+ // NOT that handing it back would let a native button activate the entry —
1953
+ // that was the first draft of this comment and it is false, because the
1954
+ // click a handed-back press synthesizes reaches `onClick`, which asks the
1955
+ // same `controlAnswer` and gets the same refusal. Recorded wrong-then-right
1956
+ // because the argument, not the behaviour, is what the next reader inherits.
1957
+ //
1958
+ // `' '` is the modern `KeyboardEvent.key` value for the space bar; legacy
1959
+ // `'Spacebar'` is not bound. `viewer/navigation.ts` is the package's one
1960
+ // other key reader that binds it and spells it the same way. (`create/keys.ts`
1961
+ // is NOT a precedent either way: it binds no space key, and lowercases the
1962
+ // names it does bind.)
1963
+ if (event.key !== 'Enter' && event.key !== ' ')
1964
+ return;
1965
+ // TWO OF THE REFUSALS `create/keys.ts` ARGUES FOR ITS OWN BINDINGS, and for
1966
+ // the same reasons — a chord is the platform's shortcut, and `Enter` while
1967
+ // an input method is composing confirms its candidate. Read off the event
1968
+ // rather than imported: those predicates are that module's private
1969
+ // business, and this is the shell's half. Its third, `repeat`, is answered
1970
+ // below rather than here, and the difference is not cosmetic — see there.
1971
+ if (event.ctrlKey || event.metaKey || event.altKey)
1972
+ return;
1973
+ if (event.isComposing)
1974
+ return;
1975
+ // THE FOCUSED ELEMENT. `onClick` reads `event.target` because a click's
1976
+ // target IS where the pointer landed; a keydown's target is the focused
1977
+ // element only under the browser's own dispatch, so a press delegated from a
1978
+ // container would name the container instead. `interaction()` and
1979
+ // `navigateFocus` both read `activeElement` for that reason, and an arm in
1980
+ // the same handler answering a different question would be the outlier.
1981
+ const active = doc.activeElement;
1982
+ if (!isElement(active))
1983
+ return;
1984
+ const answer = controlAnswer(active);
1985
+ if (answer === null)
1986
+ return;
1987
+ if (answer.kind === 'refused' && answer.reason === 'input')
1988
+ return;
1989
+ // A REPEAT IS SWALLOWED, NOT HANDED BACK, and it has to be resolved first to
1990
+ // know that it is ours to swallow. Handing it back looked like the modest
1991
+ // choice and is the one that breaks: `Space` activates a `<button>` on
1992
+ // KEYUP, so a held `Space` whose first keydown this arm cancels and whose
1993
+ // second it releases lets the platform mark the button active — and the
1994
+ // keyup then synthesizes a click that dispatches the act a SECOND time. One
1995
+ // held key, two edits; on a toggle the two cancel and the control reads
1996
+ // dead. So the reader holding a key is one decision, which is what
1997
+ // `create/keys.ts` says a repeat is, and cancelling is how it stays one.
1998
+ // (`Enter` repeats natively either way; only `Space` carries the asymmetry,
1999
+ // and a rule that split them would be a rule about keyboards rather than
2000
+ // about acts.)
2001
+ // A REFUSAL IS THIS PACKAGE'S OWN ANSWER, so it keeps the press: `Space`
2002
+ // would otherwise scroll the page under a reader who has just pressed
2003
+ // something, and there is nothing else waiting for this key.
2004
+ if (answer.kind === 'refused') {
744
2005
  event.preventDefault();
2006
+ return;
2007
+ }
2008
+ // DISPATCH FIRST, THEN DECIDE WHETHER THE PRESS WAS OURS TO KEEP — and that
2009
+ // order is the whole of this arm's remaining subtlety.
2010
+ //
2011
+ // `data-ig-command` IS A SHARED NAMESPACE. `reduceHost`'s `default` arm says
2012
+ // so in terms: a host's own chrome publishes on the same attribute, and
2013
+ // layer 1 already does — the freshness `refresh`, `retry:index`,
2014
+ // `review-pick-order`, `dismiss:adoption` are all drawn inside this surface
2015
+ // and all answered by the host's own `click` listener, never by this
2016
+ // reducer. Cancelling their keydown would suppress the native click that
2017
+ // listener is waiting for, and every one of those controls would stop
2018
+ // answering the keyboard — a regression this arm introduced and could not
2019
+ // see, because from out here a command the reducer ignored and one it
2020
+ // handled without changing anything look identical. So the reducer is asked.
2021
+ //
2022
+ // Unclaimed means HANDS OFF, ALL THE WAY OFF — and "all the way" is the
2023
+ // part that needed a second try. Not cancelling is not enough: dispatching
2024
+ // an unclaimed command still ends in `schedule()`, and the redraw replaces
2025
+ // `surface.innerHTML` — including the button the reader is holding. Since
2026
+ // `Space` activates a `<button>` on KEYUP, destroying it before then loses
2027
+ // the activation just as completely as cancelling the press would. So the
2028
+ // reduction is computed, read, and only THEN applied. No `preventDefault`,
2029
+ // no redraw, no `activating`: the platform's own activation is that
2030
+ // control's route, repeats and all, exactly as before this arm existed.
2031
+ if (destroyed)
2032
+ return;
2033
+ const result = reduceHost(state, answer.command, landed());
2034
+ if (!result.claimed)
2035
+ return;
2036
+ applyResult(result);
2037
+ event.preventDefault();
2038
+ // THE PRESS IS NOW RECORDED AS OURS, so its repeats are swallowed at the top
2039
+ // of this handler wherever the dispatch has since sent focus. Added beside
2040
+ // any other key already held rather than replacing it.
2041
+ activating.add(event.key);
2042
+ };
2043
+ // THE HELD KEY IS LET GO. Registered beside the keydown listener rather than on
2044
+ // the document, so a mount that is torn down takes it with it — and a `keyup`
2045
+ // that never arrives because focus left the window is survivable, which is why
2046
+ // `onKeydown` clears the flag on a fresh press too.
2047
+ const onKeyup = (event) => {
2048
+ activating.delete(event.key);
745
2049
  };
746
2050
  const onPointerDown = (event) => {
747
2051
  // ONE PRIMARY MAIN-BUTTON PRESS AT A TIME. A second pointer during a drag
@@ -834,6 +2138,7 @@ export function mountWorkspace(element, options) {
834
2138
  element.addEventListener('compositionend', onCompositionEnd);
835
2139
  element.addEventListener('scroll', onScroll, true);
836
2140
  element.addEventListener('keydown', onKeydown);
2141
+ element.addEventListener('keyup', onKeyup);
837
2142
  element.addEventListener('pointerdown', onPointerDown);
838
2143
  element.addEventListener('pointermove', onPointerMove);
839
2144
  element.addEventListener('pointerup', onPointerUp);
@@ -850,6 +2155,7 @@ export function mountWorkspace(element, options) {
850
2155
  element.removeEventListener('compositionend', onCompositionEnd);
851
2156
  element.removeEventListener('scroll', onScroll, true);
852
2157
  element.removeEventListener('keydown', onKeydown);
2158
+ element.removeEventListener('keyup', onKeyup);
853
2159
  element.removeEventListener('pointerdown', onPointerDown);
854
2160
  element.removeEventListener('pointermove', onPointerMove);
855
2161
  element.removeEventListener('pointerup', onPointerUp);
@@ -890,8 +2196,10 @@ export function mountWorkspace(element, options) {
890
2196
  update(next) {
891
2197
  if (destroyed)
892
2198
  return;
893
- if (next !== undefined)
2199
+ if (next !== undefined) {
894
2200
  current = { ...current, ...next, store };
2201
+ adoptSource();
2202
+ }
895
2203
  schedule();
896
2204
  },
897
2205
  dispatch,