@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.
- package/README.md +65 -9
- package/dist/a11y/baseline.d.ts +182 -0
- package/dist/a11y/baseline.d.ts.map +1 -0
- package/dist/a11y/baseline.js +434 -0
- package/dist/a11y/baseline.js.map +1 -0
- package/dist/audit/findings.d.ts +107 -1
- package/dist/audit/findings.d.ts.map +1 -1
- package/dist/audit/findings.js +113 -6
- package/dist/audit/findings.js.map +1 -1
- package/dist/audit/panel.d.ts +277 -0
- package/dist/audit/panel.d.ts.map +1 -0
- package/dist/audit/panel.js +462 -0
- package/dist/audit/panel.js.map +1 -0
- package/dist/audit/refused.d.ts +99 -0
- package/dist/audit/refused.d.ts.map +1 -0
- package/dist/audit/refused.js +276 -0
- package/dist/audit/refused.js.map +1 -0
- package/dist/audit/styles.d.ts +1 -1
- package/dist/audit/styles.d.ts.map +1 -1
- package/dist/audit/styles.js +429 -1
- package/dist/audit/styles.js.map +1 -1
- package/dist/audit/surface.d.ts +54 -12
- package/dist/audit/surface.d.ts.map +1 -1
- package/dist/audit/surface.js +53 -13
- package/dist/audit/surface.js.map +1 -1
- package/dist/create/draft.d.ts +36 -0
- package/dist/create/draft.d.ts.map +1 -1
- package/dist/create/draft.js +40 -0
- package/dist/create/draft.js.map +1 -1
- package/dist/create/keys.d.ts +134 -9
- package/dist/create/keys.d.ts.map +1 -1
- package/dist/create/keys.js +119 -20
- package/dist/create/keys.js.map +1 -1
- package/dist/firstpass/candidates.d.ts +17 -1
- package/dist/firstpass/candidates.d.ts.map +1 -1
- package/dist/index.d.ts +42 -17
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +45 -14
- package/dist/index.js.map +1 -1
- package/dist/overlay/render.d.ts +11 -3
- package/dist/overlay/render.d.ts.map +1 -1
- package/dist/overlay/render.js +76 -20
- package/dist/overlay/render.js.map +1 -1
- package/dist/overlay/request.d.ts +71 -0
- package/dist/overlay/request.d.ts.map +1 -0
- package/dist/overlay/request.js +129 -0
- package/dist/overlay/request.js.map +1 -0
- package/dist/overlay/styles.d.ts +1 -1
- package/dist/overlay/styles.d.ts.map +1 -1
- package/dist/overlay/styles.js +78 -7
- package/dist/overlay/styles.js.map +1 -1
- package/dist/picker/render.d.ts +26 -16
- package/dist/picker/render.d.ts.map +1 -1
- package/dist/picker/render.js +25 -36
- package/dist/picker/render.js.map +1 -1
- package/dist/picker/styles.d.ts +7 -7
- package/dist/picker/styles.d.ts.map +1 -1
- package/dist/picker/styles.js +7 -36
- package/dist/picker/styles.js.map +1 -1
- package/dist/picker/view.d.ts +8 -4
- package/dist/picker/view.d.ts.map +1 -1
- package/dist/picker/view.js +4 -3
- package/dist/picker/view.js.map +1 -1
- package/dist/picker/words.d.ts +12 -10
- package/dist/picker/words.d.ts.map +1 -1
- package/dist/picker/words.js +12 -8
- package/dist/picker/words.js.map +1 -1
- package/dist/reevaluate/parts.d.ts +130 -0
- package/dist/reevaluate/parts.d.ts.map +1 -0
- package/dist/reevaluate/parts.js +260 -0
- package/dist/reevaluate/parts.js.map +1 -0
- package/dist/reevaluate/render.d.ts +18 -11
- package/dist/reevaluate/render.d.ts.map +1 -1
- package/dist/reevaluate/render.js +21 -135
- package/dist/reevaluate/render.js.map +1 -1
- package/dist/reevaluate/styles.d.ts +1 -1
- package/dist/reevaluate/styles.d.ts.map +1 -1
- package/dist/reevaluate/styles.js +26 -0
- package/dist/reevaluate/styles.js.map +1 -1
- package/dist/reevaluate/view.d.ts +16 -1
- package/dist/reevaluate/view.d.ts.map +1 -1
- package/dist/reevaluate/view.js +21 -1
- package/dist/reevaluate/view.js.map +1 -1
- package/dist/reevaluate/words.d.ts +7 -0
- package/dist/reevaluate/words.d.ts.map +1 -1
- package/dist/scale/ladder.d.ts +62 -6
- package/dist/scale/ladder.d.ts.map +1 -1
- package/dist/scale/ladder.js +129 -11
- package/dist/scale/ladder.js.map +1 -1
- package/dist/scale/render.d.ts +44 -0
- package/dist/scale/render.d.ts.map +1 -1
- package/dist/scale/render.js +206 -28
- package/dist/scale/render.js.map +1 -1
- package/dist/scale/styles.d.ts +11 -5
- package/dist/scale/styles.d.ts.map +1 -1
- package/dist/scale/styles.js +133 -4
- package/dist/scale/styles.js.map +1 -1
- package/dist/workspace/chrome.d.ts +1 -1
- package/dist/workspace/chrome.d.ts.map +1 -1
- package/dist/workspace/chrome.js +48 -0
- package/dist/workspace/chrome.js.map +1 -1
- package/dist/workspace/firstpass.d.ts +166 -0
- package/dist/workspace/firstpass.d.ts.map +1 -0
- package/dist/workspace/firstpass.js +151 -0
- package/dist/workspace/firstpass.js.map +1 -0
- package/dist/workspace/host.d.ts +283 -11
- package/dist/workspace/host.d.ts.map +1 -1
- package/dist/workspace/host.js +614 -43
- package/dist/workspace/host.js.map +1 -1
- package/dist/workspace/inspector.d.ts +72 -2
- package/dist/workspace/inspector.d.ts.map +1 -1
- package/dist/workspace/inspector.js +26 -0
- package/dist/workspace/inspector.js.map +1 -1
- package/dist/workspace/mount.d.ts +75 -6
- package/dist/workspace/mount.d.ts.map +1 -1
- package/dist/workspace/mount.js +1851 -60
- package/dist/workspace/mount.js.map +1 -1
- package/dist/workspace/rail.d.ts +5 -2
- package/dist/workspace/rail.d.ts.map +1 -1
- package/dist/workspace/rail.js.map +1 -1
- package/dist/workspace/recovery.d.ts +155 -0
- package/dist/workspace/recovery.d.ts.map +1 -0
- package/dist/workspace/recovery.js +156 -0
- package/dist/workspace/recovery.js.map +1 -0
- package/dist/workspace/render.d.ts +779 -2
- package/dist/workspace/render.d.ts.map +1 -1
- package/dist/workspace/render.js +1727 -61
- package/dist/workspace/render.js.map +1 -1
- package/dist/workspace/selection.d.ts +11 -0
- package/dist/workspace/selection.d.ts.map +1 -1
- package/dist/workspace/selection.js +10 -0
- package/dist/workspace/selection.js.map +1 -1
- package/dist/workspace/styles.d.ts +1 -1
- package/dist/workspace/styles.d.ts.map +1 -1
- package/dist/workspace/styles.js +1405 -42
- package/dist/workspace/styles.js.map +1 -1
- package/package.json +4 -4
package/dist/workspace/host.js
CHANGED
|
@@ -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 {
|
|
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
|
-
|
|
68
|
-
|
|
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) :
|
|
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) :
|
|
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
|
-
|
|
152
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
673
|
+
return emitting(state, intent.proposal, document, TO_THE_STORE);
|
|
193
674
|
}
|
|
194
675
|
}
|
|
195
|
-
/**
|
|
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
|
|
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
|
|
253
|
-
const
|
|
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
|
-
|
|
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
|
|
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
|
/**
|