@issuegraph/editor 0.4.0 → 0.22.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (137) hide show
  1. package/README.md +65 -9
  2. package/dist/a11y/baseline.d.ts +182 -0
  3. package/dist/a11y/baseline.d.ts.map +1 -0
  4. package/dist/a11y/baseline.js +434 -0
  5. package/dist/a11y/baseline.js.map +1 -0
  6. package/dist/audit/findings.d.ts +107 -1
  7. package/dist/audit/findings.d.ts.map +1 -1
  8. package/dist/audit/findings.js +113 -6
  9. package/dist/audit/findings.js.map +1 -1
  10. package/dist/audit/panel.d.ts +277 -0
  11. package/dist/audit/panel.d.ts.map +1 -0
  12. package/dist/audit/panel.js +462 -0
  13. package/dist/audit/panel.js.map +1 -0
  14. package/dist/audit/refused.d.ts +99 -0
  15. package/dist/audit/refused.d.ts.map +1 -0
  16. package/dist/audit/refused.js +276 -0
  17. package/dist/audit/refused.js.map +1 -0
  18. package/dist/audit/styles.d.ts +1 -1
  19. package/dist/audit/styles.d.ts.map +1 -1
  20. package/dist/audit/styles.js +429 -1
  21. package/dist/audit/styles.js.map +1 -1
  22. package/dist/audit/surface.d.ts +54 -12
  23. package/dist/audit/surface.d.ts.map +1 -1
  24. package/dist/audit/surface.js +53 -13
  25. package/dist/audit/surface.js.map +1 -1
  26. package/dist/create/draft.d.ts +36 -0
  27. package/dist/create/draft.d.ts.map +1 -1
  28. package/dist/create/draft.js +40 -0
  29. package/dist/create/draft.js.map +1 -1
  30. package/dist/create/keys.d.ts +134 -9
  31. package/dist/create/keys.d.ts.map +1 -1
  32. package/dist/create/keys.js +119 -20
  33. package/dist/create/keys.js.map +1 -1
  34. package/dist/firstpass/candidates.d.ts +17 -1
  35. package/dist/firstpass/candidates.d.ts.map +1 -1
  36. package/dist/index.d.ts +42 -17
  37. package/dist/index.d.ts.map +1 -1
  38. package/dist/index.js +45 -14
  39. package/dist/index.js.map +1 -1
  40. package/dist/overlay/render.d.ts +11 -3
  41. package/dist/overlay/render.d.ts.map +1 -1
  42. package/dist/overlay/render.js +76 -20
  43. package/dist/overlay/render.js.map +1 -1
  44. package/dist/overlay/request.d.ts +71 -0
  45. package/dist/overlay/request.d.ts.map +1 -0
  46. package/dist/overlay/request.js +129 -0
  47. package/dist/overlay/request.js.map +1 -0
  48. package/dist/overlay/styles.d.ts +1 -1
  49. package/dist/overlay/styles.d.ts.map +1 -1
  50. package/dist/overlay/styles.js +78 -7
  51. package/dist/overlay/styles.js.map +1 -1
  52. package/dist/picker/render.d.ts +26 -16
  53. package/dist/picker/render.d.ts.map +1 -1
  54. package/dist/picker/render.js +25 -36
  55. package/dist/picker/render.js.map +1 -1
  56. package/dist/picker/styles.d.ts +7 -7
  57. package/dist/picker/styles.d.ts.map +1 -1
  58. package/dist/picker/styles.js +7 -36
  59. package/dist/picker/styles.js.map +1 -1
  60. package/dist/picker/view.d.ts +8 -4
  61. package/dist/picker/view.d.ts.map +1 -1
  62. package/dist/picker/view.js +4 -3
  63. package/dist/picker/view.js.map +1 -1
  64. package/dist/picker/words.d.ts +12 -10
  65. package/dist/picker/words.d.ts.map +1 -1
  66. package/dist/picker/words.js +12 -8
  67. package/dist/picker/words.js.map +1 -1
  68. package/dist/reevaluate/parts.d.ts +130 -0
  69. package/dist/reevaluate/parts.d.ts.map +1 -0
  70. package/dist/reevaluate/parts.js +260 -0
  71. package/dist/reevaluate/parts.js.map +1 -0
  72. package/dist/reevaluate/render.d.ts +18 -11
  73. package/dist/reevaluate/render.d.ts.map +1 -1
  74. package/dist/reevaluate/render.js +21 -135
  75. package/dist/reevaluate/render.js.map +1 -1
  76. package/dist/reevaluate/styles.d.ts +1 -1
  77. package/dist/reevaluate/styles.d.ts.map +1 -1
  78. package/dist/reevaluate/styles.js +26 -0
  79. package/dist/reevaluate/styles.js.map +1 -1
  80. package/dist/reevaluate/view.d.ts +16 -1
  81. package/dist/reevaluate/view.d.ts.map +1 -1
  82. package/dist/reevaluate/view.js +21 -1
  83. package/dist/reevaluate/view.js.map +1 -1
  84. package/dist/reevaluate/words.d.ts +7 -0
  85. package/dist/reevaluate/words.d.ts.map +1 -1
  86. package/dist/scale/ladder.d.ts +62 -6
  87. package/dist/scale/ladder.d.ts.map +1 -1
  88. package/dist/scale/ladder.js +129 -11
  89. package/dist/scale/ladder.js.map +1 -1
  90. package/dist/scale/render.d.ts +44 -0
  91. package/dist/scale/render.d.ts.map +1 -1
  92. package/dist/scale/render.js +206 -28
  93. package/dist/scale/render.js.map +1 -1
  94. package/dist/scale/styles.d.ts +11 -5
  95. package/dist/scale/styles.d.ts.map +1 -1
  96. package/dist/scale/styles.js +133 -4
  97. package/dist/scale/styles.js.map +1 -1
  98. package/dist/workspace/chrome.d.ts +1 -1
  99. package/dist/workspace/chrome.d.ts.map +1 -1
  100. package/dist/workspace/chrome.js +48 -0
  101. package/dist/workspace/chrome.js.map +1 -1
  102. package/dist/workspace/firstpass.d.ts +166 -0
  103. package/dist/workspace/firstpass.d.ts.map +1 -0
  104. package/dist/workspace/firstpass.js +151 -0
  105. package/dist/workspace/firstpass.js.map +1 -0
  106. package/dist/workspace/host.d.ts +283 -11
  107. package/dist/workspace/host.d.ts.map +1 -1
  108. package/dist/workspace/host.js +614 -43
  109. package/dist/workspace/host.js.map +1 -1
  110. package/dist/workspace/inspector.d.ts +72 -2
  111. package/dist/workspace/inspector.d.ts.map +1 -1
  112. package/dist/workspace/inspector.js +26 -0
  113. package/dist/workspace/inspector.js.map +1 -1
  114. package/dist/workspace/mount.d.ts +75 -6
  115. package/dist/workspace/mount.d.ts.map +1 -1
  116. package/dist/workspace/mount.js +1851 -60
  117. package/dist/workspace/mount.js.map +1 -1
  118. package/dist/workspace/rail.d.ts +5 -2
  119. package/dist/workspace/rail.d.ts.map +1 -1
  120. package/dist/workspace/rail.js.map +1 -1
  121. package/dist/workspace/recovery.d.ts +155 -0
  122. package/dist/workspace/recovery.d.ts.map +1 -0
  123. package/dist/workspace/recovery.js +156 -0
  124. package/dist/workspace/recovery.js.map +1 -0
  125. package/dist/workspace/render.d.ts +779 -2
  126. package/dist/workspace/render.d.ts.map +1 -1
  127. package/dist/workspace/render.js +1727 -61
  128. package/dist/workspace/render.js.map +1 -1
  129. package/dist/workspace/selection.d.ts +11 -0
  130. package/dist/workspace/selection.d.ts.map +1 -1
  131. package/dist/workspace/selection.js +10 -0
  132. package/dist/workspace/selection.js.map +1 -1
  133. package/dist/workspace/styles.d.ts +1 -1
  134. package/dist/workspace/styles.d.ts.map +1 -1
  135. package/dist/workspace/styles.js +1405 -42
  136. package/dist/workspace/styles.js.map +1 -1
  137. package/package.json +4 -4
@@ -26,27 +26,209 @@
26
26
  * of which a host reducer should know about, and each of which the demo now
27
27
  * handles beside the mount rather than through it.
28
28
  */
29
- import { EDGE_FIELDS, isEdgeField } from '@issuegraph/core';
29
+ import { edgeIdentityEnd, isEdgeField } from '@issuegraph/core';
30
30
  import { findEdge } from '@issuegraph/store';
31
31
  import { IDLE_CREATE_DRAFT, createReducer } from "../create/draft.js";
32
+ import {} from "../firstpass/queue.js";
32
33
  import { pickerView } from "../picker/view.js";
33
34
  import { INITIAL_SCALE_STATE, scaleReducer } from "../scale/commands.js";
35
+ import { INITIAL_FIRST_PASS, firstPassReducer, } from "./firstpass.js";
34
36
  import { INITIAL_SELECTION, selectedEdgeId, selectedKey, selectionReducer, } from "./selection.js";
35
37
  export const INITIAL_HOST_STATE = Object.freeze({
36
38
  selection: INITIAL_SELECTION,
37
39
  scale: INITIAL_SCALE_STATE,
38
40
  auditFiltered: false,
41
+ canvasOpen: false,
42
+ inspectorDismissed: false,
43
+ auditOpen: false,
44
+ expanded: [],
39
45
  railStart: 0,
40
46
  draft: IDLE_CREATE_DRAFT,
41
47
  targetQuery: '',
42
48
  drag: null,
43
49
  drop: null,
50
+ firstPass: INITIAL_FIRST_PASS,
51
+ diffOpen: null,
44
52
  });
45
53
  function settled(state) {
46
- return { state, effects: [] };
54
+ return { state, effects: [], claimed: true };
55
+ }
56
+ /** The `default` arm's result: not this reducer's command. See {@link HostResult.claimed}. */
57
+ function unclaimed(state) {
58
+ return { state, effects: [], claimed: false };
59
+ }
60
+ /**
61
+ * The issue whose own frontmatter declares a relationship.
62
+ *
63
+ * ONE SPELLING OF A FACT TWO DECISIONS DEPEND ON. `from` is the carrier for
64
+ * every field, the symmetric ones included: §4.3 puts a relationship in ONE
65
+ * issue's block, and `from` is the end it was declared from — which is why a
66
+ * flip of a symmetric kind is refused rather than moving it. Both readers
67
+ * below — where the panel goes when the edge it was showing stops being drawn,
68
+ * and which issue a refused edit is stated under — wrote `edge.from` for
69
+ * themselves, which is two answers to one question, agreeing only for as long
70
+ * as nobody touched either.
71
+ *
72
+ * IT CAN ONLY BE READ WHILE THE DOCUMENT STILL HOLDS THE EDGE, and every reader
73
+ * of it is shaped by that. Once the edge is gone its identity is the only record
74
+ * left, and it is a weaker record for the SYMMETRIC fields: `edgeIdentity` sorts
75
+ * their endpoints, so a `serialize-with` declared from `z` to `a` and one
76
+ * declared from `a` to `z` are one string and `from` is not a function of it. A revision of `@issuegraph/core` named the carrying end of
77
+ * every identity anyway; for the symmetric fields that answer was the sort order
78
+ * wearing the carrier's name, and a refusal about such an edge was stated on the
79
+ * far end's panel — the panel the reader was NOT on — whenever the pair sorted
80
+ * the other way. `edgeIdentityEnd` now answers `either` for exactly those and
81
+ * names the carrier for the directed fields, so this function stays the only
82
+ * source of the fact while the edge is there, and nothing downstream has to
83
+ * guess which kind of record it is holding.
84
+ */
85
+ function carrierOf(edge) {
86
+ return edge.from;
87
+ }
88
+ function endsOf(document, proposal) {
89
+ // A CREATE HAS NO EDGE YET, and its own two references are the whole of what
90
+ // it is about — including the `unknown-issue` case, where one of them is an
91
+ // issue the document does not hold and no lookup could recover it.
92
+ if (proposal.op === 'create')
93
+ return { kind: 'pair', carrier: proposal.from, far: proposal.to };
94
+ const edge = findEdge(document, proposal.edgeId);
95
+ if (edge !== undefined)
96
+ return { kind: 'pair', carrier: carrierOf(edge), far: edge.to };
97
+ // AND THE EDGE CAN BE ABSENT, which is the `unknown-edge` refusal itself: the
98
+ // reader acted on a relationship a landed write had already removed. All that
99
+ // is left is the identity the act named.
100
+ return { kind: 'identity', edgeId: proposal.edgeId };
101
+ }
102
+ /**
103
+ * Which issue an edit is ABOUT — the panel that states it, and the panel it is
104
+ * refused on.
105
+ *
106
+ * THE SUBJECT OF AN EDIT IS DECIDED ONCE, AND THAT MEANS ONCE IN TIME AS WELL
107
+ * AS ONCE IN THE SOURCE. It used to be decided twice and in two vocabularies:
108
+ * the panel derived its own subject from the selection (canonicalized onto a
109
+ * together unit's lead), and the inspector separately asked whether the REFUSED
110
+ * EDGE's identity named that subject. Two derivations of one fact diverge, and
111
+ * they did, three times over. This function replaced the second derivation —
112
+ * and then a fourth divergence appeared inside it, because it was still being
113
+ * CALLED twice: once when the edit went out, and again when the refusal came
114
+ * back, against a document a sibling write had changed in between. So the shell
115
+ * calls it at the moment of emission, records the answer against the write, and
116
+ * reads the record afterwards. See `HostEffect`'s `carrier` and `mount.ts`.
117
+ *
118
+ * THE CARRIER'S END WINS, AND THE FAR END IS THE FALLBACK. The relationship is
119
+ * declared in the carrier's block, so that is the panel a reader made the edit
120
+ * from and the panel that can undo it. A create naming an unknown SOURCE has
121
+ * no such panel, and stating the refusal on the target's — the only issue the
122
+ * document holds — beats stating it nowhere.
123
+ *
124
+ * AND WITH ONLY AN IDENTITY LEFT, THE SAME RANKING APPLIES AS FAR AS THE FORMAT
125
+ * KEPT IT. `edgeIdentity` discards the declaring end of a SYMMETRIC pair and
126
+ * keeps it for a directed one, so `edgeIdentityEnd` names the carrier of a
127
+ * directed identity and answers `either` for a symmetric one — and both arms
128
+ * here rank by one rule instead of two. THIS ARM HAS BEEN WRONG IN BOTH
129
+ * DIRECTIONS: ranking a symmetric identity's segments invented an order the
130
+ * format had thrown away and stated a refusal on the far end's panel, and the
131
+ * correction — ranking nothing at all — discarded the order a DIRECTED identity
132
+ * still carries, so a host proposing on the shared store had its refusal placed
133
+ * under whichever end `document.issues` listed first. `either`, and a directed
134
+ * identity whose carrier this backlog does not hold, both fall through to the
135
+ * fallback this arm always had: "an issue this backlog holds and this edit was
136
+ * between", in the document's own order. The reader's own edits reach none of
137
+ * it, because their carrier was taken while the edge was still there.
138
+ *
139
+ * `null` when the document holds NEITHER end, which is not a refusal being
140
+ * dropped: there is no panel for an issue this backlog does not carry, so
141
+ * there is nowhere the reader could be standing to read it.
142
+ */
143
+ export function editCarrier(document, proposal) {
144
+ const keys = document.issues.map((issue) => issue.ref);
145
+ const ends = endsOf(document, proposal);
146
+ switch (ends.kind) {
147
+ case 'pair':
148
+ return keys.find((ref) => ref === ends.carrier) ?? keys.find((ref) => ref === ends.far) ?? null;
149
+ case 'identity':
150
+ return (keys.find((ref) => edgeIdentityEnd(ends.edgeId, ref) === 'carrier') ??
151
+ keys.find((ref) => edgeIdentityEnd(ends.edgeId, ref) !== null) ??
152
+ null);
153
+ }
154
+ }
155
+ /** The reader's own edits: performed as a plain write, with nothing to take back by name. */
156
+ const TO_THE_STORE = { kind: 'propose' };
157
+ /**
158
+ * The effect a route emits, as an exhaustive switch rather than a pair of
159
+ * branches: a third way to write is a compile error here, not a route that
160
+ * quietly builds its own effect again.
161
+ */
162
+ function effectFor(route, proposal, carrier) {
163
+ switch (route.kind) {
164
+ case 'propose':
165
+ return { kind: 'propose', proposal, carrier };
166
+ case 'first-pass-apply':
167
+ return { kind: 'first-pass-apply', candidateId: route.candidateId, proposal, carrier };
168
+ }
169
+ }
170
+ /**
171
+ * Emit an edit, and leave the panel on the issue the edit is about.
172
+ *
173
+ * EVERY PROPOSAL THIS REDUCER EMITS GOES THROUGH HERE, which is the point. The
174
+ * type does not prove that on its own — a new arm could still write out a
175
+ * `carrier` by hand — but it makes skipping this deliberate rather than an
176
+ * omission: both of `HostEffect`'s write-bearing arms REQUIRE the field, so a
177
+ * route that goes round the funnel has to invent an answer in plain sight
178
+ * instead of quietly not having one.
179
+ *
180
+ * §17b requires a refusal to be visible at the moment the reader is refused,
181
+ * and a refusal is stated on its carrier's panel — so the panel has
182
+ * to BE the carrier's when the edit goes out, rather than arriving there
183
+ * afterwards by whichever route happens to notice. Each route that had to
184
+ * notice separately cost a review round: a retype's projection hides the edge
185
+ * the panel was filtered to, and until `reconcileHost` was taught about hidden
186
+ * edges the panel resolved to nothing selected and the reason was drawn
187
+ * nowhere; a create completed after the reader had clicked another issue went
188
+ * out from the draft's source while the panel was headed by the issue they
189
+ * clicked, which nothing downstream can see at all — a completed draft leaves
190
+ * no trace of where it began; and a first-pass `apply` built its effect for
191
+ * itself, so a queue opened with nothing selected answered `Y`, was refused,
192
+ * and closed onto a panel that stated nothing.
193
+ *
194
+ * IT WRITES BACK WHAT WAS ALREADY THERE ON THE CREATE PATHS, which is the
195
+ * check that this is not a behaviour change smuggled in beside a fix.
196
+ * Beginning a draft already selects its source and a canvas drop already
197
+ * selects the node it started on, so the undiverted paths land on the same
198
+ * selection. The picker's routes DO move — an edit on a selected edge takes
199
+ * the panel to that edge's carrier now rather than one render later, which is
200
+ * where a retype or a flip already ended up through `reconcileHost` and where
201
+ * a delete of the selected edge used to leave the reader with `none` once the
202
+ * write landed and took the edge away.
203
+ *
204
+ * SO DOES THE FIRST PASS, AND IT MOVES THE PANEL UNDER A SURFACE THAT COVERS
205
+ * IT. That is the intended effect rather than a side one: the overlay is modal,
206
+ * the reader answers `Y`, and where they are put down when it closes is the
207
+ * only chance a refusal of that answer has of being read. Whether the write
208
+ * will be refused is not knowable here, so every `apply` moves the panel and
209
+ * the last one answered is the panel the reader lands on.
210
+ *
211
+ * AN EDIT ABOUT NO ISSUE THIS DOCUMENT HOLDS LEAVES THE SELECTION ALONE. See
212
+ * {@link editCarrier}: there is no panel to move to, and clearing would take
213
+ * the reader off the one they are on for a reason they could not see. The
214
+ * effect still carries the `null`, because "no panel states this" is an answer
215
+ * the shell must not go and compute a different one for.
216
+ */
217
+ function emitting(state, proposal, document, route) {
218
+ const carrier = editCarrier(document, proposal);
219
+ return {
220
+ state: carrier === null
221
+ ? state
222
+ : {
223
+ ...state,
224
+ selection: selectionReducer(INITIAL_SELECTION, { kind: 'select-issue', key: carrier }),
225
+ },
226
+ effects: [effectFor(route, proposal, carrier)],
227
+ claimed: true,
228
+ };
47
229
  }
48
230
  /** A draft step: apply the create reducer, and emit the proposal it completes. */
49
- function drafted(state, command) {
231
+ function drafted(state, command, document) {
50
232
  const result = createReducer(state.draft, command);
51
233
  if (result.proposal === null) {
52
234
  // BEGINNING A DRAFT SELECTS ITS SOURCE. The inspector draws the picker for
@@ -63,22 +245,121 @@ function drafted(state, command) {
63
245
  const chrome = command.kind === 'cancel' ? { targetQuery: '', drop: null } : {};
64
246
  return settled({ ...state, draft: result.draft, selection, ...chrome });
65
247
  }
248
+ // THE DRAFT ENDS WHERE IT BEGAN. `begin` selects the source three lines
249
+ // above; `emitting` puts the panel back on the edit's carrier, which for a
250
+ // create IS that source. Without it a click on another issue between the two
251
+ // steps left the write going out from one issue and the panel headed by
252
+ // another — and a refusal about the first stated on no panel at all.
253
+ const proposed = emitting(state, result.proposal, document, TO_THE_STORE);
66
254
  return {
67
- state: { ...state, draft: IDLE_CREATE_DRAFT, targetQuery: '', drop: null },
68
- effects: [{ kind: 'propose', proposal: result.proposal }],
255
+ ...proposed,
256
+ state: { ...proposed.state, draft: IDLE_CREATE_DRAFT, targetQuery: '', drop: null },
69
257
  };
70
258
  }
259
+ /**
260
+ * Whether a string names an {@link Answer}.
261
+ *
262
+ * Its own guard rather than a cast: the value arrives off a DOM attribute, and
263
+ * `AGENTS.md`'s strict-TypeScript rule forbids asserting it into the union. The
264
+ * three names are `queue.ts`'s, read from a frozen tuple so an answer added
265
+ * there fails a build here rather than being silently unreachable.
266
+ */
267
+ const ANSWERS = Object.freeze(['apply', 'reject', 'skip']);
268
+ function isAnswer(value) {
269
+ return ANSWERS.some((answer) => answer === value);
270
+ }
271
+ /**
272
+ * Apply one first-pass command, and turn what it produced into effects.
273
+ *
274
+ * The two emissions are exactly the two things `QueueResult` reports and the
275
+ * queue itself cannot perform: the proposal an `apply` stands for, and the
276
+ * answer an `undo` took back. Nothing else here reaches the store, which is what
277
+ * keeps §17e's consent rule a property of the shape — drawing a candidate,
278
+ * opening the surface, closing it and skipping all emit nothing because there is
279
+ * no arm on which they could.
280
+ *
281
+ * THE APPLY GOES THROUGH {@link emitting}, AND FOR A ROUND IT DID NOT. This
282
+ * function built the `first-pass-apply` effect itself, which made the claim
283
+ * "every proposal this reducer emits goes through one funnel" false at the one
284
+ * emission that could not be reached any other way — the overlay is modal, so
285
+ * the reader's hands are nowhere near the panel. A queue opened with nothing
286
+ * selected, or with an unrelated issue selected, answered `Y`, had the create
287
+ * refused, closed, and put the reader back exactly where they had been: a panel
288
+ * that states no refusal because the edit was about some other issue. It is the
289
+ * `document` argument that this route was missing, so that is what it now takes.
290
+ */
291
+ function firstPassed(state, command, document) {
292
+ const outcome = firstPassReducer(state.firstPass, command);
293
+ // THE DRAFT IS NOT TOUCHED HERE, and an earlier revision's attempt to is worth
294
+ // recording: opening the surface does have to cancel a live draft — the queue
295
+ // covers the target search and the kind chooser, and a draft left standing
296
+ // behind it is re-entered on close with the reader's context gone — but this
297
+ // reducer cannot tell an open that will SCAN from one the shell is about to
298
+ // refuse for want of a source. Clearing on every open destroyed the reader's
299
+ // draft for a queue that then never appeared. So the cancel belongs to the
300
+ // shell, which knows, and it is dispatched there — see `mount.ts`.
301
+ let next = { ...state, firstPass: outcome.state };
302
+ const effects = [];
303
+ if (outcome.scanning !== null)
304
+ effects.push({ kind: 'find-candidates', scan: outcome.scanning });
305
+ const result = outcome.result;
306
+ if (result !== null) {
307
+ // THE ANSWER'S OWN CANDIDATE, read off the queue's record of it rather than
308
+ // off the screen: by the time this runs the cursor has already advanced.
309
+ const given = result.state.answers[result.state.answers.length - 1];
310
+ if (result.proposal !== null && given !== undefined) {
311
+ const applied = emitting(next, result.proposal, document, {
312
+ kind: 'first-pass-apply',
313
+ candidateId: given.candidate.id,
314
+ });
315
+ // THE PANEL MOVE IS KEPT, not discarded beside the effect. It is the whole
316
+ // of what this route was missing: the overlay closes onto the issue the
317
+ // answered create was about, which is the panel its refusal is stated on.
318
+ next = applied.state;
319
+ effects.push(...applied.effects);
320
+ }
321
+ // ONLY A WITHDRAWN `apply` REACHES THE STORE. A withdrawn `reject` or `skip`
322
+ // dispatched nothing, so there is nothing out there to take back.
323
+ if (result.withdrawn !== null && result.withdrawn.answer === 'apply') {
324
+ effects.push({ kind: 'first-pass-withdraw', candidateId: result.withdrawn.candidate.id });
325
+ }
326
+ }
327
+ return { state: next, effects, claimed: true };
328
+ }
71
329
  /** A pointer on an issue: a target while one is being chosen, a selection otherwise. */
72
- function pointed(state, key) {
330
+ function pointed(state, key, document) {
73
331
  const choosingTarget = state.draft.kind !== null && state.draft.target === null;
74
332
  if (choosingTarget && key !== state.draft.source) {
75
- return drafted(state, { kind: 'target', ref: key });
333
+ return drafted(state, { kind: 'target', ref: key }, document);
76
334
  }
77
335
  return settled({
78
336
  ...state,
79
337
  selection: selectionReducer(state.selection, { kind: 'select-issue', key }),
80
338
  });
81
339
  }
340
+ /**
341
+ * Move the selection onto an issue, taking no other reading of the click.
342
+ *
343
+ * The same shape as {@link selectEdge} one function down, and for the same
344
+ * reason: a navigation that leaves a half-built draft armed behind it would let
345
+ * the NEXT click land somewhere the reader is no longer looking.
346
+ *
347
+ * AND IT DOES NOT TOGGLE. `select-issue` clears when it names what is already
348
+ * selected — right for a row click, which is ambivalent, and wrong for a
349
+ * control that says where to arrive: an audit finding whose member is already
350
+ * inspected would answer "go and look" by emptying the panel.
351
+ */
352
+ function revealIssue(state, key) {
353
+ return settled({
354
+ ...state,
355
+ // `reveal-issue`, NOT `select-issue`: the latter TOGGLES, so revealing the
356
+ // issue already selected would empty the panel the reader was sent to.
357
+ selection: selectionReducer(state.selection, { kind: 'reveal-issue', key }),
358
+ draft: IDLE_CREATE_DRAFT,
359
+ targetQuery: '',
360
+ drop: null,
361
+ });
362
+ }
82
363
  function selectEdge(state, edgeId) {
83
364
  return settled({
84
365
  ...state,
@@ -95,6 +376,25 @@ function pickerProposal(document, edgeId, choice) {
95
376
  return view.flip?.proposal ?? null;
96
377
  return view.options.find((option) => option.kind === choice.field && !option.current)?.proposal ?? null;
97
378
  }
379
+ /**
380
+ * Whether either of §17k's two transient surfaces is currently open.
381
+ *
382
+ * ONE PREDICATE, TWO CALLERS. `reduceHost`'s `narrow-dismiss` arm decides
383
+ * whether the press changed anything, and `mountWorkspace` decides whether to
384
+ * cancel it; those have to agree, and a shell that cancelled a press this
385
+ * reducer answered `unclaimed` to would take Escape away from whatever was
386
+ * listening outside. See {@link HostResult.claimed}.
387
+ *
388
+ * THE INSPECTOR HALF IS DERIVED FROM THE SELECTION, which is the point of
389
+ * storing the DISMISSAL rather than the openness: §17k opens the panel "by
390
+ * selection", so with nothing selected there is nothing lifted, whatever the
391
+ * reader last pressed.
392
+ */
393
+ export function narrowOverlayOpen(state) {
394
+ if (state.canvasOpen)
395
+ return true;
396
+ return state.selection.kind !== 'none' && !state.inspectorDismissed;
397
+ }
98
398
  function controlled(state, name, target, value, document) {
99
399
  const edgeId = selectedEdgeId(state.selection);
100
400
  switch (name) {
@@ -106,7 +406,21 @@ function controlled(state, name, target, value, document) {
106
406
  // open blocker, the claimed peer — as this control, so a reader can reach
107
407
  // the issue holding the one they are looking at. It is a pointer on that
108
408
  // issue, with a pointer's rules: a selection, or the target of a draft.
109
- return target === undefined ? settled(state) : pointed(state, target);
409
+ return target === undefined ? settled(state) : pointed(state, target, document);
410
+ case 'reveal-issue':
411
+ // NAVIGATION THAT CANNOT WRITE, which is why it is not `select-issue`.
412
+ // A pointer's rules are right for the hold deep link and wrong here: with
413
+ // a draft awaiting its target, `pointed` reads the click as CHOOSING that
414
+ // target and `drafted` emits the create proposal — so an audit finding's
415
+ // "go and look" would silently declare a relationship. §17d's whole rule
416
+ // for this surface is that it "offers navigation and never a remedy", and
417
+ // a control that can write is a remedy however it is labelled.
418
+ //
419
+ // IT ABANDONS THE DRAFT RATHER THAN REFUSING TO MOVE, the discipline
420
+ // `selectEdge` above already keeps. Nothing is written either way, and a
421
+ // button that silently did nothing while a draft was open would be the
422
+ // dead affordance this control exists to avoid.
423
+ return target === undefined ? settled(state) : revealIssue(state, target);
110
424
  case 'clear':
111
425
  return settled({
112
426
  ...state,
@@ -134,52 +448,219 @@ function controlled(state, name, target, value, document) {
134
448
  if (edgeId === null || value === undefined || !isEdgeField(value))
135
449
  return settled(state);
136
450
  const proposal = pickerProposal(document, edgeId, { kind: 'retype', field: value });
137
- return proposal === null ? settled(state) : { state, effects: [{ kind: 'propose', proposal }] };
451
+ return proposal === null ? settled(state) : emitting(state, proposal, document, TO_THE_STORE);
138
452
  }
139
453
  case 'flip': {
140
454
  if (edgeId === null)
141
455
  return settled(state);
142
456
  const proposal = pickerProposal(document, edgeId, { kind: 'flip' });
143
- return proposal === null ? settled(state) : { state, effects: [{ kind: 'propose', proposal }] };
457
+ return proposal === null ? settled(state) : emitting(state, proposal, document, TO_THE_STORE);
144
458
  }
145
459
  case 'dismiss-change':
146
- return { state, effects: [{ kind: 'dismiss-change' }] };
147
- // --- the mount's chrome ---
148
- case 'audit-filter':
149
- return settled({ ...state, auditFiltered: !state.auditFiltered });
460
+ return { state, effects: [{ kind: 'dismiss-change' }], claimed: true };
150
461
  case 'add': {
151
- const source = selectedKey(state.selection);
152
- return source === null ? settled(state) : drafted(state, { kind: 'begin', source });
462
+ // THE CONTROL'S OWN SUBJECT FIRST, AND THE SELECTION AS THE FALLBACK —
463
+ // the same one rule the `delete` arm below states, for the same reason.
464
+ //
465
+ // `inspectorView` CANONICALIZES a selection naming a together-unit member
466
+ // onto its slot's LEAD, and `renderWorkspace` words the whole panel from
467
+ // that: the heading, the title, every row. `selectedKey` answers the RAW
468
+ // key. So with a partner selected the panel was titled with the lead and
469
+ // its `+ add` began a relationship from the partner — one control writing
470
+ // about a different issue from the one every other line of its own panel
471
+ // named. The panel publishes the canonical key on the control, which is
472
+ // the fact only it holds.
473
+ const source = target ?? selectedKey(state.selection);
474
+ return source === null ? settled(state) : drafted(state, { kind: 'begin', source }, document);
153
475
  }
154
476
  case 'kind':
155
477
  return value === undefined || !isEdgeField(value)
156
478
  ? settled(state)
157
- : drafted(state, { kind: 'type', edgeKind: value });
479
+ : drafted(state, { kind: 'type', edgeKind: value }, document);
480
+ case 'cancel': {
481
+ // A CANCEL WITH NOTHING TO CANCEL IS NOT THIS REDUCER'S PRESS.
482
+ //
483
+ // This settled unconditionally, which looks harmless — resetting an idle
484
+ // draft to idle changes no state — and is not, because `claimed` is what
485
+ // CANCELS THE KEY PRESS. `create/keys.ts` binds `escape` to `cancel` for
486
+ // any focused row, so with no draft in flight an idle reset was silently
487
+ // eating every Escape on the surface. §17d's overlay is dismissed on
488
+ // Escape and its arm sits below this one: a reader who opened the panel,
489
+ // looked back at the order to judge a finding, and pressed Escape got
490
+ // nothing, while the same press from the header count worked.
491
+ //
492
+ // THE SAME RULE THE `expand` ARMS FOLLOW, and the `default` arm states in
493
+ // its own words: a settled no-op and an unclaimed one are identical in
494
+ // the state and completely different at the keyboard.
495
+ // BY VALUE, NOT BY IDENTITY. `state.draft` is rebuilt by the draft
496
+ // reducer, so `=== IDLE_CREATE_DRAFT` is false for a structurally idle
497
+ // draft that went somewhere and came back.
498
+ const idle = state.draft.source === null &&
499
+ state.draft.target === null &&
500
+ state.draft.kind === null &&
501
+ state.targetQuery === '' &&
502
+ state.drop === null;
503
+ return idle
504
+ ? unclaimed(state)
505
+ : settled({ ...state, draft: IDLE_CREATE_DRAFT, targetQuery: '', drop: null });
506
+ }
507
+ case 'delete': {
508
+ // THE CONTROL'S OWN EDGE FIRST, AND THE SELECTION AS THE FALLBACK.
509
+ //
510
+ // This arm read the selection and ignored `target` entirely, which was
511
+ // sound for exactly as long as the only delete control lived inside
512
+ // `if (edgeId !== null)` — one button, about the one selected edge, and
513
+ // nothing else could publish the command. §17a's inspector puts a remove
514
+ // control on EVERY relationship row, and against the old arm each of them
515
+ // was wrong in one of two ways: with an issue selected `edgeId` is `null`
516
+ // and every row's remove was a silent no-op, and with row B's edge
517
+ // selected row A's remove deleted B. A control that deletes a different
518
+ // relationship from the one it sits on is worse than one that does
519
+ // nothing.
520
+ //
521
+ // THE FALLBACK IS NOT A CONVENIENCE — it is the keyboard. `create/keys.ts`
522
+ // binds `⌫` to the SELECTED edge, because a selection is the only edge a
523
+ // keyboard has named, and that intent arrives through `intended` rather
524
+ // than here; what still needs the fallback is the mount's own labelled
525
+ // delete button, which is drawn only for a selected edge and carries no
526
+ // target. One rule — "the edge the act names" — with the selection as the
527
+ // subject when nothing else names one.
528
+ const subject = target ?? edgeId;
529
+ return subject === null
530
+ ? settled(state)
531
+ : emitting(state, { op: 'delete', edgeId: subject }, document, TO_THE_STORE);
532
+ }
533
+ // --- the mount's chrome: what still needs a DOM, or a host's own surface ---
534
+ // `add`, `kind`, `cancel` and `delete` used to be listed here, and they are
535
+ // not the mount's any more: `renderWorkspace` draws every one of them in its
536
+ // own markup, so a host rendering the package without mounting it publishes
537
+ // them too. What is left below genuinely is the shell's — a live input over
538
+ // the reader's query and the matches it offers, the audit toggle the header
539
+ // publishes, and the two write-recovery controls the mount draws beside a
540
+ // failed record.
541
+ case 'audit-filter':
542
+ return settled({ ...state, auditFiltered: !state.auditFiltered });
543
+ // §17k's STRIP CONTROL, and it really is a toggle: the same control closes
544
+ // the canvas it opened, which is what `aria-expanded` on it promises. The
545
+ // dismissal arm below is the one that must not toggle.
546
+ case 'canvas-strip':
547
+ return settled({ ...state, canvasOpen: !state.canvasOpen });
548
+ // ESCAPE'S ARM, AND IT CLOSES BOTH OF §17k'S TRANSIENTS AT ONCE.
549
+ //
550
+ // THE REASON IS THAT NOTHING HERE MAY ASK HOW WIDE THE SURFACE IS. Exactly
551
+ // one of the two is on screen at any width — the lifted inspector only
552
+ // between `1120` and `1359`, the opened canvas only below `1120` — and the
553
+ // stylesheet, which does know, is the thing that decides which. So one
554
+ // press closes whichever of them the reader can see, and the other change
555
+ // is to a state that is not being drawn. The alternative is this reducer or
556
+ // the shell measuring a box in a keydown handler to pick an arm, which is
557
+ // the window-reading §17k's implementation note rules out.
558
+ //
559
+ // CLOSING, NEVER TOGGLING, for the reason §17d's own dismissal already
560
+ // records: a toggle bound to a dismissal key REOPENS on a second press,
561
+ // which is the opposite of "Escape to dismiss".
562
+ //
563
+ // AND IT IS `unclaimed` WITH NOTHING OPEN, which is this reducer's answer
564
+ // about THIS command and not a promise about the key: `mount.ts` asks
565
+ // `narrowOverlayOpen` before dispatching — one rule, two places, not two
566
+ // opinions — and an Escape it declines is still cancelled further down by
567
+ // the create map's always-available `cancel`.
568
+ case 'narrow-dismiss':
569
+ return narrowOverlayOpen(state)
570
+ ? settled({ ...state, canvasOpen: false, inspectorDismissed: true })
571
+ : unclaimed(state);
572
+ // §17d's HEADER COUNT, which now opens the overlay instead of filtering.
573
+ // *"A persistent, quiet count in the workspace header. It never moves,
574
+ // never animates, and is always the same click."*
575
+ case 'audit-panel':
576
+ return settled({ ...state, auditOpen: !state.auditOpen });
577
+ // ESCAPE'S ARM, AND IT IS NOT THE TOGGLE. A toggle bound to a dismissal
578
+ // key REOPENS the panel on a second press, which is the opposite of
579
+ // "dismissed on Escape" — and the shell cannot know whether the first
580
+ // press was swallowed by something else. Closing is idempotent; toggling
581
+ // is not.
582
+ case 'audit-close':
583
+ return state.auditOpen ? settled({ ...state, auditOpen: false }) : unclaimed(state);
584
+ // §16f's `→` / `←` on a rail row.
585
+ //
586
+ // A BARE `expand` IS SOMEBODY ELSE'S COMMAND, and this is the whole reason
587
+ // the arms are shaped this way. `@issuegraph/viewer`'s own panel-size
588
+ // button publishes exactly `expand` / `collapse` with NO target (see
589
+ // `parts.ts`, `class="ig-size"`), so one name carries two meanings and only
590
+ // the target tells them apart.
591
+ //
592
+ // UNCLAIMED, NOT MERELY UNCHANGED, when no row is named. The two are
593
+ // indistinguishable in the state and completely different at the keyboard:
594
+ // `claimed` is what cancels the press, so settling here would swallow the
595
+ // size button for every host that draws one — taking a working control away
596
+ // as a side effect of adding an unrelated one. The `default` arm a few
597
+ // cases down says this in its own words; these two arms defer to it rather
598
+ // than restate it.
599
+ case 'expand':
600
+ if (target === undefined || target === '')
601
+ return unclaimed(state);
602
+ return settled(state.expanded.includes(target)
603
+ ? state
604
+ : { ...state, expanded: [...state.expanded, target] });
605
+ case 'collapse':
606
+ if (target === undefined || target === '')
607
+ return unclaimed(state);
608
+ return settled({
609
+ ...state,
610
+ expanded: state.expanded.filter((key) => key !== target),
611
+ });
158
612
  case 'target-query':
159
613
  return settled({ ...state, targetQuery: value ?? '' });
160
614
  case 'target':
161
- return target === undefined ? settled(state) : drafted(state, { kind: 'target', ref: target });
162
- case 'cancel':
163
- return settled({ ...state, draft: IDLE_CREATE_DRAFT, targetQuery: '', drop: null });
164
- case 'delete':
165
- return edgeId === null
166
- ? settled(state)
167
- : { state, effects: [{ kind: 'propose', proposal: { op: 'delete', edgeId } }] };
615
+ return target === undefined ? settled(state) : drafted(state, { kind: 'target', ref: target }, document);
168
616
  case 'retry':
169
- return target === undefined ? settled(state) : { state, effects: [{ kind: 'retry', mutationId: target }] };
617
+ return target === undefined
618
+ ? settled(state)
619
+ : { state, effects: [{ kind: 'retry', mutationId: target }], claimed: true };
170
620
  case 'discard':
171
621
  return target === undefined
172
622
  ? settled(state)
173
- : { state, effects: [{ kind: 'discard', mutationId: target }] };
623
+ : { state, effects: [{ kind: 'discard', mutationId: target }], claimed: true };
624
+ // A TOGGLE, AND NOTHING LEAVES THE CLIENT. Showing a held document is a
625
+ // reading act: it dispatches nothing, adopts nothing, and cannot be the
626
+ // step that resolves a conflict. So it emits NO effect — which is also the
627
+ // strongest thing that can be said about it, and what its test asserts.
628
+ //
629
+ // IT IS A `control` LIKE ITS TWO SIBLINGS, not a command arm of its own.
630
+ // The shell turns every `data-ig-command` into `{ kind: 'control', name,
631
+ // target }`, so an arm outside that shape has no route from a button and
632
+ // would be unreachable code behind a control that did nothing.
633
+ case 'view-diff':
634
+ return target === undefined
635
+ ? settled(state)
636
+ : settled({ ...state, diffOpen: state.diffOpen === target ? null : target });
637
+ // --- the first pass ---
638
+ case 'first-pass':
639
+ return firstPassed(state, { kind: 'open' }, document);
640
+ case 'first-pass-close':
641
+ return firstPassed(state, { kind: 'close' }, document);
642
+ case 'first-pass-answer':
643
+ return value === undefined || !isAnswer(value)
644
+ ? settled(state)
645
+ : firstPassed(state, { kind: 'queue', command: { kind: 'answer', answer: value } }, document);
646
+ case 'undo':
647
+ // GUARDED BY THE PHASE, not by the control's existence: `undo` is a name a
648
+ // host's own chrome could publish too, and `firstPassReducer` answers a
649
+ // queue command with no queue by changing nothing.
650
+ return firstPassed(state, { kind: 'queue', command: { kind: 'undo' } }, document);
174
651
  default:
175
652
  // A COMMAND THIS REDUCER DOES NOT KNOW CHANGES NOTHING. A host's own
176
653
  // chrome may publish commands on the same attribute — the demo's theme
177
654
  // switch does — and those are the host's to read from its own listener,
178
655
  // never a reason for this reducer to guess.
179
- return settled(state);
656
+ //
657
+ // AND IT SAYS SO, rather than being indistinguishable from an arm that
658
+ // settled. See {@link HostResult.claimed}: a keyboard shell that cancels
659
+ // this press takes the host's control away from the keyboard entirely.
660
+ return unclaimed(state);
180
661
  }
181
662
  }
182
- function intended(state, intent) {
663
+ function intended(state, intent, document) {
183
664
  switch (intent.kind) {
184
665
  case 'none':
185
666
  // `T` opens the picker; the picker is already drawn whenever an edge is
@@ -187,16 +668,38 @@ function intended(state, intent) {
187
668
  case 'retype':
188
669
  return settled(state);
189
670
  case 'create':
190
- return drafted(state, intent.command);
671
+ return drafted(state, intent.command, document);
191
672
  case 'propose':
192
- return { state, effects: [{ kind: 'propose', proposal: intent.proposal }] };
673
+ return emitting(state, intent.proposal, document, TO_THE_STORE);
193
674
  }
194
675
  }
195
- /** The one reducer. `document` is the landed document, for edge lookups. */
676
+ /**
677
+ * The one reducer. `document` is the landed document, for edge lookups.
678
+ *
679
+ * ## The one rule this wrapper adds
680
+ *
681
+ * §17k's lifted inspector is *"opened by selection"*, and a selection moves
682
+ * from eight different arms below — a rail row, a canvas node, a mark, an audit
683
+ * remedy's `reveal-issue`, a hold's deep link, a drop, a create that settles,
684
+ * and reconciliation dropping a stale one. Clearing the dismissal in each of
685
+ * them is eight chances to forget, and forgetting reads as a panel that opens
686
+ * for some selections and not others.
687
+ *
688
+ * SO IT IS ASKED OF THE RESULT, ONCE: if the selection MOVED and now names
689
+ * something, the panel is open again. Comparing the two selections rather than
690
+ * listing the commands that change one is what makes a ninth arm free.
691
+ */
196
692
  export function reduceHost(state, command, document) {
693
+ const result = reduceCommand(state, command, document);
694
+ const moved = result.state.selection !== state.selection;
695
+ if (!moved || result.state.selection.kind === 'none' || !result.state.inspectorDismissed)
696
+ return result;
697
+ return { ...result, state: { ...result.state, inspectorDismissed: false } };
698
+ }
699
+ function reduceCommand(state, command, document) {
197
700
  switch (command.kind) {
198
701
  case 'point':
199
- return pointed(state, command.key);
702
+ return pointed(state, command.key, document);
200
703
  case 'group': {
201
704
  // A mark names either an edge (its store identity) or a slot (its lead).
202
705
  // A MARK NAMING NEITHER CHANGES NOTHING. The canvas draws an edge the
@@ -207,13 +710,15 @@ export function reduceHost(state, command, document) {
207
710
  if (findEdge(document, command.id) !== undefined)
208
711
  return selectEdge(state, command.id);
209
712
  if (document.issues.some((issue) => issue.ref === command.id))
210
- return pointed(state, command.id);
713
+ return pointed(state, command.id, document);
211
714
  return settled(state);
212
715
  }
213
716
  case 'control':
214
717
  return controlled(state, command.name, command.target, command.value, document);
718
+ case 'first-pass':
719
+ return firstPassed(state, command.command, document);
215
720
  case 'intent':
216
- return intended(state, command.intent);
721
+ return intended(state, command.intent, document);
217
722
  case 'scroll':
218
723
  return settled({ ...state, railStart: Math.max(0, Math.floor(command.start)) });
219
724
  case 'drag-start':
@@ -237,7 +742,7 @@ export function reduceHost(state, command, document) {
237
742
  }
238
743
  }
239
744
  /**
240
- * Bring the state back into agreement with a document that moved under it.
745
+ * Bring the state back into agreement with what the reader can SEE.
241
746
  *
242
747
  * The store re-renders on every landed write, and a write can remove what the
243
748
  * state names: a retype or a flip gives the edge a NEW identity, a delete
@@ -246,20 +751,88 @@ export function reduceHost(state, command, document) {
246
751
  * the inspector showing a picker for nothing — the viewer already refuses a
247
752
  * stale selection the same way, so the host does too, from the document
248
753
  * rather than from memory of what it just proposed.
754
+ *
755
+ * ## `hidden` is the OTHER half of "no longer carries", and the landed
756
+ * document cannot state it
757
+ *
758
+ * `document` is what LANDED, and an unsettled retype or flip lands nothing —
759
+ * so the edge the reader was inspecting is still in it, and the check above
760
+ * passes, while the store's projection has already hidden that edge and the
761
+ * workspace has already stopped drawing it. Two documents were answering
762
+ * "does this selection resolve", and they disagreed for the whole life of the
763
+ * write: the panel resolved the selection to `none` and drew the empty
764
+ * sentence, and — because a refusal is only drawn for the subject it names —
765
+ * the reason the edit was refused was drawn nowhere at all. A reader who
766
+ * retyped an edge into a relationship that already exists saw the picker
767
+ * close and nothing else. `hidden` is the store's own set of landed edges its
768
+ * projection is not showing, so both halves of the question are asked here
769
+ * and the answer is one.
770
+ *
771
+ * A HIDDEN EDGE RETURNS THE PANEL TO ITS CARRIER, not to nothing. Through
772
+ * {@link carrierOf}, which is the same rule the refusal's own carrier was taken
773
+ * by when the edit went out — so the panel this lands on and the panel the
774
+ * refusal is stated under are one answer rather than two that agree. Clearing to `none`
775
+ * instead would be the same silence the check above already produced.
776
+ *
777
+ * IT IS A BACKSTOP NOW, NOT THE ONLY GUARD. `emitting` already puts the panel
778
+ * on the carrier the moment the reader's own edit goes out, so the route this
779
+ * paragraph was written for cannot reach here any more. What still can is a
780
+ * SIBLING write: another edit's retype or flip hiding the edge this selection
781
+ * names, which no act of the reader's announced.
782
+ *
783
+ * RECONCILING ONTO THE PROJECTED REPLACEMENT was the other candidate — follow
784
+ * the selection to the identity the edit produced — and it cannot be done from
785
+ * here or anywhere else. It works only when that identity happens to be
786
+ * landed, which is exactly one of the two refusals this route reaches:
787
+ * `duplicate-edge` produces an edge that already exists, but `cardinality`
788
+ * produces a PHANTOM, and a selection naming an edge the landed document does
789
+ * not carry is dropped by the first check in this very function on the next
790
+ * render. Measured: `reconcileHost` returns `{ kind: 'none' }` for it. That is
791
+ * the same vanishing one frame later, and it is the same decision the phantom
792
+ * capsule already records by publishing no `select-edge` — there is nothing
793
+ * there to select.
249
794
  */
250
- export function reconcileHost(state, document) {
795
+ export function reconcileHost(state, document, hidden) {
251
796
  const edgeId = selectedEdgeId(state.selection);
252
- const selection = edgeId !== null && findEdge(document, edgeId) === undefined ? INITIAL_SELECTION : state.selection;
253
- const issueKey = selectedKey(state.selection);
797
+ const edge = edgeId === null ? undefined : findEdge(document, edgeId);
798
+ const resolved = edgeId === null
799
+ ? state.selection
800
+ : edge === undefined
801
+ ? INITIAL_SELECTION
802
+ : hidden.has(edge.id)
803
+ ? selectionReducer(INITIAL_SELECTION, { kind: 'select-issue', key: carrierOf(edge) })
804
+ : state.selection;
805
+ const issueKey = selectedKey(resolved);
254
806
  const known = new Set(document.issues.map((issue) => issue.ref));
807
+ // ASKED OF `resolved`, NOT OF `state.selection`. The carrier above is read
808
+ // off an edge, and an edge can name an issue the document does not list;
809
+ // asking the question of the selection that came IN would let that one
810
+ // through unchecked, which is the stale name this function exists to refuse.
811
+ const selection = issueKey !== null && !known.has(issueKey) ? INITIAL_SELECTION : resolved;
255
812
  const draftStands = (state.draft.source === null || known.has(state.draft.source)) &&
256
813
  (state.draft.target === null || known.has(state.draft.target));
257
- if (selection === state.selection && draftStands && (issueKey === null || known.has(issueKey))) {
814
+ // §16f's OPEN ROWS ARE STALE NAMES TOO, and for the same reason the selection
815
+ // above is: a row the reader opened can be retired by a later write, and the
816
+ // key would otherwise sit in this set for the life of the session.
817
+ //
818
+ // THE HARM IS NOT THE MISS, IT IS THE RETURN. A key matching no drawn row
819
+ // costs one `includes` and nothing else — the rail is windowed, so most open
820
+ // keys are undrawn most of the time and that is normal. What is not normal is
821
+ // an issue being retired and a LATER one arriving under the same ref: it
822
+ // would render already open, having never been asked to. That is precisely
823
+ // the class this function exists to refuse.
824
+ //
825
+ // FILTERED AGAINST THE WHOLE DOCUMENT, never the rail window. `known` is
826
+ // every issue the document lists, so scrolling a row out of view cannot close
827
+ // it — a reader who opens a row, scrolls past and comes back finds it open.
828
+ const expanded = state.expanded.filter((key) => known.has(key));
829
+ const expandedStands = expanded.length === state.expanded.length;
830
+ if (selection === state.selection && draftStands && expandedStands)
258
831
  return state;
259
- }
260
832
  return {
261
833
  ...state,
262
- selection: issueKey !== null && !known.has(issueKey) ? INITIAL_SELECTION : selection,
834
+ selection,
835
+ ...(expandedStands ? {} : { expanded }),
263
836
  ...(draftStands ? {} : { draft: IDLE_CREATE_DRAFT, targetQuery: '', drop: null }),
264
837
  };
265
838
  }
@@ -273,8 +846,6 @@ export function targetMatches(issues, query, source, limit = 8) {
273
846
  .filter((issue) => issue.ref.includes(needle) || issue.title.toLowerCase().includes(needle))
274
847
  .slice(0, limit);
275
848
  }
276
- /** The edge kinds, in the format's order — the keyboard path's `1`–`5` is this list. */
277
- export const KINDS = EDGE_FIELDS;
278
849
  /** How far the reader may scroll into the rail window before it is re-cut around them, in rows. */
279
850
  export const RAIL_SLACK = 20;
280
851
  /**