@issuegraph/viewer 0.2.0 → 0.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +39 -2
- package/dist/document.d.ts +286 -3
- package/dist/document.d.ts.map +1 -1
- package/dist/document.js +247 -1
- package/dist/document.js.map +1 -1
- package/dist/element.d.ts +11 -0
- package/dist/element.d.ts.map +1 -1
- package/dist/element.js.map +1 -1
- package/dist/index.d.ts +6 -6
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +30 -4
- package/dist/index.js.map +1 -1
- package/dist/layout.d.ts +51 -50
- package/dist/layout.d.ts.map +1 -1
- package/dist/layout.js +364 -168
- package/dist/layout.js.map +1 -1
- package/dist/mount.d.ts +13 -0
- package/dist/mount.d.ts.map +1 -1
- package/dist/mount.js +94 -3
- package/dist/mount.js.map +1 -1
- package/dist/navigation.d.ts +14 -0
- package/dist/navigation.d.ts.map +1 -1
- package/dist/navigation.js +20 -0
- package/dist/navigation.js.map +1 -1
- package/dist/parts.d.ts +338 -1
- package/dist/parts.d.ts.map +1 -1
- package/dist/parts.js +899 -30
- package/dist/parts.js.map +1 -1
- package/dist/projections/graph.d.ts +10 -0
- package/dist/projections/graph.d.ts.map +1 -1
- package/dist/projections/graph.js +413 -311
- package/dist/projections/graph.js.map +1 -1
- package/dist/projections/linear.d.ts +90 -0
- package/dist/projections/linear.d.ts.map +1 -1
- package/dist/projections/linear.js +220 -28
- package/dist/projections/linear.js.map +1 -1
- package/dist/projections/tree.d.ts.map +1 -1
- package/dist/projections/tree.js +29 -3
- package/dist/projections/tree.js.map +1 -1
- package/dist/styles.d.ts +10 -1
- package/dist/styles.d.ts.map +1 -1
- package/dist/styles.js +825 -68
- package/dist/styles.js.map +1 -1
- package/dist/theme.d.ts +41 -10
- package/dist/theme.d.ts.map +1 -1
- package/dist/theme.js +265 -11
- package/dist/theme.js.map +1 -1
- package/dist/vocabulary.d.ts +39 -0
- package/dist/vocabulary.d.ts.map +1 -1
- package/dist/vocabulary.js +27 -0
- package/dist/vocabulary.js.map +1 -1
- package/package.json +2 -2
package/dist/parts.js
CHANGED
|
@@ -7,9 +7,54 @@
|
|
|
7
7
|
* is held", and a change to the grammar lands in every projection at once.
|
|
8
8
|
*/
|
|
9
9
|
import { edgeIdentity } from '@issuegraph/core';
|
|
10
|
-
import { element } from "./element.js";
|
|
10
|
+
import { element, svg } from "./element.js";
|
|
11
11
|
import { GROUP_ATTRIBUTE } from "./scene.js";
|
|
12
|
-
import { EDGE_ORDER, treatmentFor } from "./vocabulary.js";
|
|
12
|
+
import { EDGE_ORDER, dashArrayFor, labelFrom, treatmentFor } from "./vocabulary.js";
|
|
13
|
+
/**
|
|
14
|
+
* The attribute a control the viewer publishes but does not wire carries. The
|
|
15
|
+
* same one `@issuegraph/editor` reads for its own controls, so a host that
|
|
16
|
+
* already listens for the editor's commands hears this one through the same
|
|
17
|
+
* listener.
|
|
18
|
+
*/
|
|
19
|
+
export const COMMAND_ATTRIBUTE = 'data-ig-command';
|
|
20
|
+
/**
|
|
21
|
+
* A mark for the eye and for nothing else.
|
|
22
|
+
*
|
|
23
|
+
* FOUR CHANNELS SEPARATE A RELATIONSHIP AND ONLY ONE OF THEM IS AUDIBLE. The
|
|
24
|
+
* glyph is `aria-hidden` because `⊘` announces as whatever a given screen
|
|
25
|
+
* reader's character table calls it — "circle with slash", or nothing at all —
|
|
26
|
+
* which is a description of the mark rather than of the relationship. Whatever
|
|
27
|
+
* carries the NAME has to be something else: the word beside it, or an
|
|
28
|
+
* `aria-label` on the control the mark sits inside.
|
|
29
|
+
*
|
|
30
|
+
* IT IS ITS OWN FUNCTION BECAUSE THE PAIRING IS NOT ITS ONLY CONSUMER. A word
|
|
31
|
+
* beside the glyph is the common shape and {@link glyphAndLabel} is it, but a
|
|
32
|
+
* glyph-only control — §17a's `✕` in a relationship row's slot, whose name is
|
|
33
|
+
* on the button — genuinely needs the hidden mark with no visible word. Given
|
|
34
|
+
* only the pairing, that call site writes the class and the `aria-hidden` out
|
|
35
|
+
* by hand, which is the copy the pairing exists to prevent wearing a different
|
|
36
|
+
* shape. Two published pieces, one rule.
|
|
37
|
+
*/
|
|
38
|
+
export function hiddenGlyph(glyph) {
|
|
39
|
+
return element('span', { class: 'ig-glyph', 'aria-hidden': 'true' }, [glyph]);
|
|
40
|
+
}
|
|
41
|
+
/**
|
|
42
|
+
* A badge's inner pair — the glyph a reader can see and the word a reader can
|
|
43
|
+
* hear.
|
|
44
|
+
*
|
|
45
|
+
* THE WORD IS THE ACCESSIBLE NAME, and it is the vocabulary's own. The mark
|
|
46
|
+
* beside it is {@link hiddenGlyph}, so the `aria-hidden` that makes this pair
|
|
47
|
+
* work at all is written in one place for every surface that draws one.
|
|
48
|
+
*
|
|
49
|
+
* EXPORTED FOR THE EDITOR, which draws the same pair in §17a's inspector rows
|
|
50
|
+
* and its numbered kind list. A second spelling of the pairing there would be
|
|
51
|
+
* free to drop the `aria-hidden` — the failure it exists to prevent — and would
|
|
52
|
+
* put the same relationship on screen in two shapes one zone apart from the
|
|
53
|
+
* §16 badge this already draws.
|
|
54
|
+
*/
|
|
55
|
+
export function glyphAndLabel(glyph, label) {
|
|
56
|
+
return [hiddenGlyph(glyph), element('span', {}, [label])];
|
|
57
|
+
}
|
|
13
58
|
/**
|
|
14
59
|
* Filled = ready now. Hollow = ready once a named rank lands. Dashed = held.
|
|
15
60
|
*
|
|
@@ -55,34 +100,126 @@ export function identity(issue) {
|
|
|
55
100
|
]);
|
|
56
101
|
}
|
|
57
102
|
/**
|
|
58
|
-
* The provenance
|
|
59
|
-
*
|
|
60
|
-
*
|
|
103
|
+
* The provenance SENTENCE, without the line it normally sits on.
|
|
104
|
+
*
|
|
105
|
+
* ONE WORDING, TWO PLACEMENTS. §16 hangs this sentence off a turnstile as a
|
|
106
|
+
* subordinate rail line; §17a's inspector needs the same sentence inside its
|
|
107
|
+
* own "why rank" paragraph, where a `↳` would be rail furniture imported into
|
|
108
|
+
* a panel. So the wording lives here and the furniture lives in
|
|
109
|
+
* {@link provenanceLine}, which is the only difference between the two.
|
|
110
|
+
*
|
|
111
|
+
* IT IS ON THE PUBLIC SURFACE FOR THE EDITOR, and that is the whole reason it
|
|
112
|
+
* exists separately. Layer 2 composing this is layer 2 reading layer 1's
|
|
113
|
+
* wording; layer 2 switching on `RankProvenance` itself would be a second
|
|
114
|
+
* wording of one fact, free to drift from the rail's. The seam permits adding
|
|
115
|
+
* to this layer's surface — what it forbids is reaching past it.
|
|
116
|
+
*
|
|
117
|
+
* NO TURNSTILE HERE, DELIBERATELY. `provenanceLine` adds it. A caller that
|
|
118
|
+
* wants the §16 line calls that; a caller that wants the sentence calls this.
|
|
61
119
|
*/
|
|
62
|
-
export function
|
|
120
|
+
export function provenanceClause(provenance) {
|
|
63
121
|
if (provenance === undefined)
|
|
64
122
|
return null;
|
|
65
123
|
switch (provenance.kind) {
|
|
66
124
|
case 'matched-query':
|
|
67
|
-
return element('
|
|
125
|
+
return element('span', {}, [
|
|
68
126
|
`matched ordered query ${String(provenance.index)} · `,
|
|
69
127
|
element('span', { class: 'ig-id' }, [provenance.label]),
|
|
70
128
|
]);
|
|
71
129
|
case 'declared-tier':
|
|
72
|
-
|
|
73
|
-
|
|
130
|
+
// NEUTRAL, AND THAT IS THE POINT. This arm means only that no ordering
|
|
131
|
+
// query matched and the issue stayed in its tier — it does NOT mean the
|
|
132
|
+
// priority was absent. §16a's own row happens to be one where it was, and
|
|
133
|
+
// borrowing that row's sentence made the panel tell every reader that an
|
|
134
|
+
// explicitly-declared P0 had no declared priority. A panel whose job is
|
|
135
|
+
// explaining the order cannot invent the reason.
|
|
136
|
+
return element('span', {}, [
|
|
137
|
+
'no ordered query matched — ranked in tier ',
|
|
138
|
+
element('span', { class: 'ig-id' }, [`P${String(provenance.priority)}`]),
|
|
74
139
|
]);
|
|
75
140
|
case 'promotion': {
|
|
76
141
|
const via = provenance.promotedBy.length === 0
|
|
77
|
-
?
|
|
78
|
-
:
|
|
79
|
-
|
|
142
|
+
? null
|
|
143
|
+
: element('span', {}, [
|
|
144
|
+
' — inherited from ',
|
|
145
|
+
element('span', { class: 'ig-id' }, [provenance.promotedBy.join(', ')]),
|
|
146
|
+
', which it blocks',
|
|
147
|
+
]);
|
|
148
|
+
return element('span', {}, [
|
|
149
|
+
'effective priority ',
|
|
80
150
|
element('span', { class: 'ig-id' }, [provenance.notation]),
|
|
81
|
-
|
|
151
|
+
via,
|
|
82
152
|
]);
|
|
83
153
|
}
|
|
84
154
|
}
|
|
85
155
|
}
|
|
156
|
+
/**
|
|
157
|
+
* The provenance line, in the three forms the design fixes: a matched ordering
|
|
158
|
+
* query, the declared tier, and an effective-priority promotion that names the
|
|
159
|
+
* dependent the urgency arrived through.
|
|
160
|
+
*
|
|
161
|
+
* The wording is {@link provenanceClause}'s; this adds only §16's turnstile and
|
|
162
|
+
* the paragraph it hangs on, so the rail and the inspector cannot come to
|
|
163
|
+
* disagree about how one fact reads.
|
|
164
|
+
*/
|
|
165
|
+
export function provenanceLine(provenance) {
|
|
166
|
+
const clause = provenanceClause(provenance);
|
|
167
|
+
if (clause === null)
|
|
168
|
+
return null;
|
|
169
|
+
return element('p', { class: 'ig-provenance' }, [turn(), clause]);
|
|
170
|
+
}
|
|
171
|
+
/**
|
|
172
|
+
* The turnstile every explanation line hangs off.
|
|
173
|
+
*
|
|
174
|
+
* §16a puts one in front of the provenance sentence, the hold sentence and the
|
|
175
|
+
* caveat note alike, and it is what makes those lines read as subordinate to
|
|
176
|
+
* the row above WITHOUT dimming them. The line that shipped was muted instead,
|
|
177
|
+
* which files the panel's own reason for existing — why is this here — under
|
|
178
|
+
* decoration.
|
|
179
|
+
*/
|
|
180
|
+
function turn() {
|
|
181
|
+
return element('span', { class: 'ig-turn', 'aria-hidden': 'true' }, ['↳']);
|
|
182
|
+
}
|
|
183
|
+
/**
|
|
184
|
+
* The priority chip a row wears on its badge row, derived from the same
|
|
185
|
+
* provenance the sentence beneath it explains.
|
|
186
|
+
*
|
|
187
|
+
* ONE FACT, TWO READS, and the frame draws both on purpose: the chip is what a
|
|
188
|
+
* reader scanning a column of rows sees, and the sentence is what they read
|
|
189
|
+
* when the chip surprises them. A promotion is the one chip the design accents,
|
|
190
|
+
* because a low tier pulled to the top because it blocks a high one is the most
|
|
191
|
+
* interesting thing the panel can say.
|
|
192
|
+
*/
|
|
193
|
+
export function priorityBadge(provenance) {
|
|
194
|
+
if (provenance === undefined)
|
|
195
|
+
return null;
|
|
196
|
+
switch (provenance.kind) {
|
|
197
|
+
case 'matched-query':
|
|
198
|
+
return element('span', { class: 'ig-badge', 'data-priority': 'query' }, [provenance.label]);
|
|
199
|
+
case 'declared-tier':
|
|
200
|
+
// `· tier`, NOT `· default`. The chip says which tier ranked the row; it
|
|
201
|
+
// cannot say the tier was a default, because this arm does not know that.
|
|
202
|
+
return element('span', { class: 'ig-badge', 'data-priority': 'tier' }, [
|
|
203
|
+
`P${String(provenance.priority)} · tier`,
|
|
204
|
+
]);
|
|
205
|
+
case 'promotion':
|
|
206
|
+
return element('span', { class: 'ig-badge', 'data-priority': 'promoted' }, [
|
|
207
|
+
provenance.notation,
|
|
208
|
+
]);
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
/**
|
|
212
|
+
* The evidence chip: `✓ verified`, or nothing.
|
|
213
|
+
*
|
|
214
|
+
* ABSENT READS `asserted`, which §16d states in those words, so an absent field
|
|
215
|
+
* draws no chip rather than a muted one — a panel that prints "asserted" on
|
|
216
|
+
* every row has spent a chip slot on the default.
|
|
217
|
+
*/
|
|
218
|
+
export function evidenceBadge(issue) {
|
|
219
|
+
if (issue?.evidence !== 'verified')
|
|
220
|
+
return null;
|
|
221
|
+
return element('span', { class: 'ig-badge', 'data-evidence': 'verified', title: 'evidence: verified' }, glyphAndLabel('✓', 'verified'));
|
|
222
|
+
}
|
|
86
223
|
/**
|
|
87
224
|
* One hold, rendered with the family it belongs to visible in the markup — and
|
|
88
225
|
* its cause and subject beside it when the host supplied them, so a host reads
|
|
@@ -90,12 +227,485 @@ export function provenanceLine(provenance) {
|
|
|
90
227
|
* when absent: `data-code=""` would claim a cause the host did not state.
|
|
91
228
|
*/
|
|
92
229
|
export function holdLine(hold) {
|
|
230
|
+
// THE LABEL IS THE TRACKER ARM'S ALONE, and the union is what lets this read
|
|
231
|
+
// it without asking: a graph hold has no `label` to read. Drawn INSIDE the
|
|
232
|
+
// paragraph, so a labelled hold is still one row child and the slot grid's
|
|
233
|
+
// column rule holds without a second placement.
|
|
234
|
+
const label = hold.family === 'tracker' && hold.label !== undefined && hold.label !== ''
|
|
235
|
+
? element('span', { class: 'ig-badge', 'data-hold': hold.label }, [hold.label])
|
|
236
|
+
: null;
|
|
93
237
|
return element('p', {
|
|
94
238
|
class: 'ig-hold',
|
|
95
239
|
'data-family': hold.family,
|
|
96
240
|
'data-code': hold.code,
|
|
97
241
|
'data-subject': hold.subject,
|
|
98
|
-
}, [hold.reason]);
|
|
242
|
+
}, [turn(), label, element('span', {}, [hold.reason])]);
|
|
243
|
+
}
|
|
244
|
+
/**
|
|
245
|
+
* The runner's own words for the holds a footer collects — `claimed · parked` —
|
|
246
|
+
* so the footer title can name them the way the design does. Empty when no
|
|
247
|
+
* hold carries a label, which keeps the title exactly as it was before labels
|
|
248
|
+
* existed.
|
|
249
|
+
*/
|
|
250
|
+
export function footerLabels(slots) {
|
|
251
|
+
const labels = [];
|
|
252
|
+
for (const slot of slots) {
|
|
253
|
+
for (const hold of slot.holds) {
|
|
254
|
+
if (hold.family !== 'tracker' || hold.label === undefined || hold.label === '')
|
|
255
|
+
continue;
|
|
256
|
+
if (!labels.includes(hold.label))
|
|
257
|
+
labels.push(hold.label);
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
return labels.join(' · ');
|
|
261
|
+
}
|
|
262
|
+
/**
|
|
263
|
+
* The projection toggle and the size affordance — controls the viewer PUBLISHES
|
|
264
|
+
* and does not wire, exactly like the refresh button beside them.
|
|
265
|
+
*
|
|
266
|
+
* THE VIEWER CANNOT SWITCH ITS OWN PROJECTION. It is a pure renderer: the host
|
|
267
|
+
* re-renders it with a different option, which is the same shape `refresh`
|
|
268
|
+
* already has. So the toggle is drawn here — §16a and §16b both draw it, and a
|
|
269
|
+
* panel whose two views are its whole point cannot leave the way between them
|
|
270
|
+
* to the host's imagination — and the command travels on the attribute the host
|
|
271
|
+
* is already listening to.
|
|
272
|
+
*/
|
|
273
|
+
function headerControls(controls) {
|
|
274
|
+
// A CONTROL NOBODY WIRED IS WORSE THAN NO CONTROL. This package cannot switch
|
|
275
|
+
// its own projection, so the host says whether it will — and until it does,
|
|
276
|
+
// the button is not drawn rather than drawn dead.
|
|
277
|
+
if (controls === undefined || !controls.switchable)
|
|
278
|
+
return null;
|
|
279
|
+
const toggle = (projection, glyph, label) => element('button', {
|
|
280
|
+
class: 'ig-toggle-option',
|
|
281
|
+
type: 'button',
|
|
282
|
+
'aria-pressed': controls.projection === projection ? 'true' : 'false',
|
|
283
|
+
[COMMAND_ATTRIBUTE]: `projection:${projection}`,
|
|
284
|
+
}, [element('span', { class: 'ig-glyph', 'aria-hidden': 'true' }, [glyph]), label]);
|
|
285
|
+
return element('span', { class: 'ig-toggle', role: 'group', 'aria-label': 'projection' }, [
|
|
286
|
+
toggle('linear', '☰', 'List'),
|
|
287
|
+
toggle('graph', '⛓', 'Graph'),
|
|
288
|
+
]);
|
|
289
|
+
}
|
|
290
|
+
/**
|
|
291
|
+
* The expand / collapse affordance the graph's two sizes need.
|
|
292
|
+
*
|
|
293
|
+
* GATED THE SAME WAY THE TOGGLE IS, and for the same reason: `expand` and
|
|
294
|
+
* `collapse` are published commands this package cannot perform on itself, so a
|
|
295
|
+
* host that has not wired them would be given a button that does nothing.
|
|
296
|
+
*/
|
|
297
|
+
function sizeControl(controls) {
|
|
298
|
+
if (controls === undefined || !controls.switchable || controls.compact === undefined)
|
|
299
|
+
return null;
|
|
300
|
+
// ITS OWN CLASS, NOT THE REFRESH BUTTON'S. They look alike and they are not
|
|
301
|
+
// the same thing: refresh is drawn only when a host supplied a word for it,
|
|
302
|
+
// and this is drawn whenever the graph is, so sharing a class made a document
|
|
303
|
+
// with no host facts render `ig-refresh` and broke the pure-graph promise.
|
|
304
|
+
return element('button', {
|
|
305
|
+
class: 'ig-size',
|
|
306
|
+
type: 'button',
|
|
307
|
+
[COMMAND_ATTRIBUTE]: controls.compact ? 'expand' : 'collapse',
|
|
308
|
+
}, [controls.compact ? '⤢ Expand' : '⤡ Collapse']);
|
|
309
|
+
}
|
|
310
|
+
export function hostHeader(document, controls) {
|
|
311
|
+
const { concurrencyCap, counts, freshness, adoption } = document.host;
|
|
312
|
+
// THREE CHIPS, NOT ONE SENTENCE, and the middle one carries the accent. §16a
|
|
313
|
+
// outlines each tally separately and tints only "how many could run right
|
|
314
|
+
// now, against the cap" — the number an operator acts on — so the panel
|
|
315
|
+
// spends its accent once instead of on a line of muted prose in which every
|
|
316
|
+
// figure looks equally worth reading. The cap rides on that chip because the
|
|
317
|
+
// comparison is the point: four ready against a cap of two says something
|
|
318
|
+
// neither number says alone.
|
|
319
|
+
const chips = [];
|
|
320
|
+
if (counts !== undefined) {
|
|
321
|
+
chips.push(element('span', { class: 'ig-count-chip', 'data-count': 'ranked' }, [
|
|
322
|
+
`${String(counts.ranked)} ranked`,
|
|
323
|
+
]), element('span', { class: 'ig-count-chip', 'data-count': 'ready' }, [
|
|
324
|
+
concurrencyCap === undefined
|
|
325
|
+
? `${String(counts.readyNow)} ready now`
|
|
326
|
+
: `${String(counts.readyNow)} ready now · cap ${String(concurrencyCap)}`,
|
|
327
|
+
]), element('span', { class: 'ig-count-chip', 'data-count': 'held' }, [
|
|
328
|
+
`${String(counts.held)} held`,
|
|
329
|
+
]));
|
|
330
|
+
}
|
|
331
|
+
else if (concurrencyCap !== undefined) {
|
|
332
|
+
chips.push(element('span', { class: 'ig-count-chip', 'data-count': 'ready' }, [
|
|
333
|
+
`cap ${String(concurrencyCap)}`,
|
|
334
|
+
]));
|
|
335
|
+
}
|
|
336
|
+
// §16h'S ONE AMBIGUITY WORTH REMOVING, stated once and here. "No edges shown"
|
|
337
|
+
// and "no edges declared" look identical on a panel, and only the host can
|
|
338
|
+
// tell them apart — it holds the whole backlog, while this document may be a
|
|
339
|
+
// window onto it. So the number is supplied like every other count, and the
|
|
340
|
+
// header prints it beside them rather than inventing a second place for it.
|
|
341
|
+
if (adoption?.counts !== undefined) {
|
|
342
|
+
chips.push(element('span', { class: 'ig-count-chip', 'data-count': 'adoption' }, [
|
|
343
|
+
`${String(adoption.counts.declaring)} of ${String(adoption.counts.total)} declare relationships`,
|
|
344
|
+
]));
|
|
345
|
+
}
|
|
346
|
+
const summary = chips.length === 0
|
|
347
|
+
? null
|
|
348
|
+
: element('p', { class: 'ig-counts', 'aria-label': 'order summary' }, chips);
|
|
349
|
+
const stamp = freshness === undefined
|
|
350
|
+
? null
|
|
351
|
+
: element('p', { class: 'ig-freshness', 'data-stale': freshness.stale === true ? 'true' : 'false' }, [
|
|
352
|
+
element('span', {}, [
|
|
353
|
+
'as of ',
|
|
354
|
+
element('span', { class: 'ig-id' }, [freshness.asOf]),
|
|
355
|
+
freshness.age === undefined || freshness.age === '' ? null : ` · ${freshness.age}`,
|
|
356
|
+
freshness.stale === true ? ' · stale' : null,
|
|
357
|
+
]),
|
|
358
|
+
// A CONTROL THE VIEWER PUBLISHES AND DOES NOT WIRE. Refreshing a mirror
|
|
359
|
+
// is fetching, which this layer never does; the host that can listens
|
|
360
|
+
// for the command. A real button rather than a styled span, so the
|
|
361
|
+
// mount's own click and Enter handling leave it alone the way they
|
|
362
|
+
// leave a deep link alone.
|
|
363
|
+
freshness.refresh === undefined || freshness.refresh === ''
|
|
364
|
+
? null
|
|
365
|
+
: element('button', { class: 'ig-refresh', type: 'button', [COMMAND_ATTRIBUTE]: 'refresh' }, [freshness.refresh]),
|
|
366
|
+
]);
|
|
367
|
+
const toggle = headerControls(controls);
|
|
368
|
+
const size = sizeControl(controls);
|
|
369
|
+
if (summary === null && stamp === null && toggle === null)
|
|
370
|
+
return null;
|
|
371
|
+
// THE PANEL'S OWN NAME IS PART OF THE HEADER BAR. §16a leads with it, and it
|
|
372
|
+
// is what tells a reader that the rows beneath are a PREVIEW of an order
|
|
373
|
+
// rather than the tracker's own list.
|
|
374
|
+
return element('header', { class: 'ig-header' }, [
|
|
375
|
+
element('div', { class: 'ig-header-top' }, [
|
|
376
|
+
element('span', { class: 'ig-header-lead' }, [
|
|
377
|
+
element('span', { class: 'ig-header-label' }, ['Order preview']),
|
|
378
|
+
toggle,
|
|
379
|
+
]),
|
|
380
|
+
element('span', { class: 'ig-header-lead' }, [stamp, size]),
|
|
381
|
+
]),
|
|
382
|
+
summary,
|
|
383
|
+
]);
|
|
384
|
+
}
|
|
385
|
+
/**
|
|
386
|
+
* The kind of condition the host stated, or nothing.
|
|
387
|
+
*
|
|
388
|
+
* Exported so each projection root can put it on the panel element as
|
|
389
|
+
* `data-ig-condition` and let the STYLESHEET decide what a condition looks
|
|
390
|
+
* like. `renderAttrs` omits a `null` attribute, so a host that stated none
|
|
391
|
+
* emits exactly the markup it emitted before this existed — which is what keeps
|
|
392
|
+
* the pure-graph promise a promise rather than an aspiration.
|
|
393
|
+
*/
|
|
394
|
+
export function conditionKind(document, chrome) {
|
|
395
|
+
// GATED LIKE THE NOTICE, because it is the same fact wearing a different
|
|
396
|
+
// channel: the stylesheet reads this attribute to outline the panel, so a
|
|
397
|
+
// chrome-less render kept the gold outline with nothing anywhere in it saying
|
|
398
|
+
// what the gold meant. An unexplained treatment is worse than none.
|
|
399
|
+
if (chrome === false)
|
|
400
|
+
return null;
|
|
401
|
+
return document.host.condition?.kind ?? null;
|
|
402
|
+
}
|
|
403
|
+
/**
|
|
404
|
+
* Whether the host has said why this panel is short, so no projection may say
|
|
405
|
+
* why itself.
|
|
406
|
+
*
|
|
407
|
+
* NOT CHROME, AND THIS IS THE DISTINCTION THE TWO HELPERS EXIST TO KEEP. What
|
|
408
|
+
* to DRAW is a rendering question, and a host composing two views of one
|
|
409
|
+
* document wants the notice and its outline once — that is `conditionKind`.
|
|
410
|
+
* What is TRUE is not: a chrome-less canvas beside an explained rail is still a
|
|
411
|
+
* view of a document whose emptiness the host has accounted for, and its own
|
|
412
|
+
* derived sentence is not a fallback explanation but a false one. The graph's
|
|
413
|
+
* reads "no issue in this document declares a relationship", which under a
|
|
414
|
+
* stated `error` is a claim about something else entirely, contradicting the
|
|
415
|
+
* panel beside it. Drawing nothing there is right; explaining it wrongly is not.
|
|
416
|
+
*/
|
|
417
|
+
export function statesItsOwnCause(document) {
|
|
418
|
+
return document.host.condition !== undefined;
|
|
419
|
+
}
|
|
420
|
+
/**
|
|
421
|
+
* One interpreter for the three arms.
|
|
422
|
+
*
|
|
423
|
+
* AN EXHAUSTIVE SWITCH, which is the whole of the branching this feature adds.
|
|
424
|
+
* The arms carry different field names, so a lookup table would have to reach
|
|
425
|
+
* them through an index the union does not offer — and the compiler proving a
|
|
426
|
+
* new arm was not forgotten is worth more here than the table's shape.
|
|
427
|
+
*
|
|
428
|
+
* THE COMMANDS ARE NAMESPACED. `data-ig-command` is one namespace shared with
|
|
429
|
+
* `@issuegraph/editor` and with whatever a host publishes beside it, and the
|
|
430
|
+
* bare word `retry` already means "retry that write" to the editor's own
|
|
431
|
+
* reducer. A control that looks wired and does something else is worse than
|
|
432
|
+
* the control this package refuses to draw at all.
|
|
433
|
+
*/
|
|
434
|
+
function noticeParts(condition) {
|
|
435
|
+
switch (condition.kind) {
|
|
436
|
+
case 'importing':
|
|
437
|
+
return { headline: condition.headline, body: [condition.caution], progress: condition.progress };
|
|
438
|
+
case 'empty':
|
|
439
|
+
return {
|
|
440
|
+
headline: condition.headline,
|
|
441
|
+
body: [condition.reason, condition.assurance],
|
|
442
|
+
...(condition.action === undefined
|
|
443
|
+
? {}
|
|
444
|
+
: {
|
|
445
|
+
control: {
|
|
446
|
+
label: condition.action.label,
|
|
447
|
+
command: 'review-pick-order',
|
|
448
|
+
...(condition.action.href === undefined ? {} : { href: condition.action.href }),
|
|
449
|
+
},
|
|
450
|
+
}),
|
|
451
|
+
};
|
|
452
|
+
case 'error':
|
|
453
|
+
return {
|
|
454
|
+
headline: condition.headline,
|
|
455
|
+
body: [condition.assurance],
|
|
456
|
+
...(condition.retry === undefined || condition.retry.trim() === ''
|
|
457
|
+
? {}
|
|
458
|
+
: { control: { label: condition.retry, command: 'retry:index' } }),
|
|
459
|
+
};
|
|
460
|
+
}
|
|
461
|
+
}
|
|
462
|
+
/**
|
|
463
|
+
* The panel-level notice: what the host says is true of the whole panel.
|
|
464
|
+
*
|
|
465
|
+
* CHROME, like the header beside it, and it takes the flag rather than leaving
|
|
466
|
+
* each projection to apply it: the rule is that a host composing two views of
|
|
467
|
+
* one document owns ONE notice for the same reason it owns one header, and a
|
|
468
|
+
* rule restated at three call sites is three places to forget it. The same
|
|
469
|
+
* argument that gave `hostHeader` its gate gives this one its parameter.
|
|
470
|
+
*
|
|
471
|
+
* `role="status"` SITS ON THE TEXT, NOT THE CONTAINER. A polite live region
|
|
472
|
+
* holding an interactive control re-announces the button every time the host
|
|
473
|
+
* re-renders, which turns a reassurance into a nag.
|
|
474
|
+
*
|
|
475
|
+
* A CONTROL IS DRAWN ONLY FOR A HOST THAT NAMED ONE, exactly as the refresh
|
|
476
|
+
* button beside the stamp is. An `href` makes it a link instead of a command —
|
|
477
|
+
* some hosts route rather than act — and the scheme was already refused or
|
|
478
|
+
* kept by `normalizeDocument`, so nothing is checked a second time here.
|
|
479
|
+
*/
|
|
480
|
+
export function conditionNotice(document, chrome) {
|
|
481
|
+
const condition = document.host.condition;
|
|
482
|
+
if (chrome === false || condition === undefined)
|
|
483
|
+
return null;
|
|
484
|
+
const parts = noticeParts(condition);
|
|
485
|
+
const control = parts.control === undefined
|
|
486
|
+
? null
|
|
487
|
+
: parts.control.href === undefined
|
|
488
|
+
? element('button', { class: 'ig-notice-action', type: 'button', [COMMAND_ATTRIBUTE]: parts.control.command }, [parts.control.label])
|
|
489
|
+
: element('a',
|
|
490
|
+
// `rel="noreferrer"`, THE SAME ANSWER `identity` GIVES. This is the
|
|
491
|
+
// package's other rendering of a host-supplied URL, and two answers
|
|
492
|
+
// to one question is the drift the shared scheme check exists to
|
|
493
|
+
// avoid — applied to the attribute set as well as to the check.
|
|
494
|
+
{ class: 'ig-notice-action', href: parts.control.href, rel: 'noreferrer' }, [parts.control.label]);
|
|
495
|
+
return element('aside', { class: 'ig-notice', 'data-ig-condition': condition.kind }, [
|
|
496
|
+
element('div', { class: 'ig-notice-text', role: 'status' }, [
|
|
497
|
+
element('p', { class: 'ig-notice-head' }, condition.glyph === undefined || condition.glyph === ''
|
|
498
|
+
? [condition.headline]
|
|
499
|
+
: glyphAndLabel(condition.glyph, condition.headline)),
|
|
500
|
+
...parts.body.map((line) => element('p', { class: 'ig-notice-body' }, [line])),
|
|
501
|
+
parts.progress === undefined ? null : element('p', { class: 'ig-notice-progress' }, [parts.progress]),
|
|
502
|
+
]),
|
|
503
|
+
control,
|
|
504
|
+
]);
|
|
505
|
+
}
|
|
506
|
+
/**
|
|
507
|
+
* §16h's one quiet line: why this panel shows no relationships at all.
|
|
508
|
+
*
|
|
509
|
+
* THE SENTENCE IS THE HOST'S, and it has to be: the cause names a host's own
|
|
510
|
+
* configuration, and a package that has never heard of a pick order cannot
|
|
511
|
+
* write it. The line, the link and the dismiss control are this package's.
|
|
512
|
+
*
|
|
513
|
+
* THE VIEWER NEVER HIDES ITS OWN LINE. Dismiss is published like every other
|
|
514
|
+
* command; the host re-renders without the note. A package that removed the
|
|
515
|
+
* element itself would be holding state, which is the one thing this layer
|
|
516
|
+
* promises it does not do.
|
|
517
|
+
*/
|
|
518
|
+
export function adoptionNote(document, chrome) {
|
|
519
|
+
const note = document.host.adoption?.note;
|
|
520
|
+
if (chrome === false || note === undefined)
|
|
521
|
+
return null;
|
|
522
|
+
return element('p', { class: 'ig-adoption' }, [
|
|
523
|
+
// THE SENTENCE STAYS PLAIN. Its link is a short label beside it, not the
|
|
524
|
+
// whole line wearing an underline — this is the one piece of commentary on
|
|
525
|
+
// a panel the design asks to read complete and calm.
|
|
526
|
+
element('span', { class: 'ig-adoption-text' }, [note.text]),
|
|
527
|
+
note.link === undefined
|
|
528
|
+
? null
|
|
529
|
+
: element('a', { class: 'ig-adoption-link', href: note.link.href, rel: 'noreferrer' }, [note.link.text]),
|
|
530
|
+
note.dismiss === undefined || note.dismiss.trim() === ''
|
|
531
|
+
? null
|
|
532
|
+
: element('button', {
|
|
533
|
+
class: 'ig-adoption-dismiss',
|
|
534
|
+
type: 'button',
|
|
535
|
+
'aria-label': note.dismiss,
|
|
536
|
+
[COMMAND_ATTRIBUTE]: 'dismiss:adoption',
|
|
537
|
+
}, [note.dismiss]),
|
|
538
|
+
]);
|
|
539
|
+
}
|
|
540
|
+
/**
|
|
541
|
+
* The NOW rows: what the runner is working this moment, drawn above the order.
|
|
542
|
+
*
|
|
543
|
+
* A POINTER IDENTITY, NEVER A FOCUS ONE. The running issue may also hold a slot
|
|
544
|
+
* — a claimed row in the footer, say — and exactly one element per key carries
|
|
545
|
+
* `data-ig-key` or `focus()` lands on whichever the renderer emitted first. So
|
|
546
|
+
* the row announces itself through {@link GROUP_ATTRIBUTE}, the way an
|
|
547
|
+
* enclosure does: clickable and hoverable, resolving to the issue through the
|
|
548
|
+
* document, and absent from the focus index. The title and identity are the
|
|
549
|
+
* ISSUE'S, looked up here; only the phase and the elapsed time are the host's
|
|
550
|
+
* words, because only the host has the clock.
|
|
551
|
+
*/
|
|
552
|
+
export function nowRows(document) {
|
|
553
|
+
if (document.host.running.length === 0)
|
|
554
|
+
return null;
|
|
555
|
+
return element('ol', { class: 'ig-now', 'aria-label': 'working now' }, document.host.running.map((job) => {
|
|
556
|
+
// Present by construction: `normalizeHost` dropped any job the document
|
|
557
|
+
// does not carry. The fallback keeps this total rather than trusting it.
|
|
558
|
+
const issue = document.byKey.get(job.key);
|
|
559
|
+
return element('li', {
|
|
560
|
+
class: 'ig-now-row',
|
|
561
|
+
[GROUP_ATTRIBUTE]: job.key,
|
|
562
|
+
// The list already announces "working now"; the row says what and how long.
|
|
563
|
+
'aria-label': `${issue?.title ?? job.key} — ${job.phase} · ${job.elapsed}`,
|
|
564
|
+
}, [
|
|
565
|
+
element('span', { class: 'ig-now-mark', 'aria-hidden': 'true' }, ['now']),
|
|
566
|
+
// THE SAME TWO-LINE HEAD EVERY ROW HAS. The band that shipped put the
|
|
567
|
+
// title, the identity and the phase on one line at three different
|
|
568
|
+
// ends of it, so the row being worked read as less structured than
|
|
569
|
+
// the queued rows beneath it — the opposite of what the frame does.
|
|
570
|
+
element('div', { class: 'ig-row-head' }, [
|
|
571
|
+
element('span', { class: 'ig-title' }, [issue?.title ?? job.key]),
|
|
572
|
+
element('span', { class: 'ig-id' }, [
|
|
573
|
+
issue === undefined ? job.key : identity(issue),
|
|
574
|
+
` · ${job.phase} · ${job.elapsed}`,
|
|
575
|
+
]),
|
|
576
|
+
]),
|
|
577
|
+
element('span', { class: 'ig-now-phase' }, [
|
|
578
|
+
element('span', { class: 'ig-now-pulse', 'aria-hidden': 'true' }),
|
|
579
|
+
'working',
|
|
580
|
+
]),
|
|
581
|
+
]);
|
|
582
|
+
}));
|
|
583
|
+
}
|
|
584
|
+
/**
|
|
585
|
+
* A together unit's members, one line each.
|
|
586
|
+
*
|
|
587
|
+
* THE UNIT IS THE ONE PLACE THE DESIGN DRAWS AN ENCLOSURE, and it draws it to
|
|
588
|
+
* say that two distinct issues are one unit of work. Joining their titles with
|
|
589
|
+
* a separator — which is what shipped — says the opposite: a reader sees one
|
|
590
|
+
* issue with an unusually long name, and the second issue's own identity and
|
|
591
|
+
* tier vanish. Returns `null` for a single-member slot, so an ordinary row is
|
|
592
|
+
* unchanged by construction.
|
|
593
|
+
*/
|
|
594
|
+
export function unitBlock(document, slot) {
|
|
595
|
+
if (slot.members.length < 2)
|
|
596
|
+
return null;
|
|
597
|
+
return element('ul', { class: 'ig-unit', 'aria-label': `one unit of ${String(slot.members.length)} issues` }, slot.members.map((member) => {
|
|
598
|
+
const issue = document.byKey.get(member);
|
|
599
|
+
// THROUGH `identity`, WHICH IS WHERE THE DEEP LINK LIVES. Printing the key
|
|
600
|
+
// as plain text took the chip away from the lead — which had one before
|
|
601
|
+
// this row shape existed — and gave the partners none either, so a unit
|
|
602
|
+
// was the one row in the panel from which no issue could be opened at
|
|
603
|
+
// all. §16e calls that chip "the only external link", so losing it on a
|
|
604
|
+
// unit loses the only way out of it.
|
|
605
|
+
return element('li', { class: 'ig-unit-member' }, [
|
|
606
|
+
element('span', { class: 'ig-title' }, [issue?.title ?? member]),
|
|
607
|
+
issue === undefined
|
|
608
|
+
? element('span', { class: 'ig-id' }, [member])
|
|
609
|
+
: element('span', { class: 'ig-id' }, [
|
|
610
|
+
identity(issue),
|
|
611
|
+
` · P${String(issue.priority)}`,
|
|
612
|
+
]),
|
|
613
|
+
]);
|
|
614
|
+
}));
|
|
615
|
+
}
|
|
616
|
+
/** The unit's own pill, above the enclosure: what it is, and how many. */
|
|
617
|
+
export function unitMark(slot) {
|
|
618
|
+
if (slot.members.length < 2)
|
|
619
|
+
return null;
|
|
620
|
+
return element('div', { class: 'ig-unit-mark' }, [
|
|
621
|
+
element('span', { class: 'ig-unit-pill' }, [
|
|
622
|
+
`⧉ one unit · ${String(slot.members.length)} issues`,
|
|
623
|
+
]),
|
|
624
|
+
element('span', { class: 'ig-unit-note' }, ['worked together']),
|
|
625
|
+
]);
|
|
626
|
+
}
|
|
627
|
+
/**
|
|
628
|
+
* The two caveats a host can put on a row, each ONE row child so the slot grid
|
|
629
|
+
* places it like provenance.
|
|
630
|
+
*
|
|
631
|
+
* `preview-only`: the host's engine could not evaluate a pick-order query, and
|
|
632
|
+
* the note says what it fell back to. `signals disagree`: two ordering signals
|
|
633
|
+
* named different priorities, the host chose one, and the design's rule is
|
|
634
|
+
* that the loser stays visible — struck through, never hidden — because a panel
|
|
635
|
+
* whose job is explaining the order cannot silently pick a winner.
|
|
636
|
+
*/
|
|
637
|
+
export function caveatLines(issue) {
|
|
638
|
+
if (issue === undefined)
|
|
639
|
+
return [];
|
|
640
|
+
const lines = [];
|
|
641
|
+
if (issue.previewOnly !== undefined) {
|
|
642
|
+
lines.push(element('p', { class: 'ig-caveat', 'data-caveat': 'preview-only' }, [
|
|
643
|
+
turn(),
|
|
644
|
+
element('span', {}, [issue.previewOnly.note]),
|
|
645
|
+
]));
|
|
646
|
+
}
|
|
647
|
+
if (issue.disagreement !== undefined) {
|
|
648
|
+
const { used, ignored } = issue.disagreement;
|
|
649
|
+
lines.push(element('p', { class: 'ig-caveat', 'data-caveat': 'disagree' }, [
|
|
650
|
+
turn(),
|
|
651
|
+
element('span', {}, [
|
|
652
|
+
`ranked by ${used} · ${ignored.carrier} declares `,
|
|
653
|
+
element('s', { class: 'ig-strike' }, [ignored.value]),
|
|
654
|
+
]),
|
|
655
|
+
]));
|
|
656
|
+
}
|
|
657
|
+
return lines;
|
|
658
|
+
}
|
|
659
|
+
/**
|
|
660
|
+
* The caveat CHIPS, which sit on the badge row rather than in front of the note.
|
|
661
|
+
*
|
|
662
|
+
* §16a treats a caveat exactly like a relationship: a chip a scanner sees, and
|
|
663
|
+
* a sentence a reader reads. Leaving the chip inline in the sentence made the
|
|
664
|
+
* badge row incomplete and the sentence lumpy — the chip is a fact ABOUT the
|
|
665
|
+
* row, not the first two words of an explanation.
|
|
666
|
+
*/
|
|
667
|
+
export function caveatBadges(issue) {
|
|
668
|
+
if (issue === undefined)
|
|
669
|
+
return [];
|
|
670
|
+
const chips = [];
|
|
671
|
+
if (issue.previewOnly !== undefined) {
|
|
672
|
+
chips.push(element('span', { class: 'ig-badge', 'data-caveat': 'preview-only' }, glyphAndLabel('◐', 'preview-only')));
|
|
673
|
+
}
|
|
674
|
+
if (issue.disagreement !== undefined) {
|
|
675
|
+
chips.push(element('span', { class: 'ig-badge', 'data-caveat': 'disagree' }, glyphAndLabel('◆', 'signals disagree')));
|
|
676
|
+
}
|
|
677
|
+
return chips;
|
|
678
|
+
}
|
|
679
|
+
/**
|
|
680
|
+
* The `⊘ not ready` chip a graph-held row wears.
|
|
681
|
+
*
|
|
682
|
+
* The frame puts it on the badge row beside the tier, so "held" is legible in
|
|
683
|
+
* the same scan as "P1" rather than only from the hatched ground or the
|
|
684
|
+
* sentence underneath.
|
|
685
|
+
*/
|
|
686
|
+
export function notReadyBadge(slot) {
|
|
687
|
+
if (slot.ready)
|
|
688
|
+
return null;
|
|
689
|
+
if (!slot.holds.some((hold) => hold.family === 'graph'))
|
|
690
|
+
return null;
|
|
691
|
+
return element('span', { class: 'ig-badge', 'data-not-ready': 'true' }, glyphAndLabel('⊘', 'not ready'));
|
|
692
|
+
}
|
|
693
|
+
/**
|
|
694
|
+
* The caveats as one sentence, for a row that has no room for a block — the
|
|
695
|
+
* graph rail positions its rows onto layout boxes, so it carries text on the
|
|
696
|
+
* label and the tooltip instead (the same choice it makes for holds).
|
|
697
|
+
*/
|
|
698
|
+
export function caveatText(issue) {
|
|
699
|
+
if (issue === undefined)
|
|
700
|
+
return '';
|
|
701
|
+
const parts = [];
|
|
702
|
+
if (issue.previewOnly !== undefined)
|
|
703
|
+
parts.push(`preview-only: ${issue.previewOnly.note}`);
|
|
704
|
+
if (issue.disagreement !== undefined) {
|
|
705
|
+
const { used, ignored } = issue.disagreement;
|
|
706
|
+
parts.push(`signals disagree: ranked by ${used}, ${ignored.carrier} declares ${ignored.value}`);
|
|
707
|
+
}
|
|
708
|
+
return parts.join(' · ');
|
|
99
709
|
}
|
|
100
710
|
/**
|
|
101
711
|
* A badge naming one relationship, on all four channels at once.
|
|
@@ -125,18 +735,22 @@ export function holdLine(hold) {
|
|
|
125
735
|
* `keyAt` walks target-upward. That is the intended half of "an edge is pointable
|
|
126
736
|
* in every projection", and it is what the canvas already does for an arc.
|
|
127
737
|
*/
|
|
128
|
-
function edgeBadge(field, edgeId,
|
|
738
|
+
function edgeBadge(field, edgeId, label, other) {
|
|
129
739
|
const treatment = treatmentFor(field);
|
|
130
740
|
return element('span', {
|
|
131
741
|
class: 'ig-badge',
|
|
132
742
|
'data-edge': field,
|
|
133
743
|
[GROUP_ATTRIBUTE]: edgeId,
|
|
134
|
-
title: `${
|
|
135
|
-
'aria-label': `${
|
|
136
|
-
},
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
744
|
+
title: `${label} ${other}`,
|
|
745
|
+
'aria-label': `${label} ${other}`,
|
|
746
|
+
},
|
|
747
|
+
// WORDED, NOT GLYPH-AND-NUMBER. `⊘ 512` asks a reader to hold a five-glyph
|
|
748
|
+
// legend in their head while scanning a column of rows; `⊘ blocked by #512`
|
|
749
|
+
// does not, and §16a and §16b both draw the word. The glyph stays — it is
|
|
750
|
+
// one of the four channels the colour-blind-safety claim rests on — and the
|
|
751
|
+
// label it now shows is the same string the accessible name already used,
|
|
752
|
+
// so what is announced and what is drawn cannot drift.
|
|
753
|
+
glyphAndLabel(treatment.glyph, `${label} ${other}`));
|
|
140
754
|
}
|
|
141
755
|
/**
|
|
142
756
|
* How many relationship badges one row draws before the rest collapse into a
|
|
@@ -194,6 +808,18 @@ function overflowBadge(omitted) {
|
|
|
194
808
|
* key — a single-key row is unchanged by construction.
|
|
195
809
|
*/
|
|
196
810
|
export function edgeBadges(document, keys) {
|
|
811
|
+
const badges = edgeBadgeList(document, keys);
|
|
812
|
+
return badges.length === 0 ? null : element('span', { class: 'ig-badges' }, badges);
|
|
813
|
+
}
|
|
814
|
+
/**
|
|
815
|
+
* The same badges, unwrapped, for a row that builds ONE badge row out of
|
|
816
|
+
* several sources — a priority chip, an evidence chip, a caveat chip and the
|
|
817
|
+
* relationships — which is what §16a draws. Returning the wrapper to such a
|
|
818
|
+
* caller left it either nesting one flex row inside another or reaching into
|
|
819
|
+
* the spec's children, and the second is how markup and its published
|
|
820
|
+
* behaviour drift.
|
|
821
|
+
*/
|
|
822
|
+
export function edgeBadgeList(document, keys) {
|
|
197
823
|
const mine = new Set(keys);
|
|
198
824
|
const badges = [];
|
|
199
825
|
// COUNTED PAST THE BUDGET, NOT BUILT. The walk still visits every edge so
|
|
@@ -237,30 +863,273 @@ export function edgeBadges(document, keys) {
|
|
|
237
863
|
// is announced as one relationship rather than as two directions.
|
|
238
864
|
const outgoing = mine.has(edge.from);
|
|
239
865
|
const other = outgoing ? edge.to : edge.from;
|
|
240
|
-
|
|
241
|
-
|
|
866
|
+
// THE VOCABULARY WORDS IT, not this loop. `labelFrom` is the one
|
|
867
|
+
// construction of "which verb, read from which end", and §17a's
|
|
868
|
+
// inspector row draws the same edge from it one zone away.
|
|
869
|
+
const label = labelFrom(treatment, outgoing);
|
|
870
|
+
badges.push(edgeBadge(field, edgeId, label, other));
|
|
242
871
|
}
|
|
243
872
|
}
|
|
244
873
|
}
|
|
245
|
-
if (badges.length === 0)
|
|
246
|
-
return null;
|
|
247
874
|
if (omitted > 0)
|
|
248
875
|
badges.push(overflowBadge(omitted));
|
|
249
|
-
return
|
|
876
|
+
return badges;
|
|
877
|
+
}
|
|
878
|
+
/**
|
|
879
|
+
* The VISIBLE TEXT of every chip a row's badge row draws, in order.
|
|
880
|
+
*
|
|
881
|
+
* EXPORTED FOR THE LAYOUT, which has to know how tall a card is before anything
|
|
882
|
+
* is rendered and cannot measure the DOM — this package is pure and its
|
|
883
|
+
* coordinates have to be the same on a server with no fonts as in a browser
|
|
884
|
+
* with them. Deriving the strings here rather than re-deriving them there is
|
|
885
|
+
* what keeps the reserved height and the drawn height about the same thing: a
|
|
886
|
+
* badge whose wording changes moves the geometry with it.
|
|
887
|
+
*/
|
|
888
|
+
export function badgeTexts(document, slot, issue, keys) {
|
|
889
|
+
// THROUGH `textOf` FOR EVERY CHIP, so the GLYPH is measured with the label it
|
|
890
|
+
// sits beside. Two of these were written out by hand and lost their glyph —
|
|
891
|
+
// `✓ verified` measured as `verified`, `⊘ not ready` as `not ready` — which
|
|
892
|
+
// under-measured exactly the chips a held row carries, and near a row
|
|
893
|
+
// boundary the browser wrapped one this packer had kept on the previous row.
|
|
894
|
+
// The builders are the one source; nothing here restates what they draw.
|
|
895
|
+
const chips = [
|
|
896
|
+
priorityBadge(issue?.provenance),
|
|
897
|
+
evidenceBadge(issue),
|
|
898
|
+
slot === undefined ? null : notReadyBadge(slot),
|
|
899
|
+
...caveatBadges(issue),
|
|
900
|
+
...edgeBadgeList(document, keys),
|
|
901
|
+
];
|
|
902
|
+
return chips.filter((chip) => chip !== null).map(textOf);
|
|
903
|
+
}
|
|
904
|
+
/** Every string inside one spec, concatenated — what a reader sees on it. */
|
|
905
|
+
function textOf(spec) {
|
|
906
|
+
let text = '';
|
|
907
|
+
for (const child of spec.children ?? []) {
|
|
908
|
+
if (child === null || child === undefined)
|
|
909
|
+
continue;
|
|
910
|
+
text += typeof child === 'string' ? child : textOf(child);
|
|
911
|
+
}
|
|
912
|
+
return text;
|
|
913
|
+
}
|
|
914
|
+
/**
|
|
915
|
+
* Everything one canvas card draws, in the order it draws it.
|
|
916
|
+
*
|
|
917
|
+
* `onSpine` decides the notes and nothing else: a spine card keeps its hold on
|
|
918
|
+
* a chip and a tooltip, because the list beside it prints the sentence, while a
|
|
919
|
+
* gutter card is the only mark the projection makes for its issue and §16b
|
|
920
|
+
* prints "open · not eligible" on it in as many words.
|
|
921
|
+
*/
|
|
922
|
+
export function cardBlocks(document, key, members, onSpine, now) {
|
|
923
|
+
const slot = document.order.slots.find((candidate) => candidate.lead === key);
|
|
924
|
+
const issue = document.byKey.get(key);
|
|
925
|
+
const blocks = [];
|
|
926
|
+
if (now) {
|
|
927
|
+
const job = document.host.running.find((candidate) => members.includes(candidate.key));
|
|
928
|
+
blocks.push({
|
|
929
|
+
kind: 'banner',
|
|
930
|
+
mark: 'now',
|
|
931
|
+
note: job === undefined ? '' : `${job.phase} · ${job.elapsed}`,
|
|
932
|
+
});
|
|
933
|
+
}
|
|
934
|
+
if (members.length > 1 && slot !== undefined) {
|
|
935
|
+
blocks.push({
|
|
936
|
+
kind: 'banner',
|
|
937
|
+
mark: `⧉ one unit · ${String(members.length)} issues`,
|
|
938
|
+
note: 'worked together',
|
|
939
|
+
});
|
|
940
|
+
blocks.push({ kind: 'unit', members });
|
|
941
|
+
}
|
|
942
|
+
else {
|
|
943
|
+
blocks.push({ kind: 'head', key });
|
|
944
|
+
}
|
|
945
|
+
const texts = badgeTexts(document, slot, issue, members);
|
|
946
|
+
if (texts.length > 0)
|
|
947
|
+
blocks.push({ kind: 'badges', texts });
|
|
948
|
+
if (!onSpine) {
|
|
949
|
+
for (const text of noteText(document, key))
|
|
950
|
+
blocks.push({ kind: 'note', text });
|
|
951
|
+
}
|
|
952
|
+
return blocks;
|
|
953
|
+
}
|
|
954
|
+
/**
|
|
955
|
+
* The sentences a card off the spine prints: why it is held, and what it
|
|
956
|
+
* duplicates.
|
|
957
|
+
*
|
|
958
|
+
* Lives here rather than in the layout for the reason {@link cardBlocks} gives:
|
|
959
|
+
* one description, two readers.
|
|
960
|
+
*/
|
|
961
|
+
export function noteText(document, key) {
|
|
962
|
+
const lines = [];
|
|
963
|
+
for (const slot of document.order.slots) {
|
|
964
|
+
if (slot.lead !== key)
|
|
965
|
+
continue;
|
|
966
|
+
for (const hold of slot.holds)
|
|
967
|
+
lines.push(hold.reason);
|
|
968
|
+
}
|
|
969
|
+
for (const exclusion of document.order.excluded) {
|
|
970
|
+
// THE BADGE ALREADY NAMES THE CANONICAL, so the sentence says only what the
|
|
971
|
+
// badge cannot: that this issue is never worked at all.
|
|
972
|
+
if (exclusion.key === key)
|
|
973
|
+
lines.push('never worked — the canonical is worked instead');
|
|
974
|
+
}
|
|
975
|
+
return lines;
|
|
976
|
+
}
|
|
977
|
+
/**
|
|
978
|
+
* The text runs one block puts on the card, each of which the layout wraps and
|
|
979
|
+
* counts as one or more lines.
|
|
980
|
+
*
|
|
981
|
+
* A `head` is a title AND an identity, and a `unit` is that pair per member —
|
|
982
|
+
* so the count is per RUN rather than per block, which is what lets the layout
|
|
983
|
+
* charge a wrapped title what it costs.
|
|
984
|
+
*/
|
|
985
|
+
export function cardText(document, block) {
|
|
986
|
+
switch (block.kind) {
|
|
987
|
+
case 'banner':
|
|
988
|
+
return [`${block.mark} ${block.note}`];
|
|
989
|
+
case 'head': {
|
|
990
|
+
const issue = document.byKey.get(block.key);
|
|
991
|
+
return [issue?.title ?? block.key, block.key];
|
|
992
|
+
}
|
|
993
|
+
case 'unit':
|
|
994
|
+
return block.members.flatMap((member) => [
|
|
995
|
+
document.byKey.get(member)?.title ?? member,
|
|
996
|
+
member,
|
|
997
|
+
]);
|
|
998
|
+
case 'badges':
|
|
999
|
+
return block.texts;
|
|
1000
|
+
case 'note':
|
|
1001
|
+
return [block.text];
|
|
1002
|
+
}
|
|
250
1003
|
}
|
|
251
|
-
/**
|
|
1004
|
+
/**
|
|
1005
|
+
* The legend: a footer bar under the drawing it explains.
|
|
1006
|
+
*
|
|
1007
|
+
* IT DRAWS THE LINE, NOT A CHIP OF IT. The claim this package makes about
|
|
1008
|
+
* colour-blind safety is that each relationship is separable by dash pattern,
|
|
1009
|
+
* terminal marker and glyph as well as hue — and a legend of five outlined
|
|
1010
|
+
* chips shows none of the first two, so a reader meeting a dotted arc has
|
|
1011
|
+
* nothing to match it against. Each sample is the same stroke and the same
|
|
1012
|
+
* dash array the canvas uses, taken from `vocabulary.ts` rather than restated,
|
|
1013
|
+
* and the terminal is the marker that edge actually ends in.
|
|
1014
|
+
*
|
|
1015
|
+
* THE READINESS KEY RIDES ALONG. The station fill is the parallelism channel —
|
|
1016
|
+
* filled, hollow, dashed — and it is the one part of the grammar the edge table
|
|
1017
|
+
* cannot explain, so §16b keys it at the far end of the same bar.
|
|
1018
|
+
*/
|
|
252
1019
|
export function legend() {
|
|
253
1020
|
return element('fieldset', { class: 'ig-legend' }, [
|
|
254
1021
|
element('legend', { class: 'ig-legend-caption' }, ['relationships']),
|
|
255
1022
|
...EDGE_ORDER.map((field) => {
|
|
256
1023
|
const treatment = treatmentFor(field);
|
|
257
|
-
return element('span', { class: 'ig-
|
|
258
|
-
|
|
259
|
-
|
|
1024
|
+
return element('span', { class: 'ig-legend-item' }, [
|
|
1025
|
+
legendSample(field),
|
|
1026
|
+
// THE PAIRING, NOT A COPY OF IT. This spelled the glyph span and the
|
|
1027
|
+
// word out by hand — the same two elements `glyphAndLabel` returns,
|
|
1028
|
+
// free to lose the `aria-hidden` that makes the mark silent, in the
|
|
1029
|
+
// file whose own header calls that the failure the shared pairing
|
|
1030
|
+
// exists to prevent.
|
|
1031
|
+
...glyphAndLabel(treatment.glyph, treatment.label),
|
|
260
1032
|
]);
|
|
261
1033
|
}),
|
|
1034
|
+
element('span', { class: 'ig-legend-keys' }, [
|
|
1035
|
+
...STATION_KEYS.map(([fill, label]) => element('span', { class: 'ig-legend-item' }, [station(fill), element('span', {}, [label])])),
|
|
1036
|
+
]),
|
|
262
1037
|
]);
|
|
263
1038
|
}
|
|
1039
|
+
const STATION_KEYS = Object.freeze([
|
|
1040
|
+
['filled', 'ready now'],
|
|
1041
|
+
['hollow', 'ready after'],
|
|
1042
|
+
['dashed', 'held'],
|
|
1043
|
+
]);
|
|
1044
|
+
/** The width and height of one drawn legend sample, in CSS pixels. */
|
|
1045
|
+
const SAMPLE_WIDTH = 34;
|
|
1046
|
+
const SAMPLE_HEIGHT = 10;
|
|
1047
|
+
/**
|
|
1048
|
+
* One relationship, drawn the way the canvas draws it.
|
|
1049
|
+
*
|
|
1050
|
+
* `together-with` is an enclosure rather than a line, so its sample is the
|
|
1051
|
+
* rounded outline the canvas puts around a unit — drawing it as a line would
|
|
1052
|
+
* be the legend teaching a shape the picture never uses.
|
|
1053
|
+
*/
|
|
1054
|
+
function legendSample(field) {
|
|
1055
|
+
const treatment = treatmentFor(field);
|
|
1056
|
+
const dash = dashArrayFor(treatment.dash);
|
|
1057
|
+
const box = { width: String(SAMPLE_WIDTH), height: String(SAMPLE_HEIGHT), 'aria-hidden': 'true' };
|
|
1058
|
+
const mid = SAMPLE_HEIGHT / 2;
|
|
1059
|
+
// BUILT IN THE SVG NAMESPACE, and `element` does not put it there. That works
|
|
1060
|
+
// by accident through `renderViewer`, whose output a host parses as HTML —
|
|
1061
|
+
// but `mountViewer` materializes the same spec with `createElement`, which
|
|
1062
|
+
// builds an HTML `<svg>` and HTML `<line>` children that draw nothing at all.
|
|
1063
|
+
// So a mounted viewer showed five blank boxes where the legend's samples are,
|
|
1064
|
+
// which is the whole of what the samples were added for.
|
|
1065
|
+
if (treatment.dash === 'enclosure') {
|
|
1066
|
+
return svg('svg', box, [
|
|
1067
|
+
svg('rect', {
|
|
1068
|
+
class: 'ig-enclosure',
|
|
1069
|
+
'data-edge': field,
|
|
1070
|
+
x: '1',
|
|
1071
|
+
y: '1',
|
|
1072
|
+
width: String(SAMPLE_WIDTH - 2),
|
|
1073
|
+
height: String(SAMPLE_HEIGHT - 2),
|
|
1074
|
+
rx: '3',
|
|
1075
|
+
}),
|
|
1076
|
+
]);
|
|
1077
|
+
}
|
|
1078
|
+
const lines = treatment.dash === 'double'
|
|
1079
|
+
? [
|
|
1080
|
+
lineSample(field, 1, SAMPLE_WIDTH, mid - 2, dash),
|
|
1081
|
+
lineSample(field, 1, SAMPLE_WIDTH, mid + 2, dash),
|
|
1082
|
+
]
|
|
1083
|
+
: [lineSample(field, 1, SAMPLE_WIDTH - 7, mid, dash)];
|
|
1084
|
+
return svg('svg', box, [...lines, terminalSample(field, treatment.terminal, mid)]);
|
|
1085
|
+
}
|
|
1086
|
+
function lineSample(field, x1, x2, y, dash) {
|
|
1087
|
+
return svg('line', {
|
|
1088
|
+
class: 'ig-edge',
|
|
1089
|
+
'data-edge': field,
|
|
1090
|
+
x1: String(x1),
|
|
1091
|
+
y1: String(y),
|
|
1092
|
+
x2: String(x2),
|
|
1093
|
+
y2: String(y),
|
|
1094
|
+
...(dash === null ? {} : { 'stroke-dasharray': dash }),
|
|
1095
|
+
});
|
|
1096
|
+
}
|
|
1097
|
+
/** The sample's pointed end, in the shape the vocabulary names for that edge. */
|
|
1098
|
+
function terminalSample(field, terminal, y) {
|
|
1099
|
+
const tip = SAMPLE_WIDTH;
|
|
1100
|
+
switch (terminal) {
|
|
1101
|
+
case 'arrow':
|
|
1102
|
+
return svg('path', {
|
|
1103
|
+
class: 'ig-terminal',
|
|
1104
|
+
'data-edge': field,
|
|
1105
|
+
fill: 'currentColor',
|
|
1106
|
+
d: `M${String(tip - 7)},${String(y - 3)} L${String(tip)},${String(y)} L${String(tip - 7)},${String(y + 3)} z`,
|
|
1107
|
+
});
|
|
1108
|
+
case 'hollow-circle':
|
|
1109
|
+
return svg('circle', {
|
|
1110
|
+
class: 'ig-terminal',
|
|
1111
|
+
'data-edge': field,
|
|
1112
|
+
fill: 'none',
|
|
1113
|
+
stroke: 'currentColor',
|
|
1114
|
+
cx: String(tip - 3),
|
|
1115
|
+
cy: String(y),
|
|
1116
|
+
r: '3',
|
|
1117
|
+
});
|
|
1118
|
+
case 'tee':
|
|
1119
|
+
return svg('line', {
|
|
1120
|
+
class: 'ig-terminal',
|
|
1121
|
+
'data-edge': field,
|
|
1122
|
+
stroke: 'currentColor',
|
|
1123
|
+
x1: String(tip - 2),
|
|
1124
|
+
y1: String(y - 4),
|
|
1125
|
+
x2: String(tip - 2),
|
|
1126
|
+
y2: String(y + 4),
|
|
1127
|
+
});
|
|
1128
|
+
case 'none':
|
|
1129
|
+
case 'enclosure':
|
|
1130
|
+
return null;
|
|
1131
|
+
}
|
|
1132
|
+
}
|
|
264
1133
|
/**
|
|
265
1134
|
* Every key a projection draws under some OTHER key's station, mapped to it.
|
|
266
1135
|
*
|