@unofficialbox/box-open-elements 0.10.0 → 0.11.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 (39) hide show
  1. package/dist/components/feedback/alert.js +1 -16
  2. package/dist/components/feedback/path.d.ts +84 -0
  3. package/dist/components/feedback/path.js +455 -0
  4. package/dist/components/feedback/skeleton.d.ts +70 -0
  5. package/dist/components/feedback/skeleton.js +231 -1
  6. package/dist/components/feedback/toast.d.ts +38 -0
  7. package/dist/components/feedback/toast.js +189 -13
  8. package/dist/components/feedback/tone.d.ts +16 -0
  9. package/dist/components/feedback/tone.js +31 -0
  10. package/dist/components/layout/grid.d.ts +41 -0
  11. package/dist/components/layout/grid.js +152 -0
  12. package/dist/element-maps.d.ts +5 -3
  13. package/dist/entries/grid.d.ts +1 -0
  14. package/dist/entries/grid.js +1 -0
  15. package/dist/entries/path.d.ts +1 -0
  16. package/dist/entries/path.js +1 -0
  17. package/dist/foundations/layout/grid.d.ts +70 -0
  18. package/dist/foundations/layout/grid.js +84 -0
  19. package/dist/foundations/layout/index.d.ts +1 -0
  20. package/dist/foundations/layout/index.js +3 -0
  21. package/dist/index.d.ts +4 -1
  22. package/dist/index.js +5 -1
  23. package/dist/patterns/audit/audit-log.js +0 -1
  24. package/dist/patterns/lineage/lineage-graph.d.ts +7 -0
  25. package/dist/patterns/lineage/lineage-graph.js +67 -6
  26. package/dist/patterns/lineage/provenance-strip.js +106 -15
  27. package/dist/patterns/notifications/notification-inbox.js +2 -18
  28. package/dist/patterns/timeline/timeline.js +44 -12
  29. package/dist/patterns/versions/graph-arrows.d.ts +22 -0
  30. package/dist/patterns/versions/graph-arrows.js +13 -0
  31. package/dist/patterns/versions/index.d.ts +1 -0
  32. package/dist/patterns/versions/index.js +1 -0
  33. package/dist/patterns/versions/version-graph.d.ts +7 -0
  34. package/dist/patterns/versions/version-graph.js +54 -6
  35. package/package.json +1 -1
  36. package/dist/components/feedback/stage-path.d.ts +0 -44
  37. package/dist/components/feedback/stage-path.js +0 -235
  38. package/dist/entries/stage-path.d.ts +0 -1
  39. package/dist/entries/stage-path.js +0 -1
@@ -1,5 +1,6 @@
1
1
  import { isLineageNodeRecord, resolveLineageDeviation } from "./types.js";
2
2
  import { computeVersionGraphLayout } from "../versions/graph-layout.js";
3
+ import { hasEndArrow, hasStartArrow, resolveGraphArrows } from "../versions/graph-arrows.js";
3
4
  import { formatItemDate } from "../content-explorer/adapters/item-summary.js";
4
5
  import { BaseElement } from "../../core/index.js";
5
6
  import { boeMotionDuration, boeMotionEasing } from "../../foundations/motion/index.js";
@@ -14,6 +15,14 @@ const escapeHtml = (value) => value
14
15
  const LANE_WIDTH = 26;
15
16
  const ROW_HEIGHT = 56;
16
17
  const EDGE_PAD = 8;
18
+ /**
19
+ * How far short of a node's centre an edge stops.
20
+ *
21
+ * The arrowhead marks the path's end vertex, so an edge running to the centre
22
+ * would bury its head under the node disc. The largest node here is ~1.05rem
23
+ * across, so backing off 9px leaves the head just clear of the biggest one.
24
+ */
25
+ const NODE_EDGE_OFFSET = 9;
17
26
  const DEVIATION_LABELS = {
18
27
  none: "in sync",
19
28
  minor: "minor deviation",
@@ -59,6 +68,21 @@ const elementStyles = `
59
68
  pointer-events: none;
60
69
  }
61
70
 
71
+ /* The arrowhead. Same colour as the stroke it terminates, stated
72
+ rather than inherited: context-stroke is not supported everywhere. */
73
+ [part="edge-arrow"] {
74
+ fill: color-mix(in srgb, var(--boe-token-text-text-secondary, #6f6f6f) 45%, transparent);
75
+ stroke: none;
76
+ }
77
+
78
+ [part="edge-arrow"][data-deviation="minor"] {
79
+ fill: color-mix(in srgb, var(--boe-token-surface-status-surface-warning, #f5b31b) 75%, transparent);
80
+ }
81
+
82
+ [part="edge-arrow"][data-deviation="major"] {
83
+ fill: color-mix(in srgb, var(--boe-token-surface-status-surface-error, #ed3757) 70%, transparent);
84
+ }
85
+
62
86
  [part="edge"] {
63
87
  fill: none;
64
88
  stroke: color-mix(in srgb, var(--boe-token-text-text-secondary, #6f6f6f) 45%, transparent);
@@ -196,7 +220,17 @@ const elementStyles = `
196
220
  export class LineageGraph extends BaseElement {
197
221
  static tagName = DEFAULT_TAG_NAME;
198
222
  static get observedAttributes() {
199
- return ["heading", "nodes"];
223
+ return ["arrows", "heading", "nodes"];
224
+ }
225
+ /**
226
+ * Which ends of each edge carry an arrowhead. Defaults to `end`, the head on
227
+ * the derived node, because the layout builds edges source → derived.
228
+ */
229
+ get arrows() {
230
+ return resolveGraphArrows(this.getAttribute("arrows"));
231
+ }
232
+ set arrows(value) {
233
+ this.setAttribute("arrows", value);
200
234
  }
201
235
  nodesValue = [];
202
236
  rovingId = null;
@@ -352,16 +386,43 @@ export class LineageGraph extends BaseElement {
352
386
  const graphHeight = layout.rowCount * ROW_HEIGHT;
353
387
  const centerX = (lane) => EDGE_PAD + lane * LANE_WIDTH + LANE_WIDTH / 2;
354
388
  const centerY = (row) => row * ROW_HEIGHT + ROW_HEIGHT / 2;
389
+ const arrows = this.arrows;
355
390
  const edgePaths = layout.edges
356
391
  .map(edge => {
357
392
  const x1 = centerX(edge.fromLane);
358
393
  const y1 = centerY(edge.fromRow);
359
394
  const x2 = centerX(edge.toLane);
360
395
  const y2 = centerY(edge.toRow);
361
- const d = edge.fromLane === edge.toLane
362
- ? `M ${x1} ${y1} L ${x2} ${y2}`
363
- : `M ${x1} ${y1} C ${x1} ${y1 - ROW_HEIGHT / 2}, ${x2} ${y2 + ROW_HEIGHT / 2}, ${x2} ${y2}`;
364
- return `<path part="edge" data-deviation="${deviationFor(edge.fromId, edge.toId)}" d="${d}"></path>`;
396
+ // One curve rule for every edge, rather than a straight line for
397
+ // same-lane edges and a cubic for lane changes: two rules made the
398
+ // graph read as two different diagrams. A cubic whose ends share an x
399
+ // degenerates to a straight vertical on its own, so same-lane edges
400
+ // still render straight without being a special case.
401
+ //
402
+ // Control points are pulled *along* the direction of travel — half the
403
+ // vertical distance each — so the bend always leaves the parent
404
+ // downward and enters the child from above. The previous version
405
+ // offset them by a fixed half-row in the wrong direction (up from the
406
+ // start, down from the end on a downward edge), so an edge spanning
407
+ // one row bowed backwards into an S while a longer one looked almost
408
+ // straight.
409
+ // Stop at each node's edge rather than its centre. A marker sits on
410
+ // the path's end vertex, so a vertex inside the node disc buries it —
411
+ // and an edge that starts and ends at the rim reads as connecting two
412
+ // nodes rather than as passing through them, which is what React Flow
413
+ // gets from anchoring edges to handles.
414
+ const yStart = y1 + Math.sign(y2 - y1) * NODE_EDGE_OFFSET;
415
+ const yEnd = y2 + Math.sign(y1 - y2) * NODE_EDGE_OFFSET;
416
+ const dy = (yEnd - yStart) / 2;
417
+ const d = `M ${x1} ${yStart} C ${x1} ${yStart + dy}, ${x2} ${yEnd - dy}, ${x2} ${yEnd}`;
418
+ const deviation = deviationFor(edge.fromId, edge.toId);
419
+ // One marker per deviation rather than context-stroke, which is not
420
+ // supported everywhere: an arrowhead in the wrong colour reads as a
421
+ // different edge.
422
+ const marker = deviation === "minor" || deviation === "major" ? `boe-graph-arrow-${deviation}` : "boe-graph-arrow";
423
+ const startMarker = hasStartArrow(arrows) ? ` marker-start="url(#${marker})"` : "";
424
+ const endMarker = hasEndArrow(arrows) ? ` marker-end="url(#${marker})"` : "";
425
+ return `<path part="edge" data-deviation="${deviation}" d="${d}"${startMarker}${endMarker}></path>`;
365
426
  })
366
427
  .join("");
367
428
  const rovingId = this.rovingId && byId.has(this.rovingId)
@@ -415,7 +476,7 @@ export class LineageGraph extends BaseElement {
415
476
  ${layout.placements.length > 0
416
477
  ? `
417
478
  <div part="graph">
418
- <svg part="edges" width="${String(graphWidth)}" height="${String(graphHeight)}" viewBox="0 0 ${String(graphWidth)} ${String(graphHeight)}" aria-hidden="true">${edgePaths}</svg>
479
+ <svg part="edges" width="${String(graphWidth)}" height="${String(graphHeight)}" viewBox="0 0 ${String(graphWidth)} ${String(graphHeight)}" aria-hidden="true"><defs><marker id="boe-graph-arrow" viewBox="0 0 8 8" refX="7" refY="4" markerWidth="4" markerHeight="4" orient="auto-start-reverse"><path part="edge-arrow" d="M 0 0 L 8 4 L 0 8 z"></path></marker><marker id="boe-graph-arrow-minor" viewBox="0 0 8 8" refX="7" refY="4" markerWidth="4" markerHeight="4" orient="auto-start-reverse"><path part="edge-arrow" data-deviation="minor" d="M 0 0 L 8 4 L 0 8 z"></path></marker><marker id="boe-graph-arrow-major" viewBox="0 0 8 8" refX="7" refY="4" markerWidth="4" markerHeight="4" orient="auto-start-reverse"><path part="edge-arrow" data-deviation="major" d="M 0 0 L 8 4 L 0 8 z"></path></marker></defs>${edgePaths}</svg>
419
480
  <ol part="rows" role="list">${rows}</ol>
420
481
  </div>
421
482
  `
@@ -21,37 +21,100 @@ const elementStyles = `
21
21
  font: inherit;
22
22
  }
23
23
 
24
+ /* Read as a flow diagram rather than a breadcrumb. The anatomy is
25
+ React Flow's, taken from its stylesheet: nodes are bordered boxes
26
+ with a small radius (not pills), ports are 6px circles sitting on
27
+ the node's edge, and the connection between them is a drawn edge
28
+ with an arrowhead rather than a text glyph.
29
+
30
+ The canvas dot grid is the other half of the signal — a node graph
31
+ reads as a canvas, not as a line of text. */
24
32
  [part="strip"] {
25
33
  display: flex;
26
- flex-wrap: wrap;
34
+ /* A canvas scrolls; it does not reflow. Wrapping put an edge at the
35
+ start of the second line with its arrow pointing into nothing,
36
+ which reads as broken rather than as a continuation. */
37
+ flex-wrap: nowrap;
38
+ overflow-x: auto;
27
39
  align-items: center;
28
- gap: 0.35rem;
40
+ gap: 0;
29
41
  margin: 0;
30
- padding: 0;
42
+ padding: 0.75rem;
31
43
  list-style: none;
44
+ border-radius: ${boeRadius.large};
45
+ background-image: radial-gradient(
46
+ color-mix(in srgb, var(--boe-token-stroke-stroke, #e8e8e8) 85%, transparent) 1px,
47
+ transparent 1px
48
+ );
49
+ background-size: 12px 12px;
50
+ background-position: -1px -1px;
32
51
  }
33
52
 
34
53
  [part="item"] {
35
54
  display: inline-flex;
36
55
  align-items: center;
37
- gap: 0.35rem;
56
+ /* No shrinking: with nowrap a long chain must scroll, not compress
57
+ the nodes until their labels ellipsize. */
58
+ flex: 0 0 auto;
38
59
  }
39
60
 
61
+ /* The node. React Flow's default: 10px padding, a small radius, a 1px
62
+ border and a centred 12px label. Width is left to the content —
63
+ React Flow fixes it at 150px because it lays out on a free canvas,
64
+ whereas this strip is a row in a header and the labels are short. */
40
65
  [part="chip"] {
41
66
  appearance: none;
67
+ position: relative;
42
68
  display: inline-flex;
43
69
  align-items: center;
44
- gap: 0.3rem;
70
+ gap: 0.35rem;
45
71
  font: inherit;
46
- font-size: 0.8rem;
72
+ font-size: 0.78rem;
47
73
  font-weight: 600;
48
- padding: 0.22rem 0.55rem;
49
- border-radius: 999px;
50
- border: 1px solid color-mix(in srgb, var(--boe-token-stroke-stroke, #e8e8e8) 60%, transparent);
74
+ padding: 0.4rem 0.6rem;
75
+ /* 4px, the nearest token to React Flow's 3px. The control radius is
76
+ 20px here, which rendered the nodes as stadium pills — the shape
77
+ the strip was moving away from. */
78
+ border-radius: ${boeRadius.size};
79
+ border: 1px solid color-mix(in srgb, var(--boe-token-stroke-stroke, #e8e8e8) 90%, var(--boe-token-text-text, #222222));
51
80
  background: var(--boe-token-surface-surface, #ffffff);
52
- color: var(--boe-token-text-text-secondary, #6f6f6f);
81
+ color: var(--boe-token-text-text, #222222);
82
+ box-shadow: 0 1px 2px rgb(0 0 0 / 6%);
53
83
  cursor: pointer;
54
- transition: background ${boeMotionDuration.interactive} ${boeMotionEasing.standard};
84
+ transition: background ${boeMotionDuration.interactive} ${boeMotionEasing.standard},
85
+ border-color ${boeMotionDuration.interactive} ${boeMotionEasing.standard};
86
+ }
87
+
88
+ /* Ports. 6px circles straddling the node's edge, ringed in the surface
89
+ colour so they read as attached to the node rather than floating —
90
+ the same construction React Flow uses for its handles. */
91
+ [part="chip"]::before,
92
+ [part="chip"]::after {
93
+ content: "";
94
+ position: absolute;
95
+ inset-block-start: 50%;
96
+ inline-size: 6px;
97
+ block-size: 6px;
98
+ border-radius: 100%;
99
+ background: var(--boe-token-text-text-secondary, #6f6f6f);
100
+ border: 1px solid var(--boe-token-surface-surface, #ffffff);
101
+ transform: translateY(-50%);
102
+ }
103
+
104
+ [part="chip"]::before {
105
+ inset-inline-start: -4px;
106
+ }
107
+
108
+ [part="chip"]::after {
109
+ inset-inline-end: -4px;
110
+ }
111
+
112
+ /* The first node has nothing feeding it and the last nothing leaving
113
+ it, so they carry one port each — React Flow's input and output
114
+ nodes do the same. */
115
+ [part="item"]:first-child [part="chip"]::before,
116
+ [part="item"]:last-child [part="chip"]::after {
117
+ display: none;
55
118
  }
56
119
 
57
120
  [part="chip"]:hover {
@@ -59,15 +122,19 @@ const elementStyles = `
59
122
  }
60
123
 
61
124
  [part="chip"][aria-current="true"] {
62
- border-color: color-mix(in srgb, var(--boe-token-surface-surface-brand, #0061d5) 45%, transparent);
125
+ border-color: var(--boe-token-surface-surface-brand, #0061d5);
63
126
  color: var(--boe-token-surface-surface-brand, #0061d5);
64
127
  background: color-mix(in srgb, var(--boe-token-surface-surface-brand, #0061d5) 8%, var(--boe-token-surface-surface, #ffffff));
65
128
  }
66
129
 
130
+ [part="chip"][aria-current="true"]::before,
131
+ [part="chip"][aria-current="true"]::after {
132
+ background: var(--boe-token-surface-surface-brand, #0061d5);
133
+ }
134
+
67
135
  [part="chip"]:focus-visible {
68
136
  outline: none;
69
137
  box-shadow: 0 0 0 3px color-mix(in srgb, var(--boe-token-surface-surface-brand, #0061d5) 18%, transparent);
70
- border-radius: ${boeRadius.large};
71
138
  }
72
139
 
73
140
  [part="kind-dot"] {
@@ -85,10 +152,34 @@ const elementStyles = `
85
152
  border-radius: 2px;
86
153
  }
87
154
 
155
+ /* The edge: a drawn line between two ports with an arrowhead, in place
156
+ of the "→" character the strip used to print. Two co-linear handles
157
+ are exactly the case where React Flow's bezier degenerates to a
158
+ straight line, so a straight edge is the faithful shape here rather
159
+ than a simplification. */
88
160
  [part="separator"] {
89
- color: var(--boe-token-text-text-secondary, #6f6f6f);
161
+ position: relative;
162
+ display: inline-flex;
163
+ align-items: center;
164
+ inline-size: 1.6rem;
165
+ block-size: 2px;
166
+ margin-inline: 4px;
167
+ background: color-mix(in srgb, var(--boe-token-stroke-stroke, #e8e8e8) 90%, var(--boe-token-text-text, #222222));
90
168
  user-select: none;
91
169
  }
170
+
171
+ [part="separator"]::after {
172
+ content: "";
173
+ position: absolute;
174
+ inset-inline-end: -1px;
175
+ inset-block-start: 50%;
176
+ inline-size: 0;
177
+ block-size: 0;
178
+ border-block: 4px solid transparent;
179
+ border-inline-start: 6px solid color-mix(in srgb, var(--boe-token-stroke-stroke, #e8e8e8) 90%, var(--boe-token-text-text, #222222));
180
+ transform: translateY(-50%);
181
+ }
182
+
92
183
  `;
93
184
  /**
94
185
  * The cheap, high-frequency lineage sibling: a linear ancestry strip
@@ -175,7 +266,7 @@ export class ProvenanceStrip extends BaseElement {
175
266
  const kind = node.kind ?? "";
176
267
  return `
177
268
  <li part="item"${kind ? ` data-kind="${escapeHtml(kind)}"` : ""}>
178
- ${index > 0 ? `<span part="separator" aria-hidden="true">→</span>` : ""}
269
+ ${index > 0 ? `<span part="separator" aria-hidden="true"></span>` : ""}
179
270
  <button
180
271
  type="button"
181
272
  part="chip"
@@ -1,4 +1,4 @@
1
- import { countUnreadNotifications, groupNotifications, isNotificationItemRecord, resolveNotificationFilter, resolveNotificationTone, } from "./types.js";
1
+ import { countUnreadNotifications, groupNotifications, isNotificationItemRecord, resolveNotificationFilter, } from "./types.js";
2
2
  import { BaseElement } from "../../core/index.js";
3
3
  import { isSafeHref } from "../internal/safe-href.js";
4
4
  import { boePanel, boeRadius } from "../../foundations/geometry/index.js";
@@ -10,20 +10,6 @@ const escapeHtml = (value) => value
10
10
  .replaceAll(">", "&gt;")
11
11
  .replaceAll('"', "&quot;")
12
12
  .replaceAll("'", "&#39;");
13
- const toneColor = (tone) => {
14
- switch (tone) {
15
- case "brand":
16
- return "var(--boe-token-surface-surface-brand, #0061d5)";
17
- case "success":
18
- return "var(--boe-token-surface-status-surface-success, #26a27b)";
19
- case "warning":
20
- return "var(--boe-token-surface-status-surface-warning, #f5b31b)";
21
- case "error":
22
- return "var(--boe-token-surface-status-surface-error, #ed3757)";
23
- default:
24
- return "var(--boe-token-text-text-secondary, #6f6f6f)";
25
- }
26
- };
27
13
  const elementStyles = `
28
14
  [hidden] {
29
15
  display: none !important;
@@ -160,7 +146,6 @@ const elementStyles = `
160
146
  align-items: start;
161
147
  padding: 0.5rem 0.55rem;
162
148
  border-radius: ${boeRadius.med};
163
- border-left: 3px solid var(--notification-tone, transparent);
164
149
  background: color-mix(in srgb, var(--boe-token-surface-surface-brand, #0061d5) 6%, transparent);
165
150
  transition: background ${boeMotionDuration.interactive} ${boeMotionEasing.standard};
166
151
  }
@@ -381,7 +366,6 @@ export class NotificationInbox extends BaseElement {
381
366
  this.dispatchEvent(new CustomEvent(name, { bubbles: true, composed: true, detail }));
382
367
  }
383
368
  itemHtml(item) {
384
- const tone = resolveNotificationTone(item.tone);
385
369
  const read = Boolean(item.read);
386
370
  const entity = item.entityRef;
387
371
  const entityHtml = entity
@@ -390,7 +374,7 @@ export class NotificationInbox extends BaseElement {
390
374
  : `<span part="entity">${escapeHtml(entity.label)}</span>`
391
375
  : "";
392
376
  return `
393
- <li part="item" data-item-id="${escapeHtml(item.id)}" data-read="${String(read)}" data-type="${escapeHtml(item.type)}" style="--notification-tone:${toneColor(tone)};">
377
+ <li part="item" data-item-id="${escapeHtml(item.id)}" data-read="${String(read)}" data-type="${escapeHtml(item.type)}">
394
378
  <span part="unread-dot" aria-hidden="true"></span>
395
379
  <span part="item-body">
396
380
  <button type="button" part="item-title" data-item-id="${escapeHtml(item.id)}">${escapeHtml(item.title)}</button>
@@ -60,6 +60,14 @@ const elementStyles = `
60
60
  margin: 0;
61
61
  padding: 0;
62
62
  display: grid;
63
+
64
+ /* Named so the spine can derive how far it has to reach rather than
65
+ restating the numbers. The line has to cross this entry's bottom
66
+ padding, the next entry's top padding and the marker's own offset
67
+ to arrive at the next marker; spelling that out as arithmetic
68
+ keeps it correct if any of the three changes. */
69
+ --timeline-event-pad: 0.55rem;
70
+ --timeline-marker-offset: 0.3rem;
63
71
  }
64
72
 
65
73
  [part="event"] {
@@ -67,29 +75,53 @@ const elementStyles = `
67
75
  display: grid;
68
76
  grid-template-columns: auto 1fr;
69
77
  gap: 0.65rem;
70
- padding: 0.55rem 0 0.55rem 0.15rem;
78
+ padding: var(--timeline-event-pad) 0 var(--timeline-event-pad) 0.15rem;
71
79
  }
72
80
 
73
- /* The connecting spine between markers. */
74
- [part="event"]:not(:last-child)::after {
75
- content: "";
76
- position: absolute;
77
- left: calc(0.15rem + 0.55rem - 1px);
78
- top: 1.9rem;
79
- bottom: -0.35rem;
80
- width: 2px;
81
- background: color-mix(in srgb, var(--boe-token-stroke-stroke, #e8e8e8) 72%, transparent);
81
+ /* The marker column, built the way box-path's base variant builds its
82
+ rail: the marker is a fixed box and the spine is a flex sibling that
83
+ takes whatever height is left, so the line runs from the marker to
84
+ the bottom of the entry however tall the entry's content turns out
85
+ to be.
86
+
87
+ The previous version positioned the spine with hand-tuned constants
88
+ (top: 1.9rem, bottom: -0.35rem) that guessed where the marker ended.
89
+ They were only ever right for one font size and one content height,
90
+ which is why the line stopped short of the next marker and dangled. */
91
+ [part="marker-rail"] {
92
+ display: flex;
93
+ flex-direction: column;
94
+ align-items: center;
95
+ align-self: stretch;
96
+ margin-top: var(--timeline-marker-offset);
82
97
  }
83
98
 
84
99
  [part="marker"] {
100
+ flex: 0 0 auto;
85
101
  inline-size: 1.1rem;
86
102
  block-size: 1.1rem;
87
- margin-top: 0.3rem;
88
103
  border-radius: 999px;
89
104
  border: 2px solid var(--timeline-tone, var(--boe-token-text-text-secondary, #6f6f6f));
90
105
  background: color-mix(in srgb, var(--timeline-tone, var(--boe-token-text-text-secondary, #6f6f6f)) 14%, var(--boe-token-surface-surface, #ffffff) 86%);
91
106
  }
92
107
 
108
+ /* The spine. Grows to fill the column, and the negative bottom margin
109
+ carries it across the gap into the next entry's marker so the two
110
+ actually meet. Hidden on the last entry, which leads nowhere. */
111
+ [part="marker-rail"]::after {
112
+ content: "";
113
+ flex: 1 1 auto;
114
+ inline-size: 2px;
115
+ margin-block: 0.25rem
116
+ calc(-1 * (var(--timeline-event-pad) * 2 + var(--timeline-marker-offset)));
117
+ border-radius: 999px;
118
+ background: var(--boe-token-stroke-stroke, #e8e8e8);
119
+ }
120
+
121
+ [part="event"]:last-child [part="marker-rail"]::after {
122
+ display: none;
123
+ }
124
+
93
125
  [part="event-body"] {
94
126
  display: grid;
95
127
  gap: 0.25rem;
@@ -328,7 +360,7 @@ export class Timeline extends BaseElement {
328
360
  const timestamp = event.timestamp ? formatItemDate(event.timestamp) : "";
329
361
  return `
330
362
  <li part="event" data-event-id="${escapeHtml(event.id)}" style="--timeline-tone:${toneColor(tone)};">
331
- <span part="marker" aria-hidden="true"></span>
363
+ <span part="marker-rail" aria-hidden="true"><span part="marker"></span></span>
332
364
  <div part="event-body">
333
365
  <div part="event-topline">
334
366
  ${event.actor ? `<span part="actor">${escapeHtml(event.actor.name)}</span>` : ""}
@@ -0,0 +1,22 @@
1
+ /**
2
+ * Which ends of a graph edge carry an arrowhead.
3
+ *
4
+ * Shared by `box-version-graph` and `box-lineage-graph`, which draw the same
5
+ * edge geometry from the same layout and should not disagree about what an
6
+ * arrow means. Lives here rather than in either component because the lineage
7
+ * graph already depends on this directory for `computeVersionGraphLayout`, and
8
+ * the reverse dependency would be a cycle.
9
+ */
10
+ export type GraphArrows = "none" | "start" | "end" | "both";
11
+ /**
12
+ * Narrow an author-supplied value, falling back to `end`.
13
+ *
14
+ * `end` is the default because these are directed graphs: the layout builds
15
+ * every edge as parent → child, so a single head on the child is the reading
16
+ * that matches the data. A typo should not silently turn the direction off.
17
+ */
18
+ export declare const resolveGraphArrows: (value: string | null | undefined) => GraphArrows;
19
+ /** Whether the head at the child end is drawn. */
20
+ export declare const hasEndArrow: (arrows: GraphArrows) => boolean;
21
+ /** Whether the head at the parent end is drawn. */
22
+ export declare const hasStartArrow: (arrows: GraphArrows) => boolean;
@@ -0,0 +1,13 @@
1
+ const GRAPH_ARROWS = new Set(["none", "start", "end", "both"]);
2
+ /**
3
+ * Narrow an author-supplied value, falling back to `end`.
4
+ *
5
+ * `end` is the default because these are directed graphs: the layout builds
6
+ * every edge as parent → child, so a single head on the child is the reading
7
+ * that matches the data. A typo should not silently turn the direction off.
8
+ */
9
+ export const resolveGraphArrows = (value) => GRAPH_ARROWS.has(value) ? value : "end";
10
+ /** Whether the head at the child end is drawn. */
11
+ export const hasEndArrow = (arrows) => arrows === "end" || arrows === "both";
12
+ /** Whether the head at the parent end is drawn. */
13
+ export const hasStartArrow = (arrows) => arrows === "start" || arrows === "both";
@@ -1,4 +1,5 @@
1
1
  export * from "./types.js";
2
2
  export * from "./graph-layout.js";
3
+ export * from "./graph-arrows.js";
3
4
  export * from "./version-list.js";
4
5
  export * from "./version-graph.js";
@@ -2,6 +2,7 @@
2
2
  export * from "./types.js";
3
3
  // Pure git-network layout engine (shared with future lineage graphs)
4
4
  export * from "./graph-layout.js";
5
+ export * from "./graph-arrows.js";
5
6
  // Accessible core contract
6
7
  export * from "./version-list.js";
7
8
  // Git-style visualization layer
@@ -1,3 +1,4 @@
1
+ import type { GraphArrows } from "./graph-arrows.js";
1
2
  import type { VersionNode } from "./types.js";
2
3
  import { BaseElement } from "../../core/index.js";
3
4
  /**
@@ -12,6 +13,12 @@ import { BaseElement } from "../../core/index.js";
12
13
  export declare class VersionGraph extends BaseElement {
13
14
  static readonly tagName: string;
14
15
  static get observedAttributes(): string[];
16
+ /**
17
+ * Which ends of each edge carry an arrowhead. Defaults to `end`, the head on
18
+ * the child, because the layout builds edges parent → child.
19
+ */
20
+ get arrows(): GraphArrows;
21
+ set arrows(value: GraphArrows);
15
22
  private versionsValue;
16
23
  private compareIds;
17
24
  private rovingId;
@@ -1,4 +1,5 @@
1
1
  import { computeVersionGraphLayout, orderVersionsForDisplay } from "./graph-layout.js";
2
+ import { hasEndArrow, hasStartArrow, resolveGraphArrows } from "./graph-arrows.js";
2
3
  import { escapeHtml, STATUS_LABELS } from "./shared.js";
3
4
  import { isVersionNodeRecord, resolveVersionKind, resolveVersionStatus } from "./types.js";
4
5
  import { formatItemDate } from "../content-explorer/adapters/item-summary.js";
@@ -8,6 +9,14 @@ const DEFAULT_TAG_NAME = "box-version-graph";
8
9
  const LANE_WIDTH = 26;
9
10
  const ROW_HEIGHT = 46;
10
11
  const EDGE_PAD = 8;
12
+ /**
13
+ * How far short of a node's centre an edge stops.
14
+ *
15
+ * The arrowhead marks the path's end vertex, so an edge running to the centre
16
+ * would bury its head under the node disc. The largest node here is ~1.05rem
17
+ * across, so backing off 9px leaves the head just clear of the biggest one.
18
+ */
19
+ const NODE_EDGE_OFFSET = 9;
11
20
  const elementStyles = `
12
21
  [hidden] {
13
22
  display: none !important;
@@ -48,6 +57,13 @@ const elementStyles = `
48
57
  pointer-events: none;
49
58
  }
50
59
 
60
+ /* The arrowhead. Same colour as the stroke it terminates, stated
61
+ rather than inherited: context-stroke is not supported everywhere. */
62
+ [part="edge-arrow"] {
63
+ fill: color-mix(in srgb, var(--boe-token-text-text-secondary, #6f6f6f) 55%, transparent);
64
+ stroke: none;
65
+ }
66
+
51
67
  [part="edge"] {
52
68
  fill: none;
53
69
  stroke: color-mix(in srgb, var(--boe-token-text-text-secondary, #6f6f6f) 55%, transparent);
@@ -187,7 +203,17 @@ const elementStyles = `
187
203
  export class VersionGraph extends BaseElement {
188
204
  static tagName = DEFAULT_TAG_NAME;
189
205
  static get observedAttributes() {
190
- return ["heading", "versions"];
206
+ return ["arrows", "heading", "versions"];
207
+ }
208
+ /**
209
+ * Which ends of each edge carry an arrowhead. Defaults to `end`, the head on
210
+ * the child, because the layout builds edges parent → child.
211
+ */
212
+ get arrows() {
213
+ return resolveGraphArrows(this.getAttribute("arrows"));
214
+ }
215
+ set arrows(value) {
216
+ this.setAttribute("arrows", value);
191
217
  }
192
218
  versionsValue = [];
193
219
  compareIds = [];
@@ -359,16 +385,38 @@ export class VersionGraph extends BaseElement {
359
385
  const graphHeight = layout.rowCount * ROW_HEIGHT;
360
386
  const centerX = (lane) => EDGE_PAD + lane * LANE_WIDTH + LANE_WIDTH / 2;
361
387
  const centerY = (row) => row * ROW_HEIGHT + ROW_HEIGHT / 2;
388
+ const arrows = this.arrows;
389
+ const startMarker = hasStartArrow(arrows) ? ` marker-start="url(#boe-graph-arrow)"` : "";
390
+ const endMarker = hasEndArrow(arrows) ? ` marker-end="url(#boe-graph-arrow)"` : "";
362
391
  const edgePaths = layout.edges
363
392
  .map(edge => {
364
393
  const x1 = centerX(edge.fromLane);
365
394
  const y1 = centerY(edge.fromRow);
366
395
  const x2 = centerX(edge.toLane);
367
396
  const y2 = centerY(edge.toRow);
368
- const d = edge.fromLane === edge.toLane
369
- ? `M ${x1} ${y1} L ${x2} ${y2}`
370
- : `M ${x1} ${y1} C ${x1} ${y1 - ROW_HEIGHT / 2}, ${x2} ${y2 + ROW_HEIGHT / 2}, ${x2} ${y2}`;
371
- return `<path part="edge" data-from="${escapeHtml(edge.fromId)}" data-to="${escapeHtml(edge.toId)}" d="${d}"></path>`;
397
+ // One curve rule for every edge, rather than a straight line for
398
+ // same-lane edges and a cubic for lane changes: two rules made the
399
+ // graph read as two different diagrams. A cubic whose ends share an x
400
+ // degenerates to a straight vertical on its own, so same-lane edges
401
+ // still render straight without being a special case.
402
+ //
403
+ // Control points are pulled *along* the direction of travel — half the
404
+ // vertical distance each — so the bend always leaves the parent
405
+ // downward and enters the child from above. The previous version
406
+ // offset them by a fixed half-row in the wrong direction (up from the
407
+ // start, down from the end on a downward edge), so an edge spanning
408
+ // one row bowed backwards into an S while a longer one looked almost
409
+ // straight.
410
+ // Stop at each node's edge rather than its centre. A marker sits on
411
+ // the path's end vertex, so a vertex inside the node disc buries it —
412
+ // and an edge that starts and ends at the rim reads as connecting two
413
+ // nodes rather than as passing through them, which is what React Flow
414
+ // gets from anchoring edges to handles.
415
+ const yStart = y1 + Math.sign(y2 - y1) * NODE_EDGE_OFFSET;
416
+ const yEnd = y2 + Math.sign(y1 - y2) * NODE_EDGE_OFFSET;
417
+ const dy = (yEnd - yStart) / 2;
418
+ const d = `M ${x1} ${yStart} C ${x1} ${yStart + dy}, ${x2} ${yEnd - dy}, ${x2} ${yEnd}`;
419
+ return `<path part="edge" data-from="${escapeHtml(edge.fromId)}" data-to="${escapeHtml(edge.toId)}" d="${d}"${startMarker}${endMarker}></path>`;
372
420
  })
373
421
  .join("");
374
422
  const rovingId = this.rovingId && byId.has(this.rovingId)
@@ -416,7 +464,7 @@ export class VersionGraph extends BaseElement {
416
464
  ${layout.placements.length > 0
417
465
  ? `
418
466
  <div part="graph">
419
- <svg part="edges" width="${String(graphWidth)}" height="${String(graphHeight)}" viewBox="0 0 ${String(graphWidth)} ${String(graphHeight)}" aria-hidden="true">${edgePaths}</svg>
467
+ <svg part="edges" width="${String(graphWidth)}" height="${String(graphHeight)}" viewBox="0 0 ${String(graphWidth)} ${String(graphHeight)}" aria-hidden="true"><defs><marker id="boe-graph-arrow" viewBox="0 0 8 8" refX="7" refY="4" markerWidth="4" markerHeight="4" orient="auto-start-reverse"><path part="edge-arrow" d="M 0 0 L 8 4 L 0 8 z"></path></marker></defs>${edgePaths}</svg>
420
468
  <ol part="rows" role="list">${rows}</ol>
421
469
  </div>
422
470
  `
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@unofficialbox/box-open-elements",
3
- "version": "0.10.0",
3
+ "version": "0.11.0",
4
4
  "description": "Box Open Elements: an open, framework-agnostic design system and web component library for Box-style experiences",
5
5
  "license": "MIT",
6
6
  "homepage": "https://unofficialbox.github.io/box-open-elements",