@issuegraph/viewer 0.3.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.
Files changed (51) hide show
  1. package/dist/document.d.ts +152 -0
  2. package/dist/document.d.ts.map +1 -1
  3. package/dist/document.js +185 -2
  4. package/dist/document.js.map +1 -1
  5. package/dist/element.d.ts +11 -0
  6. package/dist/element.d.ts.map +1 -1
  7. package/dist/element.js.map +1 -1
  8. package/dist/index.d.ts +3 -3
  9. package/dist/index.d.ts.map +1 -1
  10. package/dist/index.js +28 -2
  11. package/dist/index.js.map +1 -1
  12. package/dist/layout.d.ts +51 -50
  13. package/dist/layout.d.ts.map +1 -1
  14. package/dist/layout.js +364 -168
  15. package/dist/layout.js.map +1 -1
  16. package/dist/mount.d.ts +13 -0
  17. package/dist/mount.d.ts.map +1 -1
  18. package/dist/mount.js +69 -1
  19. package/dist/mount.js.map +1 -1
  20. package/dist/navigation.d.ts +14 -0
  21. package/dist/navigation.d.ts.map +1 -1
  22. package/dist/navigation.js +20 -0
  23. package/dist/navigation.js.map +1 -1
  24. package/dist/parts.d.ts +285 -2
  25. package/dist/parts.d.ts.map +1 -1
  26. package/dist/parts.js +781 -63
  27. package/dist/parts.js.map +1 -1
  28. package/dist/projections/graph.d.ts +10 -0
  29. package/dist/projections/graph.d.ts.map +1 -1
  30. package/dist/projections/graph.js +413 -326
  31. package/dist/projections/graph.js.map +1 -1
  32. package/dist/projections/linear.d.ts +90 -0
  33. package/dist/projections/linear.d.ts.map +1 -1
  34. package/dist/projections/linear.js +213 -31
  35. package/dist/projections/linear.js.map +1 -1
  36. package/dist/projections/tree.d.ts.map +1 -1
  37. package/dist/projections/tree.js +26 -3
  38. package/dist/projections/tree.js.map +1 -1
  39. package/dist/styles.d.ts +10 -1
  40. package/dist/styles.d.ts.map +1 -1
  41. package/dist/styles.js +803 -132
  42. package/dist/styles.js.map +1 -1
  43. package/dist/theme.d.ts +5 -6
  44. package/dist/theme.d.ts.map +1 -1
  45. package/dist/theme.js +72 -7
  46. package/dist/theme.js.map +1 -1
  47. package/dist/vocabulary.d.ts +39 -0
  48. package/dist/vocabulary.d.ts.map +1 -1
  49. package/dist/vocabulary.js +27 -0
  50. package/dist/vocabulary.js.map +1 -1
  51. package/package.json +2 -2
package/dist/parts.js CHANGED
@@ -7,9 +7,9 @@
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
13
  /**
14
14
  * The attribute a control the viewer publishes but does not wire carries. The
15
15
  * same one `@issuegraph/editor` reads for its own controls, so a host that
@@ -17,12 +17,43 @@ import { EDGE_ORDER, treatmentFor } from "./vocabulary.js";
17
17
  * listener.
18
18
  */
19
19
  export const COMMAND_ATTRIBUTE = 'data-ig-command';
20
- /** A badge's inner pair — the glyph a reader can see and the word a reader can hear. */
21
- function glyphAndLabel(glyph, label) {
22
- return [
23
- element('span', { class: 'ig-glyph', 'aria-hidden': 'true' }, [glyph]),
24
- element('span', {}, [label]),
25
- ];
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])];
26
57
  }
27
58
  /**
28
59
  * Filled = ready now. Hollow = ready once a named rank lands. Dashed = held.
@@ -69,34 +100,126 @@ export function identity(issue) {
69
100
  ]);
70
101
  }
71
102
  /**
72
- * The provenance line, in the three forms the design fixes: a matched ordering
73
- * query, the declared tier, and an effective-priority promotion that names the
74
- * dependent the urgency arrived through.
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.
75
119
  */
76
- export function provenanceLine(provenance) {
120
+ export function provenanceClause(provenance) {
77
121
  if (provenance === undefined)
78
122
  return null;
79
123
  switch (provenance.kind) {
80
124
  case 'matched-query':
81
- return element('p', { class: 'ig-provenance' }, [
125
+ return element('span', {}, [
82
126
  `matched ordered query ${String(provenance.index)} · `,
83
127
  element('span', { class: 'ig-id' }, [provenance.label]),
84
128
  ]);
85
129
  case 'declared-tier':
86
- return element('p', { class: 'ig-provenance' }, [
87
- `priority tier P${String(provenance.priority)}`,
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)}`]),
88
139
  ]);
89
140
  case 'promotion': {
90
141
  const via = provenance.promotedBy.length === 0
91
- ? ''
92
- : ` · inherited from ${provenance.promotedBy.join(', ')}`;
93
- return element('p', { class: 'ig-provenance' }, [
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 ',
94
150
  element('span', { class: 'ig-id' }, [provenance.notation]),
95
- `${via}`,
151
+ via,
96
152
  ]);
97
153
  }
98
154
  }
99
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
+ }
100
223
  /**
101
224
  * One hold, rendered with the family it belongs to visible in the markup — and
102
225
  * its cause and subject beside it when the host supplied them, so a host reads
@@ -116,7 +239,7 @@ export function holdLine(hold) {
116
239
  'data-family': hold.family,
117
240
  'data-code': hold.code,
118
241
  'data-subject': hold.subject,
119
- }, [label, label === null ? hold.reason : ` ${hold.reason}`]);
242
+ }, [turn(), label, element('span', {}, [hold.reason])]);
120
243
  }
121
244
  /**
122
245
  * The runner's own words for the holds a footer collects — `claimed · parked` —
@@ -137,31 +260,101 @@ export function footerLabels(slots) {
137
260
  return labels.join(' · ');
138
261
  }
139
262
  /**
140
- * The summary line and the freshness stamp — the host's numbers and the host's
141
- * clock, printed verbatim.
142
- *
143
- * NOTHING HERE IS COUNTED. `counts` is the host's tally over the whole order
144
- * (see `OrderCounts` for why a count over this document's slots would be a
145
- * count over a window), and the stamp is text the host formatted. `null` when
146
- * the host stated neither, so a document with no host facts draws no header.
147
- */
148
- export function hostHeader(document) {
149
- const { concurrencyCap, counts, freshness } = document.host;
150
- const pieces = [];
151
- if (counts !== undefined)
152
- pieces.push(`${String(counts.ranked)} ranked`, `${String(counts.readyNow)} ready now`);
153
- if (concurrencyCap !== undefined)
154
- pieces.push(`cap ${String(concurrencyCap)}`);
155
- if (counts !== undefined)
156
- pieces.push(`${String(counts.held)} held`);
157
- const summary = pieces.length === 0 ? null : element('p', { class: 'ig-summary' }, [pieces.join(' · ')]);
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);
158
349
  const stamp = freshness === undefined
159
350
  ? null
160
351
  : element('p', { class: 'ig-freshness', 'data-stale': freshness.stale === true ? 'true' : 'false' }, [
161
- 'as of ',
162
- element('span', { class: 'ig-id' }, [freshness.asOf]),
163
- freshness.age === undefined || freshness.age === '' ? null : ` · ${freshness.age}`,
164
- freshness.stale === true ? ' · stale' : null,
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
+ ]),
165
358
  // A CONTROL THE VIEWER PUBLISHES AND DOES NOT WIRE. Refreshing a mirror
166
359
  // is fetching, which this layer never does; the host that can listens
167
360
  // for the command. A real button rather than a styled span, so the
@@ -171,9 +364,178 @@ export function hostHeader(document) {
171
364
  ? null
172
365
  : element('button', { class: 'ig-refresh', type: 'button', [COMMAND_ATTRIBUTE]: 'refresh' }, [freshness.refresh]),
173
366
  ]);
174
- if (summary === null && stamp === null)
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)
175
400
  return null;
176
- return element('header', { class: 'ig-header' }, [summary, stamp]);
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
+ ]);
177
539
  }
178
540
  /**
179
541
  * The NOW rows: what the runner is working this moment, drawn above the order.
@@ -201,12 +563,67 @@ export function nowRows(document) {
201
563
  'aria-label': `${issue?.title ?? job.key} — ${job.phase} · ${job.elapsed}`,
202
564
  }, [
203
565
  element('span', { class: 'ig-now-mark', 'aria-hidden': 'true' }, ['now']),
204
- element('span', { class: 'ig-title' }, [issue?.title ?? job.key]),
205
- issue === undefined ? null : identity(issue),
206
- element('span', { class: 'ig-now-phase' }, [`· ${job.phase} · ${job.elapsed}`]),
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
+ ]),
207
613
  ]);
208
614
  }));
209
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
+ }
210
627
  /**
211
628
  * The two caveats a host can put on a row, each ONE row child so the slot grid
212
629
  * places it like provenance.
@@ -223,20 +640,56 @@ export function caveatLines(issue) {
223
640
  const lines = [];
224
641
  if (issue.previewOnly !== undefined) {
225
642
  lines.push(element('p', { class: 'ig-caveat', 'data-caveat': 'preview-only' }, [
226
- element('span', { class: 'ig-badge', 'data-caveat': 'preview-only' }, glyphAndLabel('◐', 'preview-only')),
227
- ` ${issue.previewOnly.note}`,
643
+ turn(),
644
+ element('span', {}, [issue.previewOnly.note]),
228
645
  ]));
229
646
  }
230
647
  if (issue.disagreement !== undefined) {
231
648
  const { used, ignored } = issue.disagreement;
232
649
  lines.push(element('p', { class: 'ig-caveat', 'data-caveat': 'disagree' }, [
233
- element('span', { class: 'ig-badge', 'data-caveat': 'disagree' }, glyphAndLabel('◆', 'signals disagree')),
234
- ` ranked by ${used} · ${ignored.carrier} declares `,
235
- element('s', { class: 'ig-strike' }, [ignored.value]),
650
+ turn(),
651
+ element('span', {}, [
652
+ `ranked by ${used} · ${ignored.carrier} declares `,
653
+ element('s', { class: 'ig-strike' }, [ignored.value]),
654
+ ]),
236
655
  ]));
237
656
  }
238
657
  return lines;
239
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
+ }
240
693
  /**
241
694
  * The caveats as one sentence, for a row that has no room for a block — the
242
695
  * graph rail positions its rows onto layout boxes, so it carries text on the
@@ -282,15 +735,22 @@ export function caveatText(issue) {
282
735
  * `keyAt` walks target-upward. That is the intended half of "an edge is pointable
283
736
  * in every projection", and it is what the canvas already does for an arc.
284
737
  */
285
- function edgeBadge(field, edgeId, detail) {
738
+ function edgeBadge(field, edgeId, label, other) {
286
739
  const treatment = treatmentFor(field);
287
740
  return element('span', {
288
741
  class: 'ig-badge',
289
742
  'data-edge': field,
290
743
  [GROUP_ATTRIBUTE]: edgeId,
291
- title: `${treatment.label} ${detail}`,
292
- 'aria-label': `${treatment.label} ${detail}`,
293
- }, glyphAndLabel(treatment.glyph, detail));
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}`));
294
754
  }
295
755
  /**
296
756
  * How many relationship badges one row draws before the rest collapse into a
@@ -348,6 +808,18 @@ function overflowBadge(omitted) {
348
808
  * key — a single-key row is unchanged by construction.
349
809
  */
350
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) {
351
823
  const mine = new Set(keys);
352
824
  const badges = [];
353
825
  // COUNTED PAST THE BUDGET, NOT BUILT. The walk still visits every edge so
@@ -391,27 +863,273 @@ export function edgeBadges(document, keys) {
391
863
  // is announced as one relationship rather than as two directions.
392
864
  const outgoing = mine.has(edge.from);
393
865
  const other = outgoing ? edge.to : edge.from;
394
- const detail = treatment.symmetric || outgoing ? other : `${other} (incoming)`;
395
- badges.push(edgeBadge(field, edgeId, detail));
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));
396
871
  }
397
872
  }
398
873
  }
399
- if (badges.length === 0)
400
- return null;
401
874
  if (omitted > 0)
402
875
  badges.push(overflowBadge(omitted));
403
- return element('span', { class: 'ig-badges' }, badges);
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);
404
903
  }
405
- /** The legend. Rendered once per scene so the grammar is readable cold. */
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
+ }
1003
+ }
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
+ */
406
1019
  export function legend() {
407
1020
  return element('fieldset', { class: 'ig-legend' }, [
408
1021
  element('legend', { class: 'ig-legend-caption' }, ['relationships']),
409
1022
  ...EDGE_ORDER.map((field) => {
410
1023
  const treatment = treatmentFor(field);
411
- return element('span', { class: 'ig-badge', 'data-edge': field }, glyphAndLabel(treatment.glyph, treatment.label));
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),
1032
+ ]);
412
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
+ ]),
413
1037
  ]);
414
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
+ }
415
1133
  /**
416
1134
  * Every key a projection draws under some OTHER key's station, mapped to it.
417
1135
  *