@issuegraph/editor 0.10.0 → 0.22.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (106) hide show
  1. package/README.md +38 -7
  2. package/dist/a11y/baseline.d.ts.map +1 -1
  3. package/dist/a11y/baseline.js +4 -0
  4. package/dist/a11y/baseline.js.map +1 -1
  5. package/dist/audit/findings.d.ts +107 -1
  6. package/dist/audit/findings.d.ts.map +1 -1
  7. package/dist/audit/findings.js +113 -6
  8. package/dist/audit/findings.js.map +1 -1
  9. package/dist/audit/panel.d.ts +277 -0
  10. package/dist/audit/panel.d.ts.map +1 -0
  11. package/dist/audit/panel.js +462 -0
  12. package/dist/audit/panel.js.map +1 -0
  13. package/dist/audit/refused.d.ts +99 -0
  14. package/dist/audit/refused.d.ts.map +1 -0
  15. package/dist/audit/refused.js +276 -0
  16. package/dist/audit/refused.js.map +1 -0
  17. package/dist/audit/styles.d.ts +1 -1
  18. package/dist/audit/styles.d.ts.map +1 -1
  19. package/dist/audit/styles.js +428 -0
  20. package/dist/audit/styles.js.map +1 -1
  21. package/dist/audit/surface.d.ts +54 -12
  22. package/dist/audit/surface.d.ts.map +1 -1
  23. package/dist/audit/surface.js +53 -13
  24. package/dist/audit/surface.js.map +1 -1
  25. package/dist/create/draft.d.ts +19 -0
  26. package/dist/create/draft.d.ts.map +1 -1
  27. package/dist/create/draft.js +21 -0
  28. package/dist/create/draft.js.map +1 -1
  29. package/dist/create/keys.d.ts +68 -8
  30. package/dist/create/keys.d.ts.map +1 -1
  31. package/dist/create/keys.js +43 -5
  32. package/dist/create/keys.js.map +1 -1
  33. package/dist/index.d.ts +38 -15
  34. package/dist/index.d.ts.map +1 -1
  35. package/dist/index.js +34 -13
  36. package/dist/index.js.map +1 -1
  37. package/dist/overlay/render.d.ts +11 -3
  38. package/dist/overlay/render.d.ts.map +1 -1
  39. package/dist/overlay/render.js +22 -6
  40. package/dist/overlay/render.js.map +1 -1
  41. package/dist/overlay/request.d.ts +71 -0
  42. package/dist/overlay/request.d.ts.map +1 -0
  43. package/dist/overlay/request.js +129 -0
  44. package/dist/overlay/request.js.map +1 -0
  45. package/dist/overlay/styles.d.ts +1 -1
  46. package/dist/overlay/styles.d.ts.map +1 -1
  47. package/dist/overlay/styles.js +38 -7
  48. package/dist/overlay/styles.js.map +1 -1
  49. package/dist/reevaluate/parts.d.ts +130 -0
  50. package/dist/reevaluate/parts.d.ts.map +1 -0
  51. package/dist/reevaluate/parts.js +260 -0
  52. package/dist/reevaluate/parts.js.map +1 -0
  53. package/dist/reevaluate/render.d.ts +18 -11
  54. package/dist/reevaluate/render.d.ts.map +1 -1
  55. package/dist/reevaluate/render.js +21 -135
  56. package/dist/reevaluate/render.js.map +1 -1
  57. package/dist/reevaluate/styles.d.ts +1 -1
  58. package/dist/reevaluate/styles.d.ts.map +1 -1
  59. package/dist/reevaluate/styles.js +26 -0
  60. package/dist/reevaluate/styles.js.map +1 -1
  61. package/dist/reevaluate/view.d.ts +16 -1
  62. package/dist/reevaluate/view.d.ts.map +1 -1
  63. package/dist/reevaluate/view.js +21 -1
  64. package/dist/reevaluate/view.js.map +1 -1
  65. package/dist/reevaluate/words.d.ts +7 -0
  66. package/dist/reevaluate/words.d.ts.map +1 -1
  67. package/dist/scale/ladder.d.ts +62 -6
  68. package/dist/scale/ladder.d.ts.map +1 -1
  69. package/dist/scale/ladder.js +119 -8
  70. package/dist/scale/ladder.js.map +1 -1
  71. package/dist/scale/render.d.ts +44 -0
  72. package/dist/scale/render.d.ts.map +1 -1
  73. package/dist/scale/render.js +197 -27
  74. package/dist/scale/render.js.map +1 -1
  75. package/dist/scale/styles.d.ts +11 -5
  76. package/dist/scale/styles.d.ts.map +1 -1
  77. package/dist/scale/styles.js +133 -4
  78. package/dist/scale/styles.js.map +1 -1
  79. package/dist/workspace/host.d.ts +83 -1
  80. package/dist/workspace/host.d.ts.map +1 -1
  81. package/dist/workspace/host.js +197 -4
  82. package/dist/workspace/host.js.map +1 -1
  83. package/dist/workspace/inspector.d.ts +33 -7
  84. package/dist/workspace/inspector.d.ts.map +1 -1
  85. package/dist/workspace/inspector.js +15 -0
  86. package/dist/workspace/inspector.js.map +1 -1
  87. package/dist/workspace/mount.d.ts +23 -1
  88. package/dist/workspace/mount.d.ts.map +1 -1
  89. package/dist/workspace/mount.js +500 -17
  90. package/dist/workspace/mount.js.map +1 -1
  91. package/dist/workspace/rail.d.ts +5 -2
  92. package/dist/workspace/rail.d.ts.map +1 -1
  93. package/dist/workspace/rail.js.map +1 -1
  94. package/dist/workspace/render.d.ts +379 -3
  95. package/dist/workspace/render.d.ts.map +1 -1
  96. package/dist/workspace/render.js +893 -133
  97. package/dist/workspace/render.js.map +1 -1
  98. package/dist/workspace/selection.d.ts +11 -0
  99. package/dist/workspace/selection.d.ts.map +1 -1
  100. package/dist/workspace/selection.js +10 -0
  101. package/dist/workspace/selection.js.map +1 -1
  102. package/dist/workspace/styles.d.ts +1 -1
  103. package/dist/workspace/styles.d.ts.map +1 -1
  104. package/dist/workspace/styles.js +868 -18
  105. package/dist/workspace/styles.js.map +1 -1
  106. package/package.json +3 -3
@@ -49,7 +49,7 @@ import { edgeIdentity } from '@issuegraph/core';
49
49
  import { nextDocument } from '@issuegraph/store';
50
50
  import { navigate, renderViewer, resolveTheme, } from '@issuegraph/viewer';
51
51
  import { CONTROL_ATTRIBUTES } from "../a11y/baseline.js";
52
- import { isChoosingKind } from "../create/draft.js";
52
+ import { isChoosingKind, isDraftLive } from "../create/draft.js";
53
53
  import { KIND_KEYS, keyIntent } from "../create/keys.js";
54
54
  import { pickerPlacement } from "../create/placement.js";
55
55
  import { firstPassIntent } from "../firstpass/keys.js";
@@ -61,7 +61,7 @@ import { renderPicker } from "../picker/render.js";
61
61
  import { pickerStylesheet } from "../picker/styles.js";
62
62
  import { scaleLadder } from "../scale/ladder.js";
63
63
  import { mountStylesheet } from "./chrome.js";
64
- import { INITIAL_HOST_STATE, editCarrier, railRowAt, railSlackFor, railWindowTarget, reconcileHost, reduceHost, targetMatches, } from "./host.js";
64
+ import { INITIAL_HOST_STATE, editCarrier, narrowOverlayOpen, railRowAt, railSlackFor, railWindowTarget, reconcileHost, reduceHost, targetMatches, } from "./host.js";
65
65
  import { conflictDiff } from "./recovery.js";
66
66
  import { renderWorkspace, } from "./render.js";
67
67
  import { selectedEdgeId, selectedKey } from "./selection.js";
@@ -74,8 +74,31 @@ const CHOOSER_SIZE = { width: 280, height: 220 };
74
74
  /** The drag threshold, in CSS pixels: a press that moves less is a click. */
75
75
  const DRAG_THRESHOLD = 6;
76
76
  const KEY_ATTRIBUTE = 'data-ig-key';
77
+ /**
78
+ * §17c's change region, which is the one node this surface carries ACROSS a
79
+ * redraw rather than replacing.
80
+ *
81
+ * Matched on the role as well as the class: the class is a styling hook, and
82
+ * what has to be preserved is the live region specifically.
83
+ */
84
+ const LIVE_REGION = '.ig-change-line[role="status"]';
77
85
  const GROUP_ATTRIBUTE = 'data-ig-group';
78
86
  const COMMAND_ATTRIBUTE = 'data-ig-command';
87
+ /**
88
+ * How many surfaces this module has mounted, so each gets an id nothing else
89
+ * on the page carries.
90
+ *
91
+ * MODULE STATE, DELIBERATELY, and it is the smallest honest way to do this. The
92
+ * renderer is pure and must stay so — `scale/render.ts`'s `searchSpec` records
93
+ * what a fixed id costs when a host draws two documents side by side — and the
94
+ * value has to be stable across every redraw of the SAME mount, so it cannot be
95
+ * derived per render. A counter in the module that does the mounting satisfies
96
+ * both: one value per `mountWorkspace` call, fixed for that surface's life.
97
+ *
98
+ * Not a random value: a deterministic sequence keeps the markup reproducible
99
+ * for anything that snapshots it.
100
+ */
101
+ let surfaces = 0;
79
102
  /**
80
103
  * Which subject a command acts on, when one command names several.
81
104
  *
@@ -123,9 +146,21 @@ function isElement(target) {
123
146
  function isFocusable(node) {
124
147
  return node !== null && node !== undefined && 'focus' in node;
125
148
  }
126
- /** The two refusals, allocated once: they carry no per-press data. */
149
+ /** The three refusals, allocated once: they carry no per-press data. */
127
150
  const REFUSED_INPUT = Object.freeze({ kind: 'refused', reason: 'input' });
128
151
  const REFUSED_INERT = Object.freeze({ kind: 'refused', reason: 'inert' });
152
+ /** A control whose activation the BROWSER performs. See {@link isNavigating}. */
153
+ const REFUSED_NAVIGATION = Object.freeze({ kind: 'refused', reason: 'navigation' });
154
+ /**
155
+ * Whether this control is a link the browser will follow by itself.
156
+ *
157
+ * THE `href` IS PART OF THE TEST. A bare `<a>` with no `href` is not a link —
158
+ * it has no activation behaviour of its own — so one carrying a command is an
159
+ * ordinary control and must still be dispatched.
160
+ */
161
+ function isNavigating(node) {
162
+ return node?.tagName.toLowerCase() === 'a' && node.getAttribute('href') !== null;
163
+ }
129
164
  function isInput(node) {
130
165
  return node !== null && node !== undefined && node.tagName === 'INPUT';
131
166
  }
@@ -176,6 +211,10 @@ export function mountWorkspace(element, options) {
176
211
  const doc = element.ownerDocument;
177
212
  const { store } = options;
178
213
  let current = options;
214
+ // ONE PER MOUNT, FIXED FOR ITS LIFE. See `surfaces` for why it is taken here
215
+ // rather than per render.
216
+ surfaces += 1;
217
+ const surfaceId = `w${String(surfaces)}`;
179
218
  const styles = doc.createElement('style');
180
219
  const surface = doc.createElement('div');
181
220
  surface.className = 'ig-mount';
@@ -188,6 +227,21 @@ export function mountWorkspace(element, options) {
188
227
  let drawn = null;
189
228
  // A rail row to focus once the window has been re-cut around it.
190
229
  let pendingFocus = null;
230
+ /**
231
+ * Set when a command opened the isolated list, cleared by the redraw that
232
+ * reveals it.
233
+ *
234
+ * THE LIST IS NOT WHERE ITS CONTROL IS, and that is what makes this
235
+ * necessary rather than tidy. §17a puts the count and the toggle at the foot
236
+ * of the ORDER RAIL; the list itself is drawn by the ladder in the CANVAS,
237
+ * because the rail is virtualized on a fixed row pitch (`railRowAt`,
238
+ * `railSpacer`) and content of arbitrary height inside that scroll track
239
+ * makes every offset beneath it name the wrong row. So pressing the toggle
240
+ * flips a control in one zone and grows a list in another, below whatever
241
+ * that zone was already scrolled to — and the reader is told the list is open
242
+ * while seeing nothing change.
243
+ */
244
+ let revealIsolated = false;
191
245
  // The keys currently holding a control they activated. See `onKeydown`'s first
192
246
  // arm for why this is a fact about the PRESS and not a question asked of
193
247
  // whatever holds focus by the time the repeats arrive.
@@ -508,6 +562,12 @@ export function mountWorkspace(element, options) {
508
562
  const dispatch = (command) => {
509
563
  if (destroyed)
510
564
  return;
565
+ // ON THE COMMAND, NOT ON THE RESULTING STATE. `open-isolated` is what the
566
+ // reader just asked for; `scale.isolatedOpen` is also true on every redraw
567
+ // that follows for any other reason, and revealing it again there would
568
+ // yank the canvas back under a reader who had scrolled away.
569
+ if (command.kind === 'control' && command.name === 'open-isolated')
570
+ revealIsolated = true;
511
571
  applyResult(reduceHost(state, command, landed()));
512
572
  };
513
573
  const zone = (name) => surface.querySelector(`.ig-zone[data-zone="${name}"]`);
@@ -889,6 +949,38 @@ export function mountWorkspace(element, options) {
889
949
  const keyed = isElement(active) ? active.closest(`[${KEY_ATTRIBUTE}]`) : null;
890
950
  return keyed !== null && surface.contains(keyed) ? keyed.getAttribute(KEY_ATTRIBUTE) : null;
891
951
  };
952
+ /**
953
+ * The subject `+ add` publishes, when that control itself owns focus.
954
+ *
955
+ * THE SECOND SOURCE FOR `KeyboardContext.focused`, and it exists because the
956
+ * first one cannot answer here: `focusedKey` reads the rail's roving tab stop,
957
+ * and a focused button has no keyed ancestor — every command control this
958
+ * package draws sits in chrome beside the keyed rows rather than inside one.
959
+ * So `R` on `+ add` reached a `relate` arm whose subject was `null`, which
960
+ * answers `none` ("nothing focused is nothing to relate FROM"). `#173`.
961
+ *
962
+ * IT READS THE CONTROL'S OWN ATTRIBUTE, NOT THE SELECTION, for the reason
963
+ * `reduceHost`'s `add` arm already reads it: `inspectorView` canonicalizes a
964
+ * selection naming a together unit's member onto the slot's LEAD, and the
965
+ * panel is worded from that, so the control publishes the canonical subject
966
+ * and the selection is only a fallback for a control that names none. Taking
967
+ * the selection here would begin the keyboard's draft from a different issue
968
+ * than the pointer's, on one screen, from one control.
969
+ *
970
+ * NARROWED TO THE `add` COMMAND, and that is a correctness bound rather than
971
+ * tidiness: {@link TARGET_ATTRIBUTE} is carried by the row remove control
972
+ * (an edge id), by a recovery card (a mutation id) and by a hold's subject
973
+ * button. Reading it off whichever control happens to hold focus would hand
974
+ * `relate` an edge id as an issue ref.
975
+ */
976
+ const focusedAddSubject = () => {
977
+ const active = doc.activeElement;
978
+ if (!isElement(active) || !surface.contains(active))
979
+ return null;
980
+ if (active.getAttribute(COMMAND_ATTRIBUTE) !== 'add')
981
+ return null;
982
+ return active.getAttribute(TARGET_ATTRIBUTE);
983
+ };
892
984
  const render = () => {
893
985
  if (destroyed)
894
986
  return;
@@ -1089,11 +1181,43 @@ export function mountWorkspace(element, options) {
1089
1181
  }
1090
1182
  const result = renderWorkspace(viewer, {
1091
1183
  words: current.words,
1184
+ // §17c's CAUSE AND EFFECT, STRAIGHT OFF THE SNAPSHOT. The store is the
1185
+ // single source of truth for both — `lastChange` persists until the next
1186
+ // edit or an explicit dismissal, and `dismissChange()` is already the
1187
+ // only thing besides the next edit that clears it — so nothing about
1188
+ // "has this been dismissed" is held out here. A second copy of that is
1189
+ // exactly the drift the command grammar exists to avoid.
1190
+ //
1191
+ // `orderStatus` is the SAME value stamped on `surface` below. They cannot
1192
+ // disagree because they are one read of one snapshot, and each is for a
1193
+ // different reader: the attribute on `surface` is the host's, on the
1194
+ // element the host holds; the option is the stylesheet's, on the root the
1195
+ // renderer owns, so an unmounted rendering greys the held rail too.
1196
+ change: snapshot.lastChange ?? null,
1197
+ orderStatus: snapshot.order.status,
1092
1198
  selection: state.selection,
1093
1199
  scale: state.scale,
1094
1200
  rail: { start: state.railStart, count: railCount() },
1201
+ // §16f's open rows. Host state, because the viewer is re-rendered from
1202
+ // scratch and cannot keep any of its own.
1203
+ expanded: state.expanded,
1204
+ // UNIQUE TO THIS MOUNT, so §17a's rail footer can name the isolated list
1205
+ // the canvas draws without two mounted workspaces on one page naming the
1206
+ // same element. See `WorkspaceOptions.surfaceId`; the counter is why a
1207
+ // second mount gets a second value.
1208
+ surfaceId,
1095
1209
  audit,
1096
1210
  auditFiltered: state.auditFiltered,
1211
+ // §17k's two reader decisions. Host state, for the reason the filter
1212
+ // beside it is: the renderer keeps nothing across a redraw, and a strip
1213
+ // that reopened itself on every landed write would be unusable.
1214
+ canvasOpen: state.canvasOpen,
1215
+ inspectorDismissed: state.inspectorDismissed,
1216
+ // §17d's overlay. Host state, for the same reason the filter beside it
1217
+ // is: the renderer keeps nothing across a redraw.
1218
+ auditOpen: state.auditOpen,
1219
+ issueUrl: current.issueUrl,
1220
+ railDensity: current.railDensity,
1097
1221
  theme: resolved,
1098
1222
  themeSelector: current.themeSelector,
1099
1223
  // THE WRITE STATES ONLY. The workspace holds the one selection, and the
@@ -1121,9 +1245,23 @@ export function mountWorkspace(element, options) {
1121
1245
  if (styles.textContent !== sheet)
1122
1246
  styles.textContent = sheet;
1123
1247
  // What the reader was doing survives the redraw: the rail's scroll offset,
1124
- // and the caret in whichever search box they were typing into.
1248
+ // the audit panel's, and the caret in whichever search box they were typing
1249
+ // into.
1125
1250
  const railBefore = zone('rail');
1126
1251
  const scrollTop = railBefore?.scrollTop ?? 0;
1252
+ // THE AUDIT SCROLLS TOO, SINCE IT TOOK A SHARE OF ITS COLUMN. Before it had
1253
+ // a bound it grew to its content and only the ZONE scrolled, so there was no
1254
+ // second offset to keep. Now a long audit is read inside its own scroller,
1255
+ // and every store update and unrelated command runs this redraw — so without
1256
+ // this a reader deep in the findings is returned to the first one by
1257
+ // something they did somewhere else entirely.
1258
+ //
1259
+ // THE REGION, NOT THE PANEL. #177 put the bound on `.ig-audit-panel`, and
1260
+ // §17d's second surface moved it up to the region holding both — so reading
1261
+ // the panel's offset now reads a number that is always zero, and restoring
1262
+ // it is a no-op a reader feels as the audit jumping back on every redraw.
1263
+ const auditBefore = surface.querySelector('.ig-audit-overlay');
1264
+ const auditScrollTop = auditBefore?.scrollTop ?? 0;
1127
1265
  const active = doc.activeElement;
1128
1266
  const activeInput = isElement(active) && isInput(active) && surface.contains(active) ? active : null;
1129
1267
  const activeCommand = activeInput?.getAttribute(COMMAND_ATTRIBUTE) ?? null;
@@ -1146,13 +1284,60 @@ export function mountWorkspace(element, options) {
1146
1284
  // redraw that destroyed the focused control and of a reader who simply is
1147
1285
  // not here.
1148
1286
  const heldFocus = isElement(active) && surface.contains(active);
1287
+ // AND WHETHER IT WAS ONE OF THE AUDIT'S OWN TAB STOPS, which are the stops
1288
+ // on this surface no token can name. Neither has a keyed ancestor or a
1289
+ // command attribute, so `focusedKey()` and `commandFocusToken()` both answer
1290
+ // nothing for them — and the last-resort arm below fires on exactly that,
1291
+ // sending a reader who was reading a long audit to the first rail row, in
1292
+ // another zone.
1293
+ //
1294
+ // TWO OF THEM SINCE §17d GAINED ITS SECOND SURFACE, and the refused block is
1295
+ // the one that needs this MOST: both of its controls are conditional, so a
1296
+ // block whose refusals all name unloaded issues has no focusable descendant
1297
+ // at all and the section itself is the only stop a reader can hold.
1298
+ //
1299
+ // A CLASS RATHER THAN THE NODE THE OFFSET CAME FROM. That identity test was
1300
+ // right while the scrolling element and the tab stop were one element; they
1301
+ // are not any more — the region scrolls and its members carry the stops —
1302
+ // so the answer has to name which stop, and it is restored by the same name.
1303
+ const AUDIT_STOPS = ['.ig-audit-refused', '.ig-audit-panel'];
1304
+ const auditStop = isElement(active)
1305
+ ? (AUDIT_STOPS.find((one) => active.matches(one)) ?? null)
1306
+ : null;
1149
1307
  // The ZONE too: an issue is commonly drawn in the rail and on the canvas,
1150
1308
  // and restoring "the first element with this key" would move focus from
1151
1309
  // a canvas node into the rail on every redraw.
1152
1310
  const focusedZone = isElement(active) ? (active.closest('.ig-zone')?.getAttribute('data-zone') ?? null) : null;
1153
1311
  drawn = { viewer, rail: result.view.rail };
1312
+ // THE LIVE REGION HAS TO SURVIVE THE REDRAW, or it announces nothing.
1313
+ //
1314
+ // `summarySpec` mounts §17c's `role="status"` region ALWAYS and leaves it
1315
+ // EMPTY until there is something to say, precisely because a status node
1316
+ // that is created already carrying its text is not reliably announced —
1317
+ // the region has to exist first and then have its contents change. This
1318
+ // function replaces the whole subtree on every redraw, so rendering that
1319
+ // region into the markup and stopping there would destroy it and insert a
1320
+ // new, already-populated one every time: the affordance present, correct
1321
+ // in the markup, and inert. Worse than absent, because it looks done.
1322
+ //
1323
+ // So the ELEMENT is carried across: re-attached still holding the previous
1324
+ // summary, and only then given the new one. The insertion says nothing and
1325
+ // the mutation is what announces, which is the sequence the region was
1326
+ // designed around.
1327
+ const liveBefore = surface.querySelector(LIVE_REGION);
1154
1328
  // Package-rendered markup, escaped by the package.
1155
1329
  surface.innerHTML = result.markup;
1330
+ const liveAfter = surface.querySelector(LIVE_REGION);
1331
+ if (liveBefore !== null && liveAfter !== null) {
1332
+ const next = liveAfter.innerHTML;
1333
+ liveAfter.replaceWith(liveBefore);
1334
+ // GUARDED, so a redraw that did not touch the summary is not a change to
1335
+ // announce. Every keystroke in the ladder's search box redraws this
1336
+ // surface, and re-assigning identical content would re-announce the last
1337
+ // edit's result on each one.
1338
+ if (liveBefore.innerHTML !== next)
1339
+ liveBefore.innerHTML = next;
1340
+ }
1156
1341
  // THE ORDER'S STATUS, PUBLISHED AS DATA. A write in flight holds the order
1157
1342
  // — it does not move until the edit lands — and a host that wants to say
1158
1343
  // so has nowhere to read it once the snapshot is consumed here. The
@@ -1169,6 +1354,41 @@ export function mountWorkspace(element, options) {
1169
1354
  // as data, the same `data-ig-state` the ladder's line carries, so a
1170
1355
  // pending edge is not drawn as a settled one and a host styles or reads
1171
1356
  // it the same way in both modes. The merge is the ladder's own.
1357
+ // §17f'S ROW SURVIVES THIS ASSIGNMENT, AND ITS CAPTION DOES NOT.
1358
+ // `innerHTML` replaces every child, and `renderWorkspace` puts the
1359
+ // toolbar in this zone as its FIRST child — so the row, the pill
1360
+ // included, was silently deleted the moment a reader switched to the
1361
+ // tree. The pill is a property of the SURFACE, and a mounted surface is
1362
+ // exactly where a host is told to supply it, so losing it here took the
1363
+ // control away in the one state it is documented to exist in.
1364
+ //
1365
+ // THE CAPTION IS DIFFERENT, and is dropped on purpose rather than by
1366
+ // accident. It states what the LADDER's canvas draws — one connected
1367
+ // component out of the whole backlog — and this branch does not draw the
1368
+ // ladder's canvas: it draws `viewer`, the whole document, as a tree. So
1369
+ // "6 out of 312 drawn here" is simply false over a tree of 312, on the
1370
+ // same rule that keeps the caption off the refusing tiers.
1371
+ const toolbar = canvas.querySelector('.ig-canvas-toolbar');
1372
+ toolbar?.querySelector('.ig-canvas-caption')?.remove();
1373
+ // THE ISOLATED BLOCK SURVIVES THIS ASSIGNMENT TOO, and for a sharper
1374
+ // reason than the row above. §17a moves the isolated CONTROL to the
1375
+ // foot of the order rail, which this branch does not touch — so the
1376
+ // control survives the switch to the tree whatever happens here, while
1377
+ // the list it opens is drawn in THIS zone and would not. The reader
1378
+ // presses it, the label turns to "hide" and `aria-expanded` to `true`,
1379
+ // and nothing appears: a control that reports a state the surface is
1380
+ // not in.
1381
+ //
1382
+ // WORSE THAN WHAT IT REPLACED, WHICH IS WHY IT IS FIXED HERE RATHER
1383
+ // THAN NOTED. Before that move the chip lived in this zone beside its
1384
+ // list, so the tree deleted BOTH and the affordance was merely absent —
1385
+ // wrong, but not lying. Keeping the list is what makes the control it
1386
+ // is now separated from honest.
1387
+ //
1388
+ // UNCONDITIONAL, unlike the toolbar's `childElementCount` guard. This
1389
+ // element is not assembled from optional parts: `isolatedSpec` returns
1390
+ // null rather than an empty husk, so anything found here has content.
1391
+ const isolated = canvas.querySelector('.ig-ladder-isolated');
1172
1392
  canvas.innerHTML = renderViewer(withoutChrome(viewer), {
1173
1393
  projection: 'tree',
1174
1394
  theme: resolved,
@@ -1176,6 +1396,21 @@ export function mountWorkspace(element, options) {
1176
1396
  // The rail beside this canvas draws the panel's one header.
1177
1397
  chrome: false,
1178
1398
  }).markup;
1399
+ // Re-inserted rather than re-rendered: the row is already assembled,
1400
+ // and rebuilding it here would be a second place that decides what it
1401
+ // says.
1402
+ //
1403
+ // ONLY IF IT STILL HAS CONTENT, which is `canvasToolbar`'s own rule —
1404
+ // "nothing to say, no row" — and removing the caption above is exactly
1405
+ // what can empty it. A host that words the caption and not the pill
1406
+ // gets a caption-only row on the direct tier, and prepending its
1407
+ // emptied husk here left a sticky padded band with a border and no
1408
+ // content over the tree. Reading the element rather than re-deriving
1409
+ // the condition keeps the rule in one place.
1410
+ if (isolated !== null)
1411
+ canvas.append(isolated);
1412
+ if (toolbar !== null && toolbar.childElementCount > 0)
1413
+ canvas.prepend(toolbar);
1179
1414
  const states = new Map(overlaysFor(viewer.edges, writeStates, selectedEdgeId(state.selection)).map((edge) => [edge.id, overlayFor(edge).attribute]));
1180
1415
  for (const badge of canvas.querySelectorAll(`[${GROUP_ATTRIBUTE}]`)) {
1181
1416
  const attribute = states.get(badge.getAttribute(GROUP_ATTRIBUTE) ?? '');
@@ -1203,6 +1438,25 @@ export function mountWorkspace(element, options) {
1203
1438
  const rail = zone('rail');
1204
1439
  if (rail !== null)
1205
1440
  rail.scrollTop = scrollTop;
1441
+ // RE-QUERIED, NEVER REUSED: `surface.innerHTML` above replaced the subtree,
1442
+ // so the element captured from is gone. A region that shrank clamps this on
1443
+ // assignment, and one the redraw removed — the audit going clean — is
1444
+ // simply absent, which is why nothing is restored rather than zeroed.
1445
+ const auditRegion = surface.querySelector('.ig-audit-overlay');
1446
+ if (auditRegion !== null)
1447
+ auditRegion.scrollTop = auditScrollTop;
1448
+ // THE LIST THE READER JUST OPENED IS BROUGHT INTO VIEW. See
1449
+ // `revealIsolated` for why the control and the list are in different zones.
1450
+ // `block: 'nearest'` so a list already visible is not scrolled at all, and
1451
+ // guarded on the method because the same guard is used for the rail's own
1452
+ // jumps — this module renders into whatever document a host hands it.
1453
+ if (revealIsolated) {
1454
+ revealIsolated = false;
1455
+ const list = surface.querySelector('.ig-isolated-list');
1456
+ if (list !== null && typeof list.scrollIntoView === 'function') {
1457
+ list.scrollIntoView({ block: 'nearest' });
1458
+ }
1459
+ }
1206
1460
  // FOCUS SURVIVES THE REDRAW, and it moves to the target search when that
1207
1461
  // step opens. The keyboard path is R -> kind -> search -> Enter, and every
1208
1462
  // step redraws: without this, R destroyed the focused row, the next press
@@ -1231,6 +1485,21 @@ export function mountWorkspace(element, options) {
1231
1485
  // irreversible answer under the reader's next Space.
1232
1486
  (again ?? overlay).focus({ preventScroll: true });
1233
1487
  }
1488
+ else if (auditStop !== null && surface.querySelector(auditStop) !== null) {
1489
+ // THE READER WAS IN THE AUDIT, SO THEY STAY THERE — and on the same one of
1490
+ // its two surfaces, which is why the stop is restored by the name it was
1491
+ // captured under rather than by whichever the redraw drew first.
1492
+ //
1493
+ // Beside the offset restored above rather than instead of it: the offset
1494
+ // says where the region is scrolled to and this says who is reading it,
1495
+ // and a redraw that kept one without the other still moves the reader out
1496
+ // of the zone.
1497
+ //
1498
+ // `preventScroll`, because the offset was already put back — focusing
1499
+ // normally would scroll the column to bring the element into view and
1500
+ // undo it.
1501
+ surface.querySelector(auditStop)?.focus({ preventScroll: true });
1502
+ }
1234
1503
  else if (activeCommand !== null) {
1235
1504
  const again = surface.querySelector(`input[${COMMAND_ATTRIBUTE}="${activeCommand}"]`);
1236
1505
  if (again !== null) {
@@ -1482,6 +1751,22 @@ export function mountWorkspace(element, options) {
1482
1751
  const name = control.getAttribute(COMMAND_ATTRIBUTE) ?? '';
1483
1752
  if (isInput(control))
1484
1753
  return REFUSED_INPUT; // the `input` listener owns these
1754
+ // AND THE BROWSER OWNS A LINK, which is the same refusal one element over.
1755
+ // §17d's `Open in GitHub` is an ANCHOR — it leaves the document, and a
1756
+ // control that navigates away must not announce itself as a button — and
1757
+ // it carries a command name only so `a11y/baseline.ts` can see it: that
1758
+ // artifact records a control through the attribute channels it knows, and
1759
+ // an anchor bearing nothing but `href` is invisible to it.
1760
+ //
1761
+ // DISPATCHING IT WOULD BE A REDRAW UNDER THE READER'S OWN CLICK. The
1762
+ // reducer has no arm for the name, so the command comes back unclaimed —
1763
+ // but `applyResult` still schedules, and the redraw replaces the surface's
1764
+ // markup, detaching the anchor mid-activation. An `<a>` is exempt from the
1765
+ // "cannot navigate" connectedness check so the navigation still happens,
1766
+ // which is precisely why this would have gone unnoticed: a full re-render
1767
+ // per click, paid for a command nothing handles.
1768
+ if (isNavigating(control))
1769
+ return REFUSED_NAVIGATION;
1485
1770
  // THE ENTRY IS INERT WITHOUT A BUNDLE, and it is withheld HERE rather
1486
1771
  // than in the reducer. `renderWorkspace` draws §17a's `First pass →`
1487
1772
  // inside this surface, so a host cannot intercept it from outside; and
@@ -1532,6 +1817,13 @@ export function mountWorkspace(element, options) {
1532
1817
  if (target.closest('[data-ig-audit-filter]') !== null) {
1533
1818
  return { kind: 'dispatch', command: { kind: 'control', name: 'audit-filter' } };
1534
1819
  }
1820
+ // §17d's HEADER COUNT. Checked AFTER the filter, and the order is not
1821
+ // arbitrary: the filter chip lives inside the panel the count opens, so a
1822
+ // click on it is inside neither the other's subtree today — but if the two
1823
+ // ever nest, the inner control is the one the reader aimed at.
1824
+ if (target.closest('[data-ig-audit-panel]') !== null) {
1825
+ return { kind: 'dispatch', command: { kind: 'control', name: 'audit-panel' } };
1826
+ }
1535
1827
  return null;
1536
1828
  };
1537
1829
  const onClick = (event) => {
@@ -1621,14 +1913,23 @@ export function mountWorkspace(element, options) {
1621
1913
  * `elsewhere` as "everything that is not our own search box", and a button is
1622
1914
  * that. The target search keeps its two bindings.
1623
1915
  *
1624
- * ## The kind chooser is the ONE button group that is not `elsewhere`
1916
+ * ## The TWO controls that are not `elsewhere`, and the test they pass
1625
1917
  *
1626
- * The rule above is right for every control it names and wrong for this one,
1918
+ * The rule above is right for every control it names and wrong for these two,
1627
1919
  * 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.
1920
+ * delete button share no vocabulary with the key map, while these two ARE the
1921
+ * key map rendered. The kind chooser's digits are `KIND_KEYS`, the same table
1922
+ * `keyIntent` resolves the press against, and `+ add` draws `RELATE_KEY`
1923
+ * itself as its hint. A control whose labels tell the reader to press `2`
1924
+ * cannot be a control the reader's `2` is refused by, and `#173` is the same
1925
+ * sentence about `R`: the hint named a key the reader genuinely has, at the
1926
+ * one focus position where it was inert.
1927
+ *
1928
+ * THE TEST IS "DOES THIS CONTROL DRAW A KEY THIS MAP READS", and it is what
1929
+ * stops the list growing on taste. It admits exactly two controls today; a
1930
+ * host's own widget, a filter, a remove button and every control that draws
1931
+ * no key stay `elsewhere`. `create/keys.ts` carries the matching half — the
1932
+ * new state admits the ONE binding it renders and no other.
1632
1933
  *
1633
1934
  * ## Asked of the DRAFT, not of where focus happens to be
1634
1935
  *
@@ -1646,6 +1947,25 @@ export function mountWorkspace(element, options) {
1646
1947
  * digits would still have died while the change typechecked and its own table
1647
1948
  * test passed.
1648
1949
  *
1950
+ * ## `add-control` reads focus, and that is not a relapse
1951
+ *
1952
+ * `#173`'s arm asks where focus is, which is the shape this section rejects —
1953
+ * so it has to survive the argument rather than repeat what it rejected, and
1954
+ * the difference is WHICH FACT IS MISSING. At the kind step the fact is the
1955
+ * DRAFT: a source with no kind IS the reader at that step, wherever a restore
1956
+ * put them, so reading focus there answers a question the draft had already
1957
+ * answered better. At the begin step there is no draft yet — beginning one is
1958
+ * the act — and the fact is the SUBJECT to begin from. The control publishes
1959
+ * it on `data-ig-target`, so what this arm reads is a control's own claim
1960
+ * about what it is for, and focus is what says the reader is standing on it.
1961
+ *
1962
+ * The `#150` failure mode is therefore absent by construction: it was focus
1963
+ * MOVING AWAY from the control the draft belonged to, and the arm here has no
1964
+ * "away" — it answers about the control still holding focus, and once the
1965
+ * draft begins the panel replaces this control with the kind list, at which
1966
+ * point the draft is the fact again and `kind-chooser` answers. That ordering
1967
+ * is written below rather than left to be inferred.
1968
+ *
1649
1969
  * ## `canvas` is asked FIRST, and that ordering is the safety property
1650
1970
  *
1651
1971
  * The only answers that move are ones that read `elsewhere` today — where
@@ -1657,6 +1977,52 @@ export function mountWorkspace(element, options) {
1657
1977
  * list sits in an inspector that carries no key — so it answers null from
1658
1978
  * inside either one however this is ordered.
1659
1979
  */
1980
+ /**
1981
+ * Whether Escape has nothing of the CREATE PATH's to cancel.
1982
+ *
1983
+ * ONE PREDICATE FOR WHAT WAS TWO. §17d's overlay and §17k's transients both
1984
+ * take Escape, and both must yield to a live draft — a reader mid-relationship
1985
+ * means "not this edge", not "close the panel behind it". Written out at each
1986
+ * site, that rule is one a later edit can move at one of them.
1987
+ *
1988
+ * FIVE FIELDS, NOT THREE. `isDraftLive` answers for the draft itself, and the
1989
+ * target search and the drop point are the create path's too: a reader who
1990
+ * typed into the target box with no draft open still has something for Escape
1991
+ * to clear, and taking the press for a panel would leave their query sitting
1992
+ * there. `cancel` clears all five, which is why all five are asked.
1993
+ */
1994
+ /**
1995
+ * Whether §17k's lifted inspector is actually ON SCREEN.
1996
+ *
1997
+ * `narrowOverlayOpen` answers about STATE, and state is not visibility here:
1998
+ * the panel is lifted between `1120` and `1359` and is an ordinary column
1999
+ * either side of that, so at the wide layout a selection makes the reducer's
2000
+ * predicate true with nothing overlaid for Escape to dismiss. The press was
2001
+ * being spent on a change the reader could not see.
2002
+ *
2003
+ * THE STYLESHEET KNOWS AND THE DOM SHOWS ITS ANSWER, which is why this is a
2004
+ * measurement rather than a width. The lifted panel is placed in the CANVAS's
2005
+ * grid area, so it overlaps the canvas's box; as a column it sits beside it
2006
+ * and cannot. That is the same fact the sheet encodes, read back rather than
2007
+ * re-derived — and it is not the window, which §17k rules out.
2008
+ *
2009
+ * A SURFACE WITH NO LAYOUT CANNOT ANSWER, and says so by yielding to the
2010
+ * state. Every box is zero-sized before layout, and under `node --test` there
2011
+ * is no layout at all; a strict reading would disable the key there and take
2012
+ * a tested behaviour away from every consumer that renders without a browser.
2013
+ */
2014
+ const inspectorIsLifted = () => {
2015
+ const inspector = surface.querySelector('.ig-zone[data-zone="inspector"]');
2016
+ const canvas = surface.querySelector('.ig-zone[data-zone="canvas"]');
2017
+ if (inspector === null || canvas === null)
2018
+ return false;
2019
+ const panel = inspector.getBoundingClientRect();
2020
+ const zone = canvas.getBoundingClientRect();
2021
+ if (panel.width === 0 && zone.width === 0)
2022
+ return true;
2023
+ return panel.left < zone.right && panel.right > zone.left;
2024
+ };
2025
+ const nothingToCancel = () => !isDraftLive(state.draft) && state.targetQuery === '' && state.drop === null;
1660
2026
  const interaction = () => {
1661
2027
  const active = doc.activeElement;
1662
2028
  if (isElement(active) && isInput(active) && active.getAttribute(COMMAND_ATTRIBUTE) === 'target-query') {
@@ -1680,7 +2046,14 @@ export function mountWorkspace(element, options) {
1680
2046
  // change's to settle.
1681
2047
  if (isInput(active))
1682
2048
  return 'elsewhere';
1683
- return isChoosingKind(state.draft) ? 'kind-chooser' : 'elsewhere';
2049
+ // THE DRAFT IS ASKED BEFORE THE CONTROL, so the section above stays true of
2050
+ // the state it was written for. The two cannot both be live in practice —
2051
+ // `createStep` withholds `+ add` for the whole of the kind step — but a rule
2052
+ // that depends on another module's rendering condition is one that changes
2053
+ // silently the day that condition does.
2054
+ if (isChoosingKind(state.draft))
2055
+ return 'kind-chooser';
2056
+ return focusedAddSubject() === null ? 'elsewhere' : 'add-control';
1684
2057
  };
1685
2058
  /**
1686
2059
  * The rail's window edge, decided BEFORE the viewer is asked.
@@ -1776,6 +2149,26 @@ export function mountWorkspace(element, options) {
1776
2149
  focusIn(zoneName, result.command.key);
1777
2150
  return true;
1778
2151
  }
2152
+ // §16f's `→ expands provenance` on a list row. The viewer cannot expand
2153
+ // anything itself, so it publishes `expand:<key>` and this is where the
2154
+ // host takes it — split into the name/target shape every other control
2155
+ // here already uses, rather than teaching the reducer a second spelling.
2156
+ //
2157
+ // THE COLON IS LOAD-BEARING. `expand` with no key is §16a's panel-size
2158
+ // control and means something else entirely; only the qualified form is a
2159
+ // row. A press that names no key is handed back rather than swallowed.
2160
+ if (result.command.kind === 'command') {
2161
+ const [name, ...rest] = result.command.command.split(':');
2162
+ // REJOINED, NOT `rest[0]`. A qualified key is `owner/repo#12`, which
2163
+ // carries no colon today — but the split is over the whole command and
2164
+ // taking only the first segment would silently truncate any key that
2165
+ // ever does.
2166
+ const row = rest.join(':');
2167
+ if ((name === 'expand' || name === 'collapse') && row !== '') {
2168
+ dispatch({ kind: 'control', name, target: row });
2169
+ return true;
2170
+ }
2171
+ }
1779
2172
  return false;
1780
2173
  };
1781
2174
  /**
@@ -1889,11 +2282,89 @@ export function mountWorkspace(element, options) {
1889
2282
  // The FOCUSED row, not the selection: on a fresh page a row can own
1890
2283
  // focus while nothing is selected, and after focus moves on, a stale
1891
2284
  // selection must not become the source of a keyboard-started draft.
1892
- focused: focusedKey(),
2285
+ // AND `+ add`'S OWN SUBJECT WHERE THERE IS NO ROW, which is still that
2286
+ // rule rather than an exception to it — the fallback is a control's
2287
+ // published claim about what it acts on, not the selection this line
2288
+ // refuses. It moves nothing that worked: the second term is consulted
2289
+ // only where the first is `null`, and where it answers, `interaction()`
2290
+ // answers `add-control`, which admits `relate` alone.
2291
+ // AND IT DOES NOT OPEN THE NAVIGATION ARM BELOW, which is the one place a
2292
+ // wider `focused` could have leaked. That arm is guarded on this field,
2293
+ // but `navigateFocus` re-derives the key from a `[data-ig-key]` ANCESTOR
2294
+ // and answers `false` without one — and no command control has one, since
2295
+ // every one this package draws sits in chrome beside the keyed rows. So
2296
+ // the guard now admits a press the arm still declines, rather than
2297
+ // stepping the rail from a focused button.
2298
+ focused: focusedKey() ?? focusedAddSubject(),
1893
2299
  match,
1894
2300
  selectedEdge: selectedEdgeId(state.selection),
1895
2301
  interaction: interaction(),
1896
2302
  };
2303
+ // §17k'S TRANSIENT SURFACES ARE DISMISSED ON ESCAPE — the ruling's own
2304
+ // words for the lifted inspector, and the strip takes the same key because
2305
+ // a canvas opened over the inspector is the same kind of thing.
2306
+ //
2307
+ // BEFORE `keyIntent`, AND THE OBVIOUS PLACEMENT IS THE WRONG ONE. Putting
2308
+ // it after reads as the safe order — let the create loop have the press
2309
+ // first — and it makes this arm DEAD. `create/keys.ts` binds `escape` to
2310
+ // `cancel` with the comment "ALWAYS AVAILABLE", and `reaches` admits it
2311
+ // whenever the interaction is `canvas`, which `interaction()` answers for
2312
+ // any focused `[data-ig-key]`. So with focus on a rail row — where it sits
2313
+ // after almost every click on this surface — `keyIntent` claims Escape for
2314
+ // a draft that does not exist and returns, and nothing below it is reached.
2315
+ // MEASURED IN A BROWSER, not reasoned about: the panel stayed open, and the
2316
+ // `dataInspector` attribute stayed `lifted` across the press.
2317
+ //
2318
+ // THE DRAFT STILL WINS, WHICH IS WHAT THE OTHER ORDER WAS FOR. A reader
2319
+ // mid-relationship pressing Escape means "not this edge", not "close the
2320
+ // panel behind it" — so the guard is `nothingToCancel`, which asks about
2321
+ // the create path rather than about the position in this function. An idle
2322
+ // one is the only state `keyIntent` would have spent the press on, and
2323
+ // cancelling an idle draft is a no-op.
2324
+ //
2325
+ // GUARDED ALSO ON `narrowOverlayOpen`, WHICH IS THE REDUCER'S OWN PREDICATE
2326
+ // rather than a second reading of the same two fields, so the two cannot
2327
+ // drift apart — `reduceHost` answers `unclaimed` for exactly the same case.
2328
+ //
2329
+ // WHAT THAT DOES NOT DO IS LET THE PRESS THROUGH. A press this arm declines
2330
+ // falls to `keyIntent`, which binds Escape to a cancel that is "ALWAYS
2331
+ // AVAILABLE" and cancels it there — so on a focused rail row Escape is
2332
+ // consumed by this surface whether or not anything was dismissed. That
2333
+ // predates this branch and is not changed here; it is written down because
2334
+ // the obvious reading of `unclaimed` is that the key reaches the host, and
2335
+ // on this surface it does not.
2336
+ //
2337
+ // THE REDUCER CANNOT ASK WHICH OF THE TWO THE READER CAN SEE, and it must
2338
+ // not: the width is the stylesheet's to know, per §17k's note that a
2339
+ // package reading the window cannot be dropped into someone else's page.
2340
+ // So it closes both and the sheet draws whichever was on screen.
2341
+ //
2342
+ // THE SHELL CAN ASK, THOUGH, AND HAS TO. An earlier revision left the guard
2343
+ // at the reducer's predicate alone and accepted "a press spent invisibly at
2344
+ // the wide layout" as a cost. It is not a cost worth taking: a key consumed
2345
+ // for a surface nobody can see is indistinguishable from a broken key. See
2346
+ // `inspectorIsLifted`, which reads the sheet's own answer off the DOM
2347
+ // rather than re-deriving it from a width.
2348
+ //
2349
+ // TWO TRANSIENTS SHARE THIS KEY NOW, AND §17d'S GOES FIRST. The audit
2350
+ // overlay is the one the reader pressed a control to open, and it is drawn
2351
+ // at EVERY width; §17k's lifted inspector is opened implicitly by a
2352
+ // selection and exists only between `1120` and `1359`. So a press with the
2353
+ // audit panel up closes the audit panel, at any width — without this order
2354
+ // a reader at the wide layout, where nothing of §17k's is drawn, would
2355
+ // press Escape and watch the panel stay put.
2356
+ if (event.key === 'Escape' && nothingToCancel()) {
2357
+ if (state.auditOpen) {
2358
+ event.preventDefault();
2359
+ dispatch({ kind: 'control', name: 'audit-close' });
2360
+ return;
2361
+ }
2362
+ if (narrowOverlayOpen(state) && (state.canvasOpen || inspectorIsLifted())) {
2363
+ event.preventDefault();
2364
+ dispatch({ kind: 'control', name: 'narrow-dismiss' });
2365
+ return;
2366
+ }
2367
+ }
1897
2368
  const intent = keyIntent(event, context);
1898
2369
  if (intent.kind !== 'none') {
1899
2370
  event.preventDefault();
@@ -1913,11 +2384,13 @@ export function mountWorkspace(element, options) {
1913
2384
  // Two facts make it true here rather than merely plausible.
1914
2385
  //
1915
2386
  // `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
2387
+ // neither an input, nor the kind step, nor `+ add`, so `keyIntent` returns
2388
+ // `none` for every key there. At the KIND STEP it answers `kind-chooser` —
2389
+ // the predicate is asked of the DRAFT, not of where focus is — and there
1919
2390
  // `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
2391
+ // again. On `+ add` it answers `add-control`, which admits `RELATE_KEY`
2392
+ // alone (`#173`) and neither of these two, so this arm still owns them
2393
+ // there. So this arm does claim `Enter` and `Space` on the inspector's
1921
2394
  // `clear`, which is where the mount's focus restore lands after `begin`.
1922
2395
  // That is a control doing what a control does, and it is new behaviour on
1923
2396
  // a step the create loop passes through every time — said out loud because
@@ -2001,8 +2474,18 @@ export function mountWorkspace(element, options) {
2001
2474
  // A REFUSAL IS THIS PACKAGE'S OWN ANSWER, so it keeps the press: `Space`
2002
2475
  // would otherwise scroll the page under a reader who has just pressed
2003
2476
  // something, and there is nothing else waiting for this key.
2477
+ //
2478
+ // EXCEPT A NAVIGATION REFUSAL, WHICH MEANS THE OPPOSITE. `input` and `inert`
2479
+ // say "this press does nothing, and nothing else wants it"; `navigation`
2480
+ // says the BROWSER performs this control's activation, so there is something
2481
+ // else waiting for the key and cancelling is how it never arrives. An
2482
+ // anchor's `Enter` activation is a DEFAULT ACTION — cancel the keydown and
2483
+ // no click is synthesized and the link never opens, which would leave §17d's
2484
+ // outward link working for a pointer and dead for a keyboard. The click path
2485
+ // still refuses to DISPATCH it; the two answers differ because the acts do.
2004
2486
  if (answer.kind === 'refused') {
2005
- event.preventDefault();
2487
+ if (answer.reason !== 'navigation')
2488
+ event.preventDefault();
2006
2489
  return;
2007
2490
  }
2008
2491
  // DISPATCH FIRST, THEN DECIDE WHETHER THE PRESS WAS OURS TO KEEP — and that