@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.
- package/README.md +31 -6
- package/dist/a11y/baseline.d.ts +182 -0
- package/dist/a11y/baseline.d.ts.map +1 -0
- package/dist/a11y/baseline.js +430 -0
- package/dist/a11y/baseline.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 +1 -1
- package/dist/create/draft.d.ts +17 -0
- package/dist/create/draft.d.ts.map +1 -1
- package/dist/create/draft.js +19 -0
- package/dist/create/draft.js.map +1 -1
- package/dist/create/keys.d.ts +70 -5
- package/dist/create/keys.d.ts.map +1 -1
- package/dist/create/keys.js +78 -17
- 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 +6 -4
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +12 -2
- package/dist/index.js.map +1 -1
- package/dist/overlay/render.d.ts.map +1 -1
- package/dist/overlay/render.js +54 -14
- package/dist/overlay/render.js.map +1 -1
- package/dist/overlay/styles.d.ts +1 -1
- package/dist/overlay/styles.d.ts.map +1 -1
- package/dist/overlay/styles.js +40 -0
- 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/scale/ladder.d.ts.map +1 -1
- package/dist/scale/ladder.js +10 -0
- package/dist/scale/ladder.js.map +1 -1
- package/dist/scale/render.d.ts.map +1 -1
- package/dist/scale/render.js +9 -1
- package/dist/scale/render.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 +212 -10
- package/dist/workspace/host.d.ts.map +1 -1
- package/dist/workspace/host.js +434 -42
- package/dist/workspace/host.js.map +1 -1
- package/dist/workspace/inspector.d.ts +45 -1
- package/dist/workspace/inspector.d.ts.map +1 -1
- package/dist/workspace/inspector.js +11 -0
- package/dist/workspace/inspector.js.map +1 -1
- package/dist/workspace/mount.d.ts +52 -5
- package/dist/workspace/mount.d.ts.map +1 -1
- package/dist/workspace/mount.js +1391 -56
- package/dist/workspace/mount.js.map +1 -1
- package/dist/workspace/rail.d.ts.map +1 -1
- package/dist/workspace/rail.js +12 -0
- 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 +402 -1
- package/dist/workspace/render.d.ts.map +1 -1
- package/dist/workspace/render.js +947 -36
- package/dist/workspace/render.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 +554 -41
- package/dist/workspace/styles.js.map +1 -1
- package/package.json +3 -3
package/dist/workspace/render.js
CHANGED
|
@@ -48,15 +48,19 @@
|
|
|
48
48
|
* forked token set here and no second palette: the theme is the viewer's, and
|
|
49
49
|
* `styles.ts` writes structure against its custom properties.
|
|
50
50
|
*/
|
|
51
|
-
import { KEY_ATTRIBUTE, element, normalizeDocument, renderMarkup, renderViewer, resolveTheme, themeCss, viewerStylesheet, } from '@issuegraph/viewer';
|
|
51
|
+
import { KEY_ATTRIBUTE, element, glyphAndLabel, hiddenGlyph, identity, labelFrom, normalizeDocument, renderMarkup, provenanceClause, renderViewer, resolveTheme, themeCss, treatmentFor, viewerStylesheet, } from '@issuegraph/viewer';
|
|
52
52
|
import { auditStylesheet } from "../audit/styles.js";
|
|
53
|
+
import { IDLE_CREATE_DRAFT } from "../create/draft.js";
|
|
54
|
+
import { KIND_KEYS } from "../create/keys.js";
|
|
55
|
+
import { OVERLAY_TREATMENTS, treatmentForState } from "../overlay/grammar.js";
|
|
53
56
|
import { edgeOverlayStylesheet } from "../overlay/styles.js";
|
|
54
57
|
import { AUDIT_SEVERITY_ATTRIBUTE, auditFilterKeeps, auditOverlay, heaviestRow, renderAuditHeader, } from "../audit/surface.js";
|
|
55
58
|
import { INITIAL_SCALE_STATE } from "../scale/commands.js";
|
|
56
59
|
import { renderScaleLadder } from "../scale/render.js";
|
|
57
60
|
import { scaleLadderStylesheet } from "../scale/styles.js";
|
|
58
|
-
import { inspectorView } from "./inspector.js";
|
|
61
|
+
import { inspectorView, } from "./inspector.js";
|
|
59
62
|
import { railWindow } from "./rail.js";
|
|
63
|
+
import { diffIsEmpty, diffWithin } from "./recovery.js";
|
|
60
64
|
import { INITIAL_SELECTION, selectedEdgeId, selectedKey, } from "./selection.js";
|
|
61
65
|
import { workspaceStylesheet } from "./styles.js";
|
|
62
66
|
/** The four fixed positions. A closed union, which is what keeps `zone` safe. */
|
|
@@ -145,6 +149,224 @@ function markRail(root, severityOf) {
|
|
|
145
149
|
}
|
|
146
150
|
return markSpec(root);
|
|
147
151
|
}
|
|
152
|
+
/**
|
|
153
|
+
* A row's right-hand slot: exactly one of `selected`, a remove control, or the
|
|
154
|
+
* inbound marker.
|
|
155
|
+
*
|
|
156
|
+
* THE ORDER IS THE FRAME'S AND IT IS ALSO THE SAFE ONE. A selected row's SLOT
|
|
157
|
+
* says so and offers nothing else, which matters because `selected` is the
|
|
158
|
+
* state that FILTERS this panel: the reader is looking at one edge on purpose,
|
|
159
|
+
* and a destructive control is not what the slot is for at that moment.
|
|
160
|
+
*
|
|
161
|
+
* THE SLOT, NOT THE ROW — §17b's flip sits beside it on exactly that row. The
|
|
162
|
+
* reasoning above is specific to a DESTRUCTIVE control; a flip is the
|
|
163
|
+
* corrective act §17b asks for, so it is admitted where a `✕` is not.
|
|
164
|
+
* {@link relationshipSpec} draws it as the slot's peer and says why there.
|
|
165
|
+
*
|
|
166
|
+
* AN INBOUND ROW GETS NO REMOVE CONTROL, and that is a fact about the format
|
|
167
|
+
* rather than caution. An incoming edge's field is declared in the OTHER
|
|
168
|
+
* issue's body, so this panel's subject cannot declare it away; a `✕` there
|
|
169
|
+
* would publish an edit that either does nothing or edits a document the
|
|
170
|
+
* reader is not looking at. The reference implementation encodes the same rule
|
|
171
|
+
* as `removable: false`, and frame 17a puts the word `inbound` in the slot the
|
|
172
|
+
* other rows spend on `✕`.
|
|
173
|
+
*
|
|
174
|
+
* THE KEYBOARD'S `⌫` IS NOT A SECOND RULE. `create/keys.ts` acts on the
|
|
175
|
+
* SELECTED edge because a selection is the only edge a keyboard has named; that
|
|
176
|
+
* is this rule with the selection as the subject, not an exception to it.
|
|
177
|
+
*
|
|
178
|
+
* `selected` IS THE PACKAGE'S OWN WORD, taken from `treatmentForState` rather
|
|
179
|
+
* than from the words object. Write-state names are already authored here —
|
|
180
|
+
* `writing…`, `failed`, `invalid` — and a `selected` on `WorkspaceWords` would
|
|
181
|
+
* be a second spelling of one the overlay grammar publishes on the canvas.
|
|
182
|
+
*/
|
|
183
|
+
function rowSlot(relationship, selected, words) {
|
|
184
|
+
if (relationship.edgeId === selected) {
|
|
185
|
+
return element('span', { class: 'ig-relationship-state', 'data-ig-state': 'selected' }, [treatmentForState('selected').label]);
|
|
186
|
+
}
|
|
187
|
+
if (relationship.direction === 'incoming') {
|
|
188
|
+
return element('span', { class: 'ig-relationship-inbound' }, [words.inbound]);
|
|
189
|
+
}
|
|
190
|
+
// THE GLYPH IS HIDDEN AND THE NAME IS AN ATTRIBUTE. `glyphAndLabel` draws a
|
|
191
|
+
// VISIBLE word beside its glyph, which is right for the kind and wrong here:
|
|
192
|
+
// the frame's slot is a bare `✕`. So the mark is layer 1's `hiddenGlyph` —
|
|
193
|
+
// the published half of that same pairing, rather than its class and its
|
|
194
|
+
// `aria-hidden` written out again here, which is exactly the local copy
|
|
195
|
+
// `glyphAndLabel`'s own header calls the failure it exists to prevent. `✕`
|
|
196
|
+
// announces as whatever a screen reader's character table calls it, which
|
|
197
|
+
// describes the mark and not the act, so the host's word becomes the
|
|
198
|
+
// button's accessible name. On the BUTTON, never on a span around it:
|
|
199
|
+
// `parts.ts` records that a plain span takes the generic role, on which ARIA
|
|
200
|
+
// prohibits naming, so the name would be dropped without a warning.
|
|
201
|
+
return element('button', {
|
|
202
|
+
type: 'button',
|
|
203
|
+
class: 'ig-relationship-remove',
|
|
204
|
+
'data-ig-command': 'delete',
|
|
205
|
+
// ITS OWN ROW'S EDGE, WHICH IS THE WHOLE POINT OF THE ATTRIBUTE HERE.
|
|
206
|
+
// `reduceHost`'s delete arm read the selection and ignored the target
|
|
207
|
+
// while the only delete control lived inside `if (edgeId !== null)`; a
|
|
208
|
+
// control repeated per row makes that wrong in both directions — a no-op
|
|
209
|
+
// with an issue selected, and a delete of the WRONG edge with a
|
|
210
|
+
// different row's edge selected.
|
|
211
|
+
'data-ig-target': relationship.edgeId,
|
|
212
|
+
'aria-label': words.remove,
|
|
213
|
+
}, [hiddenGlyph('\u2715')]);
|
|
214
|
+
}
|
|
215
|
+
/**
|
|
216
|
+
* §17b's flip, on the selected row.
|
|
217
|
+
*
|
|
218
|
+
* ## It is drawn where the sentence is, and only where the reducer can act
|
|
219
|
+
*
|
|
220
|
+
* Frame 17b draws a directed relationship as a statement with `⇅ flip` at the
|
|
221
|
+
* end of the same line, and the row already IS that statement: with an edge
|
|
222
|
+
* selected the panel filters to one relationship and
|
|
223
|
+
* {@link relationshipDescription}'s no-subject arm words it forward with both
|
|
224
|
+
* references in stored order. So the control joins the row rather than opening
|
|
225
|
+
* a second card above it — a card would need a sentence of its own, and the
|
|
226
|
+
* panel would then state one relationship twice, one element apart, which is
|
|
227
|
+
* the encoding ambiguity §17b exists to remove.
|
|
228
|
+
*
|
|
229
|
+
* ONLY ON THE SELECTED ROW, and that is a fact about the REDUCER rather than
|
|
230
|
+
* restraint. `reduceHost`'s `flip` arm takes its edge from
|
|
231
|
+
* `selectedEdgeId(state.selection)` and ignores `data-ig-target` — deliberately,
|
|
232
|
+
* because a flip is one operation about the edge the reader is looking at. A
|
|
233
|
+
* control on an unselected row would publish a command the reducer would answer
|
|
234
|
+
* about a different edge, or, with an issue selected, about none; the panel's
|
|
235
|
+
* standing rule is that a control which cannot complete the act it advertises
|
|
236
|
+
* is not drawn.
|
|
237
|
+
*
|
|
238
|
+
* THAT GUARANTEE IS THE MOUNT'S, AND A STANDALONE HOST OWES THE SAME STEP. This
|
|
239
|
+
* surface is handed a `ViewerDocument` and the reducer a `GraphDocument`, so
|
|
240
|
+
* "the selected edge exists" is only true while the two agree. `mountWorkspace`
|
|
241
|
+
* makes them agree by running `reconcileHost` against the landed document
|
|
242
|
+
* before every render, which clears a selection naming an edge that is gone and
|
|
243
|
+
* moves one naming an edge hidden behind an unsettled write. A host wiring the
|
|
244
|
+
* published attributes itself must do the same, or it can draw a flip whose
|
|
245
|
+
* press the reducer answers with nothing.
|
|
246
|
+
*
|
|
247
|
+
* ## A symmetric kind gets no control, and the absence is the finding
|
|
248
|
+
*
|
|
249
|
+
* `serialize-with` and `together-with` state one fact whichever way round they
|
|
250
|
+
* are stored, so a control to reverse them claims something the format does not
|
|
251
|
+
* say — and the store would refuse the edit as `symmetric-edge` anyway.
|
|
252
|
+
* `picker/view.ts` already answers `null` there; this agrees with it rather
|
|
253
|
+
* than contradicting it from the next zone over.
|
|
254
|
+
*
|
|
255
|
+
* ONE ORACLE FOR THAT QUESTION. The directedness is read off the SAME
|
|
256
|
+
* {@link EdgeTreatment} `relationshipDescription` already reads to word the
|
|
257
|
+
* row, which is what `labelFrom` itself branches on. Importing
|
|
258
|
+
* `isSymmetricEdgeField` here instead would put two answers to one question at
|
|
259
|
+
* one call site, in a package whose objection everywhere else is "not a wrong
|
|
260
|
+
* answer, a second answer". `render.test.ts` pins the two to agree across the
|
|
261
|
+
* whole vocabulary, so the shorter reach is safe in both directions.
|
|
262
|
+
*
|
|
263
|
+
* ## It publishes and wires nothing
|
|
264
|
+
*
|
|
265
|
+
* `data-ig-command="flip"` and no target: the attribute the reducer does not
|
|
266
|
+
* read would advertise a per-row flip it does not implement. On a `button`,
|
|
267
|
+
* because a span has no tab stop and no native activation — the control §17b
|
|
268
|
+
* names most error-prone is the last one that should be pointer-only.
|
|
269
|
+
*/
|
|
270
|
+
function flipControl(relationship, selected, words) {
|
|
271
|
+
if (relationship.edgeId !== selected)
|
|
272
|
+
return null;
|
|
273
|
+
if (treatmentFor(relationship.field).symmetric)
|
|
274
|
+
return null;
|
|
275
|
+
return element('button', { type: 'button', class: 'ig-relationship-flip', 'data-ig-command': 'flip' }, [words.flip]);
|
|
276
|
+
}
|
|
277
|
+
/**
|
|
278
|
+
* The host's sentence for a refusal, wherever the panel states one.
|
|
279
|
+
*
|
|
280
|
+
* SHARED FOR THE REASON THE HEAD BELOW IS. A refusal reaches the reader in two
|
|
281
|
+
* shapes — a capsule standing in for a relationship that does not exist, and a
|
|
282
|
+
* reason attached to one that does — and a second element built at the second
|
|
283
|
+
* site would be free to take a different class and lose the rule that styles
|
|
284
|
+
* it, on the half that ships later.
|
|
285
|
+
*/
|
|
286
|
+
function refusalReason(code, words) {
|
|
287
|
+
return element('span', { class: 'ig-relationship-reason' }, [words.refusals[code]]);
|
|
288
|
+
}
|
|
289
|
+
/**
|
|
290
|
+
* What a relationship READS as: the kind's glyph and word, and the reference at
|
|
291
|
+
* the other end. No control, no wrapper — only the description.
|
|
292
|
+
*
|
|
293
|
+
* ONE DESCRIPTION FOR THE ROW AND FOR THE CAPSULE. They state the same thing
|
|
294
|
+
* about the same edge, and they briefly stated it twice — including deriving
|
|
295
|
+
* `outgoing` from the same two values in both places, which is the shape this
|
|
296
|
+
* package calls a second answer rather than a repetition.
|
|
297
|
+
*
|
|
298
|
+
* SPLIT FROM THE CONTROL THAT WRAPS IT, rather than parameterised with a
|
|
299
|
+
* "draw a button?" flag, because the two callers do not differ in a DETAIL of
|
|
300
|
+
* one element: one of them draws no control at all. A flag would leave the
|
|
301
|
+
* element that IS the control deciding whether to be one, and every attribute
|
|
302
|
+
* on it conditional on that. The description is the shared fact; who wraps it
|
|
303
|
+
* is the caller's, exactly as the slot beside it and the border around it
|
|
304
|
+
* already are.
|
|
305
|
+
*
|
|
306
|
+
* ## It draws the OTHER end, and words the kind from the subject's end
|
|
307
|
+
*
|
|
308
|
+
* The row used to draw three bare tokens — the field's machine name, then both
|
|
309
|
+
* endpoints — one of which is the issue whose panel this is. So the reader read
|
|
310
|
+
* their own subject back on every row and had to work out which of the two
|
|
311
|
+
* references was the other one, from a token that stated no direction at all
|
|
312
|
+
* while `data-direction` sat unread on the same element.
|
|
313
|
+
*
|
|
314
|
+
* WITH NO ISSUE SUBJECT THERE IS NO "OTHER END", and both are drawn. That is an
|
|
315
|
+
* edge selection: the panel is showing one relationship rather than one issue's
|
|
316
|
+
* relationships, so the head reads as the edge is stored, `from` before `to`,
|
|
317
|
+
* and the kind takes its plain forward wording. Picking one end there would
|
|
318
|
+
* mean picking arbitrarily and then wording the sentence around the choice.
|
|
319
|
+
*/
|
|
320
|
+
function relationshipDescription(relationship, subject) {
|
|
321
|
+
const treatment = treatmentFor(relationship.field);
|
|
322
|
+
const outgoing = subject === null || relationship.from === subject;
|
|
323
|
+
// THE ROLE TRAVELS WITH THE REFERENCE, and it is not decoration. §17b's rule
|
|
324
|
+
// is that direction is STATED, never inferred — and with no issue subject
|
|
325
|
+
// both ends are drawn, so without a role the only thing saying which is which
|
|
326
|
+
// is their ORDER. That is inference, by the reader and by any host restyling
|
|
327
|
+
// the row. `directionSpec` published exactly this pair of roles before §17b's
|
|
328
|
+
// statement became the row; dropping them would have moved the statement and
|
|
329
|
+
// quietly lost the half that made it a statement.
|
|
330
|
+
//
|
|
331
|
+
// OMITTED, NOT FALSIFIED, WHERE THERE IS NO PAIR. With an issue subject the
|
|
332
|
+
// row draws one reference — the other end — and it is worded relative to the
|
|
333
|
+
// subject by `labelFrom`, so a `from`/`to` on it would name an end of the
|
|
334
|
+
// stored pair while the words name an end of the reader's sentence, and those
|
|
335
|
+
// are not always the same one.
|
|
336
|
+
const reference = (ref, role) => element('span', { class: 'ig-relationship-ref', 'data-ig-role': role }, [ref]);
|
|
337
|
+
return [
|
|
338
|
+
element('span', { class: 'ig-relationship-kind' },
|
|
339
|
+
// LAYER 1's PAIRING, not a second one. The glyph is one of the four
|
|
340
|
+
// channels the colour-blind-safety claim rests on and is
|
|
341
|
+
// `aria-hidden` because it announces as a character description;
|
|
342
|
+
// the word beside it is what a reader hears. A local copy of that
|
|
343
|
+
// arrangement is free to drop the second half.
|
|
344
|
+
glyphAndLabel(treatment.glyph, labelFrom(treatment, outgoing))),
|
|
345
|
+
...(subject === null
|
|
346
|
+
? [reference(relationship.from, 'from'), reference(relationship.to, 'to')]
|
|
347
|
+
: [reference(outgoing ? relationship.to : relationship.from)]),
|
|
348
|
+
];
|
|
349
|
+
}
|
|
350
|
+
/**
|
|
351
|
+
* A relationship's head on a row that HAS one: the description, inside the
|
|
352
|
+
* control that selects the edge.
|
|
353
|
+
*
|
|
354
|
+
* ONLY WHERE THE EDGE IS IN THE DOCUMENT. `select-edge` reduces to a selection,
|
|
355
|
+
* and the next render's `reconcileHost` drops a selection naming an edge the
|
|
356
|
+
* landed document does not carry — so on a phantom this control would close the
|
|
357
|
+
* inspector instead of inspecting anything. That is the rule the panel's own
|
|
358
|
+
* header already states about `+ add`: a control that cannot complete the act
|
|
359
|
+
* it advertises is not drawn. {@link refusalCapsule} draws the description
|
|
360
|
+
* bare for exactly that reason.
|
|
361
|
+
*/
|
|
362
|
+
function relationshipHead(relationship, subject) {
|
|
363
|
+
return element('button', {
|
|
364
|
+
type: 'button',
|
|
365
|
+
class: 'ig-relationship-select',
|
|
366
|
+
'data-ig-command': 'select-edge',
|
|
367
|
+
'data-ig-target': relationship.edgeId,
|
|
368
|
+
}, relationshipDescription(relationship, subject));
|
|
369
|
+
}
|
|
148
370
|
/**
|
|
149
371
|
* One relationship, as a row the reader can actually operate.
|
|
150
372
|
*
|
|
@@ -153,67 +375,730 @@ function markRail(root, severityOf) {
|
|
|
153
375
|
* reachable by pointer and by nothing else — and a host wiring the published
|
|
154
376
|
* attributes cannot fix that without rebuilding the semantics this package
|
|
155
377
|
* should have supplied. Every other command in the package is already on a
|
|
156
|
-
* button;
|
|
378
|
+
* button; {@link refusalCapsule}'s capsule is the same `li` + `button` shape.
|
|
157
379
|
*
|
|
158
380
|
* The `li` keeps the hue and the direction, because those describe the
|
|
159
381
|
* relationship rather than the action.
|
|
382
|
+
*
|
|
383
|
+
* ## A REFUSAL CAN LAND ON A ROW THAT STAYS
|
|
384
|
+
*
|
|
385
|
+
* `refused` is the code when the store refused an edit ABOUT this relationship
|
|
386
|
+
* and the relationship is still there — a create of an edge that already
|
|
387
|
+
* exists, a retype to the kind it already has, a flip of a symmetric kind. The
|
|
388
|
+
* reason joins the row and the row keeps its remove control, because the edge
|
|
389
|
+
* it names is real and removable; only a refusal about an edge that does not
|
|
390
|
+
* exist replaces the row, and {@link WorkspaceRefusal.phantom} is which. It is
|
|
391
|
+
* published as `data-ig-code` here for the same reason the capsule publishes
|
|
392
|
+
* it: a host styles or counts refusals off the store's vocabulary rather than
|
|
393
|
+
* by matching a sentence.
|
|
160
394
|
*/
|
|
161
|
-
function relationshipSpec(relationship) {
|
|
395
|
+
function relationshipSpec(relationship, subject, selected, words, refused) {
|
|
162
396
|
return element('li', {
|
|
163
397
|
class: 'ig-relationship',
|
|
164
398
|
'data-edge': relationship.field,
|
|
165
399
|
// Omitted rather than falsified when the subject is not an issue: an edge
|
|
166
400
|
// selection has no "my end", and `data-direction=""` would claim one.
|
|
167
401
|
'data-direction': relationship.direction ?? undefined,
|
|
402
|
+
'data-ig-code': refused,
|
|
168
403
|
}, [
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
404
|
+
relationshipHead(relationship, subject),
|
|
405
|
+
refused === undefined ? null : refusalReason(refused, words),
|
|
406
|
+
rowSlot(relationship, selected, words),
|
|
407
|
+
// A PEER OF THE SLOT RATHER THAN A FOURTH OCCUPANT OF IT. The slot's
|
|
408
|
+
// three occupants are exclusive because two are statements and one is a
|
|
409
|
+
// destructive control, and a row is only ever one of those things; the
|
|
410
|
+
// flip is neither, and it coexists with the `selected` marker on the one
|
|
411
|
+
// row that draws both. Last, because frame 17b ends the statement's line
|
|
412
|
+
// with it.
|
|
413
|
+
flipControl(relationship, selected, words),
|
|
414
|
+
]);
|
|
415
|
+
}
|
|
416
|
+
/**
|
|
417
|
+
* A refused relationship, drawn where the reader was building it.
|
|
418
|
+
*
|
|
419
|
+
* §17b's rule is that a refusal is shown IN PLACE and never snapped back, and
|
|
420
|
+
* until this the package had no surface that said so: the store marked the edge
|
|
421
|
+
* `invalid`, the canvas drew a ghost line, and the panel the reader had just
|
|
422
|
+
* used listed nothing about it. `would-cycle` is the case that matters — it is
|
|
423
|
+
* the one refusal this package family cannot detect for itself, so it arrives
|
|
424
|
+
* only after the reader has committed to the relationship.
|
|
425
|
+
*
|
|
426
|
+
* IT REPLACES THE ROW ONLY WHEN THERE IS NO RELATIONSHIP UNDER IT, and that
|
|
427
|
+
* qualification is the correction to an earlier "a refused edge is not a
|
|
428
|
+
* relationship: it is an edit that did not happen". Three of the store's codes
|
|
429
|
+
* refuse an edit ABOUT AN EDGE THAT EXISTS — `duplicate-edge`, `unchanged-kind`
|
|
430
|
+
* and `symmetric-edge` all mark a landed edge — so replacing the row for those
|
|
431
|
+
* deleted a real relationship from the panel, and with it the only control that
|
|
432
|
+
* could remove the edge the reader had just been told about.
|
|
433
|
+
* For an edge that genuinely does not exist the argument stands unchanged:
|
|
434
|
+
* listing it as an ordinary row would assert a relationship the document does
|
|
435
|
+
* not have, with a `select-edge` and a `✕` on it offering to remove something
|
|
436
|
+
* that was never added. {@link WorkspaceRefusal.phantom} is the question, and
|
|
437
|
+
* {@link relationshipEntries} is where it is asked. Either way the reader's
|
|
438
|
+
* POSITION is kept, because that is where they were working.
|
|
439
|
+
*
|
|
440
|
+
* THE SHAPE IS `scale/render.ts`'s CAPSULE — an `li` carrying a button and a
|
|
441
|
+
* stated reason — and the shape is all that is copied. That capsule's English
|
|
442
|
+
* is written inline in this package, which is a debt rather than a precedent;
|
|
443
|
+
* the reason here is the host's, keyed off the store's own code.
|
|
444
|
+
*
|
|
445
|
+
* IT NAMES THE EDGE AND OPERATES NOTHING. The capsule reused the row's head,
|
|
446
|
+
* which is a `select-edge` control — so a phantom that survived normalization
|
|
447
|
+
* (a `would-cycle` refusal reaches the panel as an edge, because the store
|
|
448
|
+
* draws a refused create precisely so a surface can mark it) published a live
|
|
449
|
+
* selector for an edge the LANDED document does not carry. Under
|
|
450
|
+
* `mountWorkspace` that click selected the phantom and the next render's
|
|
451
|
+
* `reconcileHost` dropped the selection again, so the one control on the
|
|
452
|
+
* capsule closed the inspector rather than inspecting anything. The
|
|
453
|
+
* description is drawn bare instead, on the rule
|
|
454
|
+
* {@link WorkspaceRefusal.phantom} already states in as many words: there is no
|
|
455
|
+
* relationship to operate. Its remove control was withheld for the same reason
|
|
456
|
+
* from the start; this is the half that was missed.
|
|
457
|
+
*
|
|
458
|
+
* `data-ig-code` IS THE STORE'S VOCABULARY VERBATIM, so a host that wants to
|
|
459
|
+
* style or count refusals reads the code rather than matching a sentence.
|
|
460
|
+
*/
|
|
461
|
+
function refusalCapsule(relationship, code, subject, words) {
|
|
462
|
+
return element('li', {
|
|
463
|
+
class: 'ig-relationship-refused',
|
|
464
|
+
'data-ig-code': code,
|
|
465
|
+
'data-edge': relationship?.field,
|
|
466
|
+
}, [
|
|
467
|
+
// THE RELATIONSHIP IS NAMED WHEN IT CAN BE, AND THE CAPSULE STANDS WHEN
|
|
468
|
+
// IT CANNOT. The refused edge normally reaches the document as the
|
|
469
|
+
// store's phantom — `edgeChangeFor` draws a refused create so the refusal
|
|
470
|
+
// has something to be about — but a host that projects only landed edges
|
|
471
|
+
// gives us a code and nothing to word, and a refusal the reader cannot
|
|
472
|
+
// see is worse than one drawn without its subject.
|
|
473
|
+
relationship === undefined
|
|
474
|
+
? null
|
|
475
|
+
: element('span', { class: 'ig-relationship-name' }, relationshipDescription(relationship, subject)),
|
|
476
|
+
refusalReason(code, words),
|
|
477
|
+
]);
|
|
478
|
+
}
|
|
479
|
+
/**
|
|
480
|
+
* Frame 17b's numbered kind list, and the control that withdraws from it.
|
|
481
|
+
*
|
|
482
|
+
* THE DIGITS ARE `create/keys.ts`'s OWN, through `KIND_KEYS`. Drawing
|
|
483
|
+
* `index + 1` over the kind order here would be a second construction of the
|
|
484
|
+
* keyboard's table — which is exactly what the mount's chooser did, agreeing
|
|
485
|
+
* with the key map only because both walked `EDGE_FIELDS`, with nothing
|
|
486
|
+
* pinning them to each other.
|
|
487
|
+
*
|
|
488
|
+
* THE WORD IS THE EDGE VOCABULARY'S, so an entry in this list and the row it
|
|
489
|
+
* will become read the same. A `kinds` record on `WorkspaceWords` would be a
|
|
490
|
+
* third spelling of a wording layer 1 already publishes twice over.
|
|
491
|
+
*
|
|
492
|
+
* IT IS NOT DRAWN AT THE TARGET STEP. `renderWorkspace` is markup-only and the
|
|
493
|
+
* target search is a live input over the reader's query, so that step stays the
|
|
494
|
+
* shell's. A host using this renderer without `mountWorkspace` can read,
|
|
495
|
+
* remove and BEGIN a relationship, and must supply its own target picker.
|
|
496
|
+
*
|
|
497
|
+
* `source` NAMES THE DRAFT WHEN IT IS NOT THIS PANEL'S OWN, and is `null` when
|
|
498
|
+
* it is — see {@link addStepSpec} for why the list is drawn either way. Written
|
|
499
|
+
* as the reference beside the host's phrase, which is `whyRankSpec`'s own shape
|
|
500
|
+
* for the same problem: the package names the issue and the host writes the
|
|
501
|
+
* English around it.
|
|
502
|
+
*/
|
|
503
|
+
function kindListSpec(words, source) {
|
|
504
|
+
return element('div', { class: 'ig-inspector-add' }, [
|
|
505
|
+
source === null
|
|
506
|
+
? null
|
|
507
|
+
: element('p', { class: 'ig-inspector-source' }, [
|
|
508
|
+
`${words.relatingFrom} `,
|
|
509
|
+
element('span', { class: 'ig-id' }, [source]),
|
|
510
|
+
]),
|
|
511
|
+
element('ul', { class: 'ig-kind-list' }, KIND_KEYS.map((entry) => {
|
|
512
|
+
const treatment = treatmentFor(entry.edgeKind);
|
|
513
|
+
return element('li', {}, [
|
|
514
|
+
element('button', {
|
|
515
|
+
type: 'button',
|
|
516
|
+
class: 'ig-kind-option',
|
|
517
|
+
'data-ig-command': 'kind',
|
|
518
|
+
'data-ig-value': entry.edgeKind,
|
|
519
|
+
'data-edge': entry.edgeKind,
|
|
520
|
+
}, [
|
|
521
|
+
element('span', { class: 'ig-kind-digit' }, [entry.key]),
|
|
522
|
+
...glyphAndLabel(treatment.glyph, treatment.label),
|
|
523
|
+
]),
|
|
524
|
+
]);
|
|
525
|
+
})),
|
|
526
|
+
element('button', { type: 'button', class: 'ig-inspector-cancel', 'data-ig-command': 'cancel' }, [words.cancel]),
|
|
527
|
+
]);
|
|
528
|
+
}
|
|
529
|
+
/**
|
|
530
|
+
* §17a's "why rank" block: the heading, and one sentence explaining the
|
|
531
|
+
* position.
|
|
532
|
+
*
|
|
533
|
+
* THE SYMPTOM THIS PACKAGE WAS FILED FOR. `#122` names it exactly: the frame
|
|
534
|
+
* gives the inspector a `WHY RANK 2` heading and a sentence, "the shipped
|
|
535
|
+
* inspector shows raw tokens and no sentence, even though the package already
|
|
536
|
+
* carries the function that composes provenance". It did, and this composes it.
|
|
537
|
+
*
|
|
538
|
+
* THE PROVENANCE CLAUSE IS LAYER 1's, AND THAT IS THE POINT. `provenanceClause`
|
|
539
|
+
* is the same function the §16 rail row's provenance line is built from, so the
|
|
540
|
+
* row and the panel state one fact one way. Switching on `RankProvenance` here
|
|
541
|
+
* would be a second wording, free to drift from the row's the moment either is
|
|
542
|
+
* edited — which is what the issue means by "composed from the existing
|
|
543
|
+
* provenance model rather than a second implementation of it".
|
|
544
|
+
*
|
|
545
|
+
* A HOLD'S REASON IS RENDERED VERBATIM, exactly as layer 1's `holdLine` renders
|
|
546
|
+
* it: `ViewerHold.reason` is host-authored, and rewording it here would put
|
|
547
|
+
* this package in the business of explaining a hold whose vocabulary belongs to
|
|
548
|
+
* the reader that produced it.
|
|
549
|
+
*
|
|
550
|
+
* WHAT DOES NOT APPEAR: a rank on a held slot. `@issuegraph/derive` assigns
|
|
551
|
+
* `ready ? (rank += 1) : null`, so the two are exclusive and the heading says
|
|
552
|
+
* which one it is. Frame 17a draws `#512` at rank 2 *and* "Held until #488
|
|
553
|
+
* closes"; that state is unrepresentable, and PR #126 already ruled for §16
|
|
554
|
+
* that the model wins and the em dash stands.
|
|
555
|
+
*/
|
|
556
|
+
function whyRankSpec(why, words, holds) {
|
|
557
|
+
const held = why.rank === null;
|
|
558
|
+
return element('div', { class: 'ig-why-rank', 'data-held': held ? 'true' : 'false' }, [
|
|
559
|
+
element('h3', { class: 'ig-why-rank-heading' }, [
|
|
560
|
+
held ? words.whyHeld : `${words.whyRank} ${String(why.rank)}`,
|
|
561
|
+
]),
|
|
562
|
+
element('p', { class: 'ig-why-rank-sentence' }, [
|
|
563
|
+
provenanceClause(why.provenance),
|
|
564
|
+
why.unitPartners.length === 0
|
|
565
|
+
? null
|
|
566
|
+
: element('span', { class: 'ig-why-rank-unit' }, [
|
|
567
|
+
`${words.workedAsOneUnit} `,
|
|
568
|
+
element('span', { class: 'ig-id' }, [why.unitPartners.join(', ')]),
|
|
569
|
+
]),
|
|
570
|
+
]),
|
|
571
|
+
// THE HOLDS BELONG TO THE EXPLANATION, so they live inside this block
|
|
572
|
+
// rather than beside it. They were briefly stated twice — once worded into
|
|
573
|
+
// this sentence and once in the list below it — which is the panel telling
|
|
574
|
+
// a reader the same cause in two voices.
|
|
575
|
+
//
|
|
576
|
+
// The LIST is what survived, because it is the half that carries the
|
|
577
|
+
// holder as a control: `holdRow` publishes `select-issue` on the blocker,
|
|
578
|
+
// under rules about when that control is withheld which a sentence could
|
|
579
|
+
// not express. A reason inline would have discarded them.
|
|
580
|
+
holds.length === 0 ? null : element('ul', { class: 'ig-inspector-holds' }, holds),
|
|
581
|
+
]);
|
|
582
|
+
}
|
|
583
|
+
/**
|
|
584
|
+
* §17a's workspace header: what backlog this is, how much of it is encoded,
|
|
585
|
+
* what is wrong with it, how fresh the read is, and the way into a first pass.
|
|
586
|
+
*
|
|
587
|
+
* IT IS UNCONDITIONAL NOW, AND THAT IS THE FIX. The zone used to be emitted
|
|
588
|
+
* only when an audit overlay existed — `overlay === null ? '' : zone('header',
|
|
589
|
+
* …)` — so the header WAS the audit header, and a workspace with no audit
|
|
590
|
+
* input had no header at all. §17a's header carries five facts and the audit
|
|
591
|
+
* count is one of them.
|
|
592
|
+
*
|
|
593
|
+
* WHAT IT DRAWS IS WHAT THE RAIL DOES NOT. §17a hoists the identity, the
|
|
594
|
+
* counts and the freshness stamp into a header spanning all three zones, and
|
|
595
|
+
* the rail below still draws the last two as layer 1's panel header — so this
|
|
596
|
+
* carries the three facts that appear NOWHERE else today: what backlog this
|
|
597
|
+
* is, what the audit found, and the way into a first pass.
|
|
598
|
+
*
|
|
599
|
+
* MOVING THE OTHER TWO IS ITS OWN CHANGE, not an omission here. Layer 1's panel
|
|
600
|
+
* header is one element carrying the stamp, the refresh control, the count
|
|
601
|
+
* chips AND the running-job NOW row; `SceneOptions.chrome` takes all of them or
|
|
602
|
+
* none. §17a's header replaces some and its rail does not obviously replace the
|
|
603
|
+
* NOW row, so which of them survives is a design ruling, and a half-made one
|
|
604
|
+
* would either state a fact twice or drop a control on the way past.
|
|
605
|
+
*
|
|
606
|
+
* EVERY FACT COMES FROM THE PORT THAT ALREADY CARRIES IT. `ViewerDocument.host`
|
|
607
|
+
* is commented "THE HOST-FACTS PORT", and #127 rejected deriving its numbers in
|
|
608
|
+
* terms: "Reporting is not deriving — the host's number is still what gets
|
|
609
|
+
* drawn." Counting the document here would give the header a second answer,
|
|
610
|
+
* free to disagree with the rail beside it.
|
|
611
|
+
*
|
|
612
|
+
* OMITTED WHEN ABSENT, NEVER DEFAULTED, for the reason the audit count already
|
|
613
|
+
* gives: a zero the reader can trust and a zero nobody computed are different
|
|
614
|
+
* facts, and a header that invents either is worse than one that says less.
|
|
615
|
+
*/
|
|
616
|
+
function headerMarkup(host, auditHeader) {
|
|
617
|
+
// A STRING, not a spec, because one member of this zone already is one: the
|
|
618
|
+
// audit header comes from its own leaf rendered, and it owns the filter
|
|
619
|
+
// toggle's `aria-pressed` and the count's own omitted-when-absent rule. The
|
|
620
|
+
// file's standing idiom applies — everything carrying a dynamic value goes
|
|
621
|
+
// through `renderMarkup`, and only already-rendered markup is concatenated.
|
|
622
|
+
const parts = [
|
|
623
|
+
host?.identity === undefined || host.identity === ''
|
|
624
|
+
? ''
|
|
625
|
+
: renderMarkup(element('span', { class: 'ig-workspace-identity' }, [host.identity])),
|
|
626
|
+
auditHeader,
|
|
627
|
+
host?.firstPass === undefined || host.firstPass === ''
|
|
628
|
+
? ''
|
|
629
|
+
: renderMarkup(element('button', { type: 'button', class: 'ig-workspace-firstpass', 'data-ig-command': 'first-pass' }, [host.firstPass])),
|
|
630
|
+
];
|
|
631
|
+
return `<div class="ig-workspace-header">${parts.join('')}</div>`;
|
|
632
|
+
}
|
|
633
|
+
function panelScope(view) {
|
|
634
|
+
const { subject } = view;
|
|
635
|
+
switch (subject.kind) {
|
|
636
|
+
case 'none':
|
|
637
|
+
return { kind: 'none' };
|
|
638
|
+
case 'issue':
|
|
639
|
+
return {
|
|
640
|
+
kind: 'issue',
|
|
641
|
+
keys: new Set([subject.issue.key, ...(subject.whyRank?.unitPartners ?? [])]),
|
|
642
|
+
};
|
|
643
|
+
case 'edge':
|
|
644
|
+
return { kind: 'edge', edgeId: subject.relationship.edgeId };
|
|
645
|
+
}
|
|
646
|
+
}
|
|
647
|
+
/** Whether this panel is the one that states this record. */
|
|
648
|
+
function statedHere(scope, record) {
|
|
649
|
+
switch (scope.kind) {
|
|
650
|
+
case 'none':
|
|
651
|
+
return false;
|
|
652
|
+
case 'issue':
|
|
653
|
+
return record.carrier !== null && scope.keys.has(record.carrier);
|
|
654
|
+
case 'edge':
|
|
655
|
+
return record.edgeId === scope.edgeId;
|
|
656
|
+
}
|
|
657
|
+
}
|
|
658
|
+
/**
|
|
659
|
+
* The three affordances, mapped to the commands that carry them out.
|
|
660
|
+
*
|
|
661
|
+
* `satisfies Record<OverlayAffordance, string>` IS THE POINT. A fourth
|
|
662
|
+
* affordance is a compile error here rather than a button that renders and does
|
|
663
|
+
* nothing, and the vocabulary it is total over cannot spell `merge` — §17b's
|
|
664
|
+
* one prohibition, encoded in a type rather than left to a reviewer.
|
|
665
|
+
*/
|
|
666
|
+
const AFFORDANCE_COMMANDS = Object.freeze({
|
|
667
|
+
'view-diff': 'view-diff',
|
|
668
|
+
retry: 'retry',
|
|
669
|
+
'discard-mine': 'discard',
|
|
670
|
+
});
|
|
671
|
+
/**
|
|
672
|
+
* The region a card's `view-diff` control discloses.
|
|
673
|
+
*
|
|
674
|
+
* Derived from the write's own identity so `aria-controls` can name it, and
|
|
675
|
+
* scoped by a prefix because a page may mount more than one workspace.
|
|
676
|
+
*/
|
|
677
|
+
function diffRegionId(mutationId) {
|
|
678
|
+
return `ig-recovery-diff-${mutationId}`;
|
|
679
|
+
}
|
|
680
|
+
/** One edge in a difference, named the way the relationship rows name one. */
|
|
681
|
+
function diffEdgeSpec(edge) {
|
|
682
|
+
return element('li', { class: 'ig-recovery-edge', 'data-edge': edge.kind }, [
|
|
683
|
+
// OUTGOING, because a diff row names the edge as the document stores it —
|
|
684
|
+
// `from` then `to` — rather than from the point of view of a subject. There
|
|
685
|
+
// is no subject here: the list is about the document, not about one issue.
|
|
686
|
+
element('span', { class: 'ig-relationship-kind' }, [labelFrom(treatmentFor(edge.kind), true)]),
|
|
687
|
+
element('span', { class: 'ig-id' }, [edge.from]),
|
|
688
|
+
element('span', { class: 'ig-id' }, [edge.to]),
|
|
689
|
+
]);
|
|
690
|
+
}
|
|
691
|
+
/** One side of a difference, or nothing when that side is empty. */
|
|
692
|
+
function diffSideSpec(heading, edges) {
|
|
693
|
+
return edges.length === 0
|
|
694
|
+
? null
|
|
695
|
+
: element('div', { class: 'ig-recovery-side' }, [
|
|
696
|
+
element('h5', { class: 'ig-recovery-side-name' }, [heading]),
|
|
697
|
+
element('ul', { class: 'ig-recovery-edges' }, edges.map(diffEdgeSpec)),
|
|
698
|
+
]);
|
|
699
|
+
}
|
|
700
|
+
/**
|
|
701
|
+
* The held difference, drawn.
|
|
702
|
+
*
|
|
703
|
+
* NOTHING HERE COMBINES THE TWO SIDES. They are separate lists under separate
|
|
704
|
+
* headings, with no control that takes both — which is what "never auto-merge"
|
|
705
|
+
* looks like in markup rather than in a comment.
|
|
706
|
+
*/
|
|
707
|
+
function diffSpec(diff, words, id) {
|
|
708
|
+
if (diffIsEmpty(diff)) {
|
|
709
|
+
return element('p', { class: 'ig-recovery-diff-empty', id }, [words.diffEmpty]);
|
|
710
|
+
}
|
|
711
|
+
return element('div', { class: 'ig-recovery-diff', id }, [
|
|
712
|
+
diffSideSpec(words.upstreamOnly, diff.upstreamOnly),
|
|
713
|
+
diffSideSpec(words.mineOnly, diff.mineOnly),
|
|
714
|
+
diffSideSpec(words.mineRemoved, diff.mineRemoved),
|
|
715
|
+
diff.carrierReversed.length === 0
|
|
716
|
+
? null
|
|
717
|
+
: element('div', { class: 'ig-recovery-side' }, [
|
|
718
|
+
element('h5', { class: 'ig-recovery-side-name' }, [words.carrierReversed]),
|
|
719
|
+
element('ul', { class: 'ig-recovery-edges' },
|
|
720
|
+
// BOTH DIRECTIONS, SIDE BY SIDE. Which end declares a symmetric
|
|
721
|
+
// relationship is the fact `edgeId` discards, so stating one
|
|
722
|
+
// direction here would be this package picking a winner.
|
|
723
|
+
diff.carrierReversed.flatMap((change) => [
|
|
724
|
+
diffEdgeSpec(change.mine),
|
|
725
|
+
diffEdgeSpec(change.upstream),
|
|
726
|
+
])),
|
|
727
|
+
]),
|
|
728
|
+
diff.issuesChanged.length === 0
|
|
729
|
+
? null
|
|
730
|
+
: element('div', { class: 'ig-recovery-side' }, [
|
|
731
|
+
element('h5', { class: 'ig-recovery-side-name' }, [words.issuesChanged]),
|
|
732
|
+
element('ul', { class: 'ig-recovery-issues' }, diff.issuesChanged.map((change) => element('li', { class: 'ig-recovery-issue' }, [
|
|
733
|
+
element('span', { class: 'ig-id' }, [change.ref]),
|
|
734
|
+
// BOTH TITLES, NEVER ONE RECONCILED ONE. A single line here
|
|
735
|
+
// would be this package choosing a winner, which is the whole
|
|
736
|
+
// of what §17b forbids.
|
|
737
|
+
element('span', { class: 'ig-recovery-was' }, [change.mine?.title ?? '']),
|
|
738
|
+
element('span', { class: 'ig-recovery-now' }, [change.upstream?.title ?? '']),
|
|
739
|
+
]))),
|
|
740
|
+
]),
|
|
741
|
+
]);
|
|
742
|
+
}
|
|
743
|
+
/**
|
|
744
|
+
* The label for one affordance on one card.
|
|
745
|
+
*
|
|
746
|
+
* KEYED OFF THE CARD'S KIND, not off the affordance alone: `retry` is two
|
|
747
|
+
* different operations and §17b gives them two different names.
|
|
748
|
+
*/
|
|
749
|
+
function affordanceWord(affordance, kind, words) {
|
|
750
|
+
switch (affordance) {
|
|
751
|
+
case 'view-diff':
|
|
752
|
+
return words.viewDiff;
|
|
753
|
+
case 'retry':
|
|
754
|
+
return kind === 'conflict' ? words.retryOnLatest : words.retry;
|
|
755
|
+
case 'discard-mine':
|
|
756
|
+
return words.discardMine;
|
|
757
|
+
}
|
|
758
|
+
}
|
|
759
|
+
/**
|
|
760
|
+
* One unsettled write the reader can act on.
|
|
761
|
+
*
|
|
762
|
+
* THE BUTTONS ARE THE GRAMMAR TABLE'S, NOT A LIST WRITTEN HERE.
|
|
763
|
+
* `OVERLAY_TREATMENTS[kind].affordances` is what the line already draws its
|
|
764
|
+
* state from, so the card and the edge cannot come to offer different things —
|
|
765
|
+
* and because `OverlayAffordance` has no `merge` member, no entry in that table
|
|
766
|
+
* can produce a merge button. That is what this construction buys, and it is
|
|
767
|
+
* worth being exact about what it does NOT buy: the behavioural guarantee that
|
|
768
|
+
* nothing merges lives one layer down, in a store that exposes no merge call
|
|
769
|
+
* and a `HostEffect` with no merge arm.
|
|
770
|
+
*
|
|
771
|
+
* EVERY BUTTON CARRIES `data-ig-target`. The mount turns an attribute-borne
|
|
772
|
+
* command into `{ kind: 'control', name, target }`, and the reducer's `retry`
|
|
773
|
+
* and `discard` arms return NO EFFECT when `target` is undefined. Without the
|
|
774
|
+
* attribute all three controls render, read correctly, and do nothing — while
|
|
775
|
+
* every assertion about the command names stays green.
|
|
776
|
+
*/
|
|
777
|
+
function recoveryCard(recovery, words, diffOpen, keys) {
|
|
778
|
+
const treatment = OVERLAY_TREATMENTS[recovery.kind];
|
|
779
|
+
const open = recovery.kind === 'conflict' && diffOpen === recovery.mutationId;
|
|
780
|
+
return element('li', {
|
|
781
|
+
class: 'ig-recovery',
|
|
782
|
+
// THE STORE'S OWN VOCABULARY, so a host styles or counts these off the
|
|
783
|
+
// state rather than by matching a sentence — `data-ig-code`'s rule.
|
|
784
|
+
'data-ig-state': recovery.kind,
|
|
785
|
+
}, [
|
|
786
|
+
element('h4', { class: 'ig-recovery-name' }, [
|
|
787
|
+
recovery.kind === 'conflict' ? words.conflict : words.failed,
|
|
178
788
|
]),
|
|
789
|
+
recovery.kind === 'failed'
|
|
790
|
+
? element('p', { class: 'ig-recovery-reason' }, [recovery.reason])
|
|
791
|
+
: null,
|
|
792
|
+
// THE REFRESH ERROR SITS ABOVE THE CONTROLS, because it is the reason the
|
|
793
|
+
// control the reader last pressed did nothing.
|
|
794
|
+
recovery.kind === 'conflict' && recovery.refreshError !== null
|
|
795
|
+
? element('p', { class: 'ig-recovery-refresh-error' }, [
|
|
796
|
+
`${words.retryFailed} `,
|
|
797
|
+
element('span', { class: 'ig-recovery-refresh-detail' }, [recovery.refreshError]),
|
|
798
|
+
])
|
|
799
|
+
: null,
|
|
800
|
+
element('div', { class: 'ig-recovery-actions' }, treatment.affordances.map((affordance) => element('button', {
|
|
801
|
+
type: 'button',
|
|
802
|
+
class: 'ig-recovery-action',
|
|
803
|
+
'data-ig-command': AFFORDANCE_COMMANDS[affordance],
|
|
804
|
+
'data-ig-target': recovery.mutationId,
|
|
805
|
+
// A DISCLOSURE, NOT A TOGGLE BUTTON. `view-diff` shows and hides
|
|
806
|
+
// a region, so `aria-expanded` (with `aria-controls` naming it)
|
|
807
|
+
// is the pattern; `aria-pressed` announces a two-state button and
|
|
808
|
+
// tells a screen-reader user nothing about the region that
|
|
809
|
+
// appeared. The other two act once and carry neither.
|
|
810
|
+
'aria-expanded': affordance === 'view-diff' ? (open ? 'true' : 'false') : undefined,
|
|
811
|
+
// ONLY WHILE THE REGION EXISTS. The difference is rendered only
|
|
812
|
+
// when open, so naming it while shut pointed `aria-controls` at
|
|
813
|
+
// no element at all — which the test for this control already
|
|
814
|
+
// calls "worse than none at all", while checking only the open
|
|
815
|
+
// case where it does resolve. `aria-expanded="false"` is
|
|
816
|
+
// complete on its own; `aria-controls` is optional, and a
|
|
817
|
+
// dangling IDREF is invisible on screen and total for a
|
|
818
|
+
// screen-reader user. Found by the a11y baseline's
|
|
819
|
+
// reference rule on its first run.
|
|
820
|
+
'aria-controls': affordance === 'view-diff' && open ? diffRegionId(recovery.mutationId) : undefined,
|
|
821
|
+
}, [affordanceWord(affordance, recovery.kind, words)]))),
|
|
822
|
+
// NARROWED BY THE PANEL'S KEY SET, not by the carrier alone. A together
|
|
823
|
+
// unit's lead speaks for its partners, so a single key would drop a
|
|
824
|
+
// partner's conflicted edge from the one panel entitled to state it.
|
|
825
|
+
// `null` keys is the unplaced region, which is nobody's unit: nothing is
|
|
826
|
+
// narrowed away there.
|
|
827
|
+
open && recovery.kind === 'conflict'
|
|
828
|
+
? diffSpec(keys === null ? recovery.diff : diffWithin(recovery.diff, keys), words, diffRegionId(recovery.mutationId))
|
|
829
|
+
: null,
|
|
830
|
+
]);
|
|
831
|
+
}
|
|
832
|
+
/** The cards a panel states, or nothing when it states none. */
|
|
833
|
+
function recoveryListSpec(recoveries, words, diffOpen, keys, heading) {
|
|
834
|
+
if (recoveries.length === 0)
|
|
835
|
+
return null;
|
|
836
|
+
return element('div', { class: 'ig-recovery-region' }, [
|
|
837
|
+
heading === null ? null : element('h3', { class: 'ig-inspector-heading' }, [heading]),
|
|
838
|
+
element('ul', { class: 'ig-recovery-list' }, recoveries.map((recovery) => recoveryCard(recovery, words, diffOpen, keys))),
|
|
179
839
|
]);
|
|
180
840
|
}
|
|
181
|
-
|
|
841
|
+
/**
|
|
842
|
+
* The relationship list, with a refused edit drawn where the reader made it.
|
|
843
|
+
*
|
|
844
|
+
* ## A refusal about an edge that EXISTS keeps its row
|
|
845
|
+
*
|
|
846
|
+
* See {@link WorkspaceRefusal.phantom}. Four of the store's codes mark a landed
|
|
847
|
+
* edge, so the capsule replaces the row only when there is no relationship
|
|
848
|
+
* under it; otherwise the reason joins the row and the remove control stays.
|
|
849
|
+
*
|
|
850
|
+
* ## Whose refusal it is, is not a question asked here
|
|
851
|
+
*
|
|
852
|
+
* {@link statedHere} asks it, of the carrier the refusal arrived carrying —
|
|
853
|
+
* and that is the whole of the change this function used to be the wrong place
|
|
854
|
+
* for. `refusals` is one list for the whole surface, not one per panel, and
|
|
855
|
+
* this function used to filter it by asking whether the refused edge's
|
|
856
|
+
* IDENTITY named the panel's subject. That is a second derivation of the
|
|
857
|
+
* edit's subject, run against a subject derived by a different rule, and every
|
|
858
|
+
* way the two can come apart is a refusal drawn in the wrong place or in no
|
|
859
|
+
* place: on every panel when nothing matched, on none when the panel had been
|
|
860
|
+
* canonicalized onto a together unit's lead, on none again when the projection
|
|
861
|
+
* had hidden the edge and left the panel with no subject at all. The refusal
|
|
862
|
+
* names its own carrier now, so there is one derivation and nothing for a
|
|
863
|
+
* second one to disagree with.
|
|
864
|
+
*
|
|
865
|
+
* ## The list is the ledger's, and the LAST refusal on an edge is the one
|
|
866
|
+
*
|
|
867
|
+
* One row states one reason, and the reader can be refused twice on one
|
|
868
|
+
* relationship — `ProjectedEdge.writes` is a list because two edits touching
|
|
869
|
+
* one edge compose in the order the reader made them. The last is the one they
|
|
870
|
+
* just caused; the first is one they have read and moved past. That collapse
|
|
871
|
+
* is `Map`'s repeated-key rule over `refusals` IN ORDER, which is why the
|
|
872
|
+
* option is a list rather than a map a caller had already collapsed: a caller
|
|
873
|
+
* assembling one from two passes loses the chronology at the join, and reports
|
|
874
|
+
* a stale reason for an edge the reader has just been refused on again.
|
|
875
|
+
*
|
|
876
|
+
* ## A refusal with no row is still drawn
|
|
877
|
+
*
|
|
878
|
+
* The refused edge normally reaches the document — the store draws a refused
|
|
879
|
+
* create as a phantom precisely so a surface can mark it — but that depends on
|
|
880
|
+
* the host's projection, and a refusal that vanished because the host projects
|
|
881
|
+
* only landed edges is the "never silently dropped" half of §17b's rule failing
|
|
882
|
+
* quietly. So a refusal this panel states and no row carries is appended after
|
|
883
|
+
* the rest, as a capsule.
|
|
884
|
+
*/
|
|
885
|
+
function relationshipEntries(view, subject, selected, context) {
|
|
886
|
+
const scope = panelScope(view);
|
|
887
|
+
const stated = new Map();
|
|
888
|
+
for (const refusal of context.refusals) {
|
|
889
|
+
if (statedHere(scope, refusal))
|
|
890
|
+
stated.set(refusal.edgeId, refusal);
|
|
891
|
+
}
|
|
892
|
+
const listed = new Set();
|
|
893
|
+
const rows = view.relationships.map((relationship) => {
|
|
894
|
+
const refusal = stated.get(relationship.edgeId);
|
|
895
|
+
if (refusal === undefined) {
|
|
896
|
+
return relationshipSpec(relationship, subject, selected, context.words, undefined);
|
|
897
|
+
}
|
|
898
|
+
listed.add(relationship.edgeId);
|
|
899
|
+
return refusal.phantom
|
|
900
|
+
? refusalCapsule(relationship, refusal.code, subject, context.words)
|
|
901
|
+
: relationshipSpec(relationship, subject, selected, context.words, refusal.code);
|
|
902
|
+
});
|
|
903
|
+
const orphaned = [...stated.values()]
|
|
904
|
+
.filter((refusal) => !listed.has(refusal.edgeId))
|
|
905
|
+
.map((refusal) => refusalCapsule(undefined, refusal.code, subject, context.words));
|
|
906
|
+
return [...rows, ...orphaned];
|
|
907
|
+
}
|
|
908
|
+
/**
|
|
909
|
+
* §17a's inspector panel.
|
|
910
|
+
*
|
|
911
|
+
* ## The heading and the clear control are the panel's, not the list's
|
|
912
|
+
*
|
|
913
|
+
* Both used to hang off the relationship list, and the clear control was drawn
|
|
914
|
+
* only while an EDGE selection was narrowing it — so a reader who had selected
|
|
915
|
+
* an issue had no way back to nothing selected, on a control whose own doc
|
|
916
|
+
* says it "returns to nothing selected" and whose command is `clear`. The
|
|
917
|
+
* condition it wanted was "is there a selection to clear", which is what it now
|
|
918
|
+
* asks. `INITIAL_SELECTION` resolves to `none`, so the control is absent
|
|
919
|
+
* exactly when pressing it would do nothing.
|
|
920
|
+
*
|
|
921
|
+
* ## The create step is drawn here, and the target step is not
|
|
922
|
+
*
|
|
923
|
+
* See {@link kindListSpec}. What the panel owns is `+ add` and the numbered
|
|
924
|
+
* kind list; the target search stays in the shell because it is a live input
|
|
925
|
+
* over the reader's query and this renderer returns markup.
|
|
926
|
+
*
|
|
927
|
+
* WITH AN EDGE SELECTED IT DRAWS NEITHER. That is not the create path being
|
|
928
|
+
* unavailable — it is the panel filtered to one relationship, where "add a
|
|
929
|
+
* relationship from the selected issue" has no subject: `reduceHost`'s `add`
|
|
930
|
+
* reads `selectedKey`, which answers `null` for an edge selection. A control
|
|
931
|
+
* that could not complete the act it advertises is the finding the audit
|
|
932
|
+
* header already records paying for once.
|
|
933
|
+
*/
|
|
934
|
+
function inspectorSpec(view, context) {
|
|
935
|
+
const { words } = context;
|
|
182
936
|
const subject = view.subject;
|
|
937
|
+
// THE CANONICAL SUBJECT, NOT THE SELECTION'S KEY. `inspectorView` folds a
|
|
938
|
+
// together-unit member onto its slot's lead and lists the LEAD's
|
|
939
|
+
// relationships, so a row worded against the key the reader clicked would
|
|
940
|
+
// read every relationship from the wrong end on exactly the units where a
|
|
941
|
+
// partner was selected. Read off the view, which is what the list was built
|
|
942
|
+
// from.
|
|
943
|
+
const key = subject.kind === 'issue' ? subject.issue.key : null;
|
|
944
|
+
const selected = subject.kind === 'edge' ? subject.relationship.edgeId : null;
|
|
945
|
+
const entries = relationshipEntries(view, key, selected, context);
|
|
946
|
+
// THE SAME SCOPE THE RELATIONSHIP LIST USED, asked once here rather than
|
|
947
|
+
// recomputed per region: two calls could not disagree today, but a panel that
|
|
948
|
+
// decides its own reach twice is the shape `carrier` exists to have ended.
|
|
949
|
+
const scope = panelScope(view);
|
|
183
950
|
return element('div', { class: 'ig-inspector', 'data-subject': subject.kind }, [
|
|
951
|
+
element('div', { class: 'ig-inspector-head' }, [
|
|
952
|
+
element('h2', { class: 'ig-inspector-name' }, [words.inspector]),
|
|
953
|
+
subject.kind === 'none'
|
|
954
|
+
? null
|
|
955
|
+
: element('button', { type: 'button', class: 'ig-inspector-clear', 'data-ig-command': 'clear' }, [words.clearSelection]),
|
|
956
|
+
]),
|
|
184
957
|
subject.kind === 'none'
|
|
185
958
|
? element('p', { class: 'ig-inspector-empty' }, [words.nothingSelected])
|
|
186
959
|
: null,
|
|
187
960
|
subject.kind === 'issue'
|
|
188
961
|
? element('div', { class: 'ig-inspector-issue' }, [
|
|
189
|
-
element('
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
[subject.position.rank === null ? '—' : String(subject.position.rank)]),
|
|
201
|
-
subject.position === null || subject.position.holds.length === 0
|
|
962
|
+
element('h3', { class: 'ig-inspector-title' }, [subject.issue.title]),
|
|
963
|
+
// LAYER 1's CHIP, not a second spelling of it. `identity` links the
|
|
964
|
+
// qualified reference when the host gave a URL and prints it plain
|
|
965
|
+
// when it did not — the rule for which is exactly the knowledge this
|
|
966
|
+
// package must not carry a second copy of.
|
|
967
|
+
identity(subject.issue),
|
|
968
|
+
// THE RANK IS THE HEADING'S NOW, so there is no separate position
|
|
969
|
+
// line: it printed the number, or the em dash for a held slot, which
|
|
970
|
+
// is exactly what `WHY RANK n` and `WHY HELD` already say. Two
|
|
971
|
+
// elements for one fact is how they come to disagree.
|
|
972
|
+
subject.whyRank === null
|
|
202
973
|
? null
|
|
203
|
-
:
|
|
974
|
+
: whyRankSpec(subject.whyRank, words, subject.whyRank.holds.map((hold) => holdRow(hold, context.known, (subjectKey) => context.leadOf.get(subjectKey) === context.leadOf.get(subject.issue.key)))),
|
|
204
975
|
])
|
|
205
976
|
: null,
|
|
206
977
|
element('div', { class: 'ig-inspector-relationships', 'data-filtered': view.filtered ? 'true' : 'false' }, [
|
|
207
978
|
element('h3', { class: 'ig-inspector-heading' }, [words.relationships]),
|
|
208
|
-
|
|
209
|
-
|
|
979
|
+
// A STATED EMPTY, AND ONLY WHERE THERE IS A SUBJECT TO STATE IT ABOUT.
|
|
980
|
+
// With nothing selected the panel already says so once, in
|
|
981
|
+
// `nothingSelected`; adding "no relationships" under it would be the
|
|
982
|
+
// same absence reported twice, in two registers, on the render where
|
|
983
|
+
// the reader has asked nothing yet.
|
|
984
|
+
entries.length === 0 && subject.kind !== 'none'
|
|
985
|
+
? element('p', { class: 'ig-inspector-none' }, [words.noRelationships])
|
|
210
986
|
: null,
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
987
|
+
entries.length === 0 ? null : element('ul', { class: 'ig-relationship-list' }, entries),
|
|
988
|
+
// THE STEPS ARE EXCLUSIVE, AND THE CHAIN IS THE SHELL'S OWN. `+ add`
|
|
989
|
+
// begins a draft; the numbered list is what a live draft with no kind
|
|
990
|
+
// yet looks like. Drawing `+ add` beside a live list would offer a
|
|
991
|
+
// reader mid-draft a control that RESETS the draft they are in —
|
|
992
|
+
// `create/draft.ts` makes `begin` clear the kind and the target
|
|
993
|
+
// deliberately.
|
|
994
|
+
// ASKED UNCONDITIONALLY, because the step belongs to the DRAFT and only
|
|
995
|
+
// `+ add` belongs to the subject. `subject.kind !== 'issue' ? null : …`
|
|
996
|
+
// was one guard doing both jobs, and it dropped a live draft's controls
|
|
997
|
+
// on a panel with no issue subject — which `reconcileHost` reaches by
|
|
998
|
+
// clearing a selection whose issue a write removed while leaving a
|
|
999
|
+
// draft begun from another issue standing.
|
|
1000
|
+
addStepSpec(context, subject.kind === 'issue' ? subject.issue.key : null),
|
|
214
1001
|
]),
|
|
1002
|
+
// STATED WHERE THE READER MADE THE EDIT, by the same rule the refusals use
|
|
1003
|
+
// — one `statedHere`, one carrier, no second derivation of whose panel this
|
|
1004
|
+
// is. No heading: the cards carry their own state names, and a heading over
|
|
1005
|
+
// a region that is usually absent is a heading a reader learns to skip.
|
|
1006
|
+
recoveryListSpec(context.recoveries.filter((recovery) => statedHere(scope, recovery)), words.recovery, context.diffOpen, scope.kind === 'issue' ? scope.keys : null, null),
|
|
1007
|
+
// THE WRITES WITH NOWHERE TO SIT, ON EVERY PANEL. A recovery whose carrier
|
|
1008
|
+
// is `null` is about issues this backlog does not hold, so no panel is
|
|
1009
|
+
// entitled to it — and the refusal path drops exactly these. Dropping a
|
|
1010
|
+
// REFUSAL costs a sentence the reader can live without; dropping a RECOVERY
|
|
1011
|
+
// takes away the only retry and discard they have, which is #137's
|
|
1012
|
+
// Done-when 1 ("offers all three resolutions from the editor's own
|
|
1013
|
+
// surface") failing on the state where it matters most. So they are drawn
|
|
1014
|
+
// here, under a heading that says why they are not on a relationship.
|
|
1015
|
+
// AND NEVER TWICE ON ONE PANEL. `statedHere`'s EDGE arm matches on
|
|
1016
|
+
// `edgeId` and ignores the carrier, so an edge panel narrowed to a
|
|
1017
|
+
// null-carrier recovery's own edge states it above AND here — two identical
|
|
1018
|
+
// cards, two tab stops, and two buttons carrying one write's id.
|
|
1019
|
+
recoveryListSpec(context.recoveries.filter((recovery) => recovery.carrier === null && !statedHere(scope, recovery)), words.recovery, context.diffOpen, null, words.recovery.unplaced),
|
|
215
1020
|
]);
|
|
216
1021
|
}
|
|
1022
|
+
/**
|
|
1023
|
+
* Which step of the create path the panel is showing.
|
|
1024
|
+
*
|
|
1025
|
+
* THE STEP FOLLOWS THE DRAFT, NOT THE SELECTION, and the draft's source is
|
|
1026
|
+
* NAMED when it is not this panel's subject. The two can diverge: `pointed`
|
|
1027
|
+
* diverts a click to the draft only once a kind has been chosen, so at the kind
|
|
1028
|
+
* step a click anywhere else moves the selection and leaves the draft's source
|
|
1029
|
+
* behind — and `R` on a together unit's non-lead member begins a draft from
|
|
1030
|
+
* that member while `inspectorView` canonicalizes the panel onto the slot's
|
|
1031
|
+
* LEAD, so the two disagree from the first keystroke. Unnamed, the panel showed
|
|
1032
|
+
* "choose a kind" beneath the heading of one issue while the write would go out
|
|
1033
|
+
* from another. Named, it says whose draft it is.
|
|
1034
|
+
*
|
|
1035
|
+
* WITHHOLDING THE LIST WAS THE FIRST ANSWER AND IT WAS WORSE. It left the
|
|
1036
|
+
* reader a LIVE draft with no pointer route to it at all: the numbered choices
|
|
1037
|
+
* were gone, the cancel that sits with them was gone, and the shell draws no
|
|
1038
|
+
* chooser of its own at this step — `mount.ts`'s floating one is drawn only
|
|
1039
|
+
* under a canvas drop, and its target search only once a kind is chosen. So the
|
|
1040
|
+
* one control still on screen was `+ add`, which does not cancel a draft, it
|
|
1041
|
+
* RESETS one, silently moving the source to whatever the reader happened to be
|
|
1042
|
+
* looking at. "The shell's cancel still reaches it" was the reasoning, and the
|
|
1043
|
+
* shell has no cancel to reach it with. A draft the reader cannot abandon with
|
|
1044
|
+
* a pointer is the failure `create/keys.ts` names for the keyboard, and the
|
|
1045
|
+
* cost of the alternative — one phrase naming an issue that is not the panel's
|
|
1046
|
+
* subject — is the cost the shell's own chooser always paid, in the sentence it
|
|
1047
|
+
* printed above its kinds.
|
|
1048
|
+
*
|
|
1049
|
+
* `+ add` IS NOT DRAWN BESIDE IT, on either panel. Two entries into one draft is
|
|
1050
|
+
* the rule {@link WorkspaceOptions.drop} already states for the floating
|
|
1051
|
+
* chooser, and here the second entry is the destructive one: `create/draft.ts`
|
|
1052
|
+
* makes `begin` clear the kind and the target deliberately, so a reader
|
|
1053
|
+
* mid-draft offered `+ add` is offered a reset wearing the label of a start.
|
|
1054
|
+
* They restart by cancelling first, which is now a control they can see.
|
|
1055
|
+
*
|
|
1056
|
+
* IT IS ASKED FOR EVERY SUBJECT, INCLUDING NONE. `+ add` is the half that needs
|
|
1057
|
+
* one — `reduceHost`'s `add` arm answers `null` for an edge selection and for
|
|
1058
|
+
* none, so a control there could not complete — and the step is not. One guard
|
|
1059
|
+
* over both dropped a live draft's controls on a panel with no issue subject,
|
|
1060
|
+
* which `reconcileHost` reaches: it clears a selection whose issue a landed
|
|
1061
|
+
* write removed while leaving a draft begun from a DIFFERENT issue standing,
|
|
1062
|
+
* because that draft's own references are all still in the document.
|
|
1063
|
+
*
|
|
1064
|
+
* `null` AT THE TARGET STEP AND UNDER A LIVE DROP, which are two more different
|
|
1065
|
+
* silences and both deliberate. The target step belongs to the shell, which
|
|
1066
|
+
* owns the live input — and draws its own cancel beside it, naming the source
|
|
1067
|
+
* in the same sentence. A drop means a chooser is already open at the pointer,
|
|
1068
|
+
* with a cancel of its own, and a second copy of the same step in the column
|
|
1069
|
+
* beside it would be two controls writing to one draft.
|
|
1070
|
+
*/
|
|
1071
|
+
function addStepSpec(context, subject) {
|
|
1072
|
+
const { draft, drop, words } = context;
|
|
1073
|
+
// THE ONE LIVE KIND STEP, WHEREVER IT WAS BEGUN. Asked of the draft alone, so
|
|
1074
|
+
// there is exactly one panel state for it rather than one per subject — and
|
|
1075
|
+
// so a panel with no issue subject at all still carries the cancel.
|
|
1076
|
+
if (draft.source !== null && draft.kind === null && drop === null) {
|
|
1077
|
+
return kindListSpec(words, draft.source === subject ? null : draft.source);
|
|
1078
|
+
}
|
|
1079
|
+
// `+ add` NEEDS A SUBJECT TO BEGIN FROM, and `reduceHost`'s `add` arm answers
|
|
1080
|
+
// `null` for an edge selection and for none — so drawing it there would
|
|
1081
|
+
// publish an act that cannot complete.
|
|
1082
|
+
if (subject !== null && draft.source !== subject) {
|
|
1083
|
+
return element('div', { class: 'ig-inspector-add' }, [
|
|
1084
|
+
element('button', {
|
|
1085
|
+
type: 'button',
|
|
1086
|
+
class: 'ig-inspector-addbutton',
|
|
1087
|
+
'data-ig-command': 'add',
|
|
1088
|
+
// THE CANONICAL SUBJECT, PUBLISHED, for the reason the row's remove
|
|
1089
|
+
// control publishes its own edge. `reduceHost`'s `add` arm read
|
|
1090
|
+
// `selectedKey` — the RAW key — while everything around it is worded
|
|
1091
|
+
// against the slot LEAD that `inspectorView` canonicalizes to, so
|
|
1092
|
+
// selecting a together-unit PARTNER drew a panel titled with the lead
|
|
1093
|
+
// and began a relationship from the partner. The act names its
|
|
1094
|
+
// subject; the selection is the fallback for a control that names
|
|
1095
|
+
// none.
|
|
1096
|
+
'data-ig-target': subject,
|
|
1097
|
+
}, [words.addRelationship]),
|
|
1098
|
+
]);
|
|
1099
|
+
}
|
|
1100
|
+
return null;
|
|
1101
|
+
}
|
|
217
1102
|
/**
|
|
218
1103
|
* One hold in the inspector, with its cause and its subject on the markup.
|
|
219
1104
|
*
|
|
@@ -259,7 +1144,12 @@ function holdRow(hold, known, inInspectedSlot) {
|
|
|
259
1144
|
'data-code': hold.code,
|
|
260
1145
|
'data-subject': hold.subject,
|
|
261
1146
|
}, [
|
|
262
|
-
hold
|
|
1147
|
+
// THE RUNNER'S WORD, as the rail draws it: the same hold must not read
|
|
1148
|
+
// `claimed …` in one zone and bare in the other.
|
|
1149
|
+
hold.family === 'tracker' && hold.label !== undefined && hold.label !== ''
|
|
1150
|
+
? element('span', { class: 'ig-badge', 'data-hold': hold.label }, [hold.label])
|
|
1151
|
+
: null,
|
|
1152
|
+
hold.family === 'tracker' && hold.label !== undefined && hold.label !== '' ? ` ${hold.reason}` : hold.reason,
|
|
263
1153
|
hold.subject === undefined || !known.has(hold.subject) || inInspectedSlot(hold.subject)
|
|
264
1154
|
? null
|
|
265
1155
|
: element('button', {
|
|
@@ -372,14 +1262,35 @@ export function renderWorkspace(input, options) {
|
|
|
372
1262
|
const inspector = inspectorView(document, selection);
|
|
373
1263
|
const markup = [
|
|
374
1264
|
`<div class="ig-workspace">`,
|
|
375
|
-
overlay === null ? '' :
|
|
1265
|
+
zone('header', headerMarkup(document.host, overlay === null ? '' : renderAuditHeader(overlay, { filtered }))),
|
|
376
1266
|
zone('rail', [
|
|
377
1267
|
railSpacer(rail.before, 'before'),
|
|
378
1268
|
renderMarkup(markRail(railRender.scene.root, (key) => severityByKey.get(key))),
|
|
379
1269
|
railSpacer(rail.after, 'after'),
|
|
380
1270
|
].join('')),
|
|
381
1271
|
zone('canvas', canvas.markup),
|
|
382
|
-
zone('inspector', renderMarkup(inspectorSpec(inspector,
|
|
1272
|
+
zone('inspector', renderMarkup(inspectorSpec(inspector, {
|
|
1273
|
+
words: options.words,
|
|
1274
|
+
known,
|
|
1275
|
+
leadOf,
|
|
1276
|
+
draft: options.draft ?? IDLE_CREATE_DRAFT,
|
|
1277
|
+
drop: options.drop ?? null,
|
|
1278
|
+
// FORWARDED IN THE ORDER IT ARRIVED, and turned into an answer per
|
|
1279
|
+
// edge inside `relationshipEntries` — after the panel has decided
|
|
1280
|
+
// which of them are its to state. Collapsing here instead put the
|
|
1281
|
+
// "one reason per row" rule one layer away from the filter that
|
|
1282
|
+
// decides which reasons there are, and made the ORDER of this array
|
|
1283
|
+
// load-bearing at a distance: a caller building it from two passes
|
|
1284
|
+
// lost the ledger's chronology at the join, and the panel reported a
|
|
1285
|
+
// refusal the reader had already read instead of the one they had
|
|
1286
|
+
// just caused.
|
|
1287
|
+
refusals: options.refusals ?? [],
|
|
1288
|
+
// FORWARDED IN THE LEDGER'S ORDER too, for the reason above: the
|
|
1289
|
+
// cards are drawn in the order the writes were made, so the one the
|
|
1290
|
+
// reader just caused is the one nearest what they were doing.
|
|
1291
|
+
recoveries: options.recoveries ?? [],
|
|
1292
|
+
diffOpen: options.diffOpen ?? null,
|
|
1293
|
+
}))),
|
|
383
1294
|
`</div>`,
|
|
384
1295
|
].join('');
|
|
385
1296
|
return {
|