@issuegraph/editor 0.3.0 → 0.10.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (87) hide show
  1. package/README.md +31 -6
  2. package/dist/a11y/baseline.d.ts +182 -0
  3. package/dist/a11y/baseline.d.ts.map +1 -0
  4. package/dist/a11y/baseline.js +430 -0
  5. package/dist/a11y/baseline.js.map +1 -0
  6. package/dist/audit/styles.d.ts +1 -1
  7. package/dist/audit/styles.d.ts.map +1 -1
  8. package/dist/audit/styles.js +1 -1
  9. package/dist/create/draft.d.ts +17 -0
  10. package/dist/create/draft.d.ts.map +1 -1
  11. package/dist/create/draft.js +19 -0
  12. package/dist/create/draft.js.map +1 -1
  13. package/dist/create/keys.d.ts +70 -5
  14. package/dist/create/keys.d.ts.map +1 -1
  15. package/dist/create/keys.js +78 -17
  16. package/dist/create/keys.js.map +1 -1
  17. package/dist/firstpass/candidates.d.ts +17 -1
  18. package/dist/firstpass/candidates.d.ts.map +1 -1
  19. package/dist/index.d.ts +6 -4
  20. package/dist/index.d.ts.map +1 -1
  21. package/dist/index.js +12 -2
  22. package/dist/index.js.map +1 -1
  23. package/dist/overlay/render.d.ts.map +1 -1
  24. package/dist/overlay/render.js +54 -14
  25. package/dist/overlay/render.js.map +1 -1
  26. package/dist/overlay/styles.d.ts +1 -1
  27. package/dist/overlay/styles.d.ts.map +1 -1
  28. package/dist/overlay/styles.js +40 -0
  29. package/dist/overlay/styles.js.map +1 -1
  30. package/dist/picker/render.d.ts +26 -16
  31. package/dist/picker/render.d.ts.map +1 -1
  32. package/dist/picker/render.js +25 -36
  33. package/dist/picker/render.js.map +1 -1
  34. package/dist/picker/styles.d.ts +7 -7
  35. package/dist/picker/styles.d.ts.map +1 -1
  36. package/dist/picker/styles.js +7 -36
  37. package/dist/picker/styles.js.map +1 -1
  38. package/dist/picker/view.d.ts +8 -4
  39. package/dist/picker/view.d.ts.map +1 -1
  40. package/dist/picker/view.js +4 -3
  41. package/dist/picker/view.js.map +1 -1
  42. package/dist/picker/words.d.ts +12 -10
  43. package/dist/picker/words.d.ts.map +1 -1
  44. package/dist/picker/words.js +12 -8
  45. package/dist/picker/words.js.map +1 -1
  46. package/dist/scale/ladder.d.ts.map +1 -1
  47. package/dist/scale/ladder.js +10 -0
  48. package/dist/scale/ladder.js.map +1 -1
  49. package/dist/scale/render.d.ts.map +1 -1
  50. package/dist/scale/render.js +9 -1
  51. package/dist/scale/render.js.map +1 -1
  52. package/dist/workspace/chrome.d.ts +1 -1
  53. package/dist/workspace/chrome.d.ts.map +1 -1
  54. package/dist/workspace/chrome.js +48 -0
  55. package/dist/workspace/chrome.js.map +1 -1
  56. package/dist/workspace/firstpass.d.ts +166 -0
  57. package/dist/workspace/firstpass.d.ts.map +1 -0
  58. package/dist/workspace/firstpass.js +151 -0
  59. package/dist/workspace/firstpass.js.map +1 -0
  60. package/dist/workspace/host.d.ts +212 -10
  61. package/dist/workspace/host.d.ts.map +1 -1
  62. package/dist/workspace/host.js +434 -42
  63. package/dist/workspace/host.js.map +1 -1
  64. package/dist/workspace/inspector.d.ts +45 -1
  65. package/dist/workspace/inspector.d.ts.map +1 -1
  66. package/dist/workspace/inspector.js +11 -0
  67. package/dist/workspace/inspector.js.map +1 -1
  68. package/dist/workspace/mount.d.ts +52 -5
  69. package/dist/workspace/mount.d.ts.map +1 -1
  70. package/dist/workspace/mount.js +1391 -56
  71. package/dist/workspace/mount.js.map +1 -1
  72. package/dist/workspace/rail.d.ts.map +1 -1
  73. package/dist/workspace/rail.js +12 -0
  74. package/dist/workspace/rail.js.map +1 -1
  75. package/dist/workspace/recovery.d.ts +155 -0
  76. package/dist/workspace/recovery.d.ts.map +1 -0
  77. package/dist/workspace/recovery.js +156 -0
  78. package/dist/workspace/recovery.js.map +1 -0
  79. package/dist/workspace/render.d.ts +402 -1
  80. package/dist/workspace/render.d.ts.map +1 -1
  81. package/dist/workspace/render.js +947 -36
  82. package/dist/workspace/render.js.map +1 -1
  83. package/dist/workspace/styles.d.ts +1 -1
  84. package/dist/workspace/styles.d.ts.map +1 -1
  85. package/dist/workspace/styles.js +554 -41
  86. package/dist/workspace/styles.js.map +1 -1
  87. package/package.json +3 -3
@@ -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; `refusalSpec`'s capsule is the same `li` + `button` shape.
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
- element('button', {
170
- type: 'button',
171
- class: 'ig-relationship-select',
172
- 'data-ig-command': 'select-edge',
173
- 'data-ig-target': relationship.edgeId,
174
- }, [
175
- element('span', { class: 'ig-relationship-kind' }, [relationship.field]),
176
- element('span', { class: 'ig-relationship-ref' }, [relationship.from]),
177
- element('span', { class: 'ig-relationship-ref' }, [relationship.to]),
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
- function inspectorSpec(view, words, known, leadOf) {
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('h2', { class: 'ig-inspector-title' }, [subject.issue.title]),
190
- element('span', { class: 'ig-inspector-key' }, [subject.issue.key]),
191
- subject.position === null
192
- ? null
193
- : element('p', {
194
- class: 'ig-inspector-position',
195
- 'data-ready': subject.position.ready ? 'true' : 'false',
196
- },
197
- // A HELD SLOT PRINTS THE VIEWER'S EM DASH, not a number. It has
198
- // no position in the sequence, and printing one would claim work
199
- // is queued that nothing can start.
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
- : element('ul', { class: 'ig-inspector-holds' }, subject.position.holds.map((hold) => holdRow(hold, known, (key) => leadOf.get(key) === leadOf.get(subject.issue.key)))),
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
- view.filtered
209
- ? element('button', { type: 'button', class: 'ig-inspector-clear', 'data-ig-command': 'clear' }, [words.clearSelection])
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
- view.relationships.length === 0
212
- ? null
213
- : element('ul', { class: 'ig-relationship-list' }, view.relationships.map((relationship) => relationshipSpec(relationship))),
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.reason,
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 ? '' : zone('header', renderAuditHeader(overlay, { filtered })),
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, options.words, known, leadOf))),
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 {