@issuegraph/editor 0.10.0 → 0.23.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 (113) 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 +114 -7
  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 +54 -14
  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/firstpass/render.d.ts.map +1 -1
  34. package/dist/firstpass/render.js +10 -9
  35. package/dist/firstpass/render.js.map +1 -1
  36. package/dist/index.d.ts +40 -15
  37. package/dist/index.d.ts.map +1 -1
  38. package/dist/index.js +35 -13
  39. package/dist/index.js.map +1 -1
  40. package/dist/overlay/render.d.ts +11 -3
  41. package/dist/overlay/render.d.ts.map +1 -1
  42. package/dist/overlay/render.js +24 -7
  43. package/dist/overlay/render.js.map +1 -1
  44. package/dist/overlay/request.d.ts +71 -0
  45. package/dist/overlay/request.d.ts.map +1 -0
  46. package/dist/overlay/request.js +129 -0
  47. package/dist/overlay/request.js.map +1 -0
  48. package/dist/overlay/styles.d.ts +1 -1
  49. package/dist/overlay/styles.d.ts.map +1 -1
  50. package/dist/overlay/styles.js +38 -7
  51. package/dist/overlay/styles.js.map +1 -1
  52. package/dist/reevaluate/parts.d.ts +130 -0
  53. package/dist/reevaluate/parts.d.ts.map +1 -0
  54. package/dist/reevaluate/parts.js +260 -0
  55. package/dist/reevaluate/parts.js.map +1 -0
  56. package/dist/reevaluate/render.d.ts +18 -11
  57. package/dist/reevaluate/render.d.ts.map +1 -1
  58. package/dist/reevaluate/render.js +21 -135
  59. package/dist/reevaluate/render.js.map +1 -1
  60. package/dist/reevaluate/styles.d.ts +1 -1
  61. package/dist/reevaluate/styles.d.ts.map +1 -1
  62. package/dist/reevaluate/styles.js +26 -0
  63. package/dist/reevaluate/styles.js.map +1 -1
  64. package/dist/reevaluate/view.d.ts +16 -1
  65. package/dist/reevaluate/view.d.ts.map +1 -1
  66. package/dist/reevaluate/view.js +21 -1
  67. package/dist/reevaluate/view.js.map +1 -1
  68. package/dist/reevaluate/words.d.ts +7 -0
  69. package/dist/reevaluate/words.d.ts.map +1 -1
  70. package/dist/scale/ladder.d.ts +62 -6
  71. package/dist/scale/ladder.d.ts.map +1 -1
  72. package/dist/scale/ladder.js +127 -16
  73. package/dist/scale/ladder.js.map +1 -1
  74. package/dist/scale/render.d.ts +44 -0
  75. package/dist/scale/render.d.ts.map +1 -1
  76. package/dist/scale/render.js +223 -30
  77. package/dist/scale/render.js.map +1 -1
  78. package/dist/scale/styles.d.ts +11 -5
  79. package/dist/scale/styles.d.ts.map +1 -1
  80. package/dist/scale/styles.js +185 -4
  81. package/dist/scale/styles.js.map +1 -1
  82. package/dist/workspace/host.d.ts +83 -1
  83. package/dist/workspace/host.d.ts.map +1 -1
  84. package/dist/workspace/host.js +197 -4
  85. package/dist/workspace/host.js.map +1 -1
  86. package/dist/workspace/inspector.d.ts +33 -7
  87. package/dist/workspace/inspector.d.ts.map +1 -1
  88. package/dist/workspace/inspector.js +15 -0
  89. package/dist/workspace/inspector.js.map +1 -1
  90. package/dist/workspace/mount.d.ts +23 -1
  91. package/dist/workspace/mount.d.ts.map +1 -1
  92. package/dist/workspace/mount.js +605 -20
  93. package/dist/workspace/mount.js.map +1 -1
  94. package/dist/workspace/rail.d.ts +5 -2
  95. package/dist/workspace/rail.d.ts.map +1 -1
  96. package/dist/workspace/rail.js.map +1 -1
  97. package/dist/workspace/render.d.ts +390 -3
  98. package/dist/workspace/render.d.ts.map +1 -1
  99. package/dist/workspace/render.js +924 -133
  100. package/dist/workspace/render.js.map +1 -1
  101. package/dist/workspace/selection.d.ts +11 -0
  102. package/dist/workspace/selection.d.ts.map +1 -1
  103. package/dist/workspace/selection.js +10 -0
  104. package/dist/workspace/selection.js.map +1 -1
  105. package/dist/workspace/skeleton.d.ts +33 -0
  106. package/dist/workspace/skeleton.d.ts.map +1 -0
  107. package/dist/workspace/skeleton.js +56 -0
  108. package/dist/workspace/skeleton.js.map +1 -0
  109. package/dist/workspace/styles.d.ts +1 -1
  110. package/dist/workspace/styles.d.ts.map +1 -1
  111. package/dist/workspace/styles.js +931 -19
  112. package/dist/workspace/styles.js.map +1 -1
  113. package/package.json +4 -4
@@ -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,8 @@ 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 { renderWorkspaceSkeleton } from "./skeleton.js";
65
+ import { INITIAL_HOST_STATE, editCarrier, narrowOverlayOpen, railRowAt, railSlackFor, railWindowTarget, reconcileHost, reduceHost, targetMatches, } from "./host.js";
65
66
  import { conflictDiff } from "./recovery.js";
66
67
  import { renderWorkspace, } from "./render.js";
67
68
  import { selectedEdgeId, selectedKey } from "./selection.js";
@@ -74,8 +75,31 @@ const CHOOSER_SIZE = { width: 280, height: 220 };
74
75
  /** The drag threshold, in CSS pixels: a press that moves less is a click. */
75
76
  const DRAG_THRESHOLD = 6;
76
77
  const KEY_ATTRIBUTE = 'data-ig-key';
78
+ /**
79
+ * §17c's change region, which is the one node this surface carries ACROSS a
80
+ * redraw rather than replacing.
81
+ *
82
+ * Matched on the role as well as the class: the class is a styling hook, and
83
+ * what has to be preserved is the live region specifically.
84
+ */
85
+ const LIVE_REGION = '.ig-change-line[role="status"]';
77
86
  const GROUP_ATTRIBUTE = 'data-ig-group';
78
87
  const COMMAND_ATTRIBUTE = 'data-ig-command';
88
+ /**
89
+ * How many surfaces this module has mounted, so each gets an id nothing else
90
+ * on the page carries.
91
+ *
92
+ * MODULE STATE, DELIBERATELY, and it is the smallest honest way to do this. The
93
+ * renderer is pure and must stay so — `scale/render.ts`'s `searchSpec` records
94
+ * what a fixed id costs when a host draws two documents side by side — and the
95
+ * value has to be stable across every redraw of the SAME mount, so it cannot be
96
+ * derived per render. A counter in the module that does the mounting satisfies
97
+ * both: one value per `mountWorkspace` call, fixed for that surface's life.
98
+ *
99
+ * Not a random value: a deterministic sequence keeps the markup reproducible
100
+ * for anything that snapshots it.
101
+ */
102
+ let surfaces = 0;
79
103
  /**
80
104
  * Which subject a command acts on, when one command names several.
81
105
  *
@@ -123,9 +147,21 @@ function isElement(target) {
123
147
  function isFocusable(node) {
124
148
  return node !== null && node !== undefined && 'focus' in node;
125
149
  }
126
- /** The two refusals, allocated once: they carry no per-press data. */
150
+ /** The three refusals, allocated once: they carry no per-press data. */
127
151
  const REFUSED_INPUT = Object.freeze({ kind: 'refused', reason: 'input' });
128
152
  const REFUSED_INERT = Object.freeze({ kind: 'refused', reason: 'inert' });
153
+ /** A control whose activation the BROWSER performs. See {@link isNavigating}. */
154
+ const REFUSED_NAVIGATION = Object.freeze({ kind: 'refused', reason: 'navigation' });
155
+ /**
156
+ * Whether this control is a link the browser will follow by itself.
157
+ *
158
+ * THE `href` IS PART OF THE TEST. A bare `<a>` with no `href` is not a link —
159
+ * it has no activation behaviour of its own — so one carrying a command is an
160
+ * ordinary control and must still be dispatched.
161
+ */
162
+ function isNavigating(node) {
163
+ return node?.tagName.toLowerCase() === 'a' && node.getAttribute('href') !== null;
164
+ }
129
165
  function isInput(node) {
130
166
  return node !== null && node !== undefined && node.tagName === 'INPUT';
131
167
  }
@@ -176,6 +212,10 @@ export function mountWorkspace(element, options) {
176
212
  const doc = element.ownerDocument;
177
213
  const { store } = options;
178
214
  let current = options;
215
+ // ONE PER MOUNT, FIXED FOR ITS LIFE. See `surfaces` for why it is taken here
216
+ // rather than per render.
217
+ surfaces += 1;
218
+ const surfaceId = `w${String(surfaces)}`;
179
219
  const styles = doc.createElement('style');
180
220
  const surface = doc.createElement('div');
181
221
  surface.className = 'ig-mount';
@@ -188,6 +228,21 @@ export function mountWorkspace(element, options) {
188
228
  let drawn = null;
189
229
  // A rail row to focus once the window has been re-cut around it.
190
230
  let pendingFocus = null;
231
+ /**
232
+ * Set when a command opened the isolated list, cleared by the redraw that
233
+ * reveals it.
234
+ *
235
+ * THE LIST IS NOT WHERE ITS CONTROL IS, and that is what makes this
236
+ * necessary rather than tidy. §17a puts the count and the toggle at the foot
237
+ * of the ORDER RAIL; the list itself is drawn by the ladder in the CANVAS,
238
+ * because the rail is virtualized on a fixed row pitch (`railRowAt`,
239
+ * `railSpacer`) and content of arbitrary height inside that scroll track
240
+ * makes every offset beneath it name the wrong row. So pressing the toggle
241
+ * flips a control in one zone and grows a list in another, below whatever
242
+ * that zone was already scrolled to — and the reader is told the list is open
243
+ * while seeing nothing change.
244
+ */
245
+ let revealIsolated = false;
191
246
  // The keys currently holding a control they activated. See `onKeydown`'s first
192
247
  // arm for why this is a fact about the PRESS and not a question asked of
193
248
  // whatever holds focus by the time the repeats arrive.
@@ -508,6 +563,12 @@ export function mountWorkspace(element, options) {
508
563
  const dispatch = (command) => {
509
564
  if (destroyed)
510
565
  return;
566
+ // ON THE COMMAND, NOT ON THE RESULTING STATE. `open-isolated` is what the
567
+ // reader just asked for; `scale.isolatedOpen` is also true on every redraw
568
+ // that follows for any other reason, and revealing it again there would
569
+ // yank the canvas back under a reader who had scrolled away.
570
+ if (command.kind === 'control' && command.name === 'open-isolated')
571
+ revealIsolated = true;
511
572
  applyResult(reduceHost(state, command, landed()));
512
573
  };
513
574
  const zone = (name) => surface.querySelector(`.ig-zone[data-zone="${name}"]`);
@@ -872,15 +933,19 @@ export function mountWorkspace(element, options) {
872
933
  const sceneFor = (zoneName) => {
873
934
  if (drawn === null)
874
935
  return null;
936
+ // `frame: false` MIRRORS WHAT IS DRAWN. These rebuild a scene to answer a
937
+ // navigation question, and a scene built from different options than the
938
+ // markup is a second opinion about the surface. See `SceneOptions.frame`.
875
939
  if (zoneName === 'rail')
876
- return renderViewer(drawn.rail.document, { projection: 'linear', theme: theme() }).scene;
940
+ return renderViewer(drawn.rail.document, { projection: 'linear', theme: theme(), frame: false, dockLegend: true })
941
+ .scene;
877
942
  if (zoneName !== 'canvas')
878
943
  return null;
879
944
  if (current.canvas === 'tree')
880
- return renderViewer(withoutChrome(drawn.viewer), { projection: 'tree', theme: theme(), chrome: false }).scene;
945
+ return renderViewer(withoutChrome(drawn.viewer), { projection: 'tree', theme: theme(), chrome: false, frame: false, dockLegend: true }).scene;
881
946
  const ladder = scaleLadder(drawn.viewer, state.scale);
882
947
  return ladder.tier === 'direct'
883
- ? renderViewer(ladder.canvas, { projection: 'graph', theme: theme(), chrome: false }).scene
948
+ ? renderViewer(ladder.canvas, { projection: 'graph', theme: theme(), chrome: false, frame: false, dockLegend: true }).scene
884
949
  : null;
885
950
  };
886
951
  /** The row or node that owns keyboard focus, if focus is on one at all. */
@@ -889,10 +954,65 @@ export function mountWorkspace(element, options) {
889
954
  const keyed = isElement(active) ? active.closest(`[${KEY_ATTRIBUTE}]`) : null;
890
955
  return keyed !== null && surface.contains(keyed) ? keyed.getAttribute(KEY_ATTRIBUTE) : null;
891
956
  };
957
+ /**
958
+ * The subject `+ add` publishes, when that control itself owns focus.
959
+ *
960
+ * THE SECOND SOURCE FOR `KeyboardContext.focused`, and it exists because the
961
+ * first one cannot answer here: `focusedKey` reads the rail's roving tab stop,
962
+ * and a focused button has no keyed ancestor — every command control this
963
+ * package draws sits in chrome beside the keyed rows rather than inside one.
964
+ * So `R` on `+ add` reached a `relate` arm whose subject was `null`, which
965
+ * answers `none` ("nothing focused is nothing to relate FROM"). `#173`.
966
+ *
967
+ * IT READS THE CONTROL'S OWN ATTRIBUTE, NOT THE SELECTION, for the reason
968
+ * `reduceHost`'s `add` arm already reads it: `inspectorView` canonicalizes a
969
+ * selection naming a together unit's member onto the slot's LEAD, and the
970
+ * panel is worded from that, so the control publishes the canonical subject
971
+ * and the selection is only a fallback for a control that names none. Taking
972
+ * the selection here would begin the keyboard's draft from a different issue
973
+ * than the pointer's, on one screen, from one control.
974
+ *
975
+ * NARROWED TO THE `add` COMMAND, and that is a correctness bound rather than
976
+ * tidiness: {@link TARGET_ATTRIBUTE} is carried by the row remove control
977
+ * (an edge id), by a recovery card (a mutation id) and by a hold's subject
978
+ * button. Reading it off whichever control happens to hold focus would hand
979
+ * `relate` an edge id as an issue ref.
980
+ */
981
+ const focusedAddSubject = () => {
982
+ const active = doc.activeElement;
983
+ if (!isElement(active) || !surface.contains(active))
984
+ return null;
985
+ if (active.getAttribute(COMMAND_ATTRIBUTE) !== 'add')
986
+ return null;
987
+ return active.getAttribute(TARGET_ATTRIBUTE);
988
+ };
892
989
  const render = () => {
893
990
  if (destroyed)
894
991
  return;
895
992
  const snapshot = store.getSnapshot();
993
+ // THE FIRST READ IS IN FLIGHT, SO THERE IS NOTHING TO SAY YET. Drawn over
994
+ // the store's empty document, the workspace announced an empty backlog —
995
+ // "no relationships yet", no rows — about issues nobody had read. The
996
+ // placeholder holds the three zones' shape instead, and the first render
997
+ // after the load replaces it.
998
+ //
999
+ // `hydrating` ONLY, NEVER `idle`. A store nobody has asked to load is not
1000
+ // loading, and a host that fills its store another way (or a test that
1001
+ // never hydrates) must still get the document it has. A REHYDRATE is not
1002
+ // this state either: the store keeps its last good document and stays
1003
+ // `ready`, so a refresh never blanks the surface.
1004
+ if (snapshot.status === 'hydrating') {
1005
+ const skeleton = renderWorkspaceSkeleton({
1006
+ label: current.words.loading,
1007
+ theme: current.theme === undefined ? undefined : theme(),
1008
+ themeSelector: current.themeSelector,
1009
+ });
1010
+ const skeletonSheet = [skeleton.styles, mountStylesheet].join('\n');
1011
+ if (styles.textContent !== skeletonSheet)
1012
+ styles.textContent = skeletonSheet;
1013
+ surface.innerHTML = skeleton.markup;
1014
+ return;
1015
+ }
896
1016
  const document_ = landed();
897
1017
  // WHAT THE STORE IS NOT SHOWING, computed before the reconcile rather than
898
1018
  // beside the edges below, because the reconcile is the first reader of it:
@@ -1089,11 +1209,43 @@ export function mountWorkspace(element, options) {
1089
1209
  }
1090
1210
  const result = renderWorkspace(viewer, {
1091
1211
  words: current.words,
1212
+ // §17c's CAUSE AND EFFECT, STRAIGHT OFF THE SNAPSHOT. The store is the
1213
+ // single source of truth for both — `lastChange` persists until the next
1214
+ // edit or an explicit dismissal, and `dismissChange()` is already the
1215
+ // only thing besides the next edit that clears it — so nothing about
1216
+ // "has this been dismissed" is held out here. A second copy of that is
1217
+ // exactly the drift the command grammar exists to avoid.
1218
+ //
1219
+ // `orderStatus` is the SAME value stamped on `surface` below. They cannot
1220
+ // disagree because they are one read of one snapshot, and each is for a
1221
+ // different reader: the attribute on `surface` is the host's, on the
1222
+ // element the host holds; the option is the stylesheet's, on the root the
1223
+ // renderer owns, so an unmounted rendering greys the held rail too.
1224
+ change: snapshot.lastChange ?? null,
1225
+ orderStatus: snapshot.order.status,
1092
1226
  selection: state.selection,
1093
1227
  scale: state.scale,
1094
1228
  rail: { start: state.railStart, count: railCount() },
1229
+ // §16f's open rows. Host state, because the viewer is re-rendered from
1230
+ // scratch and cannot keep any of its own.
1231
+ expanded: state.expanded,
1232
+ // UNIQUE TO THIS MOUNT, so §17a's rail footer can name the isolated list
1233
+ // the canvas draws without two mounted workspaces on one page naming the
1234
+ // same element. See `WorkspaceOptions.surfaceId`; the counter is why a
1235
+ // second mount gets a second value.
1236
+ surfaceId,
1095
1237
  audit,
1096
1238
  auditFiltered: state.auditFiltered,
1239
+ // §17k's two reader decisions. Host state, for the reason the filter
1240
+ // beside it is: the renderer keeps nothing across a redraw, and a strip
1241
+ // that reopened itself on every landed write would be unusable.
1242
+ canvasOpen: state.canvasOpen,
1243
+ inspectorDismissed: state.inspectorDismissed,
1244
+ // §17d's overlay. Host state, for the same reason the filter beside it
1245
+ // is: the renderer keeps nothing across a redraw.
1246
+ auditOpen: state.auditOpen,
1247
+ issueUrl: current.issueUrl,
1248
+ railDensity: current.railDensity,
1097
1249
  theme: resolved,
1098
1250
  themeSelector: current.themeSelector,
1099
1251
  // THE WRITE STATES ONLY. The workspace holds the one selection, and the
@@ -1121,9 +1273,23 @@ export function mountWorkspace(element, options) {
1121
1273
  if (styles.textContent !== sheet)
1122
1274
  styles.textContent = sheet;
1123
1275
  // 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.
1276
+ // the audit panel's, and the caret in whichever search box they were typing
1277
+ // into.
1125
1278
  const railBefore = zone('rail');
1126
1279
  const scrollTop = railBefore?.scrollTop ?? 0;
1280
+ // THE AUDIT SCROLLS TOO, SINCE IT TOOK A SHARE OF ITS COLUMN. Before it had
1281
+ // a bound it grew to its content and only the ZONE scrolled, so there was no
1282
+ // second offset to keep. Now a long audit is read inside its own scroller,
1283
+ // and every store update and unrelated command runs this redraw — so without
1284
+ // this a reader deep in the findings is returned to the first one by
1285
+ // something they did somewhere else entirely.
1286
+ //
1287
+ // THE REGION, NOT THE PANEL. #177 put the bound on `.ig-audit-panel`, and
1288
+ // §17d's second surface moved it up to the region holding both — so reading
1289
+ // the panel's offset now reads a number that is always zero, and restoring
1290
+ // it is a no-op a reader feels as the audit jumping back on every redraw.
1291
+ const auditBefore = surface.querySelector('.ig-audit-overlay');
1292
+ const auditScrollTop = auditBefore?.scrollTop ?? 0;
1127
1293
  const active = doc.activeElement;
1128
1294
  const activeInput = isElement(active) && isInput(active) && surface.contains(active) ? active : null;
1129
1295
  const activeCommand = activeInput?.getAttribute(COMMAND_ATTRIBUTE) ?? null;
@@ -1146,13 +1312,60 @@ export function mountWorkspace(element, options) {
1146
1312
  // redraw that destroyed the focused control and of a reader who simply is
1147
1313
  // not here.
1148
1314
  const heldFocus = isElement(active) && surface.contains(active);
1315
+ // AND WHETHER IT WAS ONE OF THE AUDIT'S OWN TAB STOPS, which are the stops
1316
+ // on this surface no token can name. Neither has a keyed ancestor or a
1317
+ // command attribute, so `focusedKey()` and `commandFocusToken()` both answer
1318
+ // nothing for them — and the last-resort arm below fires on exactly that,
1319
+ // sending a reader who was reading a long audit to the first rail row, in
1320
+ // another zone.
1321
+ //
1322
+ // TWO OF THEM SINCE §17d GAINED ITS SECOND SURFACE, and the refused block is
1323
+ // the one that needs this MOST: both of its controls are conditional, so a
1324
+ // block whose refusals all name unloaded issues has no focusable descendant
1325
+ // at all and the section itself is the only stop a reader can hold.
1326
+ //
1327
+ // A CLASS RATHER THAN THE NODE THE OFFSET CAME FROM. That identity test was
1328
+ // right while the scrolling element and the tab stop were one element; they
1329
+ // are not any more — the region scrolls and its members carry the stops —
1330
+ // so the answer has to name which stop, and it is restored by the same name.
1331
+ const AUDIT_STOPS = ['.ig-audit-refused', '.ig-audit-panel'];
1332
+ const auditStop = isElement(active)
1333
+ ? (AUDIT_STOPS.find((one) => active.matches(one)) ?? null)
1334
+ : null;
1149
1335
  // The ZONE too: an issue is commonly drawn in the rail and on the canvas,
1150
1336
  // and restoring "the first element with this key" would move focus from
1151
1337
  // a canvas node into the rail on every redraw.
1152
1338
  const focusedZone = isElement(active) ? (active.closest('.ig-zone')?.getAttribute('data-zone') ?? null) : null;
1153
1339
  drawn = { viewer, rail: result.view.rail };
1340
+ // THE LIVE REGION HAS TO SURVIVE THE REDRAW, or it announces nothing.
1341
+ //
1342
+ // `summarySpec` mounts §17c's `role="status"` region ALWAYS and leaves it
1343
+ // EMPTY until there is something to say, precisely because a status node
1344
+ // that is created already carrying its text is not reliably announced —
1345
+ // the region has to exist first and then have its contents change. This
1346
+ // function replaces the whole subtree on every redraw, so rendering that
1347
+ // region into the markup and stopping there would destroy it and insert a
1348
+ // new, already-populated one every time: the affordance present, correct
1349
+ // in the markup, and inert. Worse than absent, because it looks done.
1350
+ //
1351
+ // So the ELEMENT is carried across: re-attached still holding the previous
1352
+ // summary, and only then given the new one. The insertion says nothing and
1353
+ // the mutation is what announces, which is the sequence the region was
1354
+ // designed around.
1355
+ const liveBefore = surface.querySelector(LIVE_REGION);
1154
1356
  // Package-rendered markup, escaped by the package.
1155
1357
  surface.innerHTML = result.markup;
1358
+ const liveAfter = surface.querySelector(LIVE_REGION);
1359
+ if (liveBefore !== null && liveAfter !== null) {
1360
+ const next = liveAfter.innerHTML;
1361
+ liveAfter.replaceWith(liveBefore);
1362
+ // GUARDED, so a redraw that did not touch the summary is not a change to
1363
+ // announce. Every keystroke in the ladder's search box redraws this
1364
+ // surface, and re-assigning identical content would re-announce the last
1365
+ // edit's result on each one.
1366
+ if (liveBefore.innerHTML !== next)
1367
+ liveBefore.innerHTML = next;
1368
+ }
1156
1369
  // THE ORDER'S STATUS, PUBLISHED AS DATA. A write in flight holds the order
1157
1370
  // — it does not move until the edit lands — and a host that wants to say
1158
1371
  // so has nowhere to read it once the snapshot is consumed here. The
@@ -1169,13 +1382,79 @@ export function mountWorkspace(element, options) {
1169
1382
  // as data, the same `data-ig-state` the ladder's line carries, so a
1170
1383
  // pending edge is not drawn as a settled one and a host styles or reads
1171
1384
  // it the same way in both modes. The merge is the ladder's own.
1385
+ // §17f'S ROW SURVIVES THIS ASSIGNMENT, AND ITS CAPTION DOES NOT.
1386
+ // `innerHTML` replaces every child, and `renderWorkspace` puts the
1387
+ // toolbar in this zone as its FIRST child — so the row, the pill
1388
+ // included, was silently deleted the moment a reader switched to the
1389
+ // tree. The pill is a property of the SURFACE, and a mounted surface is
1390
+ // exactly where a host is told to supply it, so losing it here took the
1391
+ // control away in the one state it is documented to exist in.
1392
+ //
1393
+ // THE CAPTION IS DIFFERENT, and is dropped on purpose rather than by
1394
+ // accident. It states what the LADDER's canvas draws — one connected
1395
+ // component out of the whole backlog — and this branch does not draw the
1396
+ // ladder's canvas: it draws `viewer`, the whole document, as a tree. So
1397
+ // "6 out of 312 drawn here" is simply false over a tree of 312, on the
1398
+ // same rule that keeps the caption off the refusing tiers.
1399
+ const toolbar = canvas.querySelector('.ig-canvas-toolbar');
1400
+ toolbar?.querySelector('.ig-canvas-caption')?.remove();
1401
+ // THE ISOLATED BLOCK SURVIVES THIS ASSIGNMENT TOO, and for a sharper
1402
+ // reason than the row above. §17a moves the isolated CONTROL to the
1403
+ // foot of the order rail, which this branch does not touch — so the
1404
+ // control survives the switch to the tree whatever happens here, while
1405
+ // the list it opens is drawn in THIS zone and would not. The reader
1406
+ // presses it, the label turns to "hide" and `aria-expanded` to `true`,
1407
+ // and nothing appears: a control that reports a state the surface is
1408
+ // not in.
1409
+ //
1410
+ // WORSE THAN WHAT IT REPLACED, WHICH IS WHY IT IS FIXED HERE RATHER
1411
+ // THAN NOTED. Before that move the chip lived in this zone beside its
1412
+ // list, so the tree deleted BOTH and the affordance was merely absent —
1413
+ // wrong, but not lying. Keeping the list is what makes the control it
1414
+ // is now separated from honest.
1415
+ //
1416
+ // UNCONDITIONAL, unlike the toolbar's `childElementCount` guard. This
1417
+ // element is not assembled from optional parts: `isolatedSpec` returns
1418
+ // null rather than an empty husk, so anything found here has content.
1419
+ const isolated = canvas.querySelector('.ig-ladder-isolated');
1172
1420
  canvas.innerHTML = renderViewer(withoutChrome(viewer), {
1173
1421
  projection: 'tree',
1174
1422
  theme: resolved,
1175
1423
  selected: selectedKey(state.selection),
1176
1424
  // The rail beside this canvas draws the panel's one header.
1177
1425
  chrome: false,
1426
+ // THE WORKSPACE RULES ITS OWN ZONES OFF, so the viewer draws no
1427
+ // frame of its own here: the rail track carries `border-right`, the
1428
+ // canvas toolbar carries `border-bottom`, and a second edge one
1429
+ // pixel away reads as a doubled hairline. See `SceneOptions.frame`.
1430
+ frame: false,
1431
+ // §16b's KEY, KEPT IN VIEW. It explains every line and station on this
1432
+ // zone, and below a scroll it was the one thing a reader had to leave the
1433
+ // reading to reach. See `SceneOptions.dockLegend`.
1434
+ dockLegend: true,
1178
1435
  }).markup;
1436
+ // Re-inserted rather than re-rendered: the row is already assembled,
1437
+ // and rebuilding it here would be a second place that decides what it
1438
+ // says.
1439
+ //
1440
+ // ONLY IF IT STILL HAS CONTENT, which is `canvasToolbar`'s own rule —
1441
+ // "nothing to say, no row" — and removing the caption above is exactly
1442
+ // what can empty it. A host that words the caption and not the pill
1443
+ // gets a caption-only row on the direct tier, and prepending its
1444
+ // emptied husk here left a sticky padded band with a border and no
1445
+ // content over the tree. Reading the element rather than re-deriving
1446
+ // the condition keeps the rule in one place.
1447
+ // THE TREE ALWAYS DRAWS A KEY, WHATEVER THE GRAPH'S TIER SAID. render.ts
1448
+ // set `data-canvas-legend` from the scale ladder, which is a fact about
1449
+ // the GRAPH — and this branch has just replaced the graph with a
1450
+ // projection that has no budget and no refusal. Left as it was, a tree
1451
+ // over a backlog past the node budget reported no key in this zone and
1452
+ // the rail drew a second one beside the one on screen.
1453
+ surface.firstElementChild?.setAttribute('data-canvas-legend', 'drawn');
1454
+ if (isolated !== null)
1455
+ canvas.append(isolated);
1456
+ if (toolbar !== null && toolbar.childElementCount > 0)
1457
+ canvas.prepend(toolbar);
1179
1458
  const states = new Map(overlaysFor(viewer.edges, writeStates, selectedEdgeId(state.selection)).map((edge) => [edge.id, overlayFor(edge).attribute]));
1180
1459
  for (const badge of canvas.querySelectorAll(`[${GROUP_ATTRIBUTE}]`)) {
1181
1460
  const attribute = states.get(badge.getAttribute(GROUP_ATTRIBUTE) ?? '');
@@ -1203,6 +1482,27 @@ export function mountWorkspace(element, options) {
1203
1482
  const rail = zone('rail');
1204
1483
  if (rail !== null)
1205
1484
  rail.scrollTop = scrollTop;
1485
+ // AFTER the subtree is in place, because it measures what was just drawn.
1486
+ dockRailLegend();
1487
+ // RE-QUERIED, NEVER REUSED: `surface.innerHTML` above replaced the subtree,
1488
+ // so the element captured from is gone. A region that shrank clamps this on
1489
+ // assignment, and one the redraw removed — the audit going clean — is
1490
+ // simply absent, which is why nothing is restored rather than zeroed.
1491
+ const auditRegion = surface.querySelector('.ig-audit-overlay');
1492
+ if (auditRegion !== null)
1493
+ auditRegion.scrollTop = auditScrollTop;
1494
+ // THE LIST THE READER JUST OPENED IS BROUGHT INTO VIEW. See
1495
+ // `revealIsolated` for why the control and the list are in different zones.
1496
+ // `block: 'nearest'` so a list already visible is not scrolled at all, and
1497
+ // guarded on the method because the same guard is used for the rail's own
1498
+ // jumps — this module renders into whatever document a host hands it.
1499
+ if (revealIsolated) {
1500
+ revealIsolated = false;
1501
+ const list = surface.querySelector('.ig-isolated-list');
1502
+ if (list !== null && typeof list.scrollIntoView === 'function') {
1503
+ list.scrollIntoView({ block: 'nearest' });
1504
+ }
1505
+ }
1206
1506
  // FOCUS SURVIVES THE REDRAW, and it moves to the target search when that
1207
1507
  // step opens. The keyboard path is R -> kind -> search -> Enter, and every
1208
1508
  // step redraws: without this, R destroyed the focused row, the next press
@@ -1231,6 +1531,21 @@ export function mountWorkspace(element, options) {
1231
1531
  // irreversible answer under the reader's next Space.
1232
1532
  (again ?? overlay).focus({ preventScroll: true });
1233
1533
  }
1534
+ else if (auditStop !== null && surface.querySelector(auditStop) !== null) {
1535
+ // THE READER WAS IN THE AUDIT, SO THEY STAY THERE — and on the same one of
1536
+ // its two surfaces, which is why the stop is restored by the name it was
1537
+ // captured under rather than by whichever the redraw drew first.
1538
+ //
1539
+ // Beside the offset restored above rather than instead of it: the offset
1540
+ // says where the region is scrolled to and this says who is reading it,
1541
+ // and a redraw that kept one without the other still moves the reader out
1542
+ // of the zone.
1543
+ //
1544
+ // `preventScroll`, because the offset was already put back — focusing
1545
+ // normally would scroll the column to bring the element into view and
1546
+ // undo it.
1547
+ surface.querySelector(auditStop)?.focus({ preventScroll: true });
1548
+ }
1234
1549
  else if (activeCommand !== null) {
1235
1550
  const again = surface.querySelector(`input[${COMMAND_ATTRIBUTE}="${activeCommand}"]`);
1236
1551
  if (again !== null) {
@@ -1482,6 +1797,22 @@ export function mountWorkspace(element, options) {
1482
1797
  const name = control.getAttribute(COMMAND_ATTRIBUTE) ?? '';
1483
1798
  if (isInput(control))
1484
1799
  return REFUSED_INPUT; // the `input` listener owns these
1800
+ // AND THE BROWSER OWNS A LINK, which is the same refusal one element over.
1801
+ // §17d's `Open in GitHub` is an ANCHOR — it leaves the document, and a
1802
+ // control that navigates away must not announce itself as a button — and
1803
+ // it carries a command name only so `a11y/baseline.ts` can see it: that
1804
+ // artifact records a control through the attribute channels it knows, and
1805
+ // an anchor bearing nothing but `href` is invisible to it.
1806
+ //
1807
+ // DISPATCHING IT WOULD BE A REDRAW UNDER THE READER'S OWN CLICK. The
1808
+ // reducer has no arm for the name, so the command comes back unclaimed —
1809
+ // but `applyResult` still schedules, and the redraw replaces the surface's
1810
+ // markup, detaching the anchor mid-activation. An `<a>` is exempt from the
1811
+ // "cannot navigate" connectedness check so the navigation still happens,
1812
+ // which is precisely why this would have gone unnoticed: a full re-render
1813
+ // per click, paid for a command nothing handles.
1814
+ if (isNavigating(control))
1815
+ return REFUSED_NAVIGATION;
1485
1816
  // THE ENTRY IS INERT WITHOUT A BUNDLE, and it is withheld HERE rather
1486
1817
  // than in the reducer. `renderWorkspace` draws §17a's `First pass →`
1487
1818
  // inside this surface, so a host cannot intercept it from outside; and
@@ -1532,6 +1863,13 @@ export function mountWorkspace(element, options) {
1532
1863
  if (target.closest('[data-ig-audit-filter]') !== null) {
1533
1864
  return { kind: 'dispatch', command: { kind: 'control', name: 'audit-filter' } };
1534
1865
  }
1866
+ // §17d's HEADER COUNT. Checked AFTER the filter, and the order is not
1867
+ // arbitrary: the filter chip lives inside the panel the count opens, so a
1868
+ // click on it is inside neither the other's subtree today — but if the two
1869
+ // ever nest, the inner control is the one the reader aimed at.
1870
+ if (target.closest('[data-ig-audit-panel]') !== null) {
1871
+ return { kind: 'dispatch', command: { kind: 'control', name: 'audit-panel' } };
1872
+ }
1535
1873
  return null;
1536
1874
  };
1537
1875
  const onClick = (event) => {
@@ -1584,6 +1922,49 @@ export function mountWorkspace(element, options) {
1584
1922
  const onCompositionEnd = (event) => {
1585
1923
  readInput(event);
1586
1924
  };
1925
+ /**
1926
+ * Move the rail's docked legend clear of this zone's own footer.
1927
+ *
1928
+ * TWO THINGS ARE PINNED TO ONE EDGE, AND ONLY THIS PACKAGE KNOWS BOTH. The
1929
+ * viewer docks its legend against the bottom of whatever scrollport it was
1930
+ * given (`SceneOptions.dockLegend`), with `bottom: 0` as the default; this
1931
+ * zone already holds `.ig-rail-footer` there. Left at the default the two
1932
+ * stack in the same place, and the footer — later in the subtree and carrying
1933
+ * its own layer — paints over the bottom of the key. The viewer cannot fix
1934
+ * that: it has never been told the footer exists.
1935
+ *
1936
+ * MEASURED, NOT DERIVED FROM THE TOKENS. The footer is one line of type at a
1937
+ * known size inside known padding, so its height looks like something the
1938
+ * stylesheet could state — and then the rail narrows, `5 with no
1939
+ * relationships` and its control wrap to two lines, and the written-down
1940
+ * number is quietly wrong by a line. The same argument `onScroll` makes about
1941
+ * the header it measures rather than derives.
1942
+ *
1943
+ * ON EVERY REDRAW AND ON EVERY RESIZE, because the wrap depends on the width
1944
+ * and a resize alone redraws nothing. The observer is guarded on the
1945
+ * constructor rather than assumed: this module renders into whatever document
1946
+ * a host hands it, and a host rendering server-side or in a minimal DOM has
1947
+ * no need of the correction — the default is simply left in place.
1948
+ */
1949
+ const dockRailLegend = () => {
1950
+ const rail = zone('rail');
1951
+ const legend = rail?.querySelector('.ig-viewer > .ig-legend');
1952
+ if (rail === null || legend == null)
1953
+ return;
1954
+ const footer = rail.querySelector('.ig-rail-footer');
1955
+ // RE-OBSERVED EVERY REDRAW, because `surface.innerHTML` replaced the zone and
1956
+ // the element the last call watched is gone. `disconnect` first so the
1957
+ // observer never accumulates dead targets.
1958
+ if (railResize !== null) {
1959
+ railResize.disconnect();
1960
+ railResize.observe(rail);
1961
+ }
1962
+ const clearance = footer === null ? 0 : Math.ceil(footer.getBoundingClientRect().height);
1963
+ // AN INLINE STYLE, because it is a measurement rather than a rule: it is
1964
+ // true of this rail at this width and of nothing else, which is exactly what
1965
+ // a stylesheet cannot say.
1966
+ legend.style.bottom = `${String(clearance)}px`;
1967
+ };
1587
1968
  const onScroll = (event) => {
1588
1969
  const rail = zone('rail');
1589
1970
  if (rail === null || event.target !== rail)
@@ -1621,14 +2002,23 @@ export function mountWorkspace(element, options) {
1621
2002
  * `elsewhere` as "everything that is not our own search box", and a button is
1622
2003
  * that. The target search keeps its two bindings.
1623
2004
  *
1624
- * ## The kind chooser is the ONE button group that is not `elsewhere`
2005
+ * ## The TWO controls that are not `elsewhere`, and the test they pass
1625
2006
  *
1626
- * The rule above is right for every control it names and wrong for this one,
2007
+ * The rule above is right for every control it names and wrong for these two,
1627
2008
  * 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.
2009
+ * delete button share no vocabulary with the key map, while these two ARE the
2010
+ * key map rendered. The kind chooser's digits are `KIND_KEYS`, the same table
2011
+ * `keyIntent` resolves the press against, and `+ add` draws `RELATE_KEY`
2012
+ * itself as its hint. A control whose labels tell the reader to press `2`
2013
+ * cannot be a control the reader's `2` is refused by, and `#173` is the same
2014
+ * sentence about `R`: the hint named a key the reader genuinely has, at the
2015
+ * one focus position where it was inert.
2016
+ *
2017
+ * THE TEST IS "DOES THIS CONTROL DRAW A KEY THIS MAP READS", and it is what
2018
+ * stops the list growing on taste. It admits exactly two controls today; a
2019
+ * host's own widget, a filter, a remove button and every control that draws
2020
+ * no key stay `elsewhere`. `create/keys.ts` carries the matching half — the
2021
+ * new state admits the ONE binding it renders and no other.
1632
2022
  *
1633
2023
  * ## Asked of the DRAFT, not of where focus happens to be
1634
2024
  *
@@ -1646,6 +2036,25 @@ export function mountWorkspace(element, options) {
1646
2036
  * digits would still have died while the change typechecked and its own table
1647
2037
  * test passed.
1648
2038
  *
2039
+ * ## `add-control` reads focus, and that is not a relapse
2040
+ *
2041
+ * `#173`'s arm asks where focus is, which is the shape this section rejects —
2042
+ * so it has to survive the argument rather than repeat what it rejected, and
2043
+ * the difference is WHICH FACT IS MISSING. At the kind step the fact is the
2044
+ * DRAFT: a source with no kind IS the reader at that step, wherever a restore
2045
+ * put them, so reading focus there answers a question the draft had already
2046
+ * answered better. At the begin step there is no draft yet — beginning one is
2047
+ * the act — and the fact is the SUBJECT to begin from. The control publishes
2048
+ * it on `data-ig-target`, so what this arm reads is a control's own claim
2049
+ * about what it is for, and focus is what says the reader is standing on it.
2050
+ *
2051
+ * The `#150` failure mode is therefore absent by construction: it was focus
2052
+ * MOVING AWAY from the control the draft belonged to, and the arm here has no
2053
+ * "away" — it answers about the control still holding focus, and once the
2054
+ * draft begins the panel replaces this control with the kind list, at which
2055
+ * point the draft is the fact again and `kind-chooser` answers. That ordering
2056
+ * is written below rather than left to be inferred.
2057
+ *
1649
2058
  * ## `canvas` is asked FIRST, and that ordering is the safety property
1650
2059
  *
1651
2060
  * The only answers that move are ones that read `elsewhere` today — where
@@ -1657,6 +2066,52 @@ export function mountWorkspace(element, options) {
1657
2066
  * list sits in an inspector that carries no key — so it answers null from
1658
2067
  * inside either one however this is ordered.
1659
2068
  */
2069
+ /**
2070
+ * Whether Escape has nothing of the CREATE PATH's to cancel.
2071
+ *
2072
+ * ONE PREDICATE FOR WHAT WAS TWO. §17d's overlay and §17k's transients both
2073
+ * take Escape, and both must yield to a live draft — a reader mid-relationship
2074
+ * means "not this edge", not "close the panel behind it". Written out at each
2075
+ * site, that rule is one a later edit can move at one of them.
2076
+ *
2077
+ * FIVE FIELDS, NOT THREE. `isDraftLive` answers for the draft itself, and the
2078
+ * target search and the drop point are the create path's too: a reader who
2079
+ * typed into the target box with no draft open still has something for Escape
2080
+ * to clear, and taking the press for a panel would leave their query sitting
2081
+ * there. `cancel` clears all five, which is why all five are asked.
2082
+ */
2083
+ /**
2084
+ * Whether §17k's lifted inspector is actually ON SCREEN.
2085
+ *
2086
+ * `narrowOverlayOpen` answers about STATE, and state is not visibility here:
2087
+ * the panel is lifted between `1120` and `1359` and is an ordinary column
2088
+ * either side of that, so at the wide layout a selection makes the reducer's
2089
+ * predicate true with nothing overlaid for Escape to dismiss. The press was
2090
+ * being spent on a change the reader could not see.
2091
+ *
2092
+ * THE STYLESHEET KNOWS AND THE DOM SHOWS ITS ANSWER, which is why this is a
2093
+ * measurement rather than a width. The lifted panel is placed in the CANVAS's
2094
+ * grid area, so it overlaps the canvas's box; as a column it sits beside it
2095
+ * and cannot. That is the same fact the sheet encodes, read back rather than
2096
+ * re-derived — and it is not the window, which §17k rules out.
2097
+ *
2098
+ * A SURFACE WITH NO LAYOUT CANNOT ANSWER, and says so by yielding to the
2099
+ * state. Every box is zero-sized before layout, and under `node --test` there
2100
+ * is no layout at all; a strict reading would disable the key there and take
2101
+ * a tested behaviour away from every consumer that renders without a browser.
2102
+ */
2103
+ const inspectorIsLifted = () => {
2104
+ const inspector = surface.querySelector('.ig-zone[data-zone="inspector"]');
2105
+ const canvas = surface.querySelector('.ig-zone[data-zone="canvas"]');
2106
+ if (inspector === null || canvas === null)
2107
+ return false;
2108
+ const panel = inspector.getBoundingClientRect();
2109
+ const zone = canvas.getBoundingClientRect();
2110
+ if (panel.width === 0 && zone.width === 0)
2111
+ return true;
2112
+ return panel.left < zone.right && panel.right > zone.left;
2113
+ };
2114
+ const nothingToCancel = () => !isDraftLive(state.draft) && state.targetQuery === '' && state.drop === null;
1660
2115
  const interaction = () => {
1661
2116
  const active = doc.activeElement;
1662
2117
  if (isElement(active) && isInput(active) && active.getAttribute(COMMAND_ATTRIBUTE) === 'target-query') {
@@ -1680,7 +2135,14 @@ export function mountWorkspace(element, options) {
1680
2135
  // change's to settle.
1681
2136
  if (isInput(active))
1682
2137
  return 'elsewhere';
1683
- return isChoosingKind(state.draft) ? 'kind-chooser' : 'elsewhere';
2138
+ // THE DRAFT IS ASKED BEFORE THE CONTROL, so the section above stays true of
2139
+ // the state it was written for. The two cannot both be live in practice —
2140
+ // `createStep` withholds `+ add` for the whole of the kind step — but a rule
2141
+ // that depends on another module's rendering condition is one that changes
2142
+ // silently the day that condition does.
2143
+ if (isChoosingKind(state.draft))
2144
+ return 'kind-chooser';
2145
+ return focusedAddSubject() === null ? 'elsewhere' : 'add-control';
1684
2146
  };
1685
2147
  /**
1686
2148
  * The rail's window edge, decided BEFORE the viewer is asked.
@@ -1776,6 +2238,26 @@ export function mountWorkspace(element, options) {
1776
2238
  focusIn(zoneName, result.command.key);
1777
2239
  return true;
1778
2240
  }
2241
+ // §16f's `→ expands provenance` on a list row. The viewer cannot expand
2242
+ // anything itself, so it publishes `expand:<key>` and this is where the
2243
+ // host takes it — split into the name/target shape every other control
2244
+ // here already uses, rather than teaching the reducer a second spelling.
2245
+ //
2246
+ // THE COLON IS LOAD-BEARING. `expand` with no key is §16a's panel-size
2247
+ // control and means something else entirely; only the qualified form is a
2248
+ // row. A press that names no key is handed back rather than swallowed.
2249
+ if (result.command.kind === 'command') {
2250
+ const [name, ...rest] = result.command.command.split(':');
2251
+ // REJOINED, NOT `rest[0]`. A qualified key is `owner/repo#12`, which
2252
+ // carries no colon today — but the split is over the whole command and
2253
+ // taking only the first segment would silently truncate any key that
2254
+ // ever does.
2255
+ const row = rest.join(':');
2256
+ if ((name === 'expand' || name === 'collapse') && row !== '') {
2257
+ dispatch({ kind: 'control', name, target: row });
2258
+ return true;
2259
+ }
2260
+ }
1779
2261
  return false;
1780
2262
  };
1781
2263
  /**
@@ -1889,11 +2371,89 @@ export function mountWorkspace(element, options) {
1889
2371
  // The FOCUSED row, not the selection: on a fresh page a row can own
1890
2372
  // focus while nothing is selected, and after focus moves on, a stale
1891
2373
  // selection must not become the source of a keyboard-started draft.
1892
- focused: focusedKey(),
2374
+ // AND `+ add`'S OWN SUBJECT WHERE THERE IS NO ROW, which is still that
2375
+ // rule rather than an exception to it — the fallback is a control's
2376
+ // published claim about what it acts on, not the selection this line
2377
+ // refuses. It moves nothing that worked: the second term is consulted
2378
+ // only where the first is `null`, and where it answers, `interaction()`
2379
+ // answers `add-control`, which admits `relate` alone.
2380
+ // AND IT DOES NOT OPEN THE NAVIGATION ARM BELOW, which is the one place a
2381
+ // wider `focused` could have leaked. That arm is guarded on this field,
2382
+ // but `navigateFocus` re-derives the key from a `[data-ig-key]` ANCESTOR
2383
+ // and answers `false` without one — and no command control has one, since
2384
+ // every one this package draws sits in chrome beside the keyed rows. So
2385
+ // the guard now admits a press the arm still declines, rather than
2386
+ // stepping the rail from a focused button.
2387
+ focused: focusedKey() ?? focusedAddSubject(),
1893
2388
  match,
1894
2389
  selectedEdge: selectedEdgeId(state.selection),
1895
2390
  interaction: interaction(),
1896
2391
  };
2392
+ // §17k'S TRANSIENT SURFACES ARE DISMISSED ON ESCAPE — the ruling's own
2393
+ // words for the lifted inspector, and the strip takes the same key because
2394
+ // a canvas opened over the inspector is the same kind of thing.
2395
+ //
2396
+ // BEFORE `keyIntent`, AND THE OBVIOUS PLACEMENT IS THE WRONG ONE. Putting
2397
+ // it after reads as the safe order — let the create loop have the press
2398
+ // first — and it makes this arm DEAD. `create/keys.ts` binds `escape` to
2399
+ // `cancel` with the comment "ALWAYS AVAILABLE", and `reaches` admits it
2400
+ // whenever the interaction is `canvas`, which `interaction()` answers for
2401
+ // any focused `[data-ig-key]`. So with focus on a rail row — where it sits
2402
+ // after almost every click on this surface — `keyIntent` claims Escape for
2403
+ // a draft that does not exist and returns, and nothing below it is reached.
2404
+ // MEASURED IN A BROWSER, not reasoned about: the panel stayed open, and the
2405
+ // `dataInspector` attribute stayed `lifted` across the press.
2406
+ //
2407
+ // THE DRAFT STILL WINS, WHICH IS WHAT THE OTHER ORDER WAS FOR. A reader
2408
+ // mid-relationship pressing Escape means "not this edge", not "close the
2409
+ // panel behind it" — so the guard is `nothingToCancel`, which asks about
2410
+ // the create path rather than about the position in this function. An idle
2411
+ // one is the only state `keyIntent` would have spent the press on, and
2412
+ // cancelling an idle draft is a no-op.
2413
+ //
2414
+ // GUARDED ALSO ON `narrowOverlayOpen`, WHICH IS THE REDUCER'S OWN PREDICATE
2415
+ // rather than a second reading of the same two fields, so the two cannot
2416
+ // drift apart — `reduceHost` answers `unclaimed` for exactly the same case.
2417
+ //
2418
+ // WHAT THAT DOES NOT DO IS LET THE PRESS THROUGH. A press this arm declines
2419
+ // falls to `keyIntent`, which binds Escape to a cancel that is "ALWAYS
2420
+ // AVAILABLE" and cancels it there — so on a focused rail row Escape is
2421
+ // consumed by this surface whether or not anything was dismissed. That
2422
+ // predates this branch and is not changed here; it is written down because
2423
+ // the obvious reading of `unclaimed` is that the key reaches the host, and
2424
+ // on this surface it does not.
2425
+ //
2426
+ // THE REDUCER CANNOT ASK WHICH OF THE TWO THE READER CAN SEE, and it must
2427
+ // not: the width is the stylesheet's to know, per §17k's note that a
2428
+ // package reading the window cannot be dropped into someone else's page.
2429
+ // So it closes both and the sheet draws whichever was on screen.
2430
+ //
2431
+ // THE SHELL CAN ASK, THOUGH, AND HAS TO. An earlier revision left the guard
2432
+ // at the reducer's predicate alone and accepted "a press spent invisibly at
2433
+ // the wide layout" as a cost. It is not a cost worth taking: a key consumed
2434
+ // for a surface nobody can see is indistinguishable from a broken key. See
2435
+ // `inspectorIsLifted`, which reads the sheet's own answer off the DOM
2436
+ // rather than re-deriving it from a width.
2437
+ //
2438
+ // TWO TRANSIENTS SHARE THIS KEY NOW, AND §17d'S GOES FIRST. The audit
2439
+ // overlay is the one the reader pressed a control to open, and it is drawn
2440
+ // at EVERY width; §17k's lifted inspector is opened implicitly by a
2441
+ // selection and exists only between `1120` and `1359`. So a press with the
2442
+ // audit panel up closes the audit panel, at any width — without this order
2443
+ // a reader at the wide layout, where nothing of §17k's is drawn, would
2444
+ // press Escape and watch the panel stay put.
2445
+ if (event.key === 'Escape' && nothingToCancel()) {
2446
+ if (state.auditOpen) {
2447
+ event.preventDefault();
2448
+ dispatch({ kind: 'control', name: 'audit-close' });
2449
+ return;
2450
+ }
2451
+ if (narrowOverlayOpen(state) && (state.canvasOpen || inspectorIsLifted())) {
2452
+ event.preventDefault();
2453
+ dispatch({ kind: 'control', name: 'narrow-dismiss' });
2454
+ return;
2455
+ }
2456
+ }
1897
2457
  const intent = keyIntent(event, context);
1898
2458
  if (intent.kind !== 'none') {
1899
2459
  event.preventDefault();
@@ -1913,11 +2473,13 @@ export function mountWorkspace(element, options) {
1913
2473
  // Two facts make it true here rather than merely plausible.
1914
2474
  //
1915
2475
  // `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
2476
+ // neither an input, nor the kind step, nor `+ add`, so `keyIntent` returns
2477
+ // `none` for every key there. At the KIND STEP it answers `kind-chooser` —
2478
+ // the predicate is asked of the DRAFT, not of where focus is — and there
1919
2479
  // `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
2480
+ // again. On `+ add` it answers `add-control`, which admits `RELATE_KEY`
2481
+ // alone (`#173`) and neither of these two, so this arm still owns them
2482
+ // there. So this arm does claim `Enter` and `Space` on the inspector's
1921
2483
  // `clear`, which is where the mount's focus restore lands after `begin`.
1922
2484
  // That is a control doing what a control does, and it is new behaviour on
1923
2485
  // a step the create loop passes through every time — said out loud because
@@ -2001,8 +2563,18 @@ export function mountWorkspace(element, options) {
2001
2563
  // A REFUSAL IS THIS PACKAGE'S OWN ANSWER, so it keeps the press: `Space`
2002
2564
  // would otherwise scroll the page under a reader who has just pressed
2003
2565
  // something, and there is nothing else waiting for this key.
2566
+ //
2567
+ // EXCEPT A NAVIGATION REFUSAL, WHICH MEANS THE OPPOSITE. `input` and `inert`
2568
+ // say "this press does nothing, and nothing else wants it"; `navigation`
2569
+ // says the BROWSER performs this control's activation, so there is something
2570
+ // else waiting for the key and cancelling is how it never arrives. An
2571
+ // anchor's `Enter` activation is a DEFAULT ACTION — cancel the keydown and
2572
+ // no click is synthesized and the link never opens, which would leave §17d's
2573
+ // outward link working for a pointer and dead for a keyboard. The click path
2574
+ // still refuses to DISPATCH it; the two answers differ because the acts do.
2004
2575
  if (answer.kind === 'refused') {
2005
- event.preventDefault();
2576
+ if (answer.reason !== 'navigation')
2577
+ event.preventDefault();
2006
2578
  return;
2007
2579
  }
2008
2580
  // DISPATCH FIRST, THEN DECIDE WHETHER THE PRESS WAS OURS TO KEEP — and that
@@ -2146,10 +2718,23 @@ export function mountWorkspace(element, options) {
2146
2718
  element.addEventListener('lostpointercapture', onPointerCancel);
2147
2719
  doc.addEventListener('pointerup', onDocumentPointerUp);
2148
2720
  doc.addEventListener('pointercancel', onDocumentPointerUp);
2721
+ /**
2722
+ * The rail's own width, watched only so the docked legend's clearance stays
2723
+ * true. See `dockRailLegend` for why a resize and not just a redraw.
2724
+ *
2725
+ * THE ELEMENT, NOT THE WINDOW. A package that reads the window cannot be
2726
+ * dropped into someone else's page — the same rule §17j states about the
2727
+ * density crossover and the workspace's own container query rests on. The
2728
+ * rail can narrow because the host resized, because a sibling grew, or
2729
+ * because a container query re-tracked the grid, and only the box itself sees
2730
+ * all three.
2731
+ */
2732
+ const railResize = typeof ResizeObserver === 'function' ? new ResizeObserver(() => { dockRailLegend(); }) : null;
2149
2733
  const unsubscribe = store.subscribe(schedule);
2150
2734
  /** Every listener off and every node this mount built removed. Shared by `destroy` and a failed first render. */
2151
2735
  const teardown = () => {
2152
2736
  unsubscribe();
2737
+ railResize?.disconnect();
2153
2738
  element.removeEventListener('click', onClick);
2154
2739
  element.removeEventListener('input', onInput);
2155
2740
  element.removeEventListener('compositionend', onCompositionEnd);