@issuegraph/editor 0.4.0 → 0.22.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +65 -9
- package/dist/a11y/baseline.d.ts +182 -0
- package/dist/a11y/baseline.d.ts.map +1 -0
- package/dist/a11y/baseline.js +434 -0
- package/dist/a11y/baseline.js.map +1 -0
- package/dist/audit/findings.d.ts +107 -1
- package/dist/audit/findings.d.ts.map +1 -1
- package/dist/audit/findings.js +113 -6
- package/dist/audit/findings.js.map +1 -1
- package/dist/audit/panel.d.ts +277 -0
- package/dist/audit/panel.d.ts.map +1 -0
- package/dist/audit/panel.js +462 -0
- package/dist/audit/panel.js.map +1 -0
- package/dist/audit/refused.d.ts +99 -0
- package/dist/audit/refused.d.ts.map +1 -0
- package/dist/audit/refused.js +276 -0
- package/dist/audit/refused.js.map +1 -0
- package/dist/audit/styles.d.ts +1 -1
- package/dist/audit/styles.d.ts.map +1 -1
- package/dist/audit/styles.js +429 -1
- package/dist/audit/styles.js.map +1 -1
- package/dist/audit/surface.d.ts +54 -12
- package/dist/audit/surface.d.ts.map +1 -1
- package/dist/audit/surface.js +53 -13
- package/dist/audit/surface.js.map +1 -1
- package/dist/create/draft.d.ts +36 -0
- package/dist/create/draft.d.ts.map +1 -1
- package/dist/create/draft.js +40 -0
- package/dist/create/draft.js.map +1 -1
- package/dist/create/keys.d.ts +134 -9
- package/dist/create/keys.d.ts.map +1 -1
- package/dist/create/keys.js +119 -20
- package/dist/create/keys.js.map +1 -1
- package/dist/firstpass/candidates.d.ts +17 -1
- package/dist/firstpass/candidates.d.ts.map +1 -1
- package/dist/index.d.ts +42 -17
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +45 -14
- package/dist/index.js.map +1 -1
- package/dist/overlay/render.d.ts +11 -3
- package/dist/overlay/render.d.ts.map +1 -1
- package/dist/overlay/render.js +76 -20
- package/dist/overlay/render.js.map +1 -1
- package/dist/overlay/request.d.ts +71 -0
- package/dist/overlay/request.d.ts.map +1 -0
- package/dist/overlay/request.js +129 -0
- package/dist/overlay/request.js.map +1 -0
- package/dist/overlay/styles.d.ts +1 -1
- package/dist/overlay/styles.d.ts.map +1 -1
- package/dist/overlay/styles.js +78 -7
- package/dist/overlay/styles.js.map +1 -1
- package/dist/picker/render.d.ts +26 -16
- package/dist/picker/render.d.ts.map +1 -1
- package/dist/picker/render.js +25 -36
- package/dist/picker/render.js.map +1 -1
- package/dist/picker/styles.d.ts +7 -7
- package/dist/picker/styles.d.ts.map +1 -1
- package/dist/picker/styles.js +7 -36
- package/dist/picker/styles.js.map +1 -1
- package/dist/picker/view.d.ts +8 -4
- package/dist/picker/view.d.ts.map +1 -1
- package/dist/picker/view.js +4 -3
- package/dist/picker/view.js.map +1 -1
- package/dist/picker/words.d.ts +12 -10
- package/dist/picker/words.d.ts.map +1 -1
- package/dist/picker/words.js +12 -8
- package/dist/picker/words.js.map +1 -1
- package/dist/reevaluate/parts.d.ts +130 -0
- package/dist/reevaluate/parts.d.ts.map +1 -0
- package/dist/reevaluate/parts.js +260 -0
- package/dist/reevaluate/parts.js.map +1 -0
- package/dist/reevaluate/render.d.ts +18 -11
- package/dist/reevaluate/render.d.ts.map +1 -1
- package/dist/reevaluate/render.js +21 -135
- package/dist/reevaluate/render.js.map +1 -1
- package/dist/reevaluate/styles.d.ts +1 -1
- package/dist/reevaluate/styles.d.ts.map +1 -1
- package/dist/reevaluate/styles.js +26 -0
- package/dist/reevaluate/styles.js.map +1 -1
- package/dist/reevaluate/view.d.ts +16 -1
- package/dist/reevaluate/view.d.ts.map +1 -1
- package/dist/reevaluate/view.js +21 -1
- package/dist/reevaluate/view.js.map +1 -1
- package/dist/reevaluate/words.d.ts +7 -0
- package/dist/reevaluate/words.d.ts.map +1 -1
- package/dist/scale/ladder.d.ts +62 -6
- package/dist/scale/ladder.d.ts.map +1 -1
- package/dist/scale/ladder.js +129 -11
- package/dist/scale/ladder.js.map +1 -1
- package/dist/scale/render.d.ts +44 -0
- package/dist/scale/render.d.ts.map +1 -1
- package/dist/scale/render.js +206 -28
- package/dist/scale/render.js.map +1 -1
- package/dist/scale/styles.d.ts +11 -5
- package/dist/scale/styles.d.ts.map +1 -1
- package/dist/scale/styles.js +133 -4
- package/dist/scale/styles.js.map +1 -1
- package/dist/workspace/chrome.d.ts +1 -1
- package/dist/workspace/chrome.d.ts.map +1 -1
- package/dist/workspace/chrome.js +48 -0
- package/dist/workspace/chrome.js.map +1 -1
- package/dist/workspace/firstpass.d.ts +166 -0
- package/dist/workspace/firstpass.d.ts.map +1 -0
- package/dist/workspace/firstpass.js +151 -0
- package/dist/workspace/firstpass.js.map +1 -0
- package/dist/workspace/host.d.ts +283 -11
- package/dist/workspace/host.d.ts.map +1 -1
- package/dist/workspace/host.js +614 -43
- package/dist/workspace/host.js.map +1 -1
- package/dist/workspace/inspector.d.ts +72 -2
- package/dist/workspace/inspector.d.ts.map +1 -1
- package/dist/workspace/inspector.js +26 -0
- package/dist/workspace/inspector.js.map +1 -1
- package/dist/workspace/mount.d.ts +75 -6
- package/dist/workspace/mount.d.ts.map +1 -1
- package/dist/workspace/mount.js +1851 -60
- package/dist/workspace/mount.js.map +1 -1
- package/dist/workspace/rail.d.ts +5 -2
- package/dist/workspace/rail.d.ts.map +1 -1
- package/dist/workspace/rail.js.map +1 -1
- package/dist/workspace/recovery.d.ts +155 -0
- package/dist/workspace/recovery.d.ts.map +1 -0
- package/dist/workspace/recovery.js +156 -0
- package/dist/workspace/recovery.js.map +1 -0
- package/dist/workspace/render.d.ts +779 -2
- package/dist/workspace/render.d.ts.map +1 -1
- package/dist/workspace/render.js +1727 -61
- package/dist/workspace/render.js.map +1 -1
- package/dist/workspace/selection.d.ts +11 -0
- package/dist/workspace/selection.d.ts.map +1 -1
- package/dist/workspace/selection.js +10 -0
- package/dist/workspace/selection.js.map +1 -1
- package/dist/workspace/styles.d.ts +1 -1
- package/dist/workspace/styles.d.ts.map +1 -1
- package/dist/workspace/styles.js +1405 -42
- package/dist/workspace/styles.js.map +1 -1
- package/package.json +4 -4
package/dist/workspace/render.js
CHANGED
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
* |-----------|-------------------------------------------------------|
|
|
10
10
|
* | header | `auditOverlay` + `renderAuditHeader` |
|
|
11
11
|
* | rail | `renderViewer(…, { projection: 'linear' })`, windowed |
|
|
12
|
-
* | canvas | `renderScaleLadder`
|
|
12
|
+
* | canvas | `canvasToolbar` + `renderScaleLadder` |
|
|
13
13
|
* | inspector | `inspectorView`, rendered here |
|
|
14
14
|
*
|
|
15
15
|
* ## The audit left-bar is applied to the rail's SPEC, never to its markup
|
|
@@ -48,15 +48,25 @@
|
|
|
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
|
+
import { edgeIdentity } from '@issuegraph/core';
|
|
53
|
+
import { renderAuditPanel } from "../audit/panel.js";
|
|
54
|
+
import { renderEncodingRefusedBlock } from "../audit/refused.js";
|
|
52
55
|
import { auditStylesheet } from "../audit/styles.js";
|
|
56
|
+
import { IDLE_CREATE_DRAFT } from "../create/draft.js";
|
|
57
|
+
import { KIND_KEYS, RELATE_KEY } from "../create/keys.js";
|
|
58
|
+
import { OVERLAY_TREATMENTS, treatmentForState } from "../overlay/grammar.js";
|
|
59
|
+
import { DELTA_ATTRIBUTE, chipSpec, deltaKind, summarySpec, textOf } from "../reevaluate/parts.js";
|
|
60
|
+
import { reevaluateStylesheet } from "../reevaluate/styles.js";
|
|
61
|
+
import { reevaluateView } from "../reevaluate/view.js";
|
|
53
62
|
import { edgeOverlayStylesheet } from "../overlay/styles.js";
|
|
54
63
|
import { AUDIT_SEVERITY_ATTRIBUTE, auditFilterKeeps, auditOverlay, heaviestRow, renderAuditHeader, } from "../audit/surface.js";
|
|
55
64
|
import { INITIAL_SCALE_STATE } from "../scale/commands.js";
|
|
56
65
|
import { renderScaleLadder } from "../scale/render.js";
|
|
57
66
|
import { scaleLadderStylesheet } from "../scale/styles.js";
|
|
58
|
-
import { inspectorView } from "./inspector.js";
|
|
67
|
+
import { inspectorView, } from "./inspector.js";
|
|
59
68
|
import { railWindow } from "./rail.js";
|
|
69
|
+
import { diffIsEmpty, diffWithin } from "./recovery.js";
|
|
60
70
|
import { INITIAL_SELECTION, selectedEdgeId, selectedKey, } from "./selection.js";
|
|
61
71
|
import { workspaceStylesheet } from "./styles.js";
|
|
62
72
|
/** The four fixed positions. A closed union, which is what keeps `zone` safe. */
|
|
@@ -71,6 +81,175 @@ export const ZONES = Object.freeze(['header', 'rail', 'canvas', 'inspector']);
|
|
|
71
81
|
function zone(name, inner) {
|
|
72
82
|
return `<section class="ig-zone" data-zone="${name}">${inner}</section>`;
|
|
73
83
|
}
|
|
84
|
+
/**
|
|
85
|
+
* §17f's canvas caption — what this canvas is drawing, and out of how much.
|
|
86
|
+
*
|
|
87
|
+
* THE GAP IS THE `direct` TIER, AND ONLY IT. The zone is not silent in general:
|
|
88
|
+
* `renderScaleLadder` appends the ladder's own chrome after the graph, so a
|
|
89
|
+
* focused canvas already carries a "return to every component" button, a
|
|
90
|
+
* declining one already prints a counted refusal sentence, and the isolated
|
|
91
|
+
* chip and the search are already there. What none of them states is the RATIO
|
|
92
|
+
* on the one tier where the canvas succeeds — it narrows to a component, draws
|
|
93
|
+
* it, and says nothing about the rest — so a canvas showing 6 of 312 issues
|
|
94
|
+
* read exactly like a backlog that has 6.
|
|
95
|
+
*
|
|
96
|
+
* SO THE STATEMENT IS SCOPED TO THAT TIER, deliberately. Above the node budget
|
|
97
|
+
* `renderScaleLadder` draws NO graph at all — the canvas is `null` and the
|
|
98
|
+
* refusal takes its place — and a caption reading "1500 out of 2000 drawn here"
|
|
99
|
+
* over an empty canvas would state a thing the surface does not do. That is the
|
|
100
|
+
* defect {@link CanvasWords} refuses on the frame's own `1 hop`, and refusing it
|
|
101
|
+
* there while committing it here would be worse than not drawing the row.
|
|
102
|
+
*
|
|
103
|
+
* THE NUMBERS ARE THE LADDER'S AND THE DOCUMENT'S, ON ONE BASIS. `nodeCount` is
|
|
104
|
+
* "the nodes the canvas would draw", the same value the refusal is computed
|
|
105
|
+
* from, so the caption cannot disagree with the ladder beneath it. The total is
|
|
106
|
+
* the NORMALIZED document's — the one every zone in this function derives from —
|
|
107
|
+
* never the raw input, whose counts no zone drew, and never `ladder.canvas`,
|
|
108
|
+
* which would print `6 out of 6`.
|
|
109
|
+
*
|
|
110
|
+
* THE COUNT EXCLUDES ISSUES WITH NO RELATIONSHIP, and the surface says so
|
|
111
|
+
* elsewhere in its own words — at the FOOT OF THE RAIL where §17a puts it
|
|
112
|
+
* (`railFooter`), or, on a host that has not worded that row, in the ladder's
|
|
113
|
+
* own chip directly beneath this caption. This sentence read "the chip below"
|
|
114
|
+
* until the rail grew the footer, and the count is now in exactly one of those
|
|
115
|
+
* two places rather than reliably in the nearer one. Nothing else in this
|
|
116
|
+
* comment moves with it: the argument below is about the two NUMBERS, not about
|
|
117
|
+
* where the second one is drawn. UNFOCUSED the two are complementary and do sum to the total:
|
|
118
|
+
* `nodeCount` is every issue carrying a relationship and the chip is every issue
|
|
119
|
+
* carrying none. FOCUSED they do not, and an earlier draft of this comment said
|
|
120
|
+
* they did — `nodeCount` is then ONE component's members, so the shortfall is
|
|
121
|
+
* the chip's population PLUS every other component. What holds either way, and
|
|
122
|
+
* what the row actually relies on, is that the two count DISJOINT sets: they can
|
|
123
|
+
* never contradict, whatever they leave between them. So the row does not
|
|
124
|
+
* restate the chip, and no third number may be derived by subtracting one from
|
|
125
|
+
* the other.
|
|
126
|
+
*/
|
|
127
|
+
function canvasToolbar(ladder, document, words) {
|
|
128
|
+
if (words === undefined)
|
|
129
|
+
return null;
|
|
130
|
+
const focused = ladder.focus;
|
|
131
|
+
const statement = ladder.tier !== 'direct'
|
|
132
|
+
? null
|
|
133
|
+
: element('p', { class: 'ig-canvas-caption' }, [
|
|
134
|
+
// NO CLAUSE AT ALL WHEN NOTHING IS FOCUSED, rather than the label with
|
|
135
|
+
// an empty value after it. An unfocused canvas is drawing every
|
|
136
|
+
// component, which is a true thing the counts already say.
|
|
137
|
+
focused === null
|
|
138
|
+
? null
|
|
139
|
+
: element('span', { class: 'ig-canvas-focus' }, [
|
|
140
|
+
element('span', {}, [words.focus]),
|
|
141
|
+
// THE PUNCTUATION IS THE PACKAGE'S, AND IT HAS TO ACTUALLY BE
|
|
142
|
+
// DRAWN. `CanvasWords.focus` tells a host to omit the colon
|
|
143
|
+
// because this draws it; an earlier revision made that promise
|
|
144
|
+
// and then emitted only a space, so a host that obeyed the
|
|
145
|
+
// contract got `focus #512`. The interpunct after the key is
|
|
146
|
+
// the frame's own typography for the same reason.
|
|
147
|
+
': ',
|
|
148
|
+
// NOT `identity()`, AND NOT BECAUSE OF THE URL. That helper is
|
|
149
|
+
// right about who owns a tracker's link shape — but it renders
|
|
150
|
+
// an ANCHOR when the host supplied a url, and an anchor here is
|
|
151
|
+
// focusable inside a region that is replaced wholesale on every
|
|
152
|
+
// mounted redraw. `focusedKey()` and `commandFocusToken()` both
|
|
153
|
+
// read null for it, so the restore falls through to the first
|
|
154
|
+
// rail row and `WorkspaceHandle.update`'s promise to keep focus
|
|
155
|
+
// is broken by a caption. The caption is a READ-OUT: the canvas
|
|
156
|
+
// below it already draws this issue as a focusable node, and the
|
|
157
|
+
// rail already links it. So the key is drawn as text, which
|
|
158
|
+
// invents no URL and takes no focus.
|
|
159
|
+
element('span', { class: 'ig-id' }, [focused]),
|
|
160
|
+
' · ',
|
|
161
|
+
]),
|
|
162
|
+
element('span', { class: 'ig-canvas-shown' }, [
|
|
163
|
+
element('span', { class: 'ig-canvas-count' }, [String(ladder.nodeCount)]),
|
|
164
|
+
' ',
|
|
165
|
+
element('span', {}, [words.of]),
|
|
166
|
+
' ',
|
|
167
|
+
element('span', { class: 'ig-canvas-count' }, [String(document.issues.length)]),
|
|
168
|
+
' ',
|
|
169
|
+
element('span', {}, [words.shown]),
|
|
170
|
+
]),
|
|
171
|
+
]);
|
|
172
|
+
// A STATE, NOT A CONTROL, AND NOT A ROLE. It carries text, so it is announced
|
|
173
|
+
// in reading order on its own; `role="img"` with an `aria-label` — the shape
|
|
174
|
+
// `station()` uses — is for an indicator with NO text, and giving one to this
|
|
175
|
+
// would replace the host's word with a copy of itself. A `button` would be
|
|
176
|
+
// the dead control `headerControls` refuses to draw.
|
|
177
|
+
const pill = words.editMode === undefined
|
|
178
|
+
? null
|
|
179
|
+
: element('span', { class: 'ig-edit-mode' }, [words.editMode]);
|
|
180
|
+
// NOTHING TO SAY, NO ROW. Both halves are conditional and independently so,
|
|
181
|
+
// and an empty bordered band above the canvas is chrome that carries no fact.
|
|
182
|
+
if (statement === null && pill === null)
|
|
183
|
+
return null;
|
|
184
|
+
return element('div', { class: 'ig-canvas-toolbar' }, [statement, pill]);
|
|
185
|
+
}
|
|
186
|
+
/**
|
|
187
|
+
* §17a's rail footer — the isolated count and the control that opens it.
|
|
188
|
+
*
|
|
189
|
+
* THE FACT IS THE DOCUMENT'S, NOT THE CANVAS'S. `ladder.isolated` is
|
|
190
|
+
* `document.issues` minus everything in a component, so it is a property of the
|
|
191
|
+
* backlog rather than of how far the graph narrowed. Frame 17a draws it at the
|
|
192
|
+
* foot of the order rail and gives the canvas toolbar the projection toggle,
|
|
193
|
+
* the focus statement and the edit-mode pill instead — so this is where the
|
|
194
|
+
* count belongs, and `renderScaleLadder` is told not to draw its own.
|
|
195
|
+
*
|
|
196
|
+
* THE COUNT AND THE CONTROL ONLY — THE LIST IS DRAWN IN THE CANVAS. Two reasons
|
|
197
|
+
* it cannot live in this zone, and the second is the load-bearing one. The
|
|
198
|
+
* footer is sticky, and an element taller than its scrollport cannot stick: the
|
|
199
|
+
* browser clamps it, so a list inside the footer would lose the pin exactly as
|
|
200
|
+
* it grew. And the rail is VIRTUALIZED — `railRowAt` turns a scroll offset into
|
|
201
|
+
* a row index by `floor((scrollTop - chrome) / pitch)`, and `railSpacer` stands
|
|
202
|
+
* in for the rows outside the window on that same fixed pitch — so content of
|
|
203
|
+
* arbitrary height anywhere in this scroll track makes every offset beneath it
|
|
204
|
+
* name the wrong row, and the window is then re-cut against a geometry that no
|
|
205
|
+
* longer holds. `renderScaleLadder` keeps drawing the list in the canvas zone,
|
|
206
|
+
* which has no such arithmetic; only the control moves here.
|
|
207
|
+
*
|
|
208
|
+
* NOTHING TO SAY, NO ROW — the rule `canvasToolbar` follows and `isolatedSpec`
|
|
209
|
+
* follows before it. With no words there is no footer, and with no isolated
|
|
210
|
+
* issues the count is not a fact anyone asked for.
|
|
211
|
+
*/
|
|
212
|
+
function railFooter(isolated, words, listId) {
|
|
213
|
+
if (words === undefined)
|
|
214
|
+
return null;
|
|
215
|
+
if (isolated.count === 0)
|
|
216
|
+
return null;
|
|
217
|
+
return element('div', { class: 'ig-rail-footer' }, [
|
|
218
|
+
// NO CLASS ON THE WRAPPER. It needs no rule of its own — the footer sets the
|
|
219
|
+
// muted body colour and `.ig-rail-count` lifts the number out of it — and a
|
|
220
|
+
// class with no rule is exactly what `styles.test.ts`'s second direction
|
|
221
|
+
// refuses.
|
|
222
|
+
element('span', {}, [
|
|
223
|
+
element('span', { class: 'ig-rail-count' }, [String(isolated.count)]),
|
|
224
|
+
' ',
|
|
225
|
+
element('span', {}, [words.isolated]),
|
|
226
|
+
]),
|
|
227
|
+
// A REAL BUTTON, NOT THE FRAME'S `cursor:pointer` SPAN. It is a control, and
|
|
228
|
+
// a span that looks pressable is the dead control `headerControls` refuses
|
|
229
|
+
// to draw; the frame's span is a mock's shorthand for one.
|
|
230
|
+
element('button', {
|
|
231
|
+
type: 'button',
|
|
232
|
+
class: 'ig-rail-isolated-toggle',
|
|
233
|
+
'aria-expanded': isolated.open ? 'true' : 'false',
|
|
234
|
+
// NAMES WHAT IT OPENS, because what it opens is not beside it. This
|
|
235
|
+
// toggle is in the rail and its list is drawn by the ladder in the
|
|
236
|
+
// canvas, so `aria-expanded` alone tells a screen-reader user that
|
|
237
|
+
// something opened and nothing about where — the one thing the adjacent
|
|
238
|
+
// disclosure it replaced never had to say. `scrollIntoView` moves the
|
|
239
|
+
// visual viewport and not accessibility focus, so it does not answer
|
|
240
|
+
// this either.
|
|
241
|
+
//
|
|
242
|
+
// ONLY WHILE THE LIST EXISTS, AND ONLY WITH AN ID TO NAME. A shut
|
|
243
|
+
// disclosure renders no list, so naming one would be a dangling
|
|
244
|
+
// reference the a11y baseline refuses — and there would be nothing to
|
|
245
|
+
// reach. With no `surfaceId` there is no value that would be unique on
|
|
246
|
+
// the host's page, and a guessed one is the collision `searchSpec`
|
|
247
|
+
// avoids.
|
|
248
|
+
...(isolated.open && listId !== undefined ? { 'aria-controls': listId } : {}),
|
|
249
|
+
'data-ig-command': isolated.open ? 'close-isolated' : 'open-isolated',
|
|
250
|
+
}, [isolated.open ? words.hide : words.show]),
|
|
251
|
+
]);
|
|
252
|
+
}
|
|
74
253
|
/**
|
|
75
254
|
* The height the rows outside the window would have taken.
|
|
76
255
|
*
|
|
@@ -112,14 +291,7 @@ function severityForRow(overlay, members) {
|
|
|
112
291
|
// the justification was the defect, not the loop.
|
|
113
292
|
return overlay === null ? undefined : heaviestRow(overlay, members)?.severity;
|
|
114
293
|
}
|
|
115
|
-
|
|
116
|
-
* Add `data-ig-audit` to the rail's keyed rows.
|
|
117
|
-
*
|
|
118
|
-
* A total walk that rebuilds the tree rather than mutating it: `ElementSpec` is
|
|
119
|
-
* `readonly` throughout, and a mutating walk would also be visible to the
|
|
120
|
-
* caller's own copy of `scene.root`.
|
|
121
|
-
*/
|
|
122
|
-
function markRail(root, severityOf) {
|
|
294
|
+
function markRail(root, severityOf, deltaOf) {
|
|
123
295
|
// TYPED AS `ElementSpec -> ElementSpec` AT THE BOUNDARY, with the child walk
|
|
124
296
|
// kept inside. A single function over `SpecChild` would hand `renderMarkup` a
|
|
125
297
|
// union it does not take, and the obvious repair — casting the result back —
|
|
@@ -133,10 +305,56 @@ function markRail(root, severityOf) {
|
|
|
133
305
|
// resolves to nothing today and to something the day a host names an issue
|
|
134
306
|
// that. Narrowed rather than coerced.
|
|
135
307
|
const severity = typeof key === 'string' ? severityOf(key) : undefined;
|
|
136
|
-
const
|
|
137
|
-
const
|
|
138
|
-
|
|
139
|
-
|
|
308
|
+
const delta = typeof key === 'string' ? deltaOf(key) : undefined;
|
|
309
|
+
const walked = spec.children?.map(markChild);
|
|
310
|
+
// THE CHIP IS APPENDED TO THE ROW, WHICH IS WHY IT IS DRAWN HERE AND NOT
|
|
311
|
+
// IN A MOUNT. §17c puts the effect in place — "only affected rows carry a
|
|
312
|
+
// delta chip · unaffected rows are left completely alone" — and
|
|
313
|
+
// `reevaluate/render.ts` deferred that to "the change that assembles the
|
|
314
|
+
// workspace" on the ground that placing a chip needs the row's geometry.
|
|
315
|
+
// It does not: the row is a SPEC here, keyed and reachable, and this walk
|
|
316
|
+
// was already composing one decoration onto it. So the chip is composed
|
|
317
|
+
// like the audit's mark rather than positioned like an overlay, and no
|
|
318
|
+
// geometry is consulted at all.
|
|
319
|
+
//
|
|
320
|
+
// AN UNAFFECTED ROW IS RETURNED WITH ITS OWN `children` ARRAY, not a
|
|
321
|
+
// rebuilt one carrying an appended `null` — `deltaOf` answering `undefined`
|
|
322
|
+
// has to leave the row byte-identical, which is the design's rule made
|
|
323
|
+
// structural rather than asserted.
|
|
324
|
+
const chip = delta === undefined ? null : chipSpec(delta.chip, delta.words, { placed: true });
|
|
325
|
+
const children = chip === null ? walked : [...(walked ?? []), chip];
|
|
326
|
+
const marked = { ...spec.attrs };
|
|
327
|
+
if (severity !== undefined)
|
|
328
|
+
marked[AUDIT_SEVERITY_ATTRIBUTE] = severity;
|
|
329
|
+
// THE CHIP IS IN THE ROW'S NAME, OR IT IS SILENT.
|
|
330
|
+
//
|
|
331
|
+
// Layer 1 gives the row an `aria-label`, and an accessible name computed
|
|
332
|
+
// from `aria-label` WINS over descendant text — so a chip appended here is
|
|
333
|
+
// seen and not heard. The rail is the surface a reader arrows through, and
|
|
334
|
+
// "which row moved, and by how much" is exactly what §17c puts on the row;
|
|
335
|
+
// the summary above carries aggregate counts and cannot recover it.
|
|
336
|
+
//
|
|
337
|
+
// APPENDED TO THE NAME rather than hung off `aria-describedby`, and the
|
|
338
|
+
// reason is that the alternative costs more than it looks. A description
|
|
339
|
+
// needs an id, this package emits none anywhere, and `a11y/baseline.ts`
|
|
340
|
+
// records what ids cost here — a host-derived token reaching an attribute
|
|
341
|
+
// that other code resolves, which is the injection class the mount's focus
|
|
342
|
+
// token was written to avoid. A name needs no id and is announced as the
|
|
343
|
+
// reader arrives rather than after it.
|
|
344
|
+
//
|
|
345
|
+
// The separator is layer 1's own: `slotLabel` joins its three parts with
|
|
346
|
+
// the same em dash, so this reads as one more part rather than a second
|
|
347
|
+
// sentence in a different hand.
|
|
348
|
+
const label = marked['aria-label'];
|
|
349
|
+
if (chip !== null && typeof label === 'string') {
|
|
350
|
+
marked['aria-label'] = `${label} — ${textOf(chip)}`;
|
|
351
|
+
}
|
|
352
|
+
// OMITTED WHEN THE CHIP HAS NO DRAWABLE KIND, rather than stamped empty.
|
|
353
|
+
// `DELTA_ATTRIBUTE` records why: an attribute on every row with one value
|
|
354
|
+
// meaning "nothing" is marking the row, not leaving it alone.
|
|
355
|
+
if (delta?.kind !== undefined)
|
|
356
|
+
marked[DELTA_ATTRIBUTE] = delta.kind;
|
|
357
|
+
const attrs = severity === undefined && delta?.kind === undefined ? spec.attrs : marked;
|
|
140
358
|
return {
|
|
141
359
|
...spec,
|
|
142
360
|
...(attrs === undefined ? {} : { attrs }),
|
|
@@ -145,6 +363,224 @@ function markRail(root, severityOf) {
|
|
|
145
363
|
}
|
|
146
364
|
return markSpec(root);
|
|
147
365
|
}
|
|
366
|
+
/**
|
|
367
|
+
* A row's right-hand slot: exactly one of `selected`, a remove control, or the
|
|
368
|
+
* inbound marker.
|
|
369
|
+
*
|
|
370
|
+
* THE ORDER IS THE FRAME'S AND IT IS ALSO THE SAFE ONE. A selected row's SLOT
|
|
371
|
+
* says so and offers nothing else, which matters because `selected` is the
|
|
372
|
+
* state that FILTERS this panel: the reader is looking at one edge on purpose,
|
|
373
|
+
* and a destructive control is not what the slot is for at that moment.
|
|
374
|
+
*
|
|
375
|
+
* THE SLOT, NOT THE ROW — §17b's flip sits beside it on exactly that row. The
|
|
376
|
+
* reasoning above is specific to a DESTRUCTIVE control; a flip is the
|
|
377
|
+
* corrective act §17b asks for, so it is admitted where a `✕` is not.
|
|
378
|
+
* {@link relationshipSpec} draws it as the slot's peer and says why there.
|
|
379
|
+
*
|
|
380
|
+
* AN INBOUND ROW GETS NO REMOVE CONTROL, and that is a fact about the format
|
|
381
|
+
* rather than caution. An incoming edge's field is declared in the OTHER
|
|
382
|
+
* issue's body, so this panel's subject cannot declare it away; a `✕` there
|
|
383
|
+
* would publish an edit that either does nothing or edits a document the
|
|
384
|
+
* reader is not looking at. The reference implementation encodes the same rule
|
|
385
|
+
* as `removable: false`, and frame 17a puts the word `inbound` in the slot the
|
|
386
|
+
* other rows spend on `✕`.
|
|
387
|
+
*
|
|
388
|
+
* THE KEYBOARD'S `⌫` IS NOT A SECOND RULE. `create/keys.ts` acts on the
|
|
389
|
+
* SELECTED edge because a selection is the only edge a keyboard has named; that
|
|
390
|
+
* is this rule with the selection as the subject, not an exception to it.
|
|
391
|
+
*
|
|
392
|
+
* `selected` IS THE PACKAGE'S OWN WORD, taken from `treatmentForState` rather
|
|
393
|
+
* than from the words object. Write-state names are already authored here —
|
|
394
|
+
* `writing…`, `failed`, `invalid` — and a `selected` on `WorkspaceWords` would
|
|
395
|
+
* be a second spelling of one the overlay grammar publishes on the canvas.
|
|
396
|
+
*/
|
|
397
|
+
function rowSlot(relationship, selected, words) {
|
|
398
|
+
if (relationship.edgeId === selected) {
|
|
399
|
+
return element('span', { class: 'ig-relationship-state', 'data-ig-state': 'selected' }, [treatmentForState('selected').label]);
|
|
400
|
+
}
|
|
401
|
+
if (relationship.direction === 'incoming') {
|
|
402
|
+
return element('span', { class: 'ig-relationship-inbound' }, [words.inbound]);
|
|
403
|
+
}
|
|
404
|
+
// THE GLYPH IS HIDDEN AND THE NAME IS AN ATTRIBUTE. `glyphAndLabel` draws a
|
|
405
|
+
// VISIBLE word beside its glyph, which is right for the kind and wrong here:
|
|
406
|
+
// the frame's slot is a bare `✕`. So the mark is layer 1's `hiddenGlyph` —
|
|
407
|
+
// the published half of that same pairing, rather than its class and its
|
|
408
|
+
// `aria-hidden` written out again here, which is exactly the local copy
|
|
409
|
+
// `glyphAndLabel`'s own header calls the failure it exists to prevent. `✕`
|
|
410
|
+
// announces as whatever a screen reader's character table calls it, which
|
|
411
|
+
// describes the mark and not the act, so the host's word becomes the
|
|
412
|
+
// button's accessible name. On the BUTTON, never on a span around it:
|
|
413
|
+
// `parts.ts` records that a plain span takes the generic role, on which ARIA
|
|
414
|
+
// prohibits naming, so the name would be dropped without a warning.
|
|
415
|
+
return element('button', {
|
|
416
|
+
type: 'button',
|
|
417
|
+
class: 'ig-relationship-remove',
|
|
418
|
+
'data-ig-command': 'delete',
|
|
419
|
+
// ITS OWN ROW'S EDGE, WHICH IS THE WHOLE POINT OF THE ATTRIBUTE HERE.
|
|
420
|
+
// `reduceHost`'s delete arm read the selection and ignored the target
|
|
421
|
+
// while the only delete control lived inside `if (edgeId !== null)`; a
|
|
422
|
+
// control repeated per row makes that wrong in both directions — a no-op
|
|
423
|
+
// with an issue selected, and a delete of the WRONG edge with a
|
|
424
|
+
// different row's edge selected.
|
|
425
|
+
'data-ig-target': relationship.edgeId,
|
|
426
|
+
'aria-label': words.remove,
|
|
427
|
+
}, [hiddenGlyph('\u2715')]);
|
|
428
|
+
}
|
|
429
|
+
/**
|
|
430
|
+
* §17b's flip, on the selected row.
|
|
431
|
+
*
|
|
432
|
+
* ## It is drawn where the sentence is, and only where the reducer can act
|
|
433
|
+
*
|
|
434
|
+
* Frame 17b draws a directed relationship as a statement with `⇅ flip` at the
|
|
435
|
+
* end of the same line, and the row already IS that statement: with an edge
|
|
436
|
+
* selected the panel filters to one relationship and
|
|
437
|
+
* {@link relationshipDescription}'s no-subject arm words it forward with both
|
|
438
|
+
* references in stored order. So the control joins the row rather than opening
|
|
439
|
+
* a second card above it — a card would need a sentence of its own, and the
|
|
440
|
+
* panel would then state one relationship twice, one element apart, which is
|
|
441
|
+
* the encoding ambiguity §17b exists to remove.
|
|
442
|
+
*
|
|
443
|
+
* ONLY ON THE SELECTED ROW, and that is a fact about the REDUCER rather than
|
|
444
|
+
* restraint. `reduceHost`'s `flip` arm takes its edge from
|
|
445
|
+
* `selectedEdgeId(state.selection)` and ignores `data-ig-target` — deliberately,
|
|
446
|
+
* because a flip is one operation about the edge the reader is looking at. A
|
|
447
|
+
* control on an unselected row would publish a command the reducer would answer
|
|
448
|
+
* about a different edge, or, with an issue selected, about none; the panel's
|
|
449
|
+
* standing rule is that a control which cannot complete the act it advertises
|
|
450
|
+
* is not drawn.
|
|
451
|
+
*
|
|
452
|
+
* THAT GUARANTEE IS THE MOUNT'S, AND A STANDALONE HOST OWES THE SAME STEP. This
|
|
453
|
+
* surface is handed a `ViewerDocument` and the reducer a `GraphDocument`, so
|
|
454
|
+
* "the selected edge exists" is only true while the two agree. `mountWorkspace`
|
|
455
|
+
* makes them agree by running `reconcileHost` against the landed document
|
|
456
|
+
* before every render, which clears a selection naming an edge that is gone and
|
|
457
|
+
* moves one naming an edge hidden behind an unsettled write. A host wiring the
|
|
458
|
+
* published attributes itself must do the same, or it can draw a flip whose
|
|
459
|
+
* press the reducer answers with nothing.
|
|
460
|
+
*
|
|
461
|
+
* ## A symmetric kind gets no control, and the absence is the finding
|
|
462
|
+
*
|
|
463
|
+
* `serialize-with` and `together-with` state one fact whichever way round they
|
|
464
|
+
* are stored, so a control to reverse them claims something the format does not
|
|
465
|
+
* say — and the store would refuse the edit as `symmetric-edge` anyway.
|
|
466
|
+
* `picker/view.ts` already answers `null` there; this agrees with it rather
|
|
467
|
+
* than contradicting it from the next zone over.
|
|
468
|
+
*
|
|
469
|
+
* ONE ORACLE FOR THAT QUESTION. The directedness is read off the SAME
|
|
470
|
+
* {@link EdgeTreatment} `relationshipDescription` already reads to word the
|
|
471
|
+
* row, which is what `labelFrom` itself branches on. Importing
|
|
472
|
+
* `isSymmetricEdgeField` here instead would put two answers to one question at
|
|
473
|
+
* one call site, in a package whose objection everywhere else is "not a wrong
|
|
474
|
+
* answer, a second answer". `render.test.ts` pins the two to agree across the
|
|
475
|
+
* whole vocabulary, so the shorter reach is safe in both directions.
|
|
476
|
+
*
|
|
477
|
+
* ## It publishes and wires nothing
|
|
478
|
+
*
|
|
479
|
+
* `data-ig-command="flip"` and no target: the attribute the reducer does not
|
|
480
|
+
* read would advertise a per-row flip it does not implement. On a `button`,
|
|
481
|
+
* because a span has no tab stop and no native activation — the control §17b
|
|
482
|
+
* names most error-prone is the last one that should be pointer-only.
|
|
483
|
+
*/
|
|
484
|
+
function flipControl(relationship, selected, words) {
|
|
485
|
+
if (relationship.edgeId !== selected)
|
|
486
|
+
return null;
|
|
487
|
+
if (treatmentFor(relationship.field).symmetric)
|
|
488
|
+
return null;
|
|
489
|
+
return element('button', { type: 'button', class: 'ig-relationship-flip', 'data-ig-command': 'flip' }, [words.flip]);
|
|
490
|
+
}
|
|
491
|
+
/**
|
|
492
|
+
* The host's sentence for a refusal, wherever the panel states one.
|
|
493
|
+
*
|
|
494
|
+
* SHARED FOR THE REASON THE HEAD BELOW IS. A refusal reaches the reader in two
|
|
495
|
+
* shapes — a capsule standing in for a relationship that does not exist, and a
|
|
496
|
+
* reason attached to one that does — and a second element built at the second
|
|
497
|
+
* site would be free to take a different class and lose the rule that styles
|
|
498
|
+
* it, on the half that ships later.
|
|
499
|
+
*/
|
|
500
|
+
function refusalReason(code, words) {
|
|
501
|
+
return element('span', { class: 'ig-relationship-reason' }, [words.refusals[code]]);
|
|
502
|
+
}
|
|
503
|
+
/**
|
|
504
|
+
* What a relationship READS as: the kind's glyph and word, and the reference at
|
|
505
|
+
* the other end. No control, no wrapper — only the description.
|
|
506
|
+
*
|
|
507
|
+
* ONE DESCRIPTION FOR THE ROW AND FOR THE CAPSULE. They state the same thing
|
|
508
|
+
* about the same edge, and they briefly stated it twice — including deriving
|
|
509
|
+
* `outgoing` from the same two values in both places, which is the shape this
|
|
510
|
+
* package calls a second answer rather than a repetition.
|
|
511
|
+
*
|
|
512
|
+
* SPLIT FROM THE CONTROL THAT WRAPS IT, rather than parameterised with a
|
|
513
|
+
* "draw a button?" flag, because the two callers do not differ in a DETAIL of
|
|
514
|
+
* one element: one of them draws no control at all. A flag would leave the
|
|
515
|
+
* element that IS the control deciding whether to be one, and every attribute
|
|
516
|
+
* on it conditional on that. The description is the shared fact; who wraps it
|
|
517
|
+
* is the caller's, exactly as the slot beside it and the border around it
|
|
518
|
+
* already are.
|
|
519
|
+
*
|
|
520
|
+
* ## It draws the OTHER end, and words the kind from the subject's end
|
|
521
|
+
*
|
|
522
|
+
* The row used to draw three bare tokens — the field's machine name, then both
|
|
523
|
+
* endpoints — one of which is the issue whose panel this is. So the reader read
|
|
524
|
+
* their own subject back on every row and had to work out which of the two
|
|
525
|
+
* references was the other one, from a token that stated no direction at all
|
|
526
|
+
* while `data-direction` sat unread on the same element.
|
|
527
|
+
*
|
|
528
|
+
* WITH NO ISSUE SUBJECT THERE IS NO "OTHER END", and both are drawn. That is an
|
|
529
|
+
* edge selection: the panel is showing one relationship rather than one issue's
|
|
530
|
+
* relationships, so the head reads as the edge is stored, `from` before `to`,
|
|
531
|
+
* and the kind takes its plain forward wording. Picking one end there would
|
|
532
|
+
* mean picking arbitrarily and then wording the sentence around the choice.
|
|
533
|
+
*/
|
|
534
|
+
function relationshipDescription(relationship, subject) {
|
|
535
|
+
const treatment = treatmentFor(relationship.field);
|
|
536
|
+
const outgoing = subject === null || relationship.from === subject;
|
|
537
|
+
// THE ROLE TRAVELS WITH THE REFERENCE, and it is not decoration. §17b's rule
|
|
538
|
+
// is that direction is STATED, never inferred — and with no issue subject
|
|
539
|
+
// both ends are drawn, so without a role the only thing saying which is which
|
|
540
|
+
// is their ORDER. That is inference, by the reader and by any host restyling
|
|
541
|
+
// the row. `directionSpec` published exactly this pair of roles before §17b's
|
|
542
|
+
// statement became the row; dropping them would have moved the statement and
|
|
543
|
+
// quietly lost the half that made it a statement.
|
|
544
|
+
//
|
|
545
|
+
// OMITTED, NOT FALSIFIED, WHERE THERE IS NO PAIR. With an issue subject the
|
|
546
|
+
// row draws one reference — the other end — and it is worded relative to the
|
|
547
|
+
// subject by `labelFrom`, so a `from`/`to` on it would name an end of the
|
|
548
|
+
// stored pair while the words name an end of the reader's sentence, and those
|
|
549
|
+
// are not always the same one.
|
|
550
|
+
const reference = (ref, role) => element('span', { class: 'ig-relationship-ref', 'data-ig-role': role }, [ref]);
|
|
551
|
+
return [
|
|
552
|
+
element('span', { class: 'ig-relationship-kind' },
|
|
553
|
+
// LAYER 1's PAIRING, not a second one. The glyph is one of the four
|
|
554
|
+
// channels the colour-blind-safety claim rests on and is
|
|
555
|
+
// `aria-hidden` because it announces as a character description;
|
|
556
|
+
// the word beside it is what a reader hears. A local copy of that
|
|
557
|
+
// arrangement is free to drop the second half.
|
|
558
|
+
glyphAndLabel(treatment.glyph, labelFrom(treatment, outgoing))),
|
|
559
|
+
...(subject === null
|
|
560
|
+
? [reference(relationship.from, 'from'), reference(relationship.to, 'to')]
|
|
561
|
+
: [reference(outgoing ? relationship.to : relationship.from)]),
|
|
562
|
+
];
|
|
563
|
+
}
|
|
564
|
+
/**
|
|
565
|
+
* A relationship's head on a row that HAS one: the description, inside the
|
|
566
|
+
* control that selects the edge.
|
|
567
|
+
*
|
|
568
|
+
* ONLY WHERE THE EDGE IS IN THE DOCUMENT. `select-edge` reduces to a selection,
|
|
569
|
+
* and the next render's `reconcileHost` drops a selection naming an edge the
|
|
570
|
+
* landed document does not carry — so on a phantom this control would close the
|
|
571
|
+
* inspector instead of inspecting anything. That is the rule the panel's own
|
|
572
|
+
* header already states about `+ add`: a control that cannot complete the act
|
|
573
|
+
* it advertises is not drawn. {@link refusalCapsule} draws the description
|
|
574
|
+
* bare for exactly that reason.
|
|
575
|
+
*/
|
|
576
|
+
function relationshipHead(relationship, subject) {
|
|
577
|
+
return element('button', {
|
|
578
|
+
type: 'button',
|
|
579
|
+
class: 'ig-relationship-select',
|
|
580
|
+
'data-ig-command': 'select-edge',
|
|
581
|
+
'data-ig-target': relationship.edgeId,
|
|
582
|
+
}, relationshipDescription(relationship, subject));
|
|
583
|
+
}
|
|
148
584
|
/**
|
|
149
585
|
* One relationship, as a row the reader can actually operate.
|
|
150
586
|
*
|
|
@@ -153,65 +589,895 @@ function markRail(root, severityOf) {
|
|
|
153
589
|
* reachable by pointer and by nothing else — and a host wiring the published
|
|
154
590
|
* attributes cannot fix that without rebuilding the semantics this package
|
|
155
591
|
* should have supplied. Every other command in the package is already on a
|
|
156
|
-
* button;
|
|
592
|
+
* button; {@link refusalCapsule}'s capsule is the same `li` + `button` shape.
|
|
157
593
|
*
|
|
158
594
|
* The `li` keeps the hue and the direction, because those describe the
|
|
159
595
|
* relationship rather than the action.
|
|
596
|
+
*
|
|
597
|
+
* ## A REFUSAL CAN LAND ON A ROW THAT STAYS
|
|
598
|
+
*
|
|
599
|
+
* `refused` is the code when the store refused an edit ABOUT this relationship
|
|
600
|
+
* and the relationship is still there — a create of an edge that already
|
|
601
|
+
* exists, a retype to the kind it already has, a flip of a symmetric kind. The
|
|
602
|
+
* reason joins the row and the row keeps its remove control, because the edge
|
|
603
|
+
* it names is real and removable; only a refusal about an edge that does not
|
|
604
|
+
* exist replaces the row, and {@link WorkspaceRefusal.phantom} is which. It is
|
|
605
|
+
* published as `data-ig-code` here for the same reason the capsule publishes
|
|
606
|
+
* it: a host styles or counts refusals off the store's vocabulary rather than
|
|
607
|
+
* by matching a sentence.
|
|
160
608
|
*/
|
|
161
|
-
function relationshipSpec(relationship) {
|
|
609
|
+
function relationshipSpec(relationship, subject, selected, words, refused) {
|
|
162
610
|
return element('li', {
|
|
163
611
|
class: 'ig-relationship',
|
|
164
612
|
'data-edge': relationship.field,
|
|
165
613
|
// Omitted rather than falsified when the subject is not an issue: an edge
|
|
166
614
|
// selection has no "my end", and `data-direction=""` would claim one.
|
|
167
615
|
'data-direction': relationship.direction ?? undefined,
|
|
616
|
+
'data-ig-code': refused,
|
|
168
617
|
}, [
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
618
|
+
relationshipHead(relationship, subject),
|
|
619
|
+
refused === undefined ? null : refusalReason(refused, words),
|
|
620
|
+
rowSlot(relationship, selected, words),
|
|
621
|
+
// A PEER OF THE SLOT RATHER THAN A FOURTH OCCUPANT OF IT. The slot's
|
|
622
|
+
// three occupants are exclusive because two are statements and one is a
|
|
623
|
+
// destructive control, and a row is only ever one of those things; the
|
|
624
|
+
// flip is neither, and it coexists with the `selected` marker on the one
|
|
625
|
+
// row that draws both. Last, because frame 17b ends the statement's line
|
|
626
|
+
// with it.
|
|
627
|
+
flipControl(relationship, selected, words),
|
|
628
|
+
]);
|
|
629
|
+
}
|
|
630
|
+
/**
|
|
631
|
+
* A refused relationship, drawn where the reader was building it.
|
|
632
|
+
*
|
|
633
|
+
* §17b's rule is that a refusal is shown IN PLACE and never snapped back, and
|
|
634
|
+
* until this the package had no surface that said so: the store marked the edge
|
|
635
|
+
* `invalid`, the canvas drew a ghost line, and the panel the reader had just
|
|
636
|
+
* used listed nothing about it. `would-cycle` is the case that matters — it is
|
|
637
|
+
* the one refusal this package family cannot detect for itself, so it arrives
|
|
638
|
+
* only after the reader has committed to the relationship.
|
|
639
|
+
*
|
|
640
|
+
* IT REPLACES THE ROW ONLY WHEN THERE IS NO RELATIONSHIP UNDER IT, and that
|
|
641
|
+
* qualification is the correction to an earlier "a refused edge is not a
|
|
642
|
+
* relationship: it is an edit that did not happen". Three of the store's codes
|
|
643
|
+
* refuse an edit ABOUT AN EDGE THAT EXISTS — `duplicate-edge`, `unchanged-kind`
|
|
644
|
+
* and `symmetric-edge` all mark a landed edge — so replacing the row for those
|
|
645
|
+
* deleted a real relationship from the panel, and with it the only control that
|
|
646
|
+
* could remove the edge the reader had just been told about.
|
|
647
|
+
* For an edge that genuinely does not exist the argument stands unchanged:
|
|
648
|
+
* listing it as an ordinary row would assert a relationship the document does
|
|
649
|
+
* not have, with a `select-edge` and a `✕` on it offering to remove something
|
|
650
|
+
* that was never added. {@link WorkspaceRefusal.phantom} is the question, and
|
|
651
|
+
* {@link relationshipEntries} is where it is asked. Either way the reader's
|
|
652
|
+
* POSITION is kept, because that is where they were working.
|
|
653
|
+
*
|
|
654
|
+
* THE SHAPE IS `scale/render.ts`'s CAPSULE — an `li` carrying a button and a
|
|
655
|
+
* stated reason — and the shape is all that is copied. That capsule's English
|
|
656
|
+
* is written inline in this package, which is a debt rather than a precedent;
|
|
657
|
+
* the reason here is the host's, keyed off the store's own code.
|
|
658
|
+
*
|
|
659
|
+
* IT NAMES THE EDGE AND OPERATES NOTHING. The capsule reused the row's head,
|
|
660
|
+
* which is a `select-edge` control — so a phantom that survived normalization
|
|
661
|
+
* (a `would-cycle` refusal reaches the panel as an edge, because the store
|
|
662
|
+
* draws a refused create precisely so a surface can mark it) published a live
|
|
663
|
+
* selector for an edge the LANDED document does not carry. Under
|
|
664
|
+
* `mountWorkspace` that click selected the phantom and the next render's
|
|
665
|
+
* `reconcileHost` dropped the selection again, so the one control on the
|
|
666
|
+
* capsule closed the inspector rather than inspecting anything. The
|
|
667
|
+
* description is drawn bare instead, on the rule
|
|
668
|
+
* {@link WorkspaceRefusal.phantom} already states in as many words: there is no
|
|
669
|
+
* relationship to operate. Its remove control was withheld for the same reason
|
|
670
|
+
* from the start; this is the half that was missed.
|
|
671
|
+
*
|
|
672
|
+
* `data-ig-code` IS THE STORE'S VOCABULARY VERBATIM, so a host that wants to
|
|
673
|
+
* style or count refusals reads the code rather than matching a sentence.
|
|
674
|
+
*/
|
|
675
|
+
function refusalCapsule(relationship, code, subject, words) {
|
|
676
|
+
return element('li', {
|
|
677
|
+
class: 'ig-relationship-refused',
|
|
678
|
+
'data-ig-code': code,
|
|
679
|
+
'data-edge': relationship?.field,
|
|
680
|
+
}, [
|
|
681
|
+
// THE RELATIONSHIP IS NAMED WHEN IT CAN BE, AND THE CAPSULE STANDS WHEN
|
|
682
|
+
// IT CANNOT. The refused edge normally reaches the document as the
|
|
683
|
+
// store's phantom — `edgeChangeFor` draws a refused create so the refusal
|
|
684
|
+
// has something to be about — but a host that projects only landed edges
|
|
685
|
+
// gives us a code and nothing to word, and a refusal the reader cannot
|
|
686
|
+
// see is worse than one drawn without its subject.
|
|
687
|
+
relationship === undefined
|
|
688
|
+
? null
|
|
689
|
+
: element('span', { class: 'ig-relationship-name' }, relationshipDescription(relationship, subject)),
|
|
690
|
+
refusalReason(code, words),
|
|
691
|
+
]);
|
|
692
|
+
}
|
|
693
|
+
/**
|
|
694
|
+
* The numbered kind list, its heading, and the control that withdraws from it.
|
|
695
|
+
*
|
|
696
|
+
* DRAWN IN BOTH FRAMES, AND THIS FUNCTION ANSWERS TO BOTH. §17b fixes the
|
|
697
|
+
* keyboard loop the digits belong to; §17a draws the same list in the inspector
|
|
698
|
+
* with a heading over it and the digit as a chip at each row's end. Those two
|
|
699
|
+
* placements are §17a's, taken here under autnmy/issuegraph#147; the flip, the
|
|
700
|
+
* target search and delete/retype remain §17b's and are not drawn here.
|
|
701
|
+
*
|
|
702
|
+
* THE DIGITS ARE `create/keys.ts`'s OWN, through `KIND_KEYS`. Drawing
|
|
703
|
+
* `index + 1` over the kind order here would be a second construction of the
|
|
704
|
+
* keyboard's table — which is exactly what the mount's chooser did, agreeing
|
|
705
|
+
* with the key map only because both walked `EDGE_FIELDS`, with nothing
|
|
706
|
+
* pinning them to each other.
|
|
707
|
+
*
|
|
708
|
+
* THE WORD IS THE EDGE VOCABULARY'S, so an entry in this list and the row it
|
|
709
|
+
* will become read the same. A `kinds` record on `WorkspaceWords` would be a
|
|
710
|
+
* third spelling of a wording layer 1 already publishes twice over.
|
|
711
|
+
*
|
|
712
|
+
* IT IS NOT DRAWN AT THE TARGET STEP. `renderWorkspace` is markup-only and the
|
|
713
|
+
* target search is a live input over the reader's query, so that step stays the
|
|
714
|
+
* shell's. A host using this renderer without `mountWorkspace` can read,
|
|
715
|
+
* remove and BEGIN a relationship, and must supply its own target picker.
|
|
716
|
+
*
|
|
717
|
+
* `source` NAMES THE DRAFT WHEN IT IS NOT THIS PANEL'S OWN, and is `null` when
|
|
718
|
+
* it is — see {@link createStep} for why the list is drawn either way. Written
|
|
719
|
+
* as the reference beside the host's phrase, which is `whyRankSpec`'s own shape
|
|
720
|
+
* for the same problem: the package names the issue and the host writes the
|
|
721
|
+
* English around it.
|
|
722
|
+
*/
|
|
723
|
+
function kindListSpec(words, source) {
|
|
724
|
+
return element('div', { class: 'ig-inspector-add' }, [
|
|
725
|
+
// §17a HEADS THE KINDS, in the caps treatment the stylesheet already
|
|
726
|
+
// declares for three headings and until now drew only two of. Without it
|
|
727
|
+
// the five numbered rows run straight on from the relationship rows above,
|
|
728
|
+
// and nothing on screen says the numbers choose a kind rather than name
|
|
729
|
+
// more relationships.
|
|
730
|
+
element('h3', { class: 'ig-inspector-heading' }, [words.addRelationshipHeading]),
|
|
731
|
+
source === null
|
|
732
|
+
? null
|
|
733
|
+
: element('p', { class: 'ig-inspector-source' }, [
|
|
734
|
+
`${words.relatingFrom} `,
|
|
735
|
+
element('span', { class: 'ig-id' }, [source]),
|
|
736
|
+
]),
|
|
737
|
+
element('ul', { class: 'ig-kind-list' }, KIND_KEYS.map((entry) => {
|
|
738
|
+
const treatment = treatmentFor(entry.edgeKind);
|
|
739
|
+
return element('li', {}, [
|
|
740
|
+
element('button', {
|
|
741
|
+
type: 'button',
|
|
742
|
+
class: 'ig-kind-option',
|
|
743
|
+
'data-ig-command': 'kind',
|
|
744
|
+
'data-ig-value': entry.edgeKind,
|
|
745
|
+
'data-edge': entry.edgeKind,
|
|
746
|
+
}, [
|
|
747
|
+
// §17a PUTS THE DIGIT LAST, IN A CHIP. The kind is what the reader
|
|
748
|
+
// is choosing between, so it comes first in the reading order and
|
|
749
|
+
// the key follows it — "blocked-by, 1" rather than "1,
|
|
750
|
+
// blocked-by". The chip is what carries the "this is a key"
|
|
751
|
+
// signal a left-hand column used to carry by alignment alone.
|
|
752
|
+
...glyphAndLabel(treatment.glyph, treatment.label),
|
|
753
|
+
element('span', { class: 'ig-kind-digit' }, [entry.key]),
|
|
754
|
+
]),
|
|
755
|
+
]);
|
|
756
|
+
})),
|
|
757
|
+
element('button', { type: 'button', class: 'ig-inspector-cancel', 'data-ig-command': 'cancel' }, [words.cancel]),
|
|
758
|
+
]);
|
|
759
|
+
}
|
|
760
|
+
/**
|
|
761
|
+
* §17a's "why rank" block: the heading, and one sentence explaining the
|
|
762
|
+
* position.
|
|
763
|
+
*
|
|
764
|
+
* THE SYMPTOM THIS PACKAGE WAS FILED FOR. `#122` names it exactly: the frame
|
|
765
|
+
* gives the inspector a `WHY RANK 2` heading and a sentence, "the shipped
|
|
766
|
+
* inspector shows raw tokens and no sentence, even though the package already
|
|
767
|
+
* carries the function that composes provenance". It did, and this composes it.
|
|
768
|
+
*
|
|
769
|
+
* THE PROVENANCE CLAUSE IS LAYER 1's, AND THAT IS THE POINT. `provenanceClause`
|
|
770
|
+
* is the same function the §16 rail row's provenance line is built from, so the
|
|
771
|
+
* row and the panel state one fact one way. Switching on `RankProvenance` here
|
|
772
|
+
* would be a second wording, free to drift from the row's the moment either is
|
|
773
|
+
* edited — which is what the issue means by "composed from the existing
|
|
774
|
+
* provenance model rather than a second implementation of it".
|
|
775
|
+
*
|
|
776
|
+
* A HOLD'S REASON IS RENDERED VERBATIM, exactly as layer 1's `holdLine` renders
|
|
777
|
+
* it: `ViewerHold.reason` is host-authored, and rewording it here would put
|
|
778
|
+
* this package in the business of explaining a hold whose vocabulary belongs to
|
|
779
|
+
* the reader that produced it.
|
|
780
|
+
*
|
|
781
|
+
* A RANK AND A HOLD ARE NO LONGER EXCLUSIVE, which is what frame 17a always
|
|
782
|
+
* asked for: `#512` at rank 2 *and* "Held until #488 closes". `RULINGS.md` §1
|
|
783
|
+
* (Claude Design, 2026-09-20) settled it and `@issuegraph/derive` now ranks a
|
|
784
|
+
* held slot whose blocker is inside the previewed order, so the state the
|
|
785
|
+
* frame draws is representable and PR #126's em dash applies only to the other
|
|
786
|
+
* arm — a slot whose blocker is outside the order, which still has no number.
|
|
787
|
+
*
|
|
788
|
+
* SO THE TWO CHANNELS ARE READ FROM DIFFERENT FIELDS, AND NEITHER IS `rank`.
|
|
789
|
+
* `data-held` is READINESS — `ready` is the slot's own verdict and the only
|
|
790
|
+
* field that answers "may this start", exactly as layer 1's `slotPosition`
|
|
791
|
+
* now reads it for the row's accessible name. The HEADING is the rank: a
|
|
792
|
+
* number when the order stated a position, and otherwise a word for what is
|
|
793
|
+
* missing.
|
|
794
|
+
*
|
|
795
|
+
* THREE HEADINGS, NOT TWO, because `rank === null` and "held" came apart. A
|
|
796
|
+
* placed slot names its number, held or not. An unplaced HELD slot says so —
|
|
797
|
+
* PR #126's em dash, in words. An unplaced READY slot is the case the old
|
|
798
|
+
* one-field expression got flatly wrong: it was told "why held" while having
|
|
799
|
+
* no hold to show. `@issuegraph/derive` cannot emit one, but `ViewerDocument`
|
|
800
|
+
* is a public port and a host composes one by hand, so it is reachable from
|
|
801
|
+
* outside this repo — and leaving the inference here would ship the defect
|
|
802
|
+
* this change exists to retire in the package next door. It reuses the rank
|
|
803
|
+
* word with the rail's own em dash rather than taking a new `WorkspaceWords`
|
|
804
|
+
* entry: the fact is "there is no number", which is what the rank cell says
|
|
805
|
+
* with the same character, and a required word would break every host.
|
|
806
|
+
*/
|
|
807
|
+
function whyRankSpec(why, words, holds) {
|
|
808
|
+
const held = !why.ready;
|
|
809
|
+
return element('div', { class: 'ig-why-rank', 'data-held': held ? 'true' : 'false' }, [
|
|
810
|
+
element('h3', { class: 'ig-why-rank-heading' }, [
|
|
811
|
+
why.rank !== null
|
|
812
|
+
? `${words.whyRank} ${String(why.rank)}`
|
|
813
|
+
: held
|
|
814
|
+
? words.whyHeld
|
|
815
|
+
: `${words.whyRank} —`,
|
|
816
|
+
]),
|
|
817
|
+
element('p', { class: 'ig-why-rank-sentence' }, [
|
|
818
|
+
provenanceClause(why.provenance),
|
|
819
|
+
why.unitPartners.length === 0
|
|
820
|
+
? null
|
|
821
|
+
: element('span', { class: 'ig-why-rank-unit' }, [
|
|
822
|
+
`${words.workedAsOneUnit} `,
|
|
823
|
+
element('span', { class: 'ig-id' }, [why.unitPartners.join(', ')]),
|
|
824
|
+
]),
|
|
178
825
|
]),
|
|
826
|
+
// THE HOLDS BELONG TO THE EXPLANATION, so they live inside this block
|
|
827
|
+
// rather than beside it. They were briefly stated twice — once worded into
|
|
828
|
+
// this sentence and once in the list below it — which is the panel telling
|
|
829
|
+
// a reader the same cause in two voices.
|
|
830
|
+
//
|
|
831
|
+
// The LIST is what survived, because it is the half that carries the
|
|
832
|
+
// holder as a control: `holdRow` publishes `select-issue` on the blocker,
|
|
833
|
+
// under rules about when that control is withheld which a sentence could
|
|
834
|
+
// not express. A reason inline would have discarded them.
|
|
835
|
+
holds.length === 0 ? null : element('ul', { class: 'ig-inspector-holds' }, holds),
|
|
179
836
|
]);
|
|
180
837
|
}
|
|
181
|
-
|
|
838
|
+
/**
|
|
839
|
+
* One header member, rendered, or the empty string.
|
|
840
|
+
*
|
|
841
|
+
* The zone is assembled by concatenating already-rendered markup — the audit
|
|
842
|
+
* header arrives that way and owns its own `aria-pressed` — so a member that is
|
|
843
|
+
* a spec has to be rendered before it joins them, and an absent member has to
|
|
844
|
+
* join as nothing rather than as a gap.
|
|
845
|
+
*/
|
|
846
|
+
function specMarkup(spec) {
|
|
847
|
+
return spec === null ? '' : renderMarkup(spec);
|
|
848
|
+
}
|
|
849
|
+
/**
|
|
850
|
+
* §17a's `312 open · 64 encoded` — how much of the backlog carries relationships.
|
|
851
|
+
*
|
|
852
|
+
* OMITTED WHEN THE HOST STATED NO NUMBERS, never defaulted, on this zone's
|
|
853
|
+
* standing rule: a zero the reader can trust and a zero nobody computed are
|
|
854
|
+
* different facts. `Adoption.counts` is optional precisely so a host that
|
|
855
|
+
* cannot count says nothing.
|
|
856
|
+
*
|
|
857
|
+
* THE TOTAL LEADS, WHICH IS THE FRAME'S ORDER AND NOT LAYER 1's. §16a words the
|
|
858
|
+
* same pair as `64 of 312 declare relationships`, subject first. §17a reads the
|
|
859
|
+
* backlog first and the encoded subset second, and the two surfaces are allowed
|
|
860
|
+
* to differ: this is the header of a grooming workspace, where "how much is
|
|
861
|
+
* left to encode" is the question, and that one is answered by the pair read in
|
|
862
|
+
* this direction.
|
|
863
|
+
*/
|
|
864
|
+
function adoptionCountsSpec(adoption, words) {
|
|
865
|
+
const counts = adoption?.counts;
|
|
866
|
+
if (counts === undefined)
|
|
867
|
+
return null;
|
|
868
|
+
return element('span', { class: 'ig-workspace-counts' }, [
|
|
869
|
+
element('span', { class: 'ig-id' }, [String(counts.total)]),
|
|
870
|
+
` ${words.open} · `,
|
|
871
|
+
element('span', { class: 'ig-id' }, [String(counts.declaring)]),
|
|
872
|
+
` ${words.encoded}`,
|
|
873
|
+
]);
|
|
874
|
+
}
|
|
875
|
+
/**
|
|
876
|
+
* §17a's `as of 14:32 ↻` — how fresh the read is, and the way to take another.
|
|
877
|
+
*
|
|
878
|
+
* THE CONTROL IS PUBLISHED AND NOT WIRED, the shape layer 1 already gives it:
|
|
879
|
+
* refreshing a mirror is fetching, which no layer here does, so the button
|
|
880
|
+
* carries `refresh` on the command attribute and the host that can listens. A
|
|
881
|
+
* real `button`, so the mount's own click and Enter handling treat it as a
|
|
882
|
+
* control rather than as a row.
|
|
883
|
+
*
|
|
884
|
+
* DRAWN ONLY WHEN THE HOST SUPPLIED A WORD FOR IT, again as layer 1 does: a
|
|
885
|
+
* control nobody named is one nobody wired.
|
|
886
|
+
*
|
|
887
|
+
* STALENESS IS AN ATTRIBUTE, NOT A WORD. See {@link WorkspaceWords.asOf}.
|
|
888
|
+
*/
|
|
889
|
+
function freshnessSpec(freshness, words) {
|
|
890
|
+
if (freshness === undefined)
|
|
891
|
+
return null;
|
|
892
|
+
return element('span', {
|
|
893
|
+
class: 'ig-workspace-freshness',
|
|
894
|
+
'data-stale': freshness.stale === true ? 'true' : 'false',
|
|
895
|
+
}, [
|
|
896
|
+
`${words.asOf} `,
|
|
897
|
+
element('span', { class: 'ig-id' }, [freshness.asOf]),
|
|
898
|
+
freshness.age === undefined || freshness.age === '' ? null : ` · ${freshness.age}`,
|
|
899
|
+
freshness.refresh === undefined || freshness.refresh === ''
|
|
900
|
+
? null
|
|
901
|
+
: element('button', { type: 'button', class: 'ig-workspace-refresh', 'data-ig-command': 'refresh' }, [freshness.refresh]),
|
|
902
|
+
]);
|
|
903
|
+
}
|
|
904
|
+
/**
|
|
905
|
+
* The host facts the RAIL is given: everything except the two §17a's header now
|
|
906
|
+
* draws.
|
|
907
|
+
*
|
|
908
|
+
* THIS IS THE WHOLE MECHANISM OF #135's MOVE, and it is a subtraction rather
|
|
909
|
+
* than a switch. Layer 1 draws a fact when it is given one and omits it when it
|
|
910
|
+
* is not — `hostHeader` returns `null` outright once nothing is left for it —
|
|
911
|
+
* so the zone that receives a fact is the zone that states it, and no option
|
|
912
|
+
* has to be invented to say so. `SceneOptions.chrome` is the switch that could
|
|
913
|
+
* not do this: it would also take the condition notice, the adoption NOTE and
|
|
914
|
+
* `data-ig-condition`, none of which §17a's header replaces.
|
|
915
|
+
*
|
|
916
|
+
* `adoption.note` SURVIVES WHILE `adoption.counts` DOES NOT, which is why this
|
|
917
|
+
* cannot drop the field whole. They are two independent members of one optional
|
|
918
|
+
* object — §16h's own note says the design draws the count where adoption is
|
|
919
|
+
* partial and the line where it is absent — and the line is the rail's panel
|
|
920
|
+
* footer, a place §17a's header is not.
|
|
921
|
+
*
|
|
922
|
+
* `running` SURVIVES BY BEING LEFT ALONE. The NOW row is not part of layer 1's
|
|
923
|
+
* panel header and never was gated with it; see {@link headerMarkup} for the
|
|
924
|
+
* premise that said otherwise.
|
|
925
|
+
*/
|
|
926
|
+
function railHostFacts(host) {
|
|
927
|
+
const { freshness, adoption, ...rest } = host;
|
|
928
|
+
return adoption?.note === undefined ? rest : { ...rest, adoption: { note: adoption.note } };
|
|
929
|
+
}
|
|
930
|
+
/**
|
|
931
|
+
* §17a's workspace header: what backlog this is, how much of it is encoded,
|
|
932
|
+
* what is wrong with it, how fresh the read is, and the way into a first pass.
|
|
933
|
+
*
|
|
934
|
+
* IT IS UNCONDITIONAL NOW, AND THAT IS THE FIX. The zone used to be emitted
|
|
935
|
+
* only when an audit overlay existed — `overlay === null ? '' : zone('header',
|
|
936
|
+
* …)` — so the header WAS the audit header, and a workspace with no audit
|
|
937
|
+
* input had no header at all. §17a's header carries five facts and the audit
|
|
938
|
+
* count is one of them.
|
|
939
|
+
*
|
|
940
|
+
* WHAT IT DRAWS IS WHAT THE RAIL DOES NOT, and #135 settled the split. §17a
|
|
941
|
+
* hoists the identity, the adoption counts and the freshness stamp into a
|
|
942
|
+
* header spanning all three zones, so this carries five facts: what backlog
|
|
943
|
+
* this is, how much of it is encoded, what the audit found, how fresh the read
|
|
944
|
+
* is, and the way into a first pass.
|
|
945
|
+
*
|
|
946
|
+
* THE RULING, RECORDED HERE BECAUSE THIS IS THE SURFACE IT IS ABOUT:
|
|
947
|
+
*
|
|
948
|
+
* - `Adoption.counts` and the freshness stamp (with its refresh control) are
|
|
949
|
+
* drawn HERE and nowhere else. §17a draws both, and layer 1's panel header
|
|
950
|
+
* drew both until this change.
|
|
951
|
+
* - `OrderCounts` and `concurrencyCap` stay in the RAIL. They are a different
|
|
952
|
+
* pair from the adoption one — how the order splits, not how much of the
|
|
953
|
+
* backlog is encoded — and §17a's header does not draw them. The rail is
|
|
954
|
+
* the order, so the order's own tally belongs to its panel header.
|
|
955
|
+
* - The running-job NOW row stays in the rail, unchanged.
|
|
956
|
+
*
|
|
957
|
+
* ⚠️ #135's PREMISE WAS WRONG ABOUT THE NOW ROW, and correcting it is what made
|
|
958
|
+
* the ruling small. The issue records that `SceneOptions.chrome` takes the
|
|
959
|
+
* stamp, the refresh control, the count chips AND the NOW row together, so that
|
|
960
|
+
* flipping it would drop a row §17a's rail does not replace. It does not:
|
|
961
|
+
* `nowRows` is called unconditionally by all three projection roots, beside the
|
|
962
|
+
* `chrome`-gated header, so no setting of that switch has ever reached it.
|
|
963
|
+
*
|
|
964
|
+
* SO THE SWITCH IS NOT THE LEVER, and it is deliberately left alone. `chrome:
|
|
965
|
+
* false` also takes the condition notice, the adoption NOTE and the panel's
|
|
966
|
+
* `data-ig-condition` — three things §17a's header does not replace — so the
|
|
967
|
+
* all-or-nothing flip could only be made by dropping them. What moves a fact
|
|
968
|
+
* instead is WHICH ZONE IS GIVEN IT: {@link railHostFacts} hands the rail the
|
|
969
|
+
* host facts minus the two this header now draws, and layer 1 then omits them
|
|
970
|
+
* on its own existing "omitted when absent" rule rather than on a new option.
|
|
971
|
+
*
|
|
972
|
+
* EVERY FACT COMES FROM THE PORT THAT ALREADY CARRIES IT. `ViewerDocument.host`
|
|
973
|
+
* is commented "THE HOST-FACTS PORT", and #127 rejected deriving its numbers in
|
|
974
|
+
* terms: "Reporting is not deriving — the host's number is still what gets
|
|
975
|
+
* drawn." Counting the document here would give the header a second answer,
|
|
976
|
+
* free to disagree with the rail beside it.
|
|
977
|
+
*
|
|
978
|
+
* OMITTED WHEN ABSENT, NEVER DEFAULTED, for the reason the audit count already
|
|
979
|
+
* gives: a zero the reader can trust and a zero nobody computed are different
|
|
980
|
+
* facts, and a header that invents either is worse than one that says less.
|
|
981
|
+
*/
|
|
982
|
+
function headerMarkup(host, auditHeader, words) {
|
|
983
|
+
// A STRING, not a spec, because one member of this zone already is one: the
|
|
984
|
+
// audit header comes from its own leaf rendered, and it owns the filter
|
|
985
|
+
// toggle's `aria-pressed` and the count's own omitted-when-absent rule. The
|
|
986
|
+
// file's standing idiom applies — everything carrying a dynamic value goes
|
|
987
|
+
// through `renderMarkup`, and only already-rendered markup is concatenated.
|
|
988
|
+
const parts = [
|
|
989
|
+
host?.identity === undefined || host.identity === ''
|
|
990
|
+
? ''
|
|
991
|
+
: renderMarkup(element('span', { class: 'ig-workspace-identity' }, [host.identity])),
|
|
992
|
+
specMarkup(adoptionCountsSpec(host?.adoption, words)),
|
|
993
|
+
auditHeader,
|
|
994
|
+
specMarkup(freshnessSpec(host?.freshness, words)),
|
|
995
|
+
host?.firstPass === undefined || host.firstPass === ''
|
|
996
|
+
? ''
|
|
997
|
+
: renderMarkup(element('button', { type: 'button', class: 'ig-workspace-firstpass', 'data-ig-command': 'first-pass' }, [host.firstPass])),
|
|
998
|
+
];
|
|
999
|
+
return `<div class="ig-workspace-header">${parts.join('')}</div>`;
|
|
1000
|
+
}
|
|
1001
|
+
function panelScope(view) {
|
|
1002
|
+
const { subject } = view;
|
|
1003
|
+
switch (subject.kind) {
|
|
1004
|
+
case 'none':
|
|
1005
|
+
return { kind: 'none' };
|
|
1006
|
+
case 'issue':
|
|
1007
|
+
return {
|
|
1008
|
+
kind: 'issue',
|
|
1009
|
+
keys: new Set([subject.issue.key, ...(subject.whyRank?.unitPartners ?? [])]),
|
|
1010
|
+
};
|
|
1011
|
+
case 'edge':
|
|
1012
|
+
return { kind: 'edge', edgeId: subject.relationship.edgeId };
|
|
1013
|
+
}
|
|
1014
|
+
}
|
|
1015
|
+
/** Whether this panel is the one that states this record. */
|
|
1016
|
+
function statedHere(scope, record) {
|
|
1017
|
+
switch (scope.kind) {
|
|
1018
|
+
case 'none':
|
|
1019
|
+
return false;
|
|
1020
|
+
case 'issue':
|
|
1021
|
+
return record.carrier !== null && scope.keys.has(record.carrier);
|
|
1022
|
+
case 'edge':
|
|
1023
|
+
return record.edgeId === scope.edgeId;
|
|
1024
|
+
}
|
|
1025
|
+
}
|
|
1026
|
+
/**
|
|
1027
|
+
* The three affordances, mapped to the commands that carry them out.
|
|
1028
|
+
*
|
|
1029
|
+
* `satisfies Record<OverlayAffordance, string>` IS THE POINT. A fourth
|
|
1030
|
+
* affordance is a compile error here rather than a button that renders and does
|
|
1031
|
+
* nothing, and the vocabulary it is total over cannot spell `merge` — §17b's
|
|
1032
|
+
* one prohibition, encoded in a type rather than left to a reviewer.
|
|
1033
|
+
*/
|
|
1034
|
+
const AFFORDANCE_COMMANDS = Object.freeze({
|
|
1035
|
+
'view-diff': 'view-diff',
|
|
1036
|
+
retry: 'retry',
|
|
1037
|
+
'discard-mine': 'discard',
|
|
1038
|
+
});
|
|
1039
|
+
/**
|
|
1040
|
+
* The region a card's `view-diff` control discloses.
|
|
1041
|
+
*
|
|
1042
|
+
* Derived from the write's own identity so `aria-controls` can name it, and
|
|
1043
|
+
* scoped by a prefix because a page may mount more than one workspace.
|
|
1044
|
+
*/
|
|
1045
|
+
function diffRegionId(mutationId) {
|
|
1046
|
+
return `ig-recovery-diff-${mutationId}`;
|
|
1047
|
+
}
|
|
1048
|
+
/** One edge in a difference, named the way the relationship rows name one. */
|
|
1049
|
+
function diffEdgeSpec(edge) {
|
|
1050
|
+
return element('li', { class: 'ig-recovery-edge', 'data-edge': edge.kind }, [
|
|
1051
|
+
// OUTGOING, because a diff row names the edge as the document stores it —
|
|
1052
|
+
// `from` then `to` — rather than from the point of view of a subject. There
|
|
1053
|
+
// is no subject here: the list is about the document, not about one issue.
|
|
1054
|
+
element('span', { class: 'ig-relationship-kind' }, [labelFrom(treatmentFor(edge.kind), true)]),
|
|
1055
|
+
element('span', { class: 'ig-id' }, [edge.from]),
|
|
1056
|
+
element('span', { class: 'ig-id' }, [edge.to]),
|
|
1057
|
+
]);
|
|
1058
|
+
}
|
|
1059
|
+
/** One side of a difference, or nothing when that side is empty. */
|
|
1060
|
+
function diffSideSpec(heading, edges) {
|
|
1061
|
+
return edges.length === 0
|
|
1062
|
+
? null
|
|
1063
|
+
: element('div', { class: 'ig-recovery-side' }, [
|
|
1064
|
+
element('h5', { class: 'ig-recovery-side-name' }, [heading]),
|
|
1065
|
+
element('ul', { class: 'ig-recovery-edges' }, edges.map(diffEdgeSpec)),
|
|
1066
|
+
]);
|
|
1067
|
+
}
|
|
1068
|
+
/**
|
|
1069
|
+
* The held difference, drawn.
|
|
1070
|
+
*
|
|
1071
|
+
* NOTHING HERE COMBINES THE TWO SIDES. They are separate lists under separate
|
|
1072
|
+
* headings, with no control that takes both — which is what "never auto-merge"
|
|
1073
|
+
* looks like in markup rather than in a comment.
|
|
1074
|
+
*/
|
|
1075
|
+
function diffSpec(diff, words, id) {
|
|
1076
|
+
if (diffIsEmpty(diff)) {
|
|
1077
|
+
return element('p', { class: 'ig-recovery-diff-empty', id }, [words.diffEmpty]);
|
|
1078
|
+
}
|
|
1079
|
+
return element('div', { class: 'ig-recovery-diff', id }, [
|
|
1080
|
+
diffSideSpec(words.upstreamOnly, diff.upstreamOnly),
|
|
1081
|
+
diffSideSpec(words.mineOnly, diff.mineOnly),
|
|
1082
|
+
diffSideSpec(words.mineRemoved, diff.mineRemoved),
|
|
1083
|
+
diff.carrierReversed.length === 0
|
|
1084
|
+
? null
|
|
1085
|
+
: element('div', { class: 'ig-recovery-side' }, [
|
|
1086
|
+
element('h5', { class: 'ig-recovery-side-name' }, [words.carrierReversed]),
|
|
1087
|
+
element('ul', { class: 'ig-recovery-edges' },
|
|
1088
|
+
// BOTH DIRECTIONS, SIDE BY SIDE. Which end declares a symmetric
|
|
1089
|
+
// relationship is the fact `edgeId` discards, so stating one
|
|
1090
|
+
// direction here would be this package picking a winner.
|
|
1091
|
+
diff.carrierReversed.flatMap((change) => [
|
|
1092
|
+
diffEdgeSpec(change.mine),
|
|
1093
|
+
diffEdgeSpec(change.upstream),
|
|
1094
|
+
])),
|
|
1095
|
+
]),
|
|
1096
|
+
diff.issuesChanged.length === 0
|
|
1097
|
+
? null
|
|
1098
|
+
: element('div', { class: 'ig-recovery-side' }, [
|
|
1099
|
+
element('h5', { class: 'ig-recovery-side-name' }, [words.issuesChanged]),
|
|
1100
|
+
element('ul', { class: 'ig-recovery-issues' }, diff.issuesChanged.map((change) => element('li', { class: 'ig-recovery-issue' }, [
|
|
1101
|
+
element('span', { class: 'ig-id' }, [change.ref]),
|
|
1102
|
+
// BOTH TITLES, NEVER ONE RECONCILED ONE. A single line here
|
|
1103
|
+
// would be this package choosing a winner, which is the whole
|
|
1104
|
+
// of what §17b forbids.
|
|
1105
|
+
element('span', { class: 'ig-recovery-was' }, [change.mine?.title ?? '']),
|
|
1106
|
+
element('span', { class: 'ig-recovery-now' }, [change.upstream?.title ?? '']),
|
|
1107
|
+
]))),
|
|
1108
|
+
]),
|
|
1109
|
+
]);
|
|
1110
|
+
}
|
|
1111
|
+
/**
|
|
1112
|
+
* The label for one affordance on one card.
|
|
1113
|
+
*
|
|
1114
|
+
* KEYED OFF THE CARD'S KIND, not off the affordance alone: `retry` is two
|
|
1115
|
+
* different operations and §17b gives them two different names.
|
|
1116
|
+
*/
|
|
1117
|
+
function affordanceWord(affordance, kind, words) {
|
|
1118
|
+
switch (affordance) {
|
|
1119
|
+
case 'view-diff':
|
|
1120
|
+
return words.viewDiff;
|
|
1121
|
+
case 'retry':
|
|
1122
|
+
return kind === 'conflict' ? words.retryOnLatest : words.retry;
|
|
1123
|
+
case 'discard-mine':
|
|
1124
|
+
return words.discardMine;
|
|
1125
|
+
}
|
|
1126
|
+
}
|
|
1127
|
+
/**
|
|
1128
|
+
* One unsettled write the reader can act on.
|
|
1129
|
+
*
|
|
1130
|
+
* THE BUTTONS ARE THE GRAMMAR TABLE'S, NOT A LIST WRITTEN HERE.
|
|
1131
|
+
* `OVERLAY_TREATMENTS[kind].affordances` is what the line already draws its
|
|
1132
|
+
* state from, so the card and the edge cannot come to offer different things —
|
|
1133
|
+
* and because `OverlayAffordance` has no `merge` member, no entry in that table
|
|
1134
|
+
* can produce a merge button. That is what this construction buys, and it is
|
|
1135
|
+
* worth being exact about what it does NOT buy: the behavioural guarantee that
|
|
1136
|
+
* nothing merges lives one layer down, in a store that exposes no merge call
|
|
1137
|
+
* and a `HostEffect` with no merge arm.
|
|
1138
|
+
*
|
|
1139
|
+
* EVERY BUTTON CARRIES `data-ig-target`. The mount turns an attribute-borne
|
|
1140
|
+
* command into `{ kind: 'control', name, target }`, and the reducer's `retry`
|
|
1141
|
+
* and `discard` arms return NO EFFECT when `target` is undefined. Without the
|
|
1142
|
+
* attribute all three controls render, read correctly, and do nothing — while
|
|
1143
|
+
* every assertion about the command names stays green.
|
|
1144
|
+
*/
|
|
1145
|
+
function recoveryCard(recovery, words, diffOpen, keys) {
|
|
1146
|
+
const treatment = OVERLAY_TREATMENTS[recovery.kind];
|
|
1147
|
+
const open = recovery.kind === 'conflict' && diffOpen === recovery.mutationId;
|
|
1148
|
+
return element('li', {
|
|
1149
|
+
class: 'ig-recovery',
|
|
1150
|
+
// THE STORE'S OWN VOCABULARY, so a host styles or counts these off the
|
|
1151
|
+
// state rather than by matching a sentence — `data-ig-code`'s rule.
|
|
1152
|
+
'data-ig-state': recovery.kind,
|
|
1153
|
+
}, [
|
|
1154
|
+
element('h4', { class: 'ig-recovery-name' }, [
|
|
1155
|
+
recovery.kind === 'conflict' ? words.conflict : words.failed,
|
|
1156
|
+
]),
|
|
1157
|
+
recovery.kind === 'failed'
|
|
1158
|
+
? element('p', { class: 'ig-recovery-reason' }, [recovery.reason])
|
|
1159
|
+
: null,
|
|
1160
|
+
// THE REFRESH ERROR SITS ABOVE THE CONTROLS, because it is the reason the
|
|
1161
|
+
// control the reader last pressed did nothing.
|
|
1162
|
+
recovery.kind === 'conflict' && recovery.refreshError !== null
|
|
1163
|
+
? element('p', { class: 'ig-recovery-refresh-error' }, [
|
|
1164
|
+
`${words.retryFailed} `,
|
|
1165
|
+
element('span', { class: 'ig-recovery-refresh-detail' }, [recovery.refreshError]),
|
|
1166
|
+
])
|
|
1167
|
+
: null,
|
|
1168
|
+
element('div', { class: 'ig-recovery-actions' }, treatment.affordances.map((affordance) => element('button', {
|
|
1169
|
+
type: 'button',
|
|
1170
|
+
class: 'ig-recovery-action',
|
|
1171
|
+
'data-ig-command': AFFORDANCE_COMMANDS[affordance],
|
|
1172
|
+
'data-ig-target': recovery.mutationId,
|
|
1173
|
+
// A DISCLOSURE, NOT A TOGGLE BUTTON. `view-diff` shows and hides
|
|
1174
|
+
// a region, so `aria-expanded` (with `aria-controls` naming it)
|
|
1175
|
+
// is the pattern; `aria-pressed` announces a two-state button and
|
|
1176
|
+
// tells a screen-reader user nothing about the region that
|
|
1177
|
+
// appeared. The other two act once and carry neither.
|
|
1178
|
+
'aria-expanded': affordance === 'view-diff' ? (open ? 'true' : 'false') : undefined,
|
|
1179
|
+
// ONLY WHILE THE REGION EXISTS. The difference is rendered only
|
|
1180
|
+
// when open, so naming it while shut pointed `aria-controls` at
|
|
1181
|
+
// no element at all — which the test for this control already
|
|
1182
|
+
// calls "worse than none at all", while checking only the open
|
|
1183
|
+
// case where it does resolve. `aria-expanded="false"` is
|
|
1184
|
+
// complete on its own; `aria-controls` is optional, and a
|
|
1185
|
+
// dangling IDREF is invisible on screen and total for a
|
|
1186
|
+
// screen-reader user. Found by the a11y baseline's
|
|
1187
|
+
// reference rule on its first run.
|
|
1188
|
+
'aria-controls': affordance === 'view-diff' && open ? diffRegionId(recovery.mutationId) : undefined,
|
|
1189
|
+
}, [affordanceWord(affordance, recovery.kind, words)]))),
|
|
1190
|
+
// NARROWED BY THE PANEL'S KEY SET, not by the carrier alone. A together
|
|
1191
|
+
// unit's lead speaks for its partners, so a single key would drop a
|
|
1192
|
+
// partner's conflicted edge from the one panel entitled to state it.
|
|
1193
|
+
// `null` keys is the unplaced region, which is nobody's unit: nothing is
|
|
1194
|
+
// narrowed away there.
|
|
1195
|
+
open && recovery.kind === 'conflict'
|
|
1196
|
+
? diffSpec(keys === null ? recovery.diff : diffWithin(recovery.diff, keys), words, diffRegionId(recovery.mutationId))
|
|
1197
|
+
: null,
|
|
1198
|
+
]);
|
|
1199
|
+
}
|
|
1200
|
+
/** The cards a panel states, or nothing when it states none. */
|
|
1201
|
+
function recoveryListSpec(recoveries, words, diffOpen, keys, heading) {
|
|
1202
|
+
if (recoveries.length === 0)
|
|
1203
|
+
return null;
|
|
1204
|
+
return element('div', { class: 'ig-recovery-region' }, [
|
|
1205
|
+
heading === null ? null : element('h3', { class: 'ig-inspector-heading' }, [heading]),
|
|
1206
|
+
element('ul', { class: 'ig-recovery-list' }, recoveries.map((recovery) => recoveryCard(recovery, words, diffOpen, keys))),
|
|
1207
|
+
]);
|
|
1208
|
+
}
|
|
1209
|
+
/**
|
|
1210
|
+
* The relationship list, with a refused edit drawn where the reader made it.
|
|
1211
|
+
*
|
|
1212
|
+
* ## A refusal about an edge that EXISTS keeps its row
|
|
1213
|
+
*
|
|
1214
|
+
* See {@link WorkspaceRefusal.phantom}. Four of the store's codes mark a landed
|
|
1215
|
+
* edge, so the capsule replaces the row only when there is no relationship
|
|
1216
|
+
* under it; otherwise the reason joins the row and the remove control stays.
|
|
1217
|
+
*
|
|
1218
|
+
* ## Whose refusal it is, is not a question asked here
|
|
1219
|
+
*
|
|
1220
|
+
* {@link statedHere} asks it, of the carrier the refusal arrived carrying —
|
|
1221
|
+
* and that is the whole of the change this function used to be the wrong place
|
|
1222
|
+
* for. `refusals` is one list for the whole surface, not one per panel, and
|
|
1223
|
+
* this function used to filter it by asking whether the refused edge's
|
|
1224
|
+
* IDENTITY named the panel's subject. That is a second derivation of the
|
|
1225
|
+
* edit's subject, run against a subject derived by a different rule, and every
|
|
1226
|
+
* way the two can come apart is a refusal drawn in the wrong place or in no
|
|
1227
|
+
* place: on every panel when nothing matched, on none when the panel had been
|
|
1228
|
+
* canonicalized onto a together unit's lead, on none again when the projection
|
|
1229
|
+
* had hidden the edge and left the panel with no subject at all. The refusal
|
|
1230
|
+
* names its own carrier now, so there is one derivation and nothing for a
|
|
1231
|
+
* second one to disagree with.
|
|
1232
|
+
*
|
|
1233
|
+
* ## The list is the ledger's, and the LAST refusal on an edge is the one
|
|
1234
|
+
*
|
|
1235
|
+
* One row states one reason, and the reader can be refused twice on one
|
|
1236
|
+
* relationship — `ProjectedEdge.writes` is a list because two edits touching
|
|
1237
|
+
* one edge compose in the order the reader made them. The last is the one they
|
|
1238
|
+
* just caused; the first is one they have read and moved past. That collapse
|
|
1239
|
+
* is `Map`'s repeated-key rule over `refusals` IN ORDER, which is why the
|
|
1240
|
+
* option is a list rather than a map a caller had already collapsed: a caller
|
|
1241
|
+
* assembling one from two passes loses the chronology at the join, and reports
|
|
1242
|
+
* a stale reason for an edge the reader has just been refused on again.
|
|
1243
|
+
*
|
|
1244
|
+
* ## A refusal with no row is still drawn
|
|
1245
|
+
*
|
|
1246
|
+
* The refused edge normally reaches the document — the store draws a refused
|
|
1247
|
+
* create as a phantom precisely so a surface can mark it — but that depends on
|
|
1248
|
+
* the host's projection, and a refusal that vanished because the host projects
|
|
1249
|
+
* only landed edges is the "never silently dropped" half of §17b's rule failing
|
|
1250
|
+
* quietly. So a refusal this panel states and no row carries is appended after
|
|
1251
|
+
* the rest, as a capsule.
|
|
1252
|
+
*/
|
|
1253
|
+
function relationshipEntries(view, subject, selected, context) {
|
|
1254
|
+
const scope = panelScope(view);
|
|
1255
|
+
const stated = new Map();
|
|
1256
|
+
for (const refusal of context.refusals) {
|
|
1257
|
+
if (statedHere(scope, refusal))
|
|
1258
|
+
stated.set(refusal.edgeId, refusal);
|
|
1259
|
+
}
|
|
1260
|
+
const listed = new Set();
|
|
1261
|
+
const rows = view.relationships.map((relationship) => {
|
|
1262
|
+
const refusal = stated.get(relationship.edgeId);
|
|
1263
|
+
if (refusal === undefined) {
|
|
1264
|
+
return relationshipSpec(relationship, subject, selected, context.words, undefined);
|
|
1265
|
+
}
|
|
1266
|
+
listed.add(relationship.edgeId);
|
|
1267
|
+
return refusal.phantom
|
|
1268
|
+
? refusalCapsule(relationship, refusal.code, subject, context.words)
|
|
1269
|
+
: relationshipSpec(relationship, subject, selected, context.words, refusal.code);
|
|
1270
|
+
});
|
|
1271
|
+
const orphaned = [...stated.values()]
|
|
1272
|
+
.filter((refusal) => !listed.has(refusal.edgeId))
|
|
1273
|
+
.map((refusal) => refusalCapsule(undefined, refusal.code, subject, context.words));
|
|
1274
|
+
return [...rows, ...orphaned];
|
|
1275
|
+
}
|
|
1276
|
+
/**
|
|
1277
|
+
* §17a's inspector panel.
|
|
1278
|
+
*
|
|
1279
|
+
* ## The heading and the clear control are the panel's, not the list's
|
|
1280
|
+
*
|
|
1281
|
+
* Both used to hang off the relationship list, and the clear control was drawn
|
|
1282
|
+
* only while an EDGE selection was narrowing it — so a reader who had selected
|
|
1283
|
+
* an issue had no way back to nothing selected, on a control whose own doc
|
|
1284
|
+
* says it "returns to nothing selected" and whose command is `clear`. The
|
|
1285
|
+
* condition it wanted was "is there a selection to clear", which is what it now
|
|
1286
|
+
* asks. `INITIAL_SELECTION` resolves to `none`, so the control is absent
|
|
1287
|
+
* exactly when pressing it would do nothing.
|
|
1288
|
+
*
|
|
1289
|
+
* ## The create step is drawn here, and the target step is not
|
|
1290
|
+
*
|
|
1291
|
+
* See {@link kindListSpec}. What the panel owns is `+ add` and the numbered
|
|
1292
|
+
* kind list; the target search stays in the shell because it is a live input
|
|
1293
|
+
* over the reader's query and this renderer returns markup.
|
|
1294
|
+
*
|
|
1295
|
+
* WITH AN EDGE SELECTED IT DRAWS NEITHER. That is not the create path being
|
|
1296
|
+
* unavailable — it is the panel filtered to one relationship, where "add a
|
|
1297
|
+
* relationship from the selected issue" has no subject: `reduceHost`'s `add`
|
|
1298
|
+
* reads `selectedKey`, which answers `null` for an edge selection. A control
|
|
1299
|
+
* that could not complete the act it advertises is the finding the audit
|
|
1300
|
+
* header already records paying for once.
|
|
1301
|
+
*/
|
|
1302
|
+
function inspectorSpec(view, context) {
|
|
1303
|
+
const { words } = context;
|
|
182
1304
|
const subject = view.subject;
|
|
1305
|
+
// THE CANONICAL SUBJECT, NOT THE SELECTION'S KEY. `inspectorView` folds a
|
|
1306
|
+
// together-unit member onto its slot's lead and lists the LEAD's
|
|
1307
|
+
// relationships, so a row worded against the key the reader clicked would
|
|
1308
|
+
// read every relationship from the wrong end on exactly the units where a
|
|
1309
|
+
// partner was selected. Read off the view, which is what the list was built
|
|
1310
|
+
// from.
|
|
1311
|
+
const key = subject.kind === 'issue' ? subject.issue.key : null;
|
|
1312
|
+
const selected = subject.kind === 'edge' ? subject.relationship.edgeId : null;
|
|
1313
|
+
const entries = relationshipEntries(view, key, selected, context);
|
|
1314
|
+
// THE SAME SCOPE THE RELATIONSHIP LIST USED, asked once here rather than
|
|
1315
|
+
// recomputed per region: two calls could not disagree today, but a panel that
|
|
1316
|
+
// decides its own reach twice is the shape `carrier` exists to have ended.
|
|
1317
|
+
const scope = panelScope(view);
|
|
1318
|
+
// ASKED ONCE, FOR THE SAME REASON `scope` IS. §17a draws the create path in
|
|
1319
|
+
// two zones — `+ add` on the relationships header, the numbered kinds under
|
|
1320
|
+
// their own heading — and a step derived separately per zone is two answers
|
|
1321
|
+
// to one question, free to disagree the day either guard moves.
|
|
1322
|
+
const step = createStep(context, key);
|
|
183
1323
|
return element('div', { class: 'ig-inspector', 'data-subject': subject.kind }, [
|
|
1324
|
+
element('div', { class: 'ig-inspector-head' }, [
|
|
1325
|
+
element('h2', { class: 'ig-inspector-name' }, [words.inspector]),
|
|
1326
|
+
subject.kind === 'none'
|
|
1327
|
+
? null
|
|
1328
|
+
: element('button', { type: 'button', class: 'ig-inspector-clear', 'data-ig-command': 'clear' }, [words.clearSelection]),
|
|
1329
|
+
]),
|
|
184
1330
|
subject.kind === 'none'
|
|
185
1331
|
? element('p', { class: 'ig-inspector-empty' }, [words.nothingSelected])
|
|
186
1332
|
: null,
|
|
187
1333
|
subject.kind === 'issue'
|
|
188
1334
|
? element('div', { class: 'ig-inspector-issue' }, [
|
|
189
|
-
element('
|
|
190
|
-
|
|
191
|
-
|
|
1335
|
+
element('h3', { class: 'ig-inspector-title' }, [subject.issue.title]),
|
|
1336
|
+
// LAYER 1's CHIP, not a second spelling of it. `identity` links the
|
|
1337
|
+
// qualified reference when the host gave a URL and prints it plain
|
|
1338
|
+
// when it did not — the rule for which is exactly the knowledge this
|
|
1339
|
+
// package must not carry a second copy of.
|
|
1340
|
+
identity(subject.issue),
|
|
1341
|
+
// THE RANK IS THE HEADING'S NOW, so there is no separate position
|
|
1342
|
+
// line: it printed the number, or the em dash for a held slot, which
|
|
1343
|
+
// is exactly what `WHY RANK n` and `WHY HELD` already say. Two
|
|
1344
|
+
// elements for one fact is how they come to disagree.
|
|
1345
|
+
subject.whyRank === null
|
|
192
1346
|
? null
|
|
193
|
-
:
|
|
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
|
|
202
|
-
? 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)))),
|
|
1347
|
+
: 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
1348
|
])
|
|
205
1349
|
: null,
|
|
1350
|
+
// ONE DERIVATION, READ TWICE. The step is asked once here and both
|
|
1351
|
+
// placements below read the answer, so the header and the body cannot
|
|
1352
|
+
// disagree about WHICH step is live. What each draws for a given arm is
|
|
1353
|
+
// still a discipline this file keeps rather than one the type checker
|
|
1354
|
+
// keeps — the single-return shape this replaces made drawing both
|
|
1355
|
+
// unrepresentable, and a value read at two sites does not. The pin that
|
|
1356
|
+
// stands where the return ordering used to is the one asserting no `add`
|
|
1357
|
+
// control exists anywhere in the panel at the kind step.
|
|
206
1358
|
element('div', { class: 'ig-inspector-relationships', 'data-filtered': view.filtered ? 'true' : 'false' }, [
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
1359
|
+
// §17a PUTS `+ add` ON THE HEADING'S OWN ROW, and the reason is the
|
|
1360
|
+
// list between them: it is unbounded, so a control after it drifts
|
|
1361
|
+
// down the panel as a subject gains relationships and is below the
|
|
1362
|
+
// fold on the busy issues a groomer most needs it for. On the header
|
|
1363
|
+
// row its position does not depend on the content.
|
|
1364
|
+
element('div', { class: 'ig-inspector-relationships-head' }, [
|
|
1365
|
+
element('h3', { class: 'ig-inspector-heading' }, [words.relationships]),
|
|
1366
|
+
step.kind === 'add' ? addControlSpec(words, step.subject) : null,
|
|
1367
|
+
]),
|
|
1368
|
+
// A STATED EMPTY, AND ONLY WHERE THERE IS A SUBJECT TO STATE IT ABOUT.
|
|
1369
|
+
// With nothing selected the panel already says so once, in
|
|
1370
|
+
// `nothingSelected`; adding "no relationships" under it would be the
|
|
1371
|
+
// same absence reported twice, in two registers, on the render where
|
|
1372
|
+
// the reader has asked nothing yet.
|
|
1373
|
+
entries.length === 0 && subject.kind !== 'none'
|
|
1374
|
+
? element('p', { class: 'ig-inspector-none' }, [words.noRelationships])
|
|
210
1375
|
: null,
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
1376
|
+
entries.length === 0 ? null : element('ul', { class: 'ig-relationship-list' }, entries),
|
|
1377
|
+
// THE STEPS ARE EXCLUSIVE, AND THE CHAIN IS THE SHELL'S OWN. `+ add`
|
|
1378
|
+
// begins a draft; the numbered list is what a live draft with no kind
|
|
1379
|
+
// yet looks like. Drawing `+ add` beside a live list would offer a
|
|
1380
|
+
// reader mid-draft a control that RESETS the draft they are in —
|
|
1381
|
+
// `create/draft.ts` makes `begin` clear the kind and the target
|
|
1382
|
+
// deliberately.
|
|
1383
|
+
step.kind === 'kinds' ? kindListSpec(words, step.source) : null,
|
|
214
1384
|
]),
|
|
1385
|
+
// STATED WHERE THE READER MADE THE EDIT, by the same rule the refusals use
|
|
1386
|
+
// — one `statedHere`, one carrier, no second derivation of whose panel this
|
|
1387
|
+
// is. No heading: the cards carry their own state names, and a heading over
|
|
1388
|
+
// a region that is usually absent is a heading a reader learns to skip.
|
|
1389
|
+
recoveryListSpec(context.recoveries.filter((recovery) => statedHere(scope, recovery)), words.recovery, context.diffOpen, scope.kind === 'issue' ? scope.keys : null, null),
|
|
1390
|
+
// THE WRITES WITH NOWHERE TO SIT, ON EVERY PANEL. A recovery whose carrier
|
|
1391
|
+
// is `null` is about issues this backlog does not hold, so no panel is
|
|
1392
|
+
// entitled to it — and the refusal path drops exactly these. Dropping a
|
|
1393
|
+
// REFUSAL costs a sentence the reader can live without; dropping a RECOVERY
|
|
1394
|
+
// takes away the only retry and discard they have, which is #137's
|
|
1395
|
+
// Done-when 1 ("offers all three resolutions from the editor's own
|
|
1396
|
+
// surface") failing on the state where it matters most. So they are drawn
|
|
1397
|
+
// here, under a heading that says why they are not on a relationship.
|
|
1398
|
+
// AND NEVER TWICE ON ONE PANEL. `statedHere`'s EDGE arm matches on
|
|
1399
|
+
// `edgeId` and ignores the carrier, so an edge panel narrowed to a
|
|
1400
|
+
// null-carrier recovery's own edge states it above AND here — two identical
|
|
1401
|
+
// cards, two tab stops, and two buttons carrying one write's id.
|
|
1402
|
+
recoveryListSpec(context.recoveries.filter((recovery) => recovery.carrier === null && !statedHere(scope, recovery)), words.recovery, context.diffOpen, null, words.recovery.unplaced),
|
|
1403
|
+
]);
|
|
1404
|
+
}
|
|
1405
|
+
function createStep(context, subject) {
|
|
1406
|
+
const { draft, drop } = context;
|
|
1407
|
+
if (draft.source !== null && draft.kind === null && drop === null) {
|
|
1408
|
+
return { kind: 'kinds', source: draft.source === subject ? null : draft.source };
|
|
1409
|
+
}
|
|
1410
|
+
// `+ add` NEEDS A SUBJECT TO BEGIN FROM, and `reduceHost`'s `add` arm answers
|
|
1411
|
+
// `null` for an edge selection and for none — so drawing it there would
|
|
1412
|
+
// publish an act that cannot complete.
|
|
1413
|
+
if (subject !== null && draft.source !== subject)
|
|
1414
|
+
return { kind: 'add', subject };
|
|
1415
|
+
return { kind: 'none' };
|
|
1416
|
+
}
|
|
1417
|
+
/**
|
|
1418
|
+
* §17a's `+ add`, with the key that does the same thing beside it.
|
|
1419
|
+
*
|
|
1420
|
+
* THE HINT IS THE BINDING'S OWN LETTER, through {@link RELATE_KEY}. A literal
|
|
1421
|
+
* here would be a second spelling of a key the map already owns, free to go
|
|
1422
|
+
* stale the day the binding moves and with nothing failing to say so — the
|
|
1423
|
+
* drift `create/keys.ts`'s header rejects in terms, and the reason `KIND_KEYS`
|
|
1424
|
+
* exists for the digits one function below.
|
|
1425
|
+
*
|
|
1426
|
+
* IT IS `aria-hidden`, AND THE BUTTON KEEPS THE HOST'S WORD AS ITS NAME. An
|
|
1427
|
+
* accessible name is computed from descendant text, so an exposed hint appends
|
|
1428
|
+
* a bare letter to the act — "add relationship R" — which announces a shortcut
|
|
1429
|
+
* as though it were part of the label. Hidden, the name is the host's word
|
|
1430
|
+
* alone. This is the pairing rule `hiddenGlyph` already applies to every mark
|
|
1431
|
+
* beside a word in this package: the mark is seen, the word is heard.
|
|
1432
|
+
*
|
|
1433
|
+
* UPPERCASE IS THE STYLESHEET'S. The map stores the lowercase key a press
|
|
1434
|
+
* normalizes to; §17a draws `R`. `text-transform` renders it, as it already
|
|
1435
|
+
* does for every heading in this panel, so there is no second string.
|
|
1436
|
+
*
|
|
1437
|
+
* NOTE THE HINT AND THE CONTROL CAN BEGIN FROM DIFFERENT ISSUES. `keyIntent`'s
|
|
1438
|
+
* `relate` arm begins from `KeyboardContext.focused`, while this button
|
|
1439
|
+
* publishes the canonical `data-ig-target`; on a together unit's non-lead
|
|
1440
|
+
* member those are different issues, which {@link createStep} records. Both
|
|
1441
|
+
* begin a draft the reader can see and cancel, so the pairing is kept.
|
|
1442
|
+
*
|
|
1443
|
+
* AND THE KEY NOW WORKS WHILE THIS BUTTON ITSELF HOLDS FOCUS — it did not when
|
|
1444
|
+
* the hint was first drawn beside it, and `#173` is the repair. The hint had
|
|
1445
|
+
* been naming a key the reader genuinely has at the one focus position where it
|
|
1446
|
+
* was inert: `interaction()` answered `elsewhere` for a focused command control
|
|
1447
|
+
* and `create/keys.ts`'s `reaches()` refuses every binding there, while
|
|
1448
|
+
* `KeyboardContext.focused` was the rail's tab stop and so `null` then, leaving
|
|
1449
|
+
* the `relate` arm with nothing to relate FROM even had the interaction
|
|
1450
|
+
* reached. Both halves moved: `add-control` is a `CreateInteraction` admitting
|
|
1451
|
+
* `RELATE_KEY` alone, and `focusedAddSubject` supplies the source from the
|
|
1452
|
+
* attribute below.
|
|
1453
|
+
*
|
|
1454
|
+
* WHICH IS WHY THE ATTRIBUTE IS LOAD-BEARING FOR THE KEYBOARD TOO, and no
|
|
1455
|
+
* longer only for the pointer: `focusedAddSubject` reads exactly this
|
|
1456
|
+
* `data-ig-target`, narrowed to the `add` command, so the note above about the
|
|
1457
|
+
* hint and the control beginning from different issues is now the ONE case
|
|
1458
|
+
* where they still can — a together unit's non-lead member holding the rail's
|
|
1459
|
+
* tab stop, where `focusedKey` answers first and this fallback is not reached.
|
|
1460
|
+
*/
|
|
1461
|
+
function addControlSpec(words, subject) {
|
|
1462
|
+
return element('button', {
|
|
1463
|
+
type: 'button',
|
|
1464
|
+
class: 'ig-inspector-addbutton',
|
|
1465
|
+
'data-ig-command': 'add',
|
|
1466
|
+
// THE CANONICAL SUBJECT, PUBLISHED, for the reason the row's remove
|
|
1467
|
+
// control publishes its own edge. `reduceHost`'s `add` arm read
|
|
1468
|
+
// `selectedKey` — the RAW key — while everything around it is worded
|
|
1469
|
+
// against the slot LEAD that `inspectorView` canonicalizes to, so
|
|
1470
|
+
// selecting a together-unit PARTNER drew a panel titled with the lead
|
|
1471
|
+
// and began a relationship from the partner. The act names its
|
|
1472
|
+
// subject; the selection is the fallback for a control that names none.
|
|
1473
|
+
'data-ig-target': subject,
|
|
1474
|
+
}, [
|
|
1475
|
+
// THE WORD IS A BARE CHILD, NOT A WRAPPED ONE. A span around it would need
|
|
1476
|
+
// a class, and a class with no rule is what `styles.test.ts` refuses —
|
|
1477
|
+
// correctly, since an element this sheet does not style is one a host
|
|
1478
|
+
// cannot theme.
|
|
1479
|
+
words.addRelationship,
|
|
1480
|
+
element('span', { class: 'ig-inspector-addkey', 'aria-hidden': 'true' }, [RELATE_KEY]),
|
|
215
1481
|
]);
|
|
216
1482
|
}
|
|
217
1483
|
/**
|
|
@@ -303,6 +1569,17 @@ export function renderWorkspace(input, options) {
|
|
|
303
1569
|
const document = sound.document;
|
|
304
1570
|
// The keys a hold's subject control may name — see `holdRow`.
|
|
305
1571
|
const known = new Set(document.issues.map((issue) => issue.key));
|
|
1572
|
+
// THE EDGE IDS THIS SURFACE ACTUALLY DRAWS, for §17d's remedies to gate on.
|
|
1573
|
+
//
|
|
1574
|
+
// BUILT FROM THE DRAWN DOCUMENT, not from the audit's. The audit reads a
|
|
1575
|
+
// document that is not always the one on screen, and the two disagreeing is
|
|
1576
|
+
// the normal case rather than the exceptional one — a host audits everything
|
|
1577
|
+
// it holds and renders a page of it.
|
|
1578
|
+
//
|
|
1579
|
+
// `edgeIdentity` RATHER THAN A HAND-SPELLED KEY, because that function is
|
|
1580
|
+
// what mints the ids a finding carries and what the host looks one up by. A
|
|
1581
|
+
// second spelling here would agree with itself and disagree with both.
|
|
1582
|
+
const drawnEdges = new Set(document.edges.map((edge) => edgeIdentity(edge.field, edge.from, edge.to)));
|
|
306
1583
|
// Which slot a key sits in, by lead — so a hold's subject that resolves to the
|
|
307
1584
|
// inspected slot gets no control. The same canonicalization `inspectorView`
|
|
308
1585
|
// applies, read off the same normalized slots.
|
|
@@ -316,18 +1593,79 @@ export function renderWorkspace(input, options) {
|
|
|
316
1593
|
// members rather than the lead, for the reason the bar is: a finding can name
|
|
317
1594
|
// a member that does not lead its unit.
|
|
318
1595
|
const filtered = overlay !== null && options.auditFiltered === true;
|
|
1596
|
+
// AN OVERLAY WITH NOTHING BEHIND IT IS NOT OPEN. `overlay === null` means the
|
|
1597
|
+
// host wired no audit at all, and a host that leaves `auditOpen` true across
|
|
1598
|
+
// that change would otherwise ask for a panel over an audit that does not
|
|
1599
|
+
// exist.
|
|
1600
|
+
const auditOpen = overlay !== null && options.auditOpen === true;
|
|
1601
|
+
// THE HEADER'S TWO FACTS ARE WITHHELD FROM THE RAIL HERE, and this is the one
|
|
1602
|
+
// place they are: see `railHostFacts` for why a subtraction rather than
|
|
1603
|
+
// `SceneOptions.chrome`.
|
|
1604
|
+
const railHost = railHostFacts(document.host);
|
|
1605
|
+
const narrowedSlots = filtered
|
|
1606
|
+
? document.order.slots.filter((slot) => slot.members.some((member) => auditFilterKeeps(overlay, member)))
|
|
1607
|
+
: document.order.slots;
|
|
1608
|
+
const narrowedExcluded = filtered
|
|
1609
|
+
? document.order.excluded.filter((exclusion) => auditFilterKeeps(overlay, exclusion.key))
|
|
1610
|
+
: document.order.excluded;
|
|
1611
|
+
// A FILTER THAT MATCHES NOTHING IS NOT A ZERO, AND THE PROJECTION CANNOT KNOW
|
|
1612
|
+
// THAT FROM WHAT IT IS HANDED. It receives a document with no slots and says
|
|
1613
|
+
// the true thing about it — "Nothing is in the order right now" — which is a
|
|
1614
|
+
// FALSE thing about the backlog, because every rank the filter hid is still
|
|
1615
|
+
// there. `RULINGS.md` §2 names it: *"a false statement about the data, and it
|
|
1616
|
+
// is the one thing this panel exists not to make."*
|
|
1617
|
+
//
|
|
1618
|
+
// It is the defect `statesItsOwnCause` already describes one input over —
|
|
1619
|
+
// *"its own derived sentence is not a fallback explanation but a false one"* —
|
|
1620
|
+
// arrived at by narrowing instead of by a condition, so it takes the same
|
|
1621
|
+
// remedy: the host RECORDS what it did and the projection stops deriving.
|
|
1622
|
+
// Recording it here rather than teaching the viewer about filters keeps the
|
|
1623
|
+
// seam where it is: narrowing is the editor's act, and only the editor knows
|
|
1624
|
+
// the denominator, because only it still holds the unnarrowed order.
|
|
1625
|
+
const emptiedByFilter = filtered && narrowedSlots.length === 0 && narrowedExcluded.length === 0;
|
|
1626
|
+
// STATED WHENEVER THE FILTER IS ON, not only when it empties the rail — and
|
|
1627
|
+
// that is a consequence of §17d's entry point moving into the overlay.
|
|
1628
|
+
//
|
|
1629
|
+
// While the filter lived on the header count it was always on screen and
|
|
1630
|
+
// always clearable. `RULINGS.md` §3 makes that count the overlay's anchor and
|
|
1631
|
+
// puts the filter in the panel head, so the ONLY control for it now closes
|
|
1632
|
+
// with the panel — and a reader who narrowed the rail, shut the panel and
|
|
1633
|
+
// scrolled away would be left looking at a short list with nothing saying why
|
|
1634
|
+
// and nothing to press. §2 forbids exactly that: *"A filter that is visibly
|
|
1635
|
+
// on, states what it is hiding, and clears in one click is not a refusal."*
|
|
1636
|
+
// All three halves have to stay true with the panel shut.
|
|
1637
|
+
const railCondition = filtered
|
|
1638
|
+
? {
|
|
1639
|
+
kind: 'filtered',
|
|
1640
|
+
headline: emptiedByFilter ? options.words.filteredEmpty : options.words.filteredActive,
|
|
1641
|
+
// THE HOST'S OWN RECORDED COUNT, NOT A SECOND DERIVATION OF IT. The
|
|
1642
|
+
// rail header already prints `8 ranked` from `counts.ranked`, and the
|
|
1643
|
+
// first version of this line counted `order.slots.length` instead —
|
|
1644
|
+
// which is 11 on the demo, because it includes the footer slots the
|
|
1645
|
+
// header does not call ranked. Two numbers for one fact, on one screen,
|
|
1646
|
+
// eight pixels apart.
|
|
1647
|
+
//
|
|
1648
|
+
// That is the defect this repository has already ruled on twice: two
|
|
1649
|
+
// capsules drawing one fact must not each hold their own sentence, or
|
|
1650
|
+
// they describe one document differently in one view. A denominator
|
|
1651
|
+
// that disagrees with the header is worse than the lie it replaced,
|
|
1652
|
+
// because it is a lie the reader can SEE is a lie and cannot resolve.
|
|
1653
|
+
//
|
|
1654
|
+
// Falls back to the unnarrowed slot count only when the host recorded
|
|
1655
|
+
// no counts at all — a number that may over-count is still better than
|
|
1656
|
+
// an empty sentence here, and the field is optional in the port.
|
|
1657
|
+
denominator: options.words.filteredDenominator(document.host.counts?.ranked ?? document.order.slots.length),
|
|
1658
|
+
action: { label: options.words.filteredClear },
|
|
1659
|
+
}
|
|
1660
|
+
: undefined;
|
|
1661
|
+
const railHostWithCause = railCondition === undefined ? railHost : { ...railHost, condition: railCondition };
|
|
319
1662
|
const railInput = filtered
|
|
320
1663
|
? {
|
|
321
1664
|
...document,
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
// ones on screen while the header said the filter was on — the toggle
|
|
325
|
-
// narrowing part of the rail and claiming to have narrowed it.
|
|
326
|
-
slots: document.order.slots.filter((slot) => slot.members.some((member) => auditFilterKeeps(overlay, member))),
|
|
327
|
-
excluded: document.order.excluded.filter((exclusion) => auditFilterKeeps(overlay, exclusion.key)),
|
|
328
|
-
},
|
|
1665
|
+
host: railHostWithCause,
|
|
1666
|
+
order: { slots: narrowedSlots, excluded: narrowedExcluded },
|
|
329
1667
|
}
|
|
330
|
-
: document;
|
|
1668
|
+
: { ...document, host: railHostWithCause };
|
|
331
1669
|
const rail = railWindow(railInput, options.rail ?? {});
|
|
332
1670
|
const railRender = renderViewer(rail.document, {
|
|
333
1671
|
projection: 'linear',
|
|
@@ -335,6 +1673,15 @@ export function renderWorkspace(input, options) {
|
|
|
335
1673
|
// The rail is where a selected ISSUE reads as current. An edge selection
|
|
336
1674
|
// resolves to no key, which is `selectedKey`'s whole job.
|
|
337
1675
|
selected: selectedKey(selection),
|
|
1676
|
+
// §16f. Passed WHOLE rather than windowed: the window moves, and a reader
|
|
1677
|
+
// who opened a row, scrolled past it and came back should find it open.
|
|
1678
|
+
// A key the window does not draw costs one `includes` miss and nothing else.
|
|
1679
|
+
...(options.expanded === undefined ? {} : { expanded: options.expanded }),
|
|
1680
|
+
// §17j'S DENSITY ESCAPE HATCH, FORWARDED TO THE RAIL AND ONLY THE RAIL.
|
|
1681
|
+
// The canvas draws a second viewer in this surface, and the density block
|
|
1682
|
+
// is about the ORDER's rows — so handing both the same value would change a
|
|
1683
|
+
// projection the option says nothing about. See `ViewerOptions.density`.
|
|
1684
|
+
...(options.railDensity === undefined ? {} : { density: options.railDensity }),
|
|
338
1685
|
});
|
|
339
1686
|
// Built once, over the window's rows, so a rail of 312 costs one pass rather
|
|
340
1687
|
// than one scan of `overlay.rows` per drawn row.
|
|
@@ -358,9 +1705,31 @@ export function renderWorkspace(input, options) {
|
|
|
358
1705
|
// scrolling position and says nothing about what surrounds the selected
|
|
359
1706
|
// issue; handing the ladder a windowed document would make its budgets — and
|
|
360
1707
|
// therefore its refusal — depend on where the reader had scrolled to.
|
|
1708
|
+
// THE LIST'S ID, DERIVED ONCE AND USED BY BOTH ZONES. Undefined unless the
|
|
1709
|
+
// caller supplied a surface value; see `WorkspaceOptions.surfaceId` for why
|
|
1710
|
+
// this renderer must not invent one.
|
|
1711
|
+
const isolatedListId = options.surfaceId === undefined ? undefined : `ig-isolated-list-${options.surfaceId}`;
|
|
361
1712
|
const canvas = renderScaleLadder(document, {
|
|
362
1713
|
state: options.scale ?? INITIAL_SCALE_STATE,
|
|
363
1714
|
theme,
|
|
1715
|
+
isolatedListId,
|
|
1716
|
+
// ONE CONTROL, ONE ZONE — and the condition is WHETHER THIS SURFACE DRAWS
|
|
1717
|
+
// ONE, which is exactly what `words.rail` says. That is the contract
|
|
1718
|
+
// `ScaleLadderOptions.isolatedChip` states: suppress the ladder's chip only
|
|
1719
|
+
// if you draw the control yourself. §17a puts the count at the foot of the
|
|
1720
|
+
// rail and `railFooter` below draws it there. The LIST is unaffected and
|
|
1721
|
+
// stays in the canvas — see `railFooter` for why this zone cannot hold it.
|
|
1722
|
+
//
|
|
1723
|
+
// "THE WORDS EXIST" AND "THE FOOTER WAS DRAWN" ARE THE SAME CONDITION HERE,
|
|
1724
|
+
// and it is worth saying why rather than leaving a reader to check. The only
|
|
1725
|
+
// other reason `railFooter` returns null is an empty isolated set — and
|
|
1726
|
+
// `isolatedSpec` refuses that case too, so at count 0 neither draws and the
|
|
1727
|
+
// flag changes nothing. Gating on the words rather than on the returned spec
|
|
1728
|
+
// keeps this to ONE derivation of the ladder: the footer is built from
|
|
1729
|
+
// `canvas.ladder` below, which is the same value this call computes.
|
|
1730
|
+
// `rail-footer.test.ts` pins both halves — no configuration yields two
|
|
1731
|
+
// copies, and none yields none.
|
|
1732
|
+
isolatedChip: options.words.rail === undefined,
|
|
364
1733
|
// THE SAME ONE VALUE THE RAIL READ. Without this the canvas drew the
|
|
365
1734
|
// selected issue as ordinary while the rail marked it current, so the
|
|
366
1735
|
// single selection this surface advertises disagreed with itself between
|
|
@@ -374,17 +1743,299 @@ export function renderWorkspace(input, options) {
|
|
|
374
1743
|
selectedEdge: selectedEdgeId(selection),
|
|
375
1744
|
projected: options.projected,
|
|
376
1745
|
});
|
|
1746
|
+
const toolbar = canvasToolbar(canvas.ladder, document, options.words.canvas);
|
|
1747
|
+
// §17k'S STRIP CONTROL — the way out of the collapsed canvas below `1120`.
|
|
1748
|
+
//
|
|
1749
|
+
// ALWAYS RENDERED, HIDDEN BY THE SHEET AT THE OTHER TWO WIDTHS, and that
|
|
1750
|
+
// order matters: the alternative is rendering it only when narrow, which
|
|
1751
|
+
// would mean this function knowing how wide it is. It does not and must not —
|
|
1752
|
+
// §17k's implementation note — so the control is emitted once and the
|
|
1753
|
+
// container query decides where it is drawn. `display: none` is also what
|
|
1754
|
+
// keeps it out of the tab order where it has nothing to do, so a reader at
|
|
1755
|
+
// the wide layout never meets a button for a strip that is not there.
|
|
1756
|
+
//
|
|
1757
|
+
// `aria-expanded` RATHER THAN `aria-pressed`, on §17d's own distinction: this
|
|
1758
|
+
// discloses a region that is already in the markup rather than toggling a
|
|
1759
|
+
// mode. The label flips with it — see `WorkspaceWords.hideCanvas`.
|
|
1760
|
+
const canvasOpen = options.canvasOpen === true;
|
|
1761
|
+
const strip = element('button', {
|
|
1762
|
+
type: 'button',
|
|
1763
|
+
class: 'ig-canvas-strip',
|
|
1764
|
+
'data-ig-command': 'canvas-strip',
|
|
1765
|
+
'aria-expanded': canvasOpen ? 'true' : 'false',
|
|
1766
|
+
}, [canvasOpen ? options.words.hideCanvas : options.words.showCanvas]);
|
|
1767
|
+
// §17k'S MIDDLE ROW IS TWO RULES AND ONLY ONE OF THEM IS A STATE. *"Opened by
|
|
1768
|
+
// selection, Escape to dismiss"*: the first is a question about `selection`,
|
|
1769
|
+
// which this function already has, so the panel is lifted when something is
|
|
1770
|
+
// selected and the reader has not dismissed it since. Storing the openness
|
|
1771
|
+
// instead would be a second copy of the selection, free to disagree with it.
|
|
1772
|
+
const lifted = selection.kind !== 'none' && options.inspectorDismissed !== true;
|
|
1773
|
+
const narrowState = ` data-canvas="${canvasOpen ? 'open' : 'strip'}"` +
|
|
1774
|
+
` data-inspector="${lifted ? 'lifted' : 'dismissed'}"`;
|
|
1775
|
+
// §17a'S RAIL FOOTER, FROM THE LADDER THE CANVAS ALREADY DERIVED. `isolated`
|
|
1776
|
+
// is a property of the whole document rather than of the tier the canvas
|
|
1777
|
+
// settled on, so the rail and the canvas cannot disagree about it: there is
|
|
1778
|
+
// one derivation and both zones read it.
|
|
1779
|
+
//
|
|
1780
|
+
// NAMED `railFooterSpec`, NOT `railFooter`, because `railFooter` is the
|
|
1781
|
+
// function and — more to the point — the bare identifier `rail` a few lines
|
|
1782
|
+
// below is the WINDOW (`options.rail`, a `RailWindowOptions`), which is a
|
|
1783
|
+
// different value from `options.words.rail`. Two things called rail in one
|
|
1784
|
+
// scope is a reading hazard worth one name, not one comment per use.
|
|
1785
|
+
const railFooterSpec = railFooter(canvas.ladder.isolated, options.words.rail, isolatedListId);
|
|
377
1786
|
const inspector = inspectorView(document, selection);
|
|
1787
|
+
// §17c'S LOOP, OVER THE RAIL THE READER IS ACTUALLY LOOKING AT.
|
|
1788
|
+
// `reevaluateView` is handed the WINDOWED scene, so a chip whose row is
|
|
1789
|
+
// scrolled out of the window resolves to no row and draws nowhere — which is
|
|
1790
|
+
// right: a chip is a mark ON a row, and there is no row. The summary still
|
|
1791
|
+
// counts it, because the summary is about the ORDER and not about the window.
|
|
1792
|
+
//
|
|
1793
|
+
// NO WORDS, NO LOOP. `words.change` is optional and its absence is the whole
|
|
1794
|
+
// reason there is no default sentence here; see `ChangeWords`.
|
|
1795
|
+
const changeWords = options.words.change;
|
|
1796
|
+
const orderStatus = options.orderStatus ?? 'settled';
|
|
1797
|
+
// EVERY REF THE ORDER HAS, WINDOWED OR NOT — and taken from the UNFILTERED
|
|
1798
|
+
// document on purpose, so a row the audit filter is hiding counts as expected
|
|
1799
|
+
// for the same reason an off-window row does. The rail this render hands the
|
|
1800
|
+
// viewer is narrowed twice over; neither narrowing is a disagreement between
|
|
1801
|
+
// the projection and the change, and only this function holds the order both
|
|
1802
|
+
// narrowings started from.
|
|
1803
|
+
const inTheOrder = new Set();
|
|
1804
|
+
for (const slot of document.order.slots) {
|
|
1805
|
+
inTheOrder.add(slot.lead);
|
|
1806
|
+
for (const member of slot.members)
|
|
1807
|
+
inTheOrder.add(member);
|
|
1808
|
+
}
|
|
1809
|
+
for (const exclusion of document.order.excluded)
|
|
1810
|
+
inTheOrder.add(exclusion.key);
|
|
1811
|
+
const change = reevaluateView(changeWords === undefined ? null : options.change, railRender.scene, orderStatus, inTheOrder);
|
|
1812
|
+
const deltaByKey = new Map(changeWords === undefined
|
|
1813
|
+
? []
|
|
1814
|
+
: change.chips.map((chip) => [
|
|
1815
|
+
chip.key,
|
|
1816
|
+
{ chip, words: changeWords, kind: deltaKind(chip) },
|
|
1817
|
+
]));
|
|
1818
|
+
// §17d'S PANEL IS AN OVERLAY OVER THE CANVAS, NOT A FOURTH ZONE.
|
|
1819
|
+
//
|
|
1820
|
+
// `RULINGS.md` §3: *"It is a transient overlay anchored to the header count,
|
|
1821
|
+
// floating over the canvas, 520px wide, dismissed on Escape."* It used to be
|
|
1822
|
+
// concatenated into the INSPECTOR zone, above the selection — §17a fixes
|
|
1823
|
+
// three zones and none of them is the audit's, so it had nowhere to go and
|
|
1824
|
+
// took a share of the one next to it. At six findings the inspector went
|
|
1825
|
+
// below the fold.
|
|
1826
|
+
//
|
|
1827
|
+
// OVER THE CANVAS AND NEVER THE RAIL, and the ruling gives the reason rather
|
|
1828
|
+
// than a preference: *"judging a finding is largely a question of what it
|
|
1829
|
+
// would do to the order, so the order has to stay readable while you
|
|
1830
|
+
// judge."* The canvas is also the zone §17f already designed to refuse.
|
|
1831
|
+
//
|
|
1832
|
+
// BUILT ONLY WHEN OPEN. "Transient" is the word, and a panel rendered hidden
|
|
1833
|
+
// is still in the accessibility tree and still in the tab order — a reader
|
|
1834
|
+
// on the keyboard would walk a list of findings they never opened.
|
|
1835
|
+
const auditPanel = !auditOpen
|
|
1836
|
+
? ''
|
|
1837
|
+
: (() => {
|
|
1838
|
+
if (overlay === null)
|
|
1839
|
+
return '';
|
|
1840
|
+
const refused = renderEncodingRefusedBlock(options.audit?.encodingRefused ?? [], {
|
|
1841
|
+
words: options.words.audit,
|
|
1842
|
+
known,
|
|
1843
|
+
issueUrl: options.issueUrl,
|
|
1844
|
+
});
|
|
1845
|
+
// `filtered` REACHES THE PANEL, and the first draft forgot it. The chip drew
|
|
1846
|
+
// `aria-pressed="false"` while the rail beside it was visibly narrowed —
|
|
1847
|
+
// a control lying about its own state, which is the exact shape §2 wrote
|
|
1848
|
+
// its ruling against.
|
|
1849
|
+
const panel = renderAuditPanel(overlay, {
|
|
1850
|
+
words: options.words.audit,
|
|
1851
|
+
known,
|
|
1852
|
+
edges: drawnEdges,
|
|
1853
|
+
filtered,
|
|
1854
|
+
});
|
|
1855
|
+
// THE REFUSAL LEADS. It is the finding that says the other three cannot
|
|
1856
|
+
// be trusted for that issue — its edges were never read — so a reader
|
|
1857
|
+
// meeting the list first would be reading conclusions drawn from a
|
|
1858
|
+
// declaration nobody could parse.
|
|
1859
|
+
const members = [refused, panel].filter((spec) => spec !== null);
|
|
1860
|
+
// NO EMPTY REGION. A wrapper drawn around nothing is a heading-less box
|
|
1861
|
+
// taking a declared share of a column whose space belongs to the
|
|
1862
|
+
// selection — the same call `renderAuditPanel` makes about a list of
|
|
1863
|
+
// nothing, one level up.
|
|
1864
|
+
if (members.length === 0)
|
|
1865
|
+
return '';
|
|
1866
|
+
return renderMarkup(element('div', { class: 'ig-audit-overlay' }, members));
|
|
1867
|
+
})();
|
|
378
1868
|
const markup = [
|
|
379
|
-
|
|
380
|
-
|
|
1869
|
+
// THE ORDER'S STATUS, ON THE SURFACE ROOT. `mount.ts` already publishes the
|
|
1870
|
+
// same value on the mounted element for a host to read; this puts it where
|
|
1871
|
+
// the stylesheet can reach it in the UNMOUNTED rendering too, so §17c's
|
|
1872
|
+
// "greyed one step, and labelled" is drawn by whoever renders rather than
|
|
1873
|
+
// only by whoever mounts. Same vocabulary, verbatim, so the two agree.
|
|
1874
|
+
// STAMPED ONLY WHERE THE LABEL CAN BE DRAWN, and the two really are one
|
|
1875
|
+
// treatment. §17c's held state is a greyed rail AND the word saying why —
|
|
1876
|
+
// "a stale-but-labelled order beats a half-computed one" — and half of that
|
|
1877
|
+
// is worse than neither: a dimmed order with no explanation is exactly the
|
|
1878
|
+
// defect the label exists to prevent. `mountWorkspace` passes the store's
|
|
1879
|
+
// status unconditionally, so a host that has not supplied `words.change`
|
|
1880
|
+
// would otherwise get the greying with the label suppressed one branch
|
|
1881
|
+
// below, on the very path the optional vocabulary exists to keep working.
|
|
1882
|
+
//
|
|
1883
|
+
// THE FACT IS NOT LOST TO A HOST. `mount.ts` publishes the same status on
|
|
1884
|
+
// the element the host holds, which is the reader this attribute was never
|
|
1885
|
+
// for: this one is the stylesheet's hook, so it is absent exactly when the
|
|
1886
|
+
// stylesheet must not act.
|
|
1887
|
+
//
|
|
1888
|
+
// §17k'S TWO READER DECISIONS RIDE THE SAME ROOT, and for the same reason
|
|
1889
|
+
// `data-order` does: the stylesheet is the only party that knows which of
|
|
1890
|
+
// the three layouts is drawn, so the surface publishes what the reader
|
|
1891
|
+
// asked for and each container block reads the one it has a use for.
|
|
1892
|
+
// Neither attribute means anything at the width where its zone is a zone.
|
|
1893
|
+
//
|
|
1894
|
+
// BOTH ARE ALWAYS STAMPED, unlike `data-order`. That one is withheld where
|
|
1895
|
+
// its label cannot be drawn — a greyed rail with no word is worse than
|
|
1896
|
+
// neither — and these two have no such pair: the value is the full state,
|
|
1897
|
+
// and an absent attribute would read as the strip being open and the
|
|
1898
|
+
// inspector being lifted, which is the opposite of both defaults.
|
|
1899
|
+
changeWords === undefined
|
|
1900
|
+
? `<div class="ig-workspace"${narrowState}>`
|
|
1901
|
+
: `<div class="ig-workspace" data-order="${orderStatus}"${narrowState}>`,
|
|
1902
|
+
zone('header', [
|
|
1903
|
+
headerMarkup(document.host, overlay === null ? '' : renderAuditHeader(overlay, { open: auditOpen }), options.words),
|
|
1904
|
+
// §17c'S SUMMARY LIVES IN THE HEADER, NOT AT THE TOP OF THE RAIL, and
|
|
1905
|
+
// the frame draws them adjacent so this is a real departure worth the
|
|
1906
|
+
// sentence. The rail zone is the SCROLLER — `mount.ts` preserves
|
|
1907
|
+
// `railBefore.scrollTop` across every redraw — so a summary placed
|
|
1908
|
+
// inside it scrolls away from the reader, and §17c's rule is that the
|
|
1909
|
+
// summary "persists until the next edit or an explicit dismiss". A
|
|
1910
|
+
// summary that survives a dismiss but not a scroll is not persistent.
|
|
1911
|
+
//
|
|
1912
|
+
// The header is where #135 settled that a workspace-wide fact is
|
|
1913
|
+
// stated, and it is stated in exactly one zone. The chips carry the
|
|
1914
|
+
// adjacency instead: cause in the header, effect on the row.
|
|
1915
|
+
// "A STALE-BUT-LABELLED ORDER BEATS A HALF-COMPUTED ONE." §17c greys the
|
|
1916
|
+
// held rail and says WHY beside it, and the greying without the label
|
|
1917
|
+
// is the half of that pair which communicates nothing.
|
|
1918
|
+
//
|
|
1919
|
+
// THE LABEL IS `summarySpec`'S, INSIDE ITS LIVE REGION, and it was a
|
|
1920
|
+
// sibling of that region until a reader pointed out this made it
|
|
1921
|
+
// silent. The store clears `lastChange` when a write goes PENDING, so
|
|
1922
|
+
// the region is empty in exactly the state the label exists for — a
|
|
1923
|
+
// sighted reader got the greying and the sentence, and a screen-reader
|
|
1924
|
+
// user was told nothing about the ranks having gone stale.
|
|
1925
|
+
changeWords === undefined
|
|
1926
|
+
? ''
|
|
1927
|
+
: renderMarkup(summarySpec(change.summary, changeWords, { held: change.held })),
|
|
1928
|
+
].join('')),
|
|
381
1929
|
zone('rail', [
|
|
382
1930
|
railSpacer(rail.before, 'before'),
|
|
383
|
-
renderMarkup(markRail(railRender.scene.root, (key) => severityByKey.get(key))),
|
|
1931
|
+
renderMarkup(markRail(railRender.scene.root, (key) => severityByKey.get(key), (key) => deltaByKey.get(key))),
|
|
384
1932
|
railSpacer(rail.after, 'after'),
|
|
1933
|
+
railFooterSpec === null ? '' : renderMarkup(railFooterSpec),
|
|
385
1934
|
].join('')),
|
|
386
|
-
zone('canvas',
|
|
387
|
-
|
|
1935
|
+
zone('canvas',
|
|
1936
|
+
// THE CAPTION LEADS THE ZONE, above the graph, exactly as frame 17a
|
|
1937
|
+
// draws it — and it is assembled HERE rather than inside the ladder.
|
|
1938
|
+
// `renderScaleLadder` is also used standalone, where `chrome: false` is
|
|
1939
|
+
// the deliberate answer, and the words this row needs are the
|
|
1940
|
+
// workspace's. Both halves are already-rendered markup, which is the
|
|
1941
|
+
// rule `zone` exists under: it writes the only hand-authored tag in this
|
|
1942
|
+
// package and takes no caller value.
|
|
1943
|
+
//
|
|
1944
|
+
// THE STRIP CONTROL LEADS THE ZONE, above the caption, because below
|
|
1945
|
+
// `1120` it is the ONLY thing in the zone the reader can reach: the
|
|
1946
|
+
// sheet hides every sibling while the canvas is collapsed. Drawn last it
|
|
1947
|
+
// would be the last stop in the zone's tab order at the wide layout,
|
|
1948
|
+
// where it is `display: none` and therefore no stop at all — so the
|
|
1949
|
+
// position is chosen for the width where it is drawn.
|
|
1950
|
+
//
|
|
1951
|
+
// AND §17d'S PANEL IS LAST so it paints over the graph rather than under
|
|
1952
|
+
// it, and it is INSIDE this zone so `workspace/styles.ts` can position it
|
|
1953
|
+
// against the zone's box — which is what keeps it off the rail without a
|
|
1954
|
+
// magic number. The strip's hiding rule names it as an exception, or a
|
|
1955
|
+
// collapsed canvas would take the audit panel down with it.
|
|
1956
|
+
[strip, toolbar]
|
|
1957
|
+
.map((spec) => (spec === null ? '' : renderMarkup(spec)))
|
|
1958
|
+
.join('') +
|
|
1959
|
+
canvas.markup +
|
|
1960
|
+
auditPanel),
|
|
1961
|
+
zone('inspector',
|
|
1962
|
+
// §17d'S AUDIT IS THE SELECTION'S SIBLING, AND THEY SHARE ONE SCROLL
|
|
1963
|
+
// TRACK. Sibling rather than child so each surface keeps its own padding
|
|
1964
|
+
// and reads as a peer of the selection rather than part of it — their
|
|
1965
|
+
// headings are `h2`s beside the inspector's for the same reason.
|
|
1966
|
+
//
|
|
1967
|
+
// THE AUDIT REGION TAKES A SHARE; THE OTHER TWO SIBLINGS DO NOT. #177
|
|
1968
|
+
// settled this. The zone is still ONE scroll track, and inside it
|
|
1969
|
+
// `workspace/styles.ts` bounds the region alone at half the column,
|
|
1970
|
+
// scrolling itself past that. So a long audit can no longer push the
|
|
1971
|
+
// selection detail further than half the column down, whatever the
|
|
1972
|
+
// finding count — the detail's head is always above the fold, which is
|
|
1973
|
+
// the bound, not a promise that all of it fits. `.ig-inspector` and the
|
|
1974
|
+
// `.ig-chrome` the mount appends keep the track's own scrolling and
|
|
1975
|
+
// cannot be clipped at any height.
|
|
1976
|
+
//
|
|
1977
|
+
// THE SHARE IS `workspace/styles.ts`', NOT `audit/styles.ts`'. An earlier
|
|
1978
|
+
// revision of this comment said otherwise; that file states in as many
|
|
1979
|
+
// words that its leaves declare no size of their own, and the
|
|
1980
|
+
// misattribution is what nearly let §17d's second surface arrive with no
|
|
1981
|
+
// bound at all.
|
|
1982
|
+
//
|
|
1983
|
+
// WHY THIS ONE. Being drawn FIRST is half the reason — it is what puts
|
|
1984
|
+
// its length in front of the detail — and being the zone's only GLOBAL
|
|
1985
|
+
// member is the other. The two below it are about what the reader is
|
|
1986
|
+
// doing; this is about the document.
|
|
1987
|
+
//
|
|
1988
|
+
// A two-pane version was built and reverted before that: it clipped
|
|
1989
|
+
// `.ig-chrome`, a THIRD sibling. Bounding one member inside the single
|
|
1990
|
+
// track is what avoids needing panes at all.
|
|
1991
|
+
//
|
|
1992
|
+
// BOTH SIDES ARE ALREADY-RENDERED MARKUP, which is the rule `zone` exists
|
|
1993
|
+
// under: it writes the only hand-authored tag in this package and takes
|
|
1994
|
+
// no caller value, and everything with a dynamic value in it went through
|
|
1995
|
+
// `renderMarkup`.
|
|
1996
|
+
//
|
|
1997
|
+
// `known` IS THE DRAWN DOCUMENT'S KEYS, the same set `holdRow` withholds
|
|
1998
|
+
// its subject control on, and NOT the audit's: a host audits what it
|
|
1999
|
+
// holds and draws a page of it, so a ref can be audited and still have no
|
|
2000
|
+
// row here. The panel cannot see that difference and is told.
|
|
2001
|
+
// §17d IS ONE REGION HOLDING TWO SURFACES, AND THAT IS WHY THEY ARE
|
|
2002
|
+
// WRAPPED RATHER THAN CONCATENATED. `audit/refused.ts` draws the fourth
|
|
2003
|
+
// class outside the findings list — SPEC surfaces a refusal "on the issue
|
|
2004
|
+
// itself" — so the zone would otherwise gain a FOURTH sibling, and #177's
|
|
2005
|
+
// answer does not survive that arithmetic: two siblings each bounded at
|
|
2006
|
+
// half the column can take all of it between them and push the selection
|
|
2007
|
+
// detail entirely below the fold, which is worse than the state #177
|
|
2008
|
+
// fixed rather than a smaller version of it.
|
|
2009
|
+
//
|
|
2010
|
+
// SO THE BOUND MOVES UP ONE LEVEL, and #177's rule is kept rather than
|
|
2011
|
+
// restated: exactly one sibling of this zone is bounded, and it is now
|
|
2012
|
+
// the REGION. The block and the panel are unbounded inside it and share
|
|
2013
|
+
// its half between them, which is also what the artboard draws — §17d
|
|
2014
|
+
// puts the findings list and the refusal side by side as one section.
|
|
2015
|
+
// `workspace/styles.ts` carries the share; neither leaf declares a size.
|
|
2016
|
+
renderMarkup(inspectorSpec(inspector, {
|
|
2017
|
+
words: options.words,
|
|
2018
|
+
audit: overlay,
|
|
2019
|
+
known,
|
|
2020
|
+
leadOf,
|
|
2021
|
+
draft: options.draft ?? IDLE_CREATE_DRAFT,
|
|
2022
|
+
drop: options.drop ?? null,
|
|
2023
|
+
// FORWARDED IN THE ORDER IT ARRIVED, and turned into an answer per
|
|
2024
|
+
// edge inside `relationshipEntries` — after the panel has decided
|
|
2025
|
+
// which of them are its to state. Collapsing here instead put the
|
|
2026
|
+
// "one reason per row" rule one layer away from the filter that
|
|
2027
|
+
// decides which reasons there are, and made the ORDER of this array
|
|
2028
|
+
// load-bearing at a distance: a caller building it from two passes
|
|
2029
|
+
// lost the ledger's chronology at the join, and the panel reported a
|
|
2030
|
+
// refusal the reader had already read instead of the one they had
|
|
2031
|
+
// just caused.
|
|
2032
|
+
refusals: options.refusals ?? [],
|
|
2033
|
+
// FORWARDED IN THE LEDGER'S ORDER too, for the reason above: the
|
|
2034
|
+
// cards are drawn in the order the writes were made, so the one the
|
|
2035
|
+
// reader just caused is the one nearest what they were doing.
|
|
2036
|
+
recoveries: options.recoveries ?? [],
|
|
2037
|
+
diffOpen: options.diffOpen ?? null,
|
|
2038
|
+
}))),
|
|
388
2039
|
`</div>`,
|
|
389
2040
|
].join('');
|
|
390
2041
|
return {
|
|
@@ -409,6 +2060,11 @@ export function renderWorkspace(input, options) {
|
|
|
409
2060
|
// it.
|
|
410
2061
|
edgeOverlayStylesheet,
|
|
411
2062
|
...(overlay === null ? [] : [auditStylesheet]),
|
|
2063
|
+
// UNCONDITIONAL, for the reason `edgeOverlayStylesheet` above records:
|
|
2064
|
+
// the summary and the chips arrive with the NEXT snapshot after an edit,
|
|
2065
|
+
// so a sheet installed only once something has already changed is missing
|
|
2066
|
+
// on exactly the render that first draws one.
|
|
2067
|
+
reevaluateStylesheet,
|
|
412
2068
|
workspaceStylesheet,
|
|
413
2069
|
].join('\n'),
|
|
414
2070
|
// CONCATENATED, NOT DEDUPED — and the dedupe that used to sit here is worth
|
|
@@ -426,7 +2082,17 @@ export function renderWorkspace(input, options) {
|
|
|
426
2082
|
// Nothing replaces it, because nothing needs to: the zones now receive an
|
|
427
2083
|
// already-sound document, so they contribute nothing to this list at all.
|
|
428
2084
|
// `reports every occurrence, and the zones add nothing` pins both halves.
|
|
429
|
-
|
|
2085
|
+
// THE PLACEMENT DIAGNOSTICS ARE THE CHANGE'S, and they are only worth
|
|
2086
|
+
// reporting because `expected` above makes them mean what they say. Dropped
|
|
2087
|
+
// entirely, a host got no signal that its projection and its change
|
|
2088
|
+
// disagree; forwarded unfiltered, every scroll past a changed row would
|
|
2089
|
+
// have reported one.
|
|
2090
|
+
diagnostics: [
|
|
2091
|
+
...sound.diagnostics,
|
|
2092
|
+
...railRender.diagnostics,
|
|
2093
|
+
...canvas.diagnostics,
|
|
2094
|
+
...change.diagnostics,
|
|
2095
|
+
],
|
|
430
2096
|
};
|
|
431
2097
|
}
|
|
432
2098
|
//# sourceMappingURL=render.js.map
|