@issuegraph/editor 0.3.0 → 0.10.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (87) hide show
  1. package/README.md +31 -6
  2. package/dist/a11y/baseline.d.ts +182 -0
  3. package/dist/a11y/baseline.d.ts.map +1 -0
  4. package/dist/a11y/baseline.js +430 -0
  5. package/dist/a11y/baseline.js.map +1 -0
  6. package/dist/audit/styles.d.ts +1 -1
  7. package/dist/audit/styles.d.ts.map +1 -1
  8. package/dist/audit/styles.js +1 -1
  9. package/dist/create/draft.d.ts +17 -0
  10. package/dist/create/draft.d.ts.map +1 -1
  11. package/dist/create/draft.js +19 -0
  12. package/dist/create/draft.js.map +1 -1
  13. package/dist/create/keys.d.ts +70 -5
  14. package/dist/create/keys.d.ts.map +1 -1
  15. package/dist/create/keys.js +78 -17
  16. package/dist/create/keys.js.map +1 -1
  17. package/dist/firstpass/candidates.d.ts +17 -1
  18. package/dist/firstpass/candidates.d.ts.map +1 -1
  19. package/dist/index.d.ts +6 -4
  20. package/dist/index.d.ts.map +1 -1
  21. package/dist/index.js +12 -2
  22. package/dist/index.js.map +1 -1
  23. package/dist/overlay/render.d.ts.map +1 -1
  24. package/dist/overlay/render.js +54 -14
  25. package/dist/overlay/render.js.map +1 -1
  26. package/dist/overlay/styles.d.ts +1 -1
  27. package/dist/overlay/styles.d.ts.map +1 -1
  28. package/dist/overlay/styles.js +40 -0
  29. package/dist/overlay/styles.js.map +1 -1
  30. package/dist/picker/render.d.ts +26 -16
  31. package/dist/picker/render.d.ts.map +1 -1
  32. package/dist/picker/render.js +25 -36
  33. package/dist/picker/render.js.map +1 -1
  34. package/dist/picker/styles.d.ts +7 -7
  35. package/dist/picker/styles.d.ts.map +1 -1
  36. package/dist/picker/styles.js +7 -36
  37. package/dist/picker/styles.js.map +1 -1
  38. package/dist/picker/view.d.ts +8 -4
  39. package/dist/picker/view.d.ts.map +1 -1
  40. package/dist/picker/view.js +4 -3
  41. package/dist/picker/view.js.map +1 -1
  42. package/dist/picker/words.d.ts +12 -10
  43. package/dist/picker/words.d.ts.map +1 -1
  44. package/dist/picker/words.js +12 -8
  45. package/dist/picker/words.js.map +1 -1
  46. package/dist/scale/ladder.d.ts.map +1 -1
  47. package/dist/scale/ladder.js +10 -0
  48. package/dist/scale/ladder.js.map +1 -1
  49. package/dist/scale/render.d.ts.map +1 -1
  50. package/dist/scale/render.js +9 -1
  51. package/dist/scale/render.js.map +1 -1
  52. package/dist/workspace/chrome.d.ts +1 -1
  53. package/dist/workspace/chrome.d.ts.map +1 -1
  54. package/dist/workspace/chrome.js +48 -0
  55. package/dist/workspace/chrome.js.map +1 -1
  56. package/dist/workspace/firstpass.d.ts +166 -0
  57. package/dist/workspace/firstpass.d.ts.map +1 -0
  58. package/dist/workspace/firstpass.js +151 -0
  59. package/dist/workspace/firstpass.js.map +1 -0
  60. package/dist/workspace/host.d.ts +212 -10
  61. package/dist/workspace/host.d.ts.map +1 -1
  62. package/dist/workspace/host.js +434 -42
  63. package/dist/workspace/host.js.map +1 -1
  64. package/dist/workspace/inspector.d.ts +45 -1
  65. package/dist/workspace/inspector.d.ts.map +1 -1
  66. package/dist/workspace/inspector.js +11 -0
  67. package/dist/workspace/inspector.js.map +1 -1
  68. package/dist/workspace/mount.d.ts +52 -5
  69. package/dist/workspace/mount.d.ts.map +1 -1
  70. package/dist/workspace/mount.js +1391 -56
  71. package/dist/workspace/mount.js.map +1 -1
  72. package/dist/workspace/rail.d.ts.map +1 -1
  73. package/dist/workspace/rail.js +12 -0
  74. package/dist/workspace/rail.js.map +1 -1
  75. package/dist/workspace/recovery.d.ts +155 -0
  76. package/dist/workspace/recovery.d.ts.map +1 -0
  77. package/dist/workspace/recovery.js +156 -0
  78. package/dist/workspace/recovery.js.map +1 -0
  79. package/dist/workspace/render.d.ts +402 -1
  80. package/dist/workspace/render.d.ts.map +1 -1
  81. package/dist/workspace/render.js +947 -36
  82. package/dist/workspace/render.js.map +1 -1
  83. package/dist/workspace/styles.d.ts +1 -1
  84. package/dist/workspace/styles.d.ts.map +1 -1
  85. package/dist/workspace/styles.js +554 -41
  86. package/dist/workspace/styles.js.map +1 -1
  87. package/package.json +3 -3
@@ -26,11 +26,13 @@
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,
@@ -41,12 +43,188 @@ export const INITIAL_HOST_STATE = Object.freeze({
41
43
  targetQuery: '',
42
44
  drag: null,
43
45
  drop: null,
46
+ firstPass: INITIAL_FIRST_PASS,
47
+ diffOpen: null,
44
48
  });
45
49
  function settled(state) {
46
- return { state, effects: [] };
50
+ return { state, effects: [], claimed: true };
51
+ }
52
+ /** The `default` arm's result: not this reducer's command. See {@link HostResult.claimed}. */
53
+ function unclaimed(state) {
54
+ return { state, effects: [], claimed: false };
55
+ }
56
+ /**
57
+ * The issue whose own frontmatter declares a relationship.
58
+ *
59
+ * ONE SPELLING OF A FACT TWO DECISIONS DEPEND ON. `from` is the carrier for
60
+ * every field, the symmetric ones included: §4.3 puts a relationship in ONE
61
+ * issue's block, and `from` is the end it was declared from — which is why a
62
+ * flip of a symmetric kind is refused rather than moving it. Both readers
63
+ * below — where the panel goes when the edge it was showing stops being drawn,
64
+ * and which issue a refused edit is stated under — wrote `edge.from` for
65
+ * themselves, which is two answers to one question, agreeing only for as long
66
+ * as nobody touched either.
67
+ *
68
+ * IT CAN ONLY BE READ WHILE THE DOCUMENT STILL HOLDS THE EDGE, and every reader
69
+ * of it is shaped by that. Once the edge is gone its identity is the only record
70
+ * left, and it is a weaker record for the SYMMETRIC fields: `edgeIdentity` sorts
71
+ * their endpoints, so a `serialize-with` declared from `z` to `a` and one
72
+ * 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
73
+ * every identity anyway; for the symmetric fields that answer was the sort order
74
+ * wearing the carrier's name, and a refusal about such an edge was stated on the
75
+ * far end's panel — the panel the reader was NOT on — whenever the pair sorted
76
+ * the other way. `edgeIdentityEnd` now answers `either` for exactly those and
77
+ * names the carrier for the directed fields, so this function stays the only
78
+ * source of the fact while the edge is there, and nothing downstream has to
79
+ * guess which kind of record it is holding.
80
+ */
81
+ function carrierOf(edge) {
82
+ return edge.from;
83
+ }
84
+ function endsOf(document, proposal) {
85
+ // A CREATE HAS NO EDGE YET, and its own two references are the whole of what
86
+ // it is about — including the `unknown-issue` case, where one of them is an
87
+ // issue the document does not hold and no lookup could recover it.
88
+ if (proposal.op === 'create')
89
+ return { kind: 'pair', carrier: proposal.from, far: proposal.to };
90
+ const edge = findEdge(document, proposal.edgeId);
91
+ if (edge !== undefined)
92
+ return { kind: 'pair', carrier: carrierOf(edge), far: edge.to };
93
+ // AND THE EDGE CAN BE ABSENT, which is the `unknown-edge` refusal itself: the
94
+ // reader acted on a relationship a landed write had already removed. All that
95
+ // is left is the identity the act named.
96
+ return { kind: 'identity', edgeId: proposal.edgeId };
97
+ }
98
+ /**
99
+ * Which issue an edit is ABOUT — the panel that states it, and the panel it is
100
+ * refused on.
101
+ *
102
+ * THE SUBJECT OF AN EDIT IS DECIDED ONCE, AND THAT MEANS ONCE IN TIME AS WELL
103
+ * AS ONCE IN THE SOURCE. It used to be decided twice and in two vocabularies:
104
+ * the panel derived its own subject from the selection (canonicalized onto a
105
+ * together unit's lead), and the inspector separately asked whether the REFUSED
106
+ * EDGE's identity named that subject. Two derivations of one fact diverge, and
107
+ * they did, three times over. This function replaced the second derivation —
108
+ * and then a fourth divergence appeared inside it, because it was still being
109
+ * CALLED twice: once when the edit went out, and again when the refusal came
110
+ * back, against a document a sibling write had changed in between. So the shell
111
+ * calls it at the moment of emission, records the answer against the write, and
112
+ * reads the record afterwards. See `HostEffect`'s `carrier` and `mount.ts`.
113
+ *
114
+ * THE CARRIER'S END WINS, AND THE FAR END IS THE FALLBACK. The relationship is
115
+ * declared in the carrier's block, so that is the panel a reader made the edit
116
+ * from and the panel that can undo it. A create naming an unknown SOURCE has
117
+ * no such panel, and stating the refusal on the target's — the only issue the
118
+ * document holds — beats stating it nowhere.
119
+ *
120
+ * AND WITH ONLY AN IDENTITY LEFT, THE SAME RANKING APPLIES AS FAR AS THE FORMAT
121
+ * KEPT IT. `edgeIdentity` discards the declaring end of a SYMMETRIC pair and
122
+ * keeps it for a directed one, so `edgeIdentityEnd` names the carrier of a
123
+ * directed identity and answers `either` for a symmetric one — and both arms
124
+ * here rank by one rule instead of two. THIS ARM HAS BEEN WRONG IN BOTH
125
+ * DIRECTIONS: ranking a symmetric identity's segments invented an order the
126
+ * format had thrown away and stated a refusal on the far end's panel, and the
127
+ * correction — ranking nothing at all — discarded the order a DIRECTED identity
128
+ * still carries, so a host proposing on the shared store had its refusal placed
129
+ * under whichever end `document.issues` listed first. `either`, and a directed
130
+ * identity whose carrier this backlog does not hold, both fall through to the
131
+ * fallback this arm always had: "an issue this backlog holds and this edit was
132
+ * between", in the document's own order. The reader's own edits reach none of
133
+ * it, because their carrier was taken while the edge was still there.
134
+ *
135
+ * `null` when the document holds NEITHER end, which is not a refusal being
136
+ * dropped: there is no panel for an issue this backlog does not carry, so
137
+ * there is nowhere the reader could be standing to read it.
138
+ */
139
+ export function editCarrier(document, proposal) {
140
+ const keys = document.issues.map((issue) => issue.ref);
141
+ const ends = endsOf(document, proposal);
142
+ switch (ends.kind) {
143
+ case 'pair':
144
+ return keys.find((ref) => ref === ends.carrier) ?? keys.find((ref) => ref === ends.far) ?? null;
145
+ case 'identity':
146
+ return (keys.find((ref) => edgeIdentityEnd(ends.edgeId, ref) === 'carrier') ??
147
+ keys.find((ref) => edgeIdentityEnd(ends.edgeId, ref) !== null) ??
148
+ null);
149
+ }
150
+ }
151
+ /** The reader's own edits: performed as a plain write, with nothing to take back by name. */
152
+ const TO_THE_STORE = { kind: 'propose' };
153
+ /**
154
+ * The effect a route emits, as an exhaustive switch rather than a pair of
155
+ * branches: a third way to write is a compile error here, not a route that
156
+ * quietly builds its own effect again.
157
+ */
158
+ function effectFor(route, proposal, carrier) {
159
+ switch (route.kind) {
160
+ case 'propose':
161
+ return { kind: 'propose', proposal, carrier };
162
+ case 'first-pass-apply':
163
+ return { kind: 'first-pass-apply', candidateId: route.candidateId, proposal, carrier };
164
+ }
165
+ }
166
+ /**
167
+ * Emit an edit, and leave the panel on the issue the edit is about.
168
+ *
169
+ * EVERY PROPOSAL THIS REDUCER EMITS GOES THROUGH HERE, which is the point. The
170
+ * type does not prove that on its own — a new arm could still write out a
171
+ * `carrier` by hand — but it makes skipping this deliberate rather than an
172
+ * omission: both of `HostEffect`'s write-bearing arms REQUIRE the field, so a
173
+ * route that goes round the funnel has to invent an answer in plain sight
174
+ * instead of quietly not having one.
175
+ *
176
+ * §17b requires a refusal to be visible at the moment the reader is refused,
177
+ * and a refusal is stated on its carrier's panel — so the panel has
178
+ * to BE the carrier's when the edit goes out, rather than arriving there
179
+ * afterwards by whichever route happens to notice. Each route that had to
180
+ * notice separately cost a review round: a retype's projection hides the edge
181
+ * the panel was filtered to, and until `reconcileHost` was taught about hidden
182
+ * edges the panel resolved to nothing selected and the reason was drawn
183
+ * nowhere; a create completed after the reader had clicked another issue went
184
+ * out from the draft's source while the panel was headed by the issue they
185
+ * clicked, which nothing downstream can see at all — a completed draft leaves
186
+ * no trace of where it began; and a first-pass `apply` built its effect for
187
+ * itself, so a queue opened with nothing selected answered `Y`, was refused,
188
+ * and closed onto a panel that stated nothing.
189
+ *
190
+ * IT WRITES BACK WHAT WAS ALREADY THERE ON THE CREATE PATHS, which is the
191
+ * check that this is not a behaviour change smuggled in beside a fix.
192
+ * Beginning a draft already selects its source and a canvas drop already
193
+ * selects the node it started on, so the undiverted paths land on the same
194
+ * selection. The picker's routes DO move — an edit on a selected edge takes
195
+ * the panel to that edge's carrier now rather than one render later, which is
196
+ * where a retype or a flip already ended up through `reconcileHost` and where
197
+ * a delete of the selected edge used to leave the reader with `none` once the
198
+ * write landed and took the edge away.
199
+ *
200
+ * SO DOES THE FIRST PASS, AND IT MOVES THE PANEL UNDER A SURFACE THAT COVERS
201
+ * IT. That is the intended effect rather than a side one: the overlay is modal,
202
+ * the reader answers `Y`, and where they are put down when it closes is the
203
+ * only chance a refusal of that answer has of being read. Whether the write
204
+ * will be refused is not knowable here, so every `apply` moves the panel and
205
+ * the last one answered is the panel the reader lands on.
206
+ *
207
+ * AN EDIT ABOUT NO ISSUE THIS DOCUMENT HOLDS LEAVES THE SELECTION ALONE. See
208
+ * {@link editCarrier}: there is no panel to move to, and clearing would take
209
+ * the reader off the one they are on for a reason they could not see. The
210
+ * effect still carries the `null`, because "no panel states this" is an answer
211
+ * the shell must not go and compute a different one for.
212
+ */
213
+ function emitting(state, proposal, document, route) {
214
+ const carrier = editCarrier(document, proposal);
215
+ return {
216
+ state: carrier === null
217
+ ? state
218
+ : {
219
+ ...state,
220
+ selection: selectionReducer(INITIAL_SELECTION, { kind: 'select-issue', key: carrier }),
221
+ },
222
+ effects: [effectFor(route, proposal, carrier)],
223
+ claimed: true,
224
+ };
47
225
  }
48
226
  /** A draft step: apply the create reducer, and emit the proposal it completes. */
49
- function drafted(state, command) {
227
+ function drafted(state, command, document) {
50
228
  const result = createReducer(state.draft, command);
51
229
  if (result.proposal === null) {
52
230
  // BEGINNING A DRAFT SELECTS ITS SOURCE. The inspector draws the picker for
@@ -63,16 +241,92 @@ function drafted(state, command) {
63
241
  const chrome = command.kind === 'cancel' ? { targetQuery: '', drop: null } : {};
64
242
  return settled({ ...state, draft: result.draft, selection, ...chrome });
65
243
  }
244
+ // THE DRAFT ENDS WHERE IT BEGAN. `begin` selects the source three lines
245
+ // above; `emitting` puts the panel back on the edit's carrier, which for a
246
+ // create IS that source. Without it a click on another issue between the two
247
+ // steps left the write going out from one issue and the panel headed by
248
+ // another — and a refusal about the first stated on no panel at all.
249
+ const proposed = emitting(state, result.proposal, document, TO_THE_STORE);
66
250
  return {
67
- state: { ...state, draft: IDLE_CREATE_DRAFT, targetQuery: '', drop: null },
68
- effects: [{ kind: 'propose', proposal: result.proposal }],
251
+ ...proposed,
252
+ state: { ...proposed.state, draft: IDLE_CREATE_DRAFT, targetQuery: '', drop: null },
69
253
  };
70
254
  }
255
+ /**
256
+ * Whether a string names an {@link Answer}.
257
+ *
258
+ * Its own guard rather than a cast: the value arrives off a DOM attribute, and
259
+ * `AGENTS.md`'s strict-TypeScript rule forbids asserting it into the union. The
260
+ * three names are `queue.ts`'s, read from a frozen tuple so an answer added
261
+ * there fails a build here rather than being silently unreachable.
262
+ */
263
+ const ANSWERS = Object.freeze(['apply', 'reject', 'skip']);
264
+ function isAnswer(value) {
265
+ return ANSWERS.some((answer) => answer === value);
266
+ }
267
+ /**
268
+ * Apply one first-pass command, and turn what it produced into effects.
269
+ *
270
+ * The two emissions are exactly the two things `QueueResult` reports and the
271
+ * queue itself cannot perform: the proposal an `apply` stands for, and the
272
+ * answer an `undo` took back. Nothing else here reaches the store, which is what
273
+ * keeps §17e's consent rule a property of the shape — drawing a candidate,
274
+ * opening the surface, closing it and skipping all emit nothing because there is
275
+ * no arm on which they could.
276
+ *
277
+ * THE APPLY GOES THROUGH {@link emitting}, AND FOR A ROUND IT DID NOT. This
278
+ * function built the `first-pass-apply` effect itself, which made the claim
279
+ * "every proposal this reducer emits goes through one funnel" false at the one
280
+ * emission that could not be reached any other way — the overlay is modal, so
281
+ * the reader's hands are nowhere near the panel. A queue opened with nothing
282
+ * selected, or with an unrelated issue selected, answered `Y`, had the create
283
+ * refused, closed, and put the reader back exactly where they had been: a panel
284
+ * that states no refusal because the edit was about some other issue. It is the
285
+ * `document` argument that this route was missing, so that is what it now takes.
286
+ */
287
+ function firstPassed(state, command, document) {
288
+ const outcome = firstPassReducer(state.firstPass, command);
289
+ // THE DRAFT IS NOT TOUCHED HERE, and an earlier revision's attempt to is worth
290
+ // recording: opening the surface does have to cancel a live draft — the queue
291
+ // covers the target search and the kind chooser, and a draft left standing
292
+ // behind it is re-entered on close with the reader's context gone — but this
293
+ // reducer cannot tell an open that will SCAN from one the shell is about to
294
+ // refuse for want of a source. Clearing on every open destroyed the reader's
295
+ // draft for a queue that then never appeared. So the cancel belongs to the
296
+ // shell, which knows, and it is dispatched there — see `mount.ts`.
297
+ let next = { ...state, firstPass: outcome.state };
298
+ const effects = [];
299
+ if (outcome.scanning !== null)
300
+ effects.push({ kind: 'find-candidates', scan: outcome.scanning });
301
+ const result = outcome.result;
302
+ if (result !== null) {
303
+ // THE ANSWER'S OWN CANDIDATE, read off the queue's record of it rather than
304
+ // off the screen: by the time this runs the cursor has already advanced.
305
+ const given = result.state.answers[result.state.answers.length - 1];
306
+ if (result.proposal !== null && given !== undefined) {
307
+ const applied = emitting(next, result.proposal, document, {
308
+ kind: 'first-pass-apply',
309
+ candidateId: given.candidate.id,
310
+ });
311
+ // THE PANEL MOVE IS KEPT, not discarded beside the effect. It is the whole
312
+ // of what this route was missing: the overlay closes onto the issue the
313
+ // answered create was about, which is the panel its refusal is stated on.
314
+ next = applied.state;
315
+ effects.push(...applied.effects);
316
+ }
317
+ // ONLY A WITHDRAWN `apply` REACHES THE STORE. A withdrawn `reject` or `skip`
318
+ // dispatched nothing, so there is nothing out there to take back.
319
+ if (result.withdrawn !== null && result.withdrawn.answer === 'apply') {
320
+ effects.push({ kind: 'first-pass-withdraw', candidateId: result.withdrawn.candidate.id });
321
+ }
322
+ }
323
+ return { state: next, effects, claimed: true };
324
+ }
71
325
  /** A pointer on an issue: a target while one is being chosen, a selection otherwise. */
72
- function pointed(state, key) {
326
+ function pointed(state, key, document) {
73
327
  const choosingTarget = state.draft.kind !== null && state.draft.target === null;
74
328
  if (choosingTarget && key !== state.draft.source) {
75
- return drafted(state, { kind: 'target', ref: key });
329
+ return drafted(state, { kind: 'target', ref: key }, document);
76
330
  }
77
331
  return settled({
78
332
  ...state,
@@ -106,7 +360,7 @@ function controlled(state, name, target, value, document) {
106
360
  // open blocker, the claimed peer — as this control, so a reader can reach
107
361
  // the issue holding the one they are looking at. It is a pointer on that
108
362
  // issue, with a pointer's rules: a selection, or the target of a draft.
109
- return target === undefined ? settled(state) : pointed(state, target);
363
+ return target === undefined ? settled(state) : pointed(state, target, document);
110
364
  case 'clear':
111
365
  return settled({
112
366
  ...state,
@@ -134,52 +388,125 @@ function controlled(state, name, target, value, document) {
134
388
  if (edgeId === null || value === undefined || !isEdgeField(value))
135
389
  return settled(state);
136
390
  const proposal = pickerProposal(document, edgeId, { kind: 'retype', field: value });
137
- return proposal === null ? settled(state) : { state, effects: [{ kind: 'propose', proposal }] };
391
+ return proposal === null ? settled(state) : emitting(state, proposal, document, TO_THE_STORE);
138
392
  }
139
393
  case 'flip': {
140
394
  if (edgeId === null)
141
395
  return settled(state);
142
396
  const proposal = pickerProposal(document, edgeId, { kind: 'flip' });
143
- return proposal === null ? settled(state) : { state, effects: [{ kind: 'propose', proposal }] };
397
+ return proposal === null ? settled(state) : emitting(state, proposal, document, TO_THE_STORE);
144
398
  }
145
399
  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 });
400
+ return { state, effects: [{ kind: 'dismiss-change' }], claimed: true };
150
401
  case 'add': {
151
- const source = selectedKey(state.selection);
152
- return source === null ? settled(state) : drafted(state, { kind: 'begin', source });
402
+ // THE CONTROL'S OWN SUBJECT FIRST, AND THE SELECTION AS THE FALLBACK —
403
+ // the same one rule the `delete` arm below states, for the same reason.
404
+ //
405
+ // `inspectorView` CANONICALIZES a selection naming a together-unit member
406
+ // onto its slot's LEAD, and `renderWorkspace` words the whole panel from
407
+ // that: the heading, the title, every row. `selectedKey` answers the RAW
408
+ // key. So with a partner selected the panel was titled with the lead and
409
+ // its `+ add` began a relationship from the partner — one control writing
410
+ // about a different issue from the one every other line of its own panel
411
+ // named. The panel publishes the canonical key on the control, which is
412
+ // the fact only it holds.
413
+ const source = target ?? selectedKey(state.selection);
414
+ return source === null ? settled(state) : drafted(state, { kind: 'begin', source }, document);
153
415
  }
154
416
  case 'kind':
155
417
  return value === undefined || !isEdgeField(value)
156
418
  ? settled(state)
157
- : drafted(state, { kind: 'type', edgeKind: value });
158
- case 'target-query':
159
- return settled({ ...state, targetQuery: value ?? '' });
160
- case 'target':
161
- return target === undefined ? settled(state) : drafted(state, { kind: 'target', ref: target });
419
+ : drafted(state, { kind: 'type', edgeKind: value }, document);
162
420
  case 'cancel':
163
421
  return settled({ ...state, draft: IDLE_CREATE_DRAFT, targetQuery: '', drop: null });
164
- case 'delete':
165
- return edgeId === null
422
+ case 'delete': {
423
+ // THE CONTROL'S OWN EDGE FIRST, AND THE SELECTION AS THE FALLBACK.
424
+ //
425
+ // This arm read the selection and ignored `target` entirely, which was
426
+ // sound for exactly as long as the only delete control lived inside
427
+ // `if (edgeId !== null)` — one button, about the one selected edge, and
428
+ // nothing else could publish the command. §17a's inspector puts a remove
429
+ // control on EVERY relationship row, and against the old arm each of them
430
+ // was wrong in one of two ways: with an issue selected `edgeId` is `null`
431
+ // and every row's remove was a silent no-op, and with row B's edge
432
+ // selected row A's remove deleted B. A control that deletes a different
433
+ // relationship from the one it sits on is worse than one that does
434
+ // nothing.
435
+ //
436
+ // THE FALLBACK IS NOT A CONVENIENCE — it is the keyboard. `create/keys.ts`
437
+ // binds `⌫` to the SELECTED edge, because a selection is the only edge a
438
+ // keyboard has named, and that intent arrives through `intended` rather
439
+ // than here; what still needs the fallback is the mount's own labelled
440
+ // delete button, which is drawn only for a selected edge and carries no
441
+ // target. One rule — "the edge the act names" — with the selection as the
442
+ // subject when nothing else names one.
443
+ const subject = target ?? edgeId;
444
+ return subject === null
166
445
  ? settled(state)
167
- : { state, effects: [{ kind: 'propose', proposal: { op: 'delete', edgeId } }] };
446
+ : emitting(state, { op: 'delete', edgeId: subject }, document, TO_THE_STORE);
447
+ }
448
+ // --- the mount's chrome: what still needs a DOM, or a host's own surface ---
449
+ // `add`, `kind`, `cancel` and `delete` used to be listed here, and they are
450
+ // not the mount's any more: `renderWorkspace` draws every one of them in its
451
+ // own markup, so a host rendering the package without mounting it publishes
452
+ // them too. What is left below genuinely is the shell's — a live input over
453
+ // the reader's query and the matches it offers, the audit toggle the header
454
+ // publishes, and the two write-recovery controls the mount draws beside a
455
+ // failed record.
456
+ case 'audit-filter':
457
+ return settled({ ...state, auditFiltered: !state.auditFiltered });
458
+ case 'target-query':
459
+ return settled({ ...state, targetQuery: value ?? '' });
460
+ case 'target':
461
+ return target === undefined ? settled(state) : drafted(state, { kind: 'target', ref: target }, document);
168
462
  case 'retry':
169
- return target === undefined ? settled(state) : { state, effects: [{ kind: 'retry', mutationId: target }] };
463
+ return target === undefined
464
+ ? settled(state)
465
+ : { state, effects: [{ kind: 'retry', mutationId: target }], claimed: true };
170
466
  case 'discard':
171
467
  return target === undefined
172
468
  ? settled(state)
173
- : { state, effects: [{ kind: 'discard', mutationId: target }] };
469
+ : { state, effects: [{ kind: 'discard', mutationId: target }], claimed: true };
470
+ // A TOGGLE, AND NOTHING LEAVES THE CLIENT. Showing a held document is a
471
+ // reading act: it dispatches nothing, adopts nothing, and cannot be the
472
+ // step that resolves a conflict. So it emits NO effect — which is also the
473
+ // strongest thing that can be said about it, and what its test asserts.
474
+ //
475
+ // IT IS A `control` LIKE ITS TWO SIBLINGS, not a command arm of its own.
476
+ // The shell turns every `data-ig-command` into `{ kind: 'control', name,
477
+ // target }`, so an arm outside that shape has no route from a button and
478
+ // would be unreachable code behind a control that did nothing.
479
+ case 'view-diff':
480
+ return target === undefined
481
+ ? settled(state)
482
+ : settled({ ...state, diffOpen: state.diffOpen === target ? null : target });
483
+ // --- the first pass ---
484
+ case 'first-pass':
485
+ return firstPassed(state, { kind: 'open' }, document);
486
+ case 'first-pass-close':
487
+ return firstPassed(state, { kind: 'close' }, document);
488
+ case 'first-pass-answer':
489
+ return value === undefined || !isAnswer(value)
490
+ ? settled(state)
491
+ : firstPassed(state, { kind: 'queue', command: { kind: 'answer', answer: value } }, document);
492
+ case 'undo':
493
+ // GUARDED BY THE PHASE, not by the control's existence: `undo` is a name a
494
+ // host's own chrome could publish too, and `firstPassReducer` answers a
495
+ // queue command with no queue by changing nothing.
496
+ return firstPassed(state, { kind: 'queue', command: { kind: 'undo' } }, document);
174
497
  default:
175
498
  // A COMMAND THIS REDUCER DOES NOT KNOW CHANGES NOTHING. A host's own
176
499
  // chrome may publish commands on the same attribute — the demo's theme
177
500
  // switch does — and those are the host's to read from its own listener,
178
501
  // never a reason for this reducer to guess.
179
- return settled(state);
502
+ //
503
+ // AND IT SAYS SO, rather than being indistinguishable from an arm that
504
+ // settled. See {@link HostResult.claimed}: a keyboard shell that cancels
505
+ // this press takes the host's control away from the keyboard entirely.
506
+ return unclaimed(state);
180
507
  }
181
508
  }
182
- function intended(state, intent) {
509
+ function intended(state, intent, document) {
183
510
  switch (intent.kind) {
184
511
  case 'none':
185
512
  // `T` opens the picker; the picker is already drawn whenever an edge is
@@ -187,16 +514,16 @@ function intended(state, intent) {
187
514
  case 'retype':
188
515
  return settled(state);
189
516
  case 'create':
190
- return drafted(state, intent.command);
517
+ return drafted(state, intent.command, document);
191
518
  case 'propose':
192
- return { state, effects: [{ kind: 'propose', proposal: intent.proposal }] };
519
+ return emitting(state, intent.proposal, document, TO_THE_STORE);
193
520
  }
194
521
  }
195
522
  /** The one reducer. `document` is the landed document, for edge lookups. */
196
523
  export function reduceHost(state, command, document) {
197
524
  switch (command.kind) {
198
525
  case 'point':
199
- return pointed(state, command.key);
526
+ return pointed(state, command.key, document);
200
527
  case 'group': {
201
528
  // A mark names either an edge (its store identity) or a slot (its lead).
202
529
  // A MARK NAMING NEITHER CHANGES NOTHING. The canvas draws an edge the
@@ -207,13 +534,15 @@ export function reduceHost(state, command, document) {
207
534
  if (findEdge(document, command.id) !== undefined)
208
535
  return selectEdge(state, command.id);
209
536
  if (document.issues.some((issue) => issue.ref === command.id))
210
- return pointed(state, command.id);
537
+ return pointed(state, command.id, document);
211
538
  return settled(state);
212
539
  }
213
540
  case 'control':
214
541
  return controlled(state, command.name, command.target, command.value, document);
542
+ case 'first-pass':
543
+ return firstPassed(state, command.command, document);
215
544
  case 'intent':
216
- return intended(state, command.intent);
545
+ return intended(state, command.intent, document);
217
546
  case 'scroll':
218
547
  return settled({ ...state, railStart: Math.max(0, Math.floor(command.start)) });
219
548
  case 'drag-start':
@@ -237,7 +566,7 @@ export function reduceHost(state, command, document) {
237
566
  }
238
567
  }
239
568
  /**
240
- * Bring the state back into agreement with a document that moved under it.
569
+ * Bring the state back into agreement with what the reader can SEE.
241
570
  *
242
571
  * The store re-renders on every landed write, and a write can remove what the
243
572
  * state names: a retype or a flip gives the edge a NEW identity, a delete
@@ -246,20 +575,71 @@ export function reduceHost(state, command, document) {
246
575
  * the inspector showing a picker for nothing — the viewer already refuses a
247
576
  * stale selection the same way, so the host does too, from the document
248
577
  * rather than from memory of what it just proposed.
578
+ *
579
+ * ## `hidden` is the OTHER half of "no longer carries", and the landed
580
+ * document cannot state it
581
+ *
582
+ * `document` is what LANDED, and an unsettled retype or flip lands nothing —
583
+ * so the edge the reader was inspecting is still in it, and the check above
584
+ * passes, while the store's projection has already hidden that edge and the
585
+ * workspace has already stopped drawing it. Two documents were answering
586
+ * "does this selection resolve", and they disagreed for the whole life of the
587
+ * write: the panel resolved the selection to `none` and drew the empty
588
+ * sentence, and — because a refusal is only drawn for the subject it names —
589
+ * the reason the edit was refused was drawn nowhere at all. A reader who
590
+ * retyped an edge into a relationship that already exists saw the picker
591
+ * close and nothing else. `hidden` is the store's own set of landed edges its
592
+ * projection is not showing, so both halves of the question are asked here
593
+ * and the answer is one.
594
+ *
595
+ * A HIDDEN EDGE RETURNS THE PANEL TO ITS CARRIER, not to nothing. Through
596
+ * {@link carrierOf}, which is the same rule the refusal's own carrier was taken
597
+ * by when the edit went out — so the panel this lands on and the panel the
598
+ * refusal is stated under are one answer rather than two that agree. Clearing to `none`
599
+ * instead would be the same silence the check above already produced.
600
+ *
601
+ * IT IS A BACKSTOP NOW, NOT THE ONLY GUARD. `emitting` already puts the panel
602
+ * on the carrier the moment the reader's own edit goes out, so the route this
603
+ * paragraph was written for cannot reach here any more. What still can is a
604
+ * SIBLING write: another edit's retype or flip hiding the edge this selection
605
+ * names, which no act of the reader's announced.
606
+ *
607
+ * RECONCILING ONTO THE PROJECTED REPLACEMENT was the other candidate — follow
608
+ * the selection to the identity the edit produced — and it cannot be done from
609
+ * here or anywhere else. It works only when that identity happens to be
610
+ * landed, which is exactly one of the two refusals this route reaches:
611
+ * `duplicate-edge` produces an edge that already exists, but `cardinality`
612
+ * produces a PHANTOM, and a selection naming an edge the landed document does
613
+ * not carry is dropped by the first check in this very function on the next
614
+ * render. Measured: `reconcileHost` returns `{ kind: 'none' }` for it. That is
615
+ * the same vanishing one frame later, and it is the same decision the phantom
616
+ * capsule already records by publishing no `select-edge` — there is nothing
617
+ * there to select.
249
618
  */
250
- export function reconcileHost(state, document) {
619
+ export function reconcileHost(state, document, hidden) {
251
620
  const edgeId = selectedEdgeId(state.selection);
252
- const selection = edgeId !== null && findEdge(document, edgeId) === undefined ? INITIAL_SELECTION : state.selection;
253
- const issueKey = selectedKey(state.selection);
621
+ const edge = edgeId === null ? undefined : findEdge(document, edgeId);
622
+ const resolved = edgeId === null
623
+ ? state.selection
624
+ : edge === undefined
625
+ ? INITIAL_SELECTION
626
+ : hidden.has(edge.id)
627
+ ? selectionReducer(INITIAL_SELECTION, { kind: 'select-issue', key: carrierOf(edge) })
628
+ : state.selection;
629
+ const issueKey = selectedKey(resolved);
254
630
  const known = new Set(document.issues.map((issue) => issue.ref));
631
+ // ASKED OF `resolved`, NOT OF `state.selection`. The carrier above is read
632
+ // off an edge, and an edge can name an issue the document does not list;
633
+ // asking the question of the selection that came IN would let that one
634
+ // through unchecked, which is the stale name this function exists to refuse.
635
+ const selection = issueKey !== null && !known.has(issueKey) ? INITIAL_SELECTION : resolved;
255
636
  const draftStands = (state.draft.source === null || known.has(state.draft.source)) &&
256
637
  (state.draft.target === null || known.has(state.draft.target));
257
- if (selection === state.selection && draftStands && (issueKey === null || known.has(issueKey))) {
638
+ if (selection === state.selection && draftStands)
258
639
  return state;
259
- }
260
640
  return {
261
641
  ...state,
262
- selection: issueKey !== null && !known.has(issueKey) ? INITIAL_SELECTION : selection,
642
+ selection,
263
643
  ...(draftStands ? {} : { draft: IDLE_CREATE_DRAFT, targetQuery: '', drop: null }),
264
644
  };
265
645
  }
@@ -273,8 +653,6 @@ export function targetMatches(issues, query, source, limit = 8) {
273
653
  .filter((issue) => issue.ref.includes(needle) || issue.title.toLowerCase().includes(needle))
274
654
  .slice(0, limit);
275
655
  }
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
656
  /** How far the reader may scroll into the rail window before it is re-cut around them, in rows. */
279
657
  export const RAIL_SLACK = 20;
280
658
  /**
@@ -287,6 +665,20 @@ export const RAIL_SLACK = 20;
287
665
  export function railSlackFor(count) {
288
666
  return Math.min(RAIL_SLACK, Math.max(1, Math.floor(count / 4)));
289
667
  }
668
+ /**
669
+ * Which order row a rail scroll offset points at.
670
+ *
671
+ * THE ROWS DO NOT START AT THE TOP OF THE RAIL. The legend sits above them, and
672
+ * so do the host header and the NOW list when the host supplies them — and the
673
+ * NOW list is one row per running job, unbounded. Dividing the raw offset by
674
+ * the row pitch read that chrome as rows: with enough jobs the window advanced
675
+ * past ranks the reader had not reached, and the rows chased the scroll
676
+ * position. The chrome's height is measured and subtracted first, so the
677
+ * offset that reaches the division is the offset INTO the rows.
678
+ */
679
+ export function railRowAt(scrollTop, chromeHeight, pitch) {
680
+ return Math.floor(Math.max(0, scrollTop - chromeHeight) / pitch);
681
+ }
290
682
  /**
291
683
  * Where the rail window should be re-cut for a scroll position, or `null` when
292
684
  * it should stay where it is.