@issuegraph/viewer 0.5.0 → 0.10.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (51) hide show
  1. package/dist/clusters.d.ts +116 -0
  2. package/dist/clusters.d.ts.map +1 -1
  3. package/dist/clusters.js +85 -2
  4. package/dist/clusters.js.map +1 -1
  5. package/dist/document.d.ts +76 -5
  6. package/dist/document.d.ts.map +1 -1
  7. package/dist/document.js +19 -1
  8. package/dist/document.js.map +1 -1
  9. package/dist/index.d.ts +16 -3
  10. package/dist/index.d.ts.map +1 -1
  11. package/dist/index.js +4 -2
  12. package/dist/index.js.map +1 -1
  13. package/dist/layout.d.ts +28 -0
  14. package/dist/layout.d.ts.map +1 -1
  15. package/dist/layout.js +7 -2
  16. package/dist/layout.js.map +1 -1
  17. package/dist/marks.d.ts +105 -0
  18. package/dist/marks.d.ts.map +1 -0
  19. package/dist/marks.js +430 -0
  20. package/dist/marks.js.map +1 -0
  21. package/dist/navigation.d.ts.map +1 -1
  22. package/dist/navigation.js +31 -0
  23. package/dist/navigation.js.map +1 -1
  24. package/dist/parts.d.ts +23 -7
  25. package/dist/parts.d.ts.map +1 -1
  26. package/dist/parts.js +80 -37
  27. package/dist/parts.js.map +1 -1
  28. package/dist/projections/graph.d.ts +15 -0
  29. package/dist/projections/graph.d.ts.map +1 -1
  30. package/dist/projections/graph.js +91 -35
  31. package/dist/projections/graph.js.map +1 -1
  32. package/dist/projections/linear.d.ts +116 -15
  33. package/dist/projections/linear.d.ts.map +1 -1
  34. package/dist/projections/linear.js +171 -69
  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 +15 -4
  38. package/dist/projections/tree.js.map +1 -1
  39. package/dist/skeleton.d.ts +63 -0
  40. package/dist/skeleton.d.ts.map +1 -0
  41. package/dist/skeleton.js +108 -0
  42. package/dist/skeleton.js.map +1 -0
  43. package/dist/styles.d.ts +1 -1
  44. package/dist/styles.d.ts.map +1 -1
  45. package/dist/styles.js +636 -24
  46. package/dist/styles.js.map +1 -1
  47. package/dist/theme.d.ts +1 -1
  48. package/dist/theme.d.ts.map +1 -1
  49. package/dist/theme.js +47 -0
  50. package/dist/theme.js.map +1 -1
  51. package/package.json +1 -1
package/dist/styles.js CHANGED
@@ -22,6 +22,245 @@
22
22
  * hairlines — and a host that wants the panel suppressed overrides the four
23
23
  * properties on `.ig-viewer`.
24
24
  */
25
+ /**
26
+ * §17j's rail density, written once and emitted under two keys.
27
+ *
28
+ * ## Why this is a function and not a block
29
+ *
30
+ * The density has to be reachable two ways — by the container's width, which is
31
+ * §17j's own rule, and by a consumer who disagrees with the crossover this
32
+ * package picked. CSS cannot express "this condition OR that selector" across
33
+ * an `@container` boundary: a rule inside the query cannot also be a rule
34
+ * outside it, so the declarations would have to be written twice. The
35
+ * stylesheet is already a template literal, so they are written once here and
36
+ * interpolated twice instead.
37
+ *
38
+ * `scope` is a selector the whole block hangs off, and every rule below is
39
+ * RELATIVE to it. The original block carried its own `.ig-viewer` prefixes on
40
+ * about half its rules; those are gone, because the scope supplies one and two
41
+ * would never match.
42
+ *
43
+ * ## What it does to specificity, which is a gain rather than a risk
44
+ *
45
+ * Every selector here gains the scope's two compound units, so each dense rule
46
+ * beats the base rule it overrides by SPECIFICITY rather than by source order.
47
+ * One of these already had to be hand-scoped for exactly that reason — the
48
+ * together-unit row, where a container query adds no specificity and the base
49
+ * rule further down the file won, leaving the unit row drawn with nothing in
50
+ * it. That hazard is now closed for the whole block rather than at one site.
51
+ */
52
+ function railDensity(scope) {
53
+ return `
54
+ ${scope} .ig-slot {
55
+ align-items: center;
56
+ grid-template-columns: var(--ig-rank-column-dense) 1fr auto;
57
+ min-height: var(--ig-row-height-dense);
58
+ padding-block: var(--ig-row-padding-block-dense);
59
+ }
60
+
61
+ /* TWO LINES, AND NEITHER WRAPS. A wrapping title is what turns a 53px row
62
+ into a 106px one, so the truncation is not cosmetic — it is what makes the
63
+ height a property of the row rather than of its longest title. */
64
+ ${scope} .ig-slot .ig-row-head {
65
+ gap: 0;
66
+ }
67
+
68
+ ${scope} .ig-slot .ig-title {
69
+ line-height: var(--ig-row-title-line);
70
+ overflow: hidden;
71
+ text-overflow: ellipsis;
72
+ white-space: nowrap;
73
+ }
74
+
75
+ /* STEP 5 OF THE DROP ORDER, AND IT IS LAST FOR A REASON '17j' STATES:
76
+ *"A row without a title is not a row."* Everything above leaves first. */
77
+ ${scope} .ig-slot .ig-row-body {
78
+ gap: var(--ig-row-line-gap);
79
+ min-width: 0;
80
+ }
81
+
82
+ /* LINE 2, AND IT DOES NOT WRAP. '17j' draws it as one mono run —
83
+ '#512 . P0 . blocked-by #488' — so the chips sit ON the identity's line
84
+ rather than under it. 'nowrap' is what makes the 53px a property of the
85
+ ROW: one long title or one extra badge would otherwise buy a second line
86
+ and double the height, which is exactly how this rail reached 408px. */
87
+ ${scope} .ig-slot .ig-row-meta {
88
+ align-items: center;
89
+ display: flex;
90
+ flex-wrap: nowrap;
91
+ gap: var(--ig-space-tight);
92
+ line-height: var(--ig-row-meta-line);
93
+ min-width: 0;
94
+ overflow: hidden;
95
+ }
96
+
97
+ /* The chips keep their own row semantics and stop wrapping, so what does not
98
+ fit is clipped rather than stacked. The drop order above decides WHICH
99
+ chips are there to clip; this only decides they stay on the line. */
100
+ ${scope} .ig-slot .ig-row-meta .ig-badges {
101
+ flex-wrap: nowrap;
102
+ min-width: 0;
103
+ }
104
+
105
+ /* The issue number never shrinks away — '17j' lists it among the five things
106
+ never dropped at any width. The title truncates first and the chips clip;
107
+ the number holds its ground. */
108
+ ${scope} .ig-slot .ig-row-meta .ig-id {
109
+ flex: none;
110
+ }
111
+
112
+ /* AT RAIL DENSITY LINE 2 IS TEXT, NOT PILLS — and this is read off the frame
113
+ rather than inferred from the height. '17j' draws the meta line as a single
114
+ mono run, '#512 . P0 . blocked-by #488': no outlines, no fills, just the
115
+ glyph and the words. §16a's wider panel is where they are chips.
116
+
117
+ IT IS ALSO WHAT MAKES 53px REACHABLE. A bordered chip is its padding plus
118
+ its stroke plus its line box, so a line of them cannot be 15px however the
119
+ line-height is set — the flex row grows to its tallest item and the row
120
+ followed it to 74px. Removing the chrome removes the reason.
121
+
122
+ THE FOUR REDUNDANT CHANNELS SURVIVE THIS, which is the thing worth
123
+ checking before doing it: dash, terminal and glyph are untouched, and hue
124
+ moves from the border to the text it was outlining. '17j' names the hold
125
+ GLYPH among the five things never dropped, and it is still drawn. */
126
+ ${scope} .ig-slot .ig-row-meta .ig-badge {
127
+ background: none;
128
+ border: 0;
129
+ border-radius: 0;
130
+ padding: 0;
131
+ /* AND THE TEXT INSIDE A CHIP DOES NOT WRAP EITHER. Stopping the ROW from
132
+ wrapping is not enough: a chip squeezed by its neighbours wraps its own
133
+ label instead, and 'signals disagree' at 84px became two 15px lines and
134
+ took the row to 67px while every sibling sat at 53. One row in seven,
135
+ which is exactly the kind of thing that survives a review and does not
136
+ survive a measurement. What does not fit is clipped, per the drop
137
+ order. */
138
+ white-space: nowrap;
139
+ }
140
+
141
+ /* AND THE SWAP, AT RAIL DENSITY. '17j' draws a together unit here as a
142
+ marker inline with an ordinary title at the ordinary 53px, so the head
143
+ comes back and the enclosure stands down. One row, one rank, in both
144
+ densities - RULINGS.md section 1 - drawn the way each width can afford. */
145
+ /* SCOPED WITH '.ig-viewer' SO ORDER CANNOT DECIDE THIS. A container query
146
+ adds no specificity, so this rule and the wide-density one it overrides
147
+ were an even 0,3,0 and the LATER of the two won — which put the base rule,
148
+ written further down the file, in charge of both densities and left the
149
+ unit row showing nothing but its em dash. Measured, not spotted: every row
150
+ was 53px and one of them was empty. The extra class makes the override win
151
+ on specificity, where it does not depend on where anyone adds a rule
152
+ later. */
153
+ ${scope} .ig-slot[data-unit='true'] > .ig-row-body > .ig-row-head {
154
+ display: flex;
155
+ }
156
+
157
+ ${scope} .ig-slot[data-unit='true'] .ig-unit-mark,
158
+ ${scope} .ig-slot[data-unit='true'] .ig-unit {
159
+ display: none;
160
+ }
161
+
162
+ /* The marker itself: the glyph and the count, on the title's line. It is a
163
+ COUNT and not a pill here because 53px has no room for one, and because
164
+ the pill's words are already the accessible name's. */
165
+ ${scope} .ig-unit-count {
166
+ color: var(--ig-edge-together-with);
167
+ display: inline;
168
+ font-family: var(--ig-font-mono);
169
+ margin-right: var(--ig-space-tight);
170
+ }
171
+
172
+ /* THE FOOTER ROW'S TITLE STAYS ON ONE LINE, as a ranked row's does. The row
173
+ is taller than a ranked row now by design — its status and its reason are
174
+ what it exists to show — but a title that wraps still takes the row with it,
175
+ so the name ellipsizes and the reason below it carries the length. */
176
+ ${scope} .ig-footer-row .ig-title {
177
+ line-height: var(--ig-row-title-line);
178
+ min-width: 0;
179
+ overflow: hidden;
180
+ text-overflow: ellipsis;
181
+ white-space: nowrap;
182
+ }
183
+
184
+ /* The identity never wraps mid-reference. */
185
+ ${scope} .ig-footer-row .ig-id {
186
+ white-space: nowrap;
187
+ }
188
+
189
+ /* THE NOW STRIP AT RAIL DENSITY. '17j' draws it as the first row of the rail
190
+ at all three backlog sizes, in the same rhythm as the rows beneath it -
191
+ RULINGS.md section 4: "a single now strip as the first row of the rail,
192
+ above rank 1, on a raised surface, carrying no rank number".
193
+
194
+ THE RANK SLOT IS ALREADY RIGHT AND IS NOT TOUCHED HERE: the strip carries a
195
+ 'now' mark where a rank would be, never a number, which is the half of
196
+ section 4 the build already had. What was wrong was only its height. */
197
+ ${scope} .ig-now-row {
198
+ min-height: var(--ig-row-height-dense);
199
+ padding-block: var(--ig-row-padding-block-dense);
200
+ }
201
+
202
+ ${scope} .ig-now-row .ig-row-head {
203
+ gap: 0;
204
+ min-width: 0;
205
+ }
206
+
207
+ ${scope} .ig-now-row .ig-title {
208
+ line-height: var(--ig-row-title-line);
209
+ overflow: hidden;
210
+ text-overflow: ellipsis;
211
+ white-space: nowrap;
212
+ }
213
+
214
+ ${scope} .ig-now-row .ig-id {
215
+ line-height: var(--ig-row-meta-line);
216
+ overflow: hidden;
217
+ text-overflow: ellipsis;
218
+ white-space: nowrap;
219
+ }
220
+
221
+ /* HIDDEN HERE, AND NOT YET EXPANDABLE — WHICH IS HALF OF WHAT 17j ASKS FOR.
222
+ The tile says: "The provenance line never renders inline here. In the rail
223
+ it is expand-on-demand; in 16a's wider panel it is inline. Same row, two
224
+ densities."
225
+
226
+ THE INLINE HALF IS DONE AND THE ON-DEMAND HALF IS NOT. There is no
227
+ row-level expand affordance at this density — 16f's right-arrow "expand
228
+ provenance" does not exist — so a rail reader reaches the explanation
229
+ through the inspector's WHY RANK block rather than through the row. The
230
+ information is one selection away, not lost, but this comment previously
231
+ claimed "expand-on-demand" and that was an overclaim about a control
232
+ nothing draws.
233
+
234
+ The markup stays whole so the wider container still renders it inline and
235
+ so the accessible name is unchanged; what the rail declines is spending
236
+ 53px of every row on it. The missing affordance is filed, not forgotten. */
237
+ ${scope} .ig-slot .ig-provenance,
238
+ ${scope} .ig-slot .ig-hold,
239
+ ${scope} .ig-slot .ig-caveat {
240
+ display: none;
241
+ }
242
+
243
+ /* AND THE ON-DEMAND HALF, WHICH IS WHAT 17j ACTUALLY ASKED FOR. A row the
244
+ reader opened with 16f's right-arrow draws its provenance again.
245
+
246
+ THE 53px IS NOT BROKEN BY THIS, it is spent deliberately: an open row is
247
+ taller because the reader asked it to be, and closing it returns the row to
248
+ the rhythm. That is the difference between a row that grows because nothing
249
+ governs it - the defect this sweep removed - and one that grows because
250
+ somebody pressed a key.
251
+
252
+ ONLY THE PROVENANCE COMES BACK. Holds and caveats stay down: 17j names the
253
+ provenance line and nothing else, and the holds are already in the
254
+ inspector's WHY RANK block as controls rather than prose. */
255
+ ${scope} .ig-slot[aria-expanded='true'] {
256
+ min-height: var(--ig-row-height-dense);
257
+ }
258
+
259
+ ${scope} .ig-slot[aria-expanded='true'] .ig-provenance {
260
+ display: block;
261
+ }
262
+ `;
263
+ }
25
264
  export const viewerStylesheet = `
26
265
  .ig-viewer {
27
266
  background: var(--ig-bg);
@@ -35,6 +274,51 @@ export const viewerStylesheet = `
35
274
  position: relative;
36
275
  }
37
276
 
277
+ /* THE FRAME IS THE STANDALONE CASE'S, AND A CONTAINER CAN DECLINE IT.
278
+ Alone in a host's page the viewer is a card and draws its own edge. Composed
279
+ into a surface that already rules its own zones off, that edge lands beside
280
+ the container's and the seam reads as a doubled hairline — so the container
281
+ passes frame: false and takes responsibility for every line on the
282
+ surface. See SceneOptions.frame for why this is an option rather than a
283
+ host stylesheet unsetting the properties above. */
284
+ .ig-viewer[data-frame='none'] {
285
+ border: 0;
286
+ border-radius: 0;
287
+ }
288
+
289
+ /* THE LEGEND DOCKS AGAINST THE CONTAINER'S SCROLLPORT, NOT THIS ROOT'S.
290
+ See SceneOptions.dockLegend for why only a container can ask for this.
291
+
292
+ THE overflow LINE IS THE WHOLE MECHANISM AND LOOKS LIKE A TIDY-UP. A sticky
293
+ element positions against its nearest SCROLLPORT, and an ancestor with
294
+ overflow: hidden is one — so with the root left as it is above, the legend
295
+ sticks to a box that never scrolls, which is the box it already sat at the
296
+ bottom of. Nothing moves, nothing errors, and the declaration below reads as
297
+ though it works. Lifting the clip hands the legend the container's scroller
298
+ instead, which is the only one that scrolls.
299
+
300
+ WHICH IS ALSO WHY THIS IS SAFE ONLY WITH THE FRAME DECLINED. The clip exists
301
+ so a drawn radius cuts its own corners; a root that has stopped clipping must
302
+ not be drawing one. The rule above and this one are written to be passed
303
+ together, and the workspace passes both.
304
+
305
+ NO z-index, DELIBERATELY. The legend is the root's last child, so it already
306
+ paints over the order it overlaps; a layer number here would be a claim about
307
+ a stacking order this package does not own. */
308
+ .ig-viewer[data-legend='docked'] {
309
+ overflow: visible;
310
+ }
311
+
312
+ /* bottom: 0 IS A DEFAULT THE CONTAINER OVERRIDES, not a fixed position. A
313
+ container with something of its own already pinned to that edge — the
314
+ grooming workspace's rail footer — sets this element's bottom to clear it,
315
+ from a height it measures rather than from a number either package wrote
316
+ down and both would have to keep true. */
317
+ .ig-viewer[data-legend='docked'] > .ig-legend {
318
+ position: sticky;
319
+ bottom: 0;
320
+ }
321
+
38
322
  .ig-viewer *,
39
323
  .ig-viewer *::before,
40
324
  .ig-viewer *::after {
@@ -46,7 +330,33 @@ export const viewerStylesheet = `
46
330
  outline-offset: calc(var(--ig-focus-ring) * -1);
47
331
  }
48
332
 
333
+ /* THE ROW'S DENSITY IS READ FROM THIS BOX, AND THAT IS §17j's CLOSING RULE
334
+ RATHER THAN A PREFERENCE. The same row renders at 390px in the workspace
335
+ rail and at 330px in §18's settings rail, so it *"must take its width from
336
+ its container and pick its drop step from that — not from a viewport media
337
+ query, and not from a prop the host sets. A package that reads the window
338
+ cannot be dropped into someone else's settings page, which is the whole BYO
339
+ premise."*
340
+
341
+ A viewport query would have been the obvious reach and it is precisely
342
+ wrong: it answers a question about the WINDOW when the row's question is
343
+ about the COLUMN it was given. Two rails of different widths on one screen
344
+ is not an edge case here — §17a's rail and §18's settings rail are that
345
+ screen.
346
+
347
+ "NOT FROM A PROP THE HOST SETS" IS ABOUT THE DEFAULT, AND data-density DOES
348
+ NOT BREAK IT. What §17j refuses is a package that CANNOT work out its own
349
+ density — one that draws a wrong row until a host wires something up, which
350
+ is how a drop-in stops being a drop-in. This box still answers that question
351
+ on its own and nothing has to be passed for it to be right. What the
352
+ attribute adds is the ability to DISAGREE, which is a different thing: the
353
+ crossover this package picked is a default, the host is the only party that
354
+ knows about print, a user preference or its own breakpoints, and an override
355
+ nobody uses costs the drop-in case nothing. See the crossover comment below
356
+ for why the number needs no ruling once it is a default. */
49
357
  .ig-list {
358
+ container-name: ig-rail;
359
+ container-type: inline-size;
50
360
  list-style: none;
51
361
  margin: 0;
52
362
  padding: 0;
@@ -323,6 +633,13 @@ export const viewerStylesheet = `
323
633
  /* ── working now ───────────────────────────────────────────────────────── */
324
634
 
325
635
  .ig-now {
636
+ /* THE NOW STRIP IS PART OF THE RAIL'S RHYTHM, so it reads the same container
637
+ the rows do. It is its own list element, so without this the density rules
638
+ scoped to 'ig-rail' simply did not reach it and the strip stood 83px tall
639
+ beside 53px rows - 57% taller than everything under it, in the one slot
640
+ 17j gives the top of the rail. */
641
+ container-name: ig-rail;
642
+ container-type: inline-size;
326
643
  list-style: none;
327
644
  margin: 0;
328
645
  padding: 0;
@@ -391,6 +708,113 @@ export const viewerStylesheet = `
391
708
  padding: var(--ig-row-padding-block) var(--ig-space-wide);
392
709
  }
393
710
 
711
+ /* ── §17j's RAIL DENSITY ───────────────────────────────────────────────────
712
+
713
+ THE 53px ROW, AND THE ORDER THINGS LEAVE IT. '17j' fixes both, and the
714
+ reason it fixes the second is that the build *"showed everything because
715
+ nothing told it what to give up"*. This is that instruction.
716
+
717
+ 9 pad + 17 title + 3 gap + 15 meta + 9 pad = 53px, divider inclusive
718
+ tracks: 30px rank · 1fr issue · auto delta
719
+
720
+ THE FINDING THIS ENCODES, which is the one to keep: at 6, 60 and 312 issues
721
+ the rail is IDENTICAL. What changes is around it — virtualisation appears,
722
+ the runner-hold footer grows, filter chips stop being optional, the canvas
723
+ starts refusing. *"If you are making the rail denser as the backlog grows,
724
+ you are compressing the surface that was never the problem."* So nothing
725
+ here keys off the backlog size, and nothing ever should.
726
+
727
+ 430 IS THIS PACKAGE'S DEFAULT, NOT A CLAIM ABOUT THE DESIGN, AND A CONSUMER
728
+ CAN TAKE IT OVER. That is the whole posture of this package — sensible
729
+ defaults, everything overridable — and it is why this number needed no
730
+ ruling in the end. '17j' gives two anchors and no crossover: the workspace
731
+ rail is 390, §18's settings rail is 330 where steps 1-3 apply "by default",
732
+ and §16a's panel, which keeps provenance inline, is wider than either and
733
+ carries no published figure anywhere. 430 sits above the rail and below that
734
+ panel. A host that does nothing gets it and gets correct behaviour; a host
735
+ that disagrees sets data-density on the viewer root and this condition stops
736
+ deciding for them.
737
+
738
+ SO THE OPEN QUESTION IS CLOSED RATHER THAN WAITING. An earlier revision of
739
+ this comment filed the distance between 390 and 430 as needing a width for
740
+ §16a's panel. It does not: a DEFAULT only has to be reasonable, and the
741
+ figure would only have mattered if this were the single crossover every
742
+ consumer had to live with. It is not, so it is not blocking.
743
+
744
+ IT CANNOT BE A TOKEN, AND THE NEXT PERSON WILL TRY. Making the threshold a
745
+ theme property is the obvious reach, and it does not work: a container
746
+ condition takes a length and only a length. Measured in a browser — a
747
+ max-width condition written as a literal applies, and the same condition
748
+ naming a custom property PARSES INTO A RULE THAT MATCHES AT NO WIDTH AT ALL,
749
+ while CSS.supports() reports that same condition as supported. It fails
750
+ silently in both directions: the rule is not dropped, it is simply never
751
+ satisfied, and the one API that could have warned agrees it is fine. That is
752
+ a CSS limitation rather than a decision, and it is why the escape hatch below
753
+ is a selector rather than a property.
754
+
755
+ '17k' MAKES THE LOWER ANCHOR EXACT, which is worth keeping. It fixes the
756
+ three workspace layouts under one rule — "the rail never yields" — so the
757
+ workspace draws its rail at 390 at every width it supports and no amount of
758
+ resizing moves it across this threshold. Before that it fell to 312 below
759
+ 1360, so 430 had to clear two numbers and only one was '17j's. The editor's
760
+ sheet pins that relationship rather than restating it: see its test "keeps
761
+ the workspace rail on the dense side of layer 1's crossover". */
762
+ @container ig-rail (max-width: 430px) {
763
+ ${railDensity('.ig-viewer:not([data-density])')}
764
+ }
765
+
766
+ /* THE ESCAPE HATCH ITSELF, AND IT IS THE SAME BLOCK WITH A DIFFERENT KEY.
767
+ Emitted a second time under an attribute that carries no width at all, so
768
+ a consumer who sets it gets the dense row wherever they want it. The
769
+ scopes above and below are mutually exclusive by construction — one is
770
+ :not([data-density]) and the other names a value — so nothing here
771
+ depends on which of them a browser reads first.
772
+
773
+ THE OTHER DIRECTION NEEDS NO RULES. data-density='wide' matches neither
774
+ scope, so the row falls back to the base density this sheet declares
775
+ above, at any container width. */
776
+ ${railDensity(".ig-viewer[data-density='dense']")}
777
+
778
+ /* STEPS 1-3 OF THE DROP ORDER, AT §18's 330px SETTINGS RAIL, which '17j'
779
+ names as the width where they apply by default.
780
+
781
+ THE SELECTORS ARE THE MARKUP'S OWN ATTRIBUTES, not class guesses: evidence
782
+ is 'data-evidence', a relationship is 'data-edge', a priority is
783
+ 'data-priority' with 'query' / 'tier' / 'promoted'. Three of these were
784
+ written from memory first and two were wrong — the parts publish
785
+ 'data-evidence="verified"', never a 'data-ig-badge'. */
786
+
787
+ /* STEP 1 — evidence and verification chips. '17j': *"Reassurance, not an
788
+ answer to any question the rail is asked."* */
789
+ @container ig-rail (max-width: 360px) {
790
+ .ig-slot .ig-badge[data-evidence] {
791
+ display: none;
792
+ }
793
+ }
794
+
795
+ /* STEP 2 — relationship badges past the FIRST, which is kept because it is
796
+ the one causing the hold. Scoped to 'data-edge' so it counts relationships
797
+ and not the priority, evidence and readiness chips that share '.ig-badge';
798
+ a positional 'nth-of-type' would have hidden whichever chip happened to sit
799
+ second, which is not what the rule says at all. The '+n more' chip carries
800
+ what left, and it already exists — 'data-omitted'. */
801
+ @container ig-rail (max-width: 345px) {
802
+ .ig-slot .ig-badge[data-edge] ~ .ig-badge[data-edge] {
803
+ display: none;
804
+ }
805
+ }
806
+
807
+ /* STEP 3 — the priority token. A DECLARED TIER goes, because '17j' says it is
808
+ *"recoverable from the rank itself"*. A PROMOTION stays: 'P3 → 0' is the
809
+ one form the rank cannot give back, and it is rank provenance rather than a
810
+ restatement. A matched query stays for the same reason — it names WHICH
811
+ query, which no rank encodes. */
812
+ @container ig-rail (max-width: 335px) {
813
+ .ig-slot .ig-badge[data-priority='tier'] {
814
+ display: none;
815
+ }
816
+ }
817
+
394
818
  /* THE ONE ROW GROUND THE FRAME DRAWS, and it is not a stripe. A together unit
395
819
  is the compound station, and the tint says so; the other ranked rows carry
396
820
  no background at all. */
@@ -464,6 +888,30 @@ export const viewerStylesheet = `
464
888
  min-width: 0;
465
889
  }
466
890
 
891
+ /* A UNIT AT THE WIDE DENSITY IS ITS ENCLOSURE, which is what §16a draws: the
892
+ pill, the note, and a member list. The ordinary head the row also carries is
893
+ the RAIL's way of drawing the same unit, so it stands down here rather than
894
+ printing the lead's title above a box that already opens with it. */
895
+ .ig-slot[data-unit='true'] > .ig-row-body > .ig-row-head {
896
+ display: none;
897
+ }
898
+
899
+ .ig-unit-count {
900
+ display: none;
901
+ }
902
+
903
+ /* LINE 2 AT THE WIDE DENSITY. §16a's panel draws the same sequence — identity
904
+ then chips — and simply has room, so here it WRAPS rather than clipping and
905
+ the provenance turnstile stays inline beneath it. Same markup, two
906
+ densities; the rail's rules are in the container query above. */
907
+ .ig-row-meta {
908
+ align-items: center;
909
+ display: flex;
910
+ flex-wrap: wrap;
911
+ gap: var(--ig-space-tight);
912
+ min-width: 0;
913
+ }
914
+
467
915
  /* ── readiness stations ────────────────────────────────────────────────── */
468
916
 
469
917
  .ig-station {
@@ -745,57 +1193,83 @@ export const viewerStylesheet = `
745
1193
 
746
1194
  /* ── the footer group: holds that earn no rank slot ────────────────────── */
747
1195
 
748
- .ig-footer {
749
- background: var(--ig-surface-2);
750
- padding: var(--ig-space) var(--ig-space-wide);
1196
+ /* NO RULE OF ITS OWN ON TOP. The last ranked row above already draws its
1197
+ bottom rule, and a second one here stacked on it as a doubled line. */
1198
+ /* THE PANEL HEADER'S LAYOUT, ONE LEVEL DOWN: a kicker and a count chip on one
1199
+ line, a sentence under them. The kicker and the chip are the header's own
1200
+ classes (see footerHead), so this rule only places them. */
1201
+ .ig-footer-head {
1202
+ display: flex;
1203
+ flex-direction: column;
1204
+ gap: var(--ig-space-tight);
1205
+ padding: var(--ig-row-padding-block-dense) var(--ig-space-wide);
751
1206
  }
752
1207
 
753
- .ig-footer-head {
1208
+ .ig-footer-title-row {
754
1209
  align-items: center;
755
1210
  display: flex;
756
1211
  gap: var(--ig-space);
757
- justify-content: space-between;
758
1212
  }
759
1213
 
760
1214
  .ig-footer-title {
761
- color: var(--ig-text-body);
762
- font-size: var(--ig-font-size-compact);
763
1215
  margin: 0;
764
1216
  }
765
1217
 
766
- .ig-footer-labels {
1218
+ .ig-footer-note {
767
1219
  color: var(--ig-text-muted);
768
- font-family: var(--ig-font-mono);
769
- font-size: var(--ig-font-size-meta);
1220
+ font-size: var(--ig-font-size-compact);
1221
+ line-height: var(--ig-row-meta-line);
1222
+ margin: 0;
770
1223
  }
771
1224
 
772
- /* ONE LINE EACH, and the frame is explicit about why: these are not facts
773
- about the work, so they earn no rank slot and no explanation block. A full
774
- row here — station, badges, provenance — was claiming the opposite. */
775
1225
  .ig-footer .ig-list {
776
1226
  display: flex;
777
1227
  flex-direction: column;
778
- gap: var(--ig-space-snug);
779
- margin-top: var(--ig-space);
780
1228
  }
781
1229
 
1230
+ /* A RANKED ROW'S GRID, PADDING AND RULE, so the group is the rest of the same
1231
+ list rather than a panel of its own: the rank track holds the held station,
1232
+ and it lines up under the stations above because it is the same track.
1233
+
1234
+ ON THE PANEL'S OWN GROUND, NOT A SECOND SURFACE, and at full strength. The
1235
+ group used to sit on --ig-surface-2 with every row at 0.72 opacity, which
1236
+ read as disabled — on the rows whose whole purpose is "open me to see why". */
782
1237
  .ig-footer-row {
783
- align-items: center;
784
- display: flex;
785
- gap: var(--ig-space-snug);
786
- opacity: 0.72;
1238
+ border-top: var(--ig-stroke) solid var(--ig-line);
1239
+ column-gap: var(--ig-space);
1240
+ cursor: pointer;
1241
+ display: grid;
1242
+ grid-template-columns: var(--ig-rank-column) 1fr;
1243
+ /* THE DENSE RANKED ROW'S PADDING, at every width: these rows carry three
1244
+ lines where a ranked row carries two, and the wider padding on top of that
1245
+ made each one twice the height of its neighbours above. */
1246
+ padding: var(--ig-row-padding-block-dense) var(--ig-space-wide);
1247
+ }
1248
+
1249
+ .ig-footer-row:hover {
1250
+ background: color-mix(in srgb, var(--ig-text) var(--ig-tint-unit), transparent);
1251
+ }
1252
+
1253
+ /* THE RANKED ROW'S SELECTED BAND, and its fill: one selection, one look,
1254
+ whichever part of the list it was made in. */
1255
+ .ig-footer-row[aria-current='true'] {
1256
+ background: color-mix(in srgb, var(--ig-accent) var(--ig-tint-fill), transparent);
1257
+ box-shadow: inset var(--ig-band-rail) 0 0 var(--ig-accent);
787
1258
  }
788
1259
 
789
1260
  .ig-footer-row .ig-title {
790
- font-size: var(--ig-font-size-compact);
1261
+ color: var(--ig-text-body);
791
1262
  }
792
1263
 
793
- .ig-footer-row .ig-id {
794
- font-size: var(--ig-font-size-meta);
1264
+ .ig-footer-row[aria-current='true'] .ig-title {
1265
+ color: var(--ig-text);
795
1266
  }
796
1267
 
797
- .ig-footer-row[aria-current='true'] {
798
- opacity: 1;
1268
+ .ig-footer-why {
1269
+ color: var(--ig-text-muted);
1270
+ font-size: var(--ig-font-size-compact);
1271
+ line-height: var(--ig-row-meta-line);
1272
+ margin: 0;
799
1273
  }
800
1274
 
801
1275
  /* ── the graph canvas ──────────────────────────────────────────────────── */
@@ -1033,6 +1507,35 @@ export const viewerStylesheet = `
1033
1507
  stroke-width: var(--ig-stroke);
1034
1508
  }
1035
1509
 
1510
+ /* A MARK IS HIT-TESTABLE, AND THAT IS THE OPPOSITE OF WHAT IT FIRST SAID.
1511
+
1512
+ It carried a pointer-events none rule, reasoning from the halo: a decoration
1513
+ over a line must not swallow the click aimed at the line. That reasoning does
1514
+ not transfer, because a halo IS the line again and a mark is NOT — every one
1515
+ of these is deliberately offset AWAY from the path, so a click on it does not
1516
+ fall through onto the edge. It falls through onto the canvas, and the viewer's
1517
+ walk climbs to the canvas group and reports a click on nothing, clearing the
1518
+ very selection the reader was making. A conflict's companion is the clearest
1519
+ case: a visibly separate line that could not be pointed at.
1520
+
1521
+ That is a defect this package has already paid for once. The overlay module
1522
+ keeps its pointer identity on the dash clone for exactly this reason, and says
1523
+ so: dropping the identity makes a clone a dead zone over its own edge. Every
1524
+ mark here publishes the same identity, so making it unhittable spent the
1525
+ identity it had just been given.
1526
+
1527
+ Colour is set per element, from the token name the caller supplied, for the
1528
+ reason the edge hues are set per element: one source for a channel. Nothing
1529
+ here paints a state, because nothing here knows what a state is. */
1530
+ .ig-edge-mark {
1531
+ font-family: var(--ig-font-ui);
1532
+ font-size: var(--ig-font-size-small);
1533
+ }
1534
+
1535
+ .ig-edge-companion {
1536
+ stroke-width: var(--ig-stroke);
1537
+ }
1538
+
1036
1539
  .ig-terminal[data-edge='blocked-by'] { color: var(--ig-edge-blocked-by); }
1037
1540
  .ig-terminal[data-edge='serialize-with'] { color: var(--ig-edge-serialize-with); }
1038
1541
  .ig-terminal[data-edge='together-with'] { color: var(--ig-edge-together-with); }
@@ -1101,6 +1604,94 @@ export const viewerStylesheet = `
1101
1604
  padding: var(--ig-space-tight) 0;
1102
1605
  }
1103
1606
 
1607
+ /* ── loading placeholder ───────────────────────────────────────────────── */
1608
+
1609
+ /* THE HOST'S HOOK, AND THE ONLY ONE. Every placeholder shape is one of these,
1610
+ filled with the second surface and holding still: this package ships no
1611
+ motion, and the fill already follows the host's theme. A host that wants a
1612
+ pulse animates this class under its own reduced-motion rule. */
1613
+ .ig-skeleton-block {
1614
+ background: var(--ig-surface-2);
1615
+ border-radius: var(--ig-radius);
1616
+ display: block;
1617
+ }
1618
+
1619
+ /* ON THE HEADER'S OWN GROUND the second surface is the ground, so the shapes
1620
+ there take the hairline colour instead, or they vanish into it. */
1621
+ .ig-header .ig-skeleton-block {
1622
+ background: var(--ig-line);
1623
+ }
1624
+
1625
+ .ig-skeleton-block[data-shape='label'] {
1626
+ block-size: var(--ig-space);
1627
+ inline-size: calc(var(--ig-char-width) * 14);
1628
+ }
1629
+
1630
+ .ig-skeleton-chips {
1631
+ display: flex;
1632
+ gap: var(--ig-space-snug);
1633
+ }
1634
+
1635
+ .ig-skeleton-block[data-shape='chip'] {
1636
+ block-size: calc(var(--ig-space) * 2);
1637
+ border-radius: var(--ig-radius);
1638
+ inline-size: calc(var(--ig-char-width) * 9);
1639
+ }
1640
+
1641
+ .ig-skeleton-block[data-shape='station'] {
1642
+ block-size: var(--ig-space);
1643
+ border-radius: 50%;
1644
+ inline-size: var(--ig-space);
1645
+ }
1646
+
1647
+ /* THE ROW'S OWN LINE HEIGHTS, so a placeholder row and a real row are the same
1648
+ height at every density the container picks. */
1649
+ .ig-skeleton-block[data-shape='title'] {
1650
+ block-size: var(--ig-row-title-line);
1651
+ inline-size: 68%;
1652
+ }
1653
+
1654
+ .ig-skeleton-block[data-shape='title'][data-width='1'] {
1655
+ inline-size: 54%;
1656
+ }
1657
+
1658
+ .ig-skeleton-block[data-shape='title'][data-width='2'] {
1659
+ inline-size: 82%;
1660
+ }
1661
+
1662
+ .ig-skeleton-block[data-shape='meta'] {
1663
+ block-size: var(--ig-row-meta-line);
1664
+ inline-size: 36%;
1665
+ }
1666
+
1667
+ /* THE TWO LINES OF A ROW, stacked with no gap of their own: each block is its
1668
+ line's full height, which already carries the leading a real line has. */
1669
+ .ig-skeleton-lines {
1670
+ display: flex;
1671
+ flex-direction: column;
1672
+ }
1673
+
1674
+ /* A placeholder row points at nothing, so it takes no pointer and no hover. */
1675
+ .ig-skeleton-row {
1676
+ pointer-events: none;
1677
+ }
1678
+
1679
+ .ig-skeleton-stage {
1680
+ align-items: center;
1681
+ display: flex;
1682
+ flex-direction: column;
1683
+ gap: var(--ig-space-wide);
1684
+ padding: var(--ig-space-wide);
1685
+ }
1686
+
1687
+ /* THE SPINE CARD'S OWN WIDTH, so the graph's placeholder sits where its cards
1688
+ will, as one solid block with the large radius the panel's cards use. */
1689
+ .ig-skeleton-block[data-shape='card'] {
1690
+ block-size: calc(var(--ig-station-box) * 4);
1691
+ border-radius: var(--ig-radius-large);
1692
+ inline-size: min(100%, var(--ig-spine-width));
1693
+ }
1694
+
1104
1695
  /* ── legend ────────────────────────────────────────────────────────────── */
1105
1696
 
1106
1697
  /* A FOOTER BAR, WHERE §16b PUTS IT, and worded rather than glyph-only. It sits
@@ -1119,8 +1710,29 @@ export const viewerStylesheet = `
1119
1710
  padding: var(--ig-space) var(--ig-space-wide);
1120
1711
  }
1121
1712
 
1713
+ /* FLOATED SO THE BROWSER STOPS TREATING IT AS A RENDERED LEGEND, which is the
1714
+ only reason this declaration exists. HTML gives the first in-flow legend of a
1715
+ fieldset a layout nothing else in CSS has: it is lifted OUT of the fieldset's
1716
+ content box, painted across the top border with a notch cut through the rule
1717
+ either side of it, and the content box then starts below it. On this bar that
1718
+ drew the caption sitting on the rule, a gap of bare ground under the caption
1719
+ where the fieldset's own surface should have been, and a top rule broken in
1720
+ the middle — all three of which looked like a styling bug and none of which
1721
+ any rule here asked for.
1722
+
1723
+ The spec's own escape hatch is the float: a legend whose computed float is
1724
+ not none is not the rendered legend, and becomes an ordinary child. This bar
1725
+ is a flex container, where float does not apply to an item at all, so the
1726
+ caption simply lays out as the first item on the row — which is what the
1727
+ flex rule above, its muted uppercase micro type and its centred alignment
1728
+ were always written for.
1729
+
1730
+ IT IS LOAD-BEARING AND LOOKS DECORATIVE, which is why it is recorded here at
1731
+ this length: delete it and the notch, the gap and the broken rule all come
1732
+ back, with nothing else in this file changed. */
1122
1733
  .ig-legend-caption {
1123
1734
  color: var(--ig-text-muted);
1735
+ float: left;
1124
1736
  font-family: var(--ig-font-mono);
1125
1737
  font-size: var(--ig-font-size-pill);
1126
1738
  letter-spacing: var(--ig-tracking-group);