beadcyte 0.4.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 (108) hide show
  1. package/CHANGELOG.md +73 -0
  2. package/LICENSE +661 -0
  3. package/README.md +386 -0
  4. package/THIRD_PARTY_NOTICES.md +56 -0
  5. package/bin/beadcyte.mjs +60 -0
  6. package/package.json +77 -0
  7. package/src/changelog-cli.mjs +105 -0
  8. package/src/changelog.mjs +196 -0
  9. package/src/cli.mjs +577 -0
  10. package/src/estimator.mjs +314 -0
  11. package/src/format.mjs +22 -0
  12. package/src/history-walk.mjs +170 -0
  13. package/src/index.mjs +5 -0
  14. package/src/mutate.mjs +193 -0
  15. package/src/projects.mjs +120 -0
  16. package/src/provenance.mjs +75 -0
  17. package/src/review-hours.mjs +117 -0
  18. package/src/roster-path.mjs +24 -0
  19. package/src/scheduler.mjs +424 -0
  20. package/src/serve.mjs +411 -0
  21. package/src/server-state.mjs +105 -0
  22. package/src/ship.mjs +178 -0
  23. package/src/stage-waits.mjs +69 -0
  24. package/src/start.mjs +111 -0
  25. package/src/stop.mjs +66 -0
  26. package/src/velocity.mjs +209 -0
  27. package/src/web/App.vue +691 -0
  28. package/src/web/app.css +54 -0
  29. package/src/web/assets/favicon.svg +12 -0
  30. package/src/web/avatar.ts +53 -0
  31. package/src/web/bead-detail.ts +240 -0
  32. package/src/web/changelog-view.ts +41 -0
  33. package/src/web/components/BeadDrawer.vue +1883 -0
  34. package/src/web/components/BeadSubGraph.vue +326 -0
  35. package/src/web/components/BeadSubGraphOverlay.vue +192 -0
  36. package/src/web/components/BeadTooltip.vue +516 -0
  37. package/src/web/components/BeadcyteMark.vue +64 -0
  38. package/src/web/components/BeadsGantt.vue +2125 -0
  39. package/src/web/components/BeadsGrid.vue +468 -0
  40. package/src/web/components/BeadsIncytes.vue +567 -0
  41. package/src/web/components/BeadsMine.vue +325 -0
  42. package/src/web/components/BeadsTable.vue +335 -0
  43. package/src/web/components/ChangelogOverlay.vue +198 -0
  44. package/src/web/components/ContextMenu.vue +386 -0
  45. package/src/web/components/ControlsPanel.vue +476 -0
  46. package/src/web/components/CostTrend.vue +206 -0
  47. package/src/web/components/FilterPopover.vue +245 -0
  48. package/src/web/components/GroupProgress.vue +274 -0
  49. package/src/web/components/LoadMeter.vue +144 -0
  50. package/src/web/components/MineRow.vue +28 -0
  51. package/src/web/components/OptionsMenu.vue +825 -0
  52. package/src/web/components/PriorityChip.vue +105 -0
  53. package/src/web/components/ScoreStrip.vue +131 -0
  54. package/src/web/components/SearchPalette.vue +210 -0
  55. package/src/web/components/ShipTrend.vue +510 -0
  56. package/src/web/components/ShortcutsOverlay.vue +164 -0
  57. package/src/web/components/Term.vue +177 -0
  58. package/src/web/components/Toast.vue +50 -0
  59. package/src/web/components/TriageMeters.vue +426 -0
  60. package/src/web/components/TypeChip.vue +96 -0
  61. package/src/web/components/Walkthrough.vue +209 -0
  62. package/src/web/components/WhatIfPanel.vue +206 -0
  63. package/src/web/components/WipBullets.vue +191 -0
  64. package/src/web/components/filter-option.ts +9 -0
  65. package/src/web/composables/url-codec.ts +136 -0
  66. package/src/web/composables/useBeadTooltip.ts +148 -0
  67. package/src/web/composables/useKeyboard.ts +97 -0
  68. package/src/web/composables/useLiveRefresh.ts +69 -0
  69. package/src/web/composables/useTheme.ts +125 -0
  70. package/src/web/composables/useUrlState.ts +208 -0
  71. package/src/web/controls-scope.ts +83 -0
  72. package/src/web/cost.ts +251 -0
  73. package/src/web/dep-headings.ts +62 -0
  74. package/src/web/economics.ts +440 -0
  75. package/src/web/env.d.ts +85 -0
  76. package/src/web/frontier.ts +208 -0
  77. package/src/web/gantt-viewport.ts +99 -0
  78. package/src/web/highlights.ts +124 -0
  79. package/src/web/index.html +46 -0
  80. package/src/web/insights.ts +107 -0
  81. package/src/web/keybindings.ts +200 -0
  82. package/src/web/load-meter.ts +72 -0
  83. package/src/web/main.ts +20 -0
  84. package/src/web/markdown.ts +14 -0
  85. package/src/web/mine.ts +137 -0
  86. package/src/web/mutations.ts +21 -0
  87. package/src/web/person.ts +102 -0
  88. package/src/web/projects-text.ts +15 -0
  89. package/src/web/projects.ts +188 -0
  90. package/src/web/refresh.ts +47 -0
  91. package/src/web/search.ts +50 -0
  92. package/src/web/shortcuts.ts +113 -0
  93. package/src/web/status-filter.ts +48 -0
  94. package/src/web/store.ts +1378 -0
  95. package/src/web/style-audit.mjs +346 -0
  96. package/src/web/styles-alt.css +111 -0
  97. package/src/web/styles-ported.css +270 -0
  98. package/src/web/subgraph.ts +362 -0
  99. package/src/web/table.ts +201 -0
  100. package/src/web/theme.ts +88 -0
  101. package/src/web/tokens.css +168 -0
  102. package/src/web/triage.ts +914 -0
  103. package/src/web/view-model.ts +717 -0
  104. package/src/web/walkthrough.ts +133 -0
  105. package/src/web/watchlist.ts +47 -0
  106. package/src/web/whatif.ts +291 -0
  107. package/src/web/window.ts +73 -0
  108. package/src/web/wip.ts +83 -0
@@ -0,0 +1,2125 @@
1
+ <script setup lang="ts">
2
+ // BeadsGantt.vue — thin renderer over DDV primitives.
3
+ //
4
+ // Consumes the view model (rows/bars/groups + meta) from the Pinia store
5
+ // and paints a SVG Gantt with the same visual vocabulary as the CLI SVG
6
+ // emitter (src/cli.mjs). Bar kinds map 1:1 to CSS classes so the palette
7
+ // tunes globally without touching the render path.
8
+ //
9
+ // Uses ddv#40 (<GanttChart>) once it lands — for now we assemble from
10
+ // <DDV> + <CartesianScaler> + <Axis> + <ThresholdLine>. See
11
+ // https://gitlab.com/incytestudios/ddv/-/work_items/40 for the eventual
12
+ // consolidation target. We manage layout + mouseState directly (via a
13
+ // ResizeObserver) rather than through useChart(), because our fixed
14
+ // row-label column and content-driven height don't fit its resize-driven
15
+ // model — DDV's primitives only require the Layout/MouseState shapes,
16
+ // and we build both from scratch.
17
+
18
+ import { computed, ref, reactive, onMounted, onBeforeUnmount, watch, nextTick } from "vue";
19
+ import {
20
+ DDV,
21
+ CartesianScaler,
22
+ ThresholdLine,
23
+ fromRange,
24
+ fromTimeFrame,
25
+ } from "@incytestudios/ddv";
26
+ import type { Layout, MouseState } from "@incytestudios/ddv";
27
+ import {
28
+ eachDayOfInterval,
29
+ eachWeekOfInterval,
30
+ eachMonthOfInterval,
31
+ eachQuarterOfInterval,
32
+ eachYearOfInterval,
33
+ format as formatDate,
34
+ eachHourOfInterval,
35
+ } from "date-fns";
36
+ import "@incytestudios/ddv/style.css";
37
+ import { isWaitKind, type ViewModel, type Bar } from "../view-model";
38
+ import { describeDelta } from "../whatif";
39
+ import {
40
+ TriangleAlert,
41
+ ChevronDown,
42
+ ChevronUp,
43
+ ArrowRight,
44
+ Ban,
45
+ Pin,
46
+ } from "@lucide/vue";
47
+ import { useBeadsStore } from "../store";
48
+ import BeadTooltip from "./BeadTooltip.vue";
49
+ import { useBeadTooltip } from "../composables/useBeadTooltip";
50
+ import {
51
+ type Viewport,
52
+ clampViewport,
53
+ initialViewport,
54
+ panViewportPx,
55
+ zoomViewport,
56
+ centreViewport,
57
+ scrollMetrics,
58
+ viewportFromScrollLeft,
59
+ wheelZoomFactor,
60
+ } from "../gantt-viewport";
61
+
62
+ const props = defineProps<{
63
+ vm: ViewModel | null;
64
+ }>();
65
+
66
+ const store = useBeadsStore();
67
+
68
+ // ── layout constants ──────────────────────────────────────────────────────
69
+ // These roughly mirror cli.mjs's SVG constants; the interactive Gantt gets
70
+ // a narrower label column and tighter rows. LABEL_COL is user-adjustable
71
+ // (see labelColWidth below) — the other values stay fixed.
72
+ const ROW_H = 20;
73
+ /** Bar height inside a row; the rest is air. style-audit.mjs reads this. */
74
+ const BAR_H = ROW_H - 8;
75
+ const GROUP_H = 26;
76
+ const GROUP_GAP = 6;
77
+ // Left padding before the first text column, on both group headers and rows.
78
+ const WATCH_PIN = 11;
79
+ // Reserved gutter for the watch pin, left of the priority chip. Held open
80
+ // whether or not a row is pinned: a column that appears and disappears would
81
+ // reflow every title the moment you watch something.
82
+ const PIN_COL_W = WATCH_PIN + 10;
83
+ const ID_COL_X = 8;
84
+ const TITLE_COL_X = 78;
85
+ const PRIORITY_COL_W = 24; // reserved right-side gutter for "P#" text
86
+ const AXIS_TOP = 36;
87
+ const AXIS_BOTTOM = 16;
88
+ const RIGHT_PAD = 24;
89
+
90
+ // ── synthetic-assignee hatch ──────────────────────────────────────────────
91
+ // A bar whose assignee the scheduler inferred keeps its kind's fill and
92
+ // opacity and has diagonal stripes cut out of it by a mask (see <defs> in
93
+ // the template and .bar.synthetic in the styles). Texture rather than a
94
+ // third opacity level: the kinds already spend opacity — future 0.75,
95
+ // backlog 0.35 — and a level between them converging on one of those after
96
+ // compositing is exactly what style-audit.test.mjs keeps catching. The
97
+ // audit reads these two numbers and checks the stripes stay legible at
98
+ // BAR_H, so a "finer stripes" tweak cannot quietly turn the hatch into a
99
+ // paler bar.
100
+ // The least a bar may be drawn (bp-vsr). Spans are honest in the data — a
101
+ // seventeen-minute bead is 0.012 of a day — so at six pixels a day it would
102
+ // vanish; this keeps it a visible sliver. It is pixels, not days, so zooming
103
+ // in reveals the real length instead of a floor pretending to be one.
104
+ const MIN_BAR_PX = 3;
105
+
106
+ // Wait segments (bp-6cg) are drawn dulled and CUT SHORT: at most this many
107
+ // pixels, anchored where the wait ends (the landing, for review; the work's
108
+ // start, for unclaimed), with a dotted connector standing in for the rest.
109
+ // Their length is not to scale; the date they end on is.
110
+ const MAX_WAIT_PX = 24;
111
+ const WAIT_INSET = 3; // px shaved off top and bottom: secondary to the work
112
+
113
+ interface WaitGeo {
114
+ x: number;
115
+ w: number;
116
+ gap: { x1: number; x2: number } | null;
117
+ }
118
+
119
+ /** Where a wait bar is drawn given the scaler, and the gap it elides. */
120
+ function waitGeo(bar: Bar, scale: (d: Date) => number): WaitGeo {
121
+ const x1 = scale(dateAt(bar.startDay));
122
+ const x2 = scale(dateAt(bar.endDay));
123
+ const full = Math.max(0, x2 - x1);
124
+ if (full <= MAX_WAIT_PX) return { x: x1, w: Math.max(MIN_BAR_PX, full), gap: null };
125
+ // Anchor at the end that means something: the landing for review, the
126
+ // work's start for unclaimed. Both are the wait's END.
127
+ return { x: x2 - MAX_WAIT_PX, w: MAX_WAIT_PX, gap: { x1, x2: x2 - MAX_WAIT_PX } };
128
+ }
129
+
130
+ const HATCH_PITCH = 6; // px from one stripe to the next, across the stripes
131
+ const HATCH_STRIPE = 3; // px of bar kept per pitch; the rest is cut away
132
+
133
+ // ── label column width (user-adjustable, persisted) ──────────────────────
134
+ // Users drag the divider between the label column and the plot to make
135
+ // room for long titles. Width is clamped so the plot always retains a
136
+ // usable minimum, and the current value round-trips through localStorage
137
+ // so the choice sticks per browser.
138
+ const LABEL_COL_MIN = 180;
139
+ const LABEL_COL_MAX = 640;
140
+ const LABEL_COL_DEFAULT = 420;
141
+ const LABEL_COL_STORAGE_KEY = "beadcyte:label-col-width";
142
+
143
+ function loadStoredLabelCol(): number {
144
+ if (typeof localStorage === "undefined") return LABEL_COL_DEFAULT;
145
+ const raw = localStorage.getItem(LABEL_COL_STORAGE_KEY);
146
+ const n = raw ? parseInt(raw, 10) : NaN;
147
+ return Number.isFinite(n) && n >= LABEL_COL_MIN && n <= LABEL_COL_MAX
148
+ ? n
149
+ : LABEL_COL_DEFAULT;
150
+ }
151
+
152
+ const labelColWidth = ref(loadStoredLabelCol());
153
+ const isResizingLabelCol = ref(false);
154
+
155
+ function onLabelColResizeStart(e: MouseEvent) {
156
+ e.preventDefault();
157
+ e.stopPropagation();
158
+ isResizingLabelCol.value = true;
159
+ const startX = e.clientX;
160
+ const startW = labelColWidth.value;
161
+ const svgRect = wrapperEl.value?.getBoundingClientRect();
162
+ const maxAllowed = svgRect
163
+ ? Math.min(LABEL_COL_MAX, svgRect.width - 200) // reserve at least 200px for plot
164
+ : LABEL_COL_MAX;
165
+ const onMove = (ev: MouseEvent) => {
166
+ const dx = ev.clientX - startX;
167
+ const next = Math.min(
168
+ Math.max(LABEL_COL_MIN, startW + dx),
169
+ maxAllowed,
170
+ );
171
+ labelColWidth.value = next;
172
+ };
173
+ const onUp = () => {
174
+ isResizingLabelCol.value = false;
175
+ localStorage.setItem(LABEL_COL_STORAGE_KEY, String(labelColWidth.value));
176
+ document.removeEventListener("mousemove", onMove);
177
+ document.removeEventListener("mouseup", onUp);
178
+ };
179
+ document.addEventListener("mousemove", onMove);
180
+ document.addEventListener("mouseup", onUp);
181
+ }
182
+
183
+ // ── content-driven geometry ───────────────────────────────────────────────
184
+
185
+ // groupBy "none" puts every row in one synthetic group with nothing worth
186
+ // labelling, so the band collapses and the rows reclaim the space.
187
+ const isUngrouped = computed(() => store.groupBy === "none");
188
+
189
+ /** Height of the strip that carries the expand pill when ungrouped. */
190
+ const PILL_STRIP_H = 22;
191
+
192
+ /**
193
+ * Per-group band height. Ungrouped collapses to zero — except for the one
194
+ * case where the band still has a job: truncation applies to the single
195
+ * bucket just as it does to any other, and the "show N more" pill lives in
196
+ * the band. Losing it would strand the hidden rows with no way to reach
197
+ * them, so a slim strip survives to carry the pill and nothing else.
198
+ */
199
+ const groupHeights = computed<number[]>(() => {
200
+ if (!props.vm) return [];
201
+ return props.vm.groups.map((g) => {
202
+ if (!isUngrouped.value) return GROUP_H;
203
+ const needsPill = g.truncatedCount > 0 || !!store.expandedGroups[g.id];
204
+ return needsPill ? PILL_STRIP_H : 0;
205
+ });
206
+ });
207
+
208
+ const groupGap = computed(() => (isUngrouped.value ? 0 : GROUP_GAP));
209
+
210
+ const contentHeight = computed(() => {
211
+ if (!props.vm) return 200;
212
+ const rowsPx = props.vm.rows.length * ROW_H;
213
+ const bandsPx = groupHeights.value.reduce((a, b) => a + b, 0);
214
+ const gapsPx = props.vm.groups.length * groupGap.value;
215
+ return AXIS_TOP + AXIS_BOTTOM + rowsPx + bandsPx + gapsPx + 8;
216
+ });
217
+
218
+ const rowYByIndex = computed<number[]>(() => {
219
+ const out: number[] = [];
220
+ if (!props.vm) return out;
221
+ let y = 0;
222
+ props.vm.groups.forEach((g, gi) => {
223
+ y += groupHeights.value[gi] ?? 0;
224
+ for (let i = 0; i < g.rowCount; i++) {
225
+ out.push(y);
226
+ y += ROW_H;
227
+ }
228
+ y += groupGap.value;
229
+ });
230
+ return out;
231
+ });
232
+
233
+ interface GroupBand {
234
+ id: string;
235
+ label: string;
236
+ top: number;
237
+ height: number;
238
+ rowCount: number;
239
+ truncatedCount: number;
240
+ syntheticCount: number;
241
+ progress: { closed: number; total: number };
242
+ }
243
+ const groupBands = computed<GroupBand[]>(() => {
244
+ const out: GroupBand[] = [];
245
+ if (!props.vm) return out;
246
+ let y = 0;
247
+ props.vm.groups.forEach((g, gi) => {
248
+ const h = groupHeights.value[gi] ?? 0;
249
+ // A zero-height band has nothing to draw and would still catch clicks.
250
+ if (h > 0) {
251
+ out.push({
252
+ id: g.id,
253
+ label: g.label,
254
+ top: y,
255
+ height: h,
256
+ rowCount: g.rowCount,
257
+ truncatedCount: g.truncatedCount,
258
+ syntheticCount: g.syntheticCount,
259
+ progress: g.progress,
260
+ });
261
+ }
262
+ y += h + g.rowCount * ROW_H + groupGap.value;
263
+ });
264
+ return out;
265
+ });
266
+
267
+ // Progress bar geometry — a slim horizontal bar spanning the label column
268
+ // showing closed/total ratio, plus a chip with the numeric split.
269
+ const PROGRESS_BAR_H = 3;
270
+ const PROGRESS_BAR_MARGIN_Y = 2;
271
+ const PROGRESS_CHIP_W = 56;
272
+ function progressFraction(p: { closed: number; total: number }): number {
273
+ return p.total > 0 ? p.closed / p.total : 0;
274
+ }
275
+
276
+ /** Hover text for a lane's inferred count. Same words as the grid's chip. */
277
+ function inferredTitle(g: GroupBand): string {
278
+ return (
279
+ `${g.syntheticCount} of ${g.rowCount} inferred by the scheduler, ` +
280
+ `not recorded on the bead`
281
+ );
282
+ }
283
+
284
+ // ── layout + mouse state (DDV inputs) ─────────────────────────────────────
285
+ // Watch the wrapper element's size and build a Layout DDV can consume.
286
+
287
+ const wrapperEl = ref<HTMLElement | null>(null);
288
+ const elementWidth = ref(0);
289
+ const elementHeight = ref(0);
290
+
291
+ let ro: ResizeObserver | null = null;
292
+ onMounted(() => {
293
+ if (!wrapperEl.value) return;
294
+ const measure = () => {
295
+ if (!wrapperEl.value) return;
296
+ const rect = wrapperEl.value.getBoundingClientRect();
297
+ elementWidth.value = rect.width;
298
+ elementHeight.value = Math.max(rect.height, contentHeight.value);
299
+ };
300
+ measure();
301
+ ro = new ResizeObserver(measure);
302
+ ro.observe(wrapperEl.value);
303
+ });
304
+ onBeforeUnmount(() => {
305
+ ro?.disconnect();
306
+ });
307
+
308
+ const layout = computed<Layout>(() => {
309
+ const w = Math.max(0, elementWidth.value - labelColWidth.value - RIGHT_PAD);
310
+ const h = Math.max(0, contentHeight.value - AXIS_TOP - AXIS_BOTTOM);
311
+ return {
312
+ plotWidth: w,
313
+ plotHeight: h,
314
+ elementWidth: elementWidth.value,
315
+ elementHeight: contentHeight.value,
316
+ computedHeight: contentHeight.value,
317
+ margins: {
318
+ top: AXIS_TOP,
319
+ right: RIGHT_PAD,
320
+ bottom: AXIS_BOTTOM,
321
+ left: labelColWidth.value,
322
+ },
323
+ };
324
+ });
325
+
326
+ // A minimal MouseState — DDV's <DDV> reads flags off it for styling, but
327
+ // the ThresholdLine + Axis + CartesianScaler don't require live coords for
328
+ // our read-only chart. Interactivity (hover/click) lands in bp-6y1.
329
+ const mouseState = reactive<MouseState>({
330
+ mouse: { x: 0, y: 0 },
331
+ normalizedMouse: { x: 0, y: 0 },
332
+ globalMouse: { x: 0, y: 0 },
333
+ isHovered: false,
334
+ isPlotHovered: false,
335
+ });
336
+
337
+ // ── scaler inputs ─────────────────────────────────────────────────────────
338
+ // The X axis is a DateDomain — DDV's Axis + useLinearScaler handle Dates
339
+ // natively, and solveTicksDateDomain auto-picks the nicest time unit
340
+ // (year → minute) for the current axis width. Bar day-offsets get converted
341
+ // to Date objects via `dateAt(day)` when they hit the scaler.
342
+ // ── horizontal viewport (bp-c6q) ─────────────────────────────────────────
343
+ // The x domain is the VISIBLE part of the window, not the window. Rules and
344
+ // arithmetic in gantt-viewport.ts; here: the state, and the gestures that
345
+ // move it. Null state means "auto": fit the window when it reads at a sane
346
+ // density, otherwise a readable span centred on today. Any preset change
347
+ // returns to auto.
348
+ const viewportState = ref<Viewport | null>(null);
349
+ const totalDays = computed(() => Math.max(1, props.vm?.meta.totalDays ?? 1));
350
+ const viewport = computed<Viewport>(() => {
351
+ const w = layout.value.plotWidth;
352
+ return viewportState.value
353
+ ? clampViewport(viewportState.value, totalDays.value, w)
354
+ : initialViewport(totalDays.value, w, props.vm?.meta.todayDay ?? -1);
355
+ });
356
+ watch(
357
+ () => [props.vm?.meta.t0?.getTime(), props.vm?.meta.t1?.getTime()],
358
+ () => {
359
+ viewportState.value = null;
360
+ },
361
+ );
362
+ const hscroll = computed(() => scrollMetrics(viewport.value, totalDays.value, layout.value.plotWidth));
363
+
364
+ const MS_PER_DAY = 86_400_000;
365
+ const xDomain = computed(() => {
366
+ const t0 = props.vm?.meta.t0 ?? new Date();
367
+ const v = viewport.value;
368
+ return fromTimeFrame([
369
+ new Date(t0.getTime() + v.startDay * MS_PER_DAY),
370
+ new Date(t0.getTime() + (v.startDay + v.spanDays) * MS_PER_DAY),
371
+ ]);
372
+ });
373
+
374
+ // Drag on the axis strip pans; dragging right brings earlier days in.
375
+ let scrubLastX: number | null = null;
376
+ function onScrubDown(e: PointerEvent) {
377
+ if (!hscroll.value.scrollable) return;
378
+ (e.currentTarget as HTMLElement).setPointerCapture(e.pointerId);
379
+ scrubLastX = e.clientX;
380
+ e.preventDefault();
381
+ }
382
+ function onScrubMove(e: PointerEvent) {
383
+ if (scrubLastX === null) return;
384
+ const dx = e.clientX - scrubLastX;
385
+ scrubLastX = e.clientX;
386
+ viewportState.value = panViewportPx(viewport.value, -dx, totalDays.value, layout.value.plotWidth);
387
+ }
388
+ function onScrubUp() {
389
+ scrubLastX = null;
390
+ }
391
+ // Wheel over the axis strip zooms around the pointer. Only here is the wheel
392
+ // taken: over the plot body a vertical wheel still scrolls the page.
393
+ function onScrubWheel(e: WheelEvent) {
394
+ if (!props.vm) return;
395
+ e.preventDefault();
396
+ const rect = (e.currentTarget as HTMLElement).getBoundingClientRect();
397
+ const frac = rect.width > 0 ? (e.clientX - rect.left) / rect.width : 0.5;
398
+ viewportState.value = zoomViewport(viewport.value, wheelZoomFactor(e.deltaY), frac, totalDays.value, layout.value.plotWidth);
399
+ }
400
+ // Over the plot, a horizontal wheel (a trackpad swipe, or shift+wheel) pans.
401
+ function onPlotWheel(e: WheelEvent) {
402
+ if (!hscroll.value.scrollable) return;
403
+ const dx = e.shiftKey ? e.deltaY : e.deltaX;
404
+ if (!e.shiftKey && Math.abs(e.deltaX) <= Math.abs(e.deltaY)) return;
405
+ if (Math.abs(dx) < 1) return;
406
+ e.preventDefault();
407
+ viewportState.value = panViewportPx(viewport.value, dx, totalDays.value, layout.value.plotWidth);
408
+ }
409
+ // The native scrollbar under the plot, kept in step both ways.
410
+ const hscrollEl = ref<HTMLElement | null>(null);
411
+ let syncingScroll = false;
412
+ function onHScroll(e: Event) {
413
+ if (syncingScroll) return;
414
+ const el = e.target as HTMLElement;
415
+ viewportState.value = viewportFromScrollLeft(viewport.value, el.scrollLeft, totalDays.value, layout.value.plotWidth);
416
+ }
417
+ watch(
418
+ () => hscroll.value.scrollLeft,
419
+ async (left) => {
420
+ await nextTick();
421
+ const el = hscrollEl.value;
422
+ if (!el || Math.abs(el.scrollLeft - left) < 0.5) return;
423
+ syncingScroll = true;
424
+ el.scrollLeft = left;
425
+ requestAnimationFrame(() => {
426
+ syncingScroll = false;
427
+ });
428
+ },
429
+ );
430
+ // t (keybindings.ts): centre today at the current zoom.
431
+ watch(
432
+ () => store.todayRequests,
433
+ () => {
434
+ const day = props.vm?.meta.todayDay;
435
+ if (day === undefined || day < 0 || day > totalDays.value) return;
436
+ viewportState.value = centreViewport(viewport.value, day, totalDays.value, layout.value.plotWidth);
437
+ },
438
+ );
439
+ const xScreenDomain = computed(() => fromRange(layout.value.plotWidth || 1));
440
+ const yDomain = computed(() => ({
441
+ min: 0,
442
+ max: Math.max(1, props.vm?.rows.length ?? 1),
443
+ }));
444
+ const yScreenDomain = computed(() => fromRange(layout.value.plotHeight || 1));
445
+
446
+ // Day-offset → Date, keyed to the view-model's window origin. Bars carry
447
+ // integer day offsets from t0; the scaler works on Dates, so we bridge here.
448
+ function dateAt(dayOffset: number): Date {
449
+ const t0 = props.vm?.meta.t0 ?? new Date();
450
+ return new Date(t0.getTime() + dayOffset * 86_400_000);
451
+ }
452
+
453
+ // ThresholdLine's `domain` prop is typed as NumericDomain, but the scaler
454
+ // handles ms timestamps in a DateDomain identically. Cast at this boundary
455
+ // so the runtime works and TS stays quiet.
456
+ const xDomainAsNumeric = computed(() => xDomain.value as unknown as {
457
+ min: number;
458
+ max: number;
459
+ });
460
+
461
+ // ── top-axis ticks ────────────────────────────────────────────────────────
462
+ // DDV's <Axis> hardcodes tick labels at the BOTTOM of the plot; for a Gantt
463
+ // we want them across the TOP. We roll our own here using the same date-fns
464
+ // unit progression DDV's solveTicksDateDomain uses, and target ~90px per
465
+ // label to keep them from crowding.
466
+
467
+ interface AxisTick {
468
+ date: Date;
469
+ label: string;
470
+ emphasize: boolean; // true for month/year starts — get a heavier gridline
471
+ }
472
+
473
+ // A "MMM d" label is ~42px at 10px, so 90 was reserving more than double
474
+ // what a label needs and pushing the ladder up a rung too early — a 90-day
475
+ // window landed on months when weeks would have fitted comfortably.
476
+ const TARGET_LABEL_PX = 64;
477
+
478
+ /**
479
+ * Boundaries are drawn one rung finer than labels, so week and day edges
480
+ * stay visible in a narrow window even when there's only room to LABEL
481
+ * months or weeks. Below this spacing the finer rung becomes a solid block
482
+ * of lines and stops being information, so it's dropped.
483
+ */
484
+ const MINOR_GRID_MIN_PX = 7;
485
+
486
+ const axis = computed<{ major: AxisTick[]; minor: Date[] }>(() => {
487
+ if (!props.vm || layout.value.plotWidth <= 0)
488
+ return { major: [], minor: [] };
489
+ // The visible interval, at the visible density (bp-c6q).
490
+ const v = viewport.value;
491
+ const origin = props.vm.meta.t0.getTime();
492
+ const t0 = new Date(origin + v.startDay * MS_PER_DAY);
493
+ const t1 = new Date(origin + (v.startDay + v.spanDays) * MS_PER_DAY);
494
+ const pxPerDay = layout.value.plotWidth / Math.max(1e-9, v.spanDays);
495
+
496
+ const interval = { start: t0, end: t1 };
497
+ const inWindow = (d: Date) => d >= t0 && d <= t1;
498
+
499
+ // Pick the SMALLEST unit whose per-unit spacing >= TARGET_LABEL_PX. That
500
+ // gives the densest labels without overlap. `each*OfInterval` returns
501
+ // unit-boundaries (start of month, etc.), some of which predate t0 — we
502
+ // filter to what actually falls inside the window.
503
+ const units: Array<{
504
+ px: number;
505
+ /** Labels narrower than "MMM d" may sit closer than TARGET_LABEL_PX. */
506
+ minPx?: number;
507
+ each: (i: { start: Date; end: Date }) => Date[];
508
+ format: (d: Date) => string;
509
+ emphasize: (d: Date) => boolean;
510
+ }> = [
511
+ // Hours exist for the zoomed viewport (bp-c6q, bp-vsr): at 240px a day
512
+ // a six-hour tick is 60px apart, which a "06:00" label fits under.
513
+ {
514
+ px: pxPerDay / 24,
515
+ minPx: 40,
516
+ each: (i) => eachHourOfInterval(i),
517
+ format: (d) => formatDate(d, "HH:mm"),
518
+ emphasize: (d) => d.getHours() === 0,
519
+ },
520
+ {
521
+ px: pxPerDay / 4,
522
+ minPx: 40,
523
+ each: (i) => eachHourOfInterval(i).filter((d) => d.getHours() % 6 === 0),
524
+ format: (d) => (d.getHours() === 0 ? formatDate(d, "MMM d") : formatDate(d, "HH:mm")),
525
+ emphasize: (d) => d.getHours() === 0,
526
+ },
527
+ {
528
+ px: pxPerDay,
529
+ each: eachDayOfInterval,
530
+ format: (d) => formatDate(d, "MMM d"),
531
+ emphasize: (d) => d.getDay() === 1,
532
+ },
533
+ {
534
+ px: pxPerDay * 7,
535
+ each: eachWeekOfInterval,
536
+ format: (d) => formatDate(d, "MMM d"),
537
+ emphasize: (d) => d.getDate() <= 7,
538
+ },
539
+ {
540
+ px: pxPerDay * 30,
541
+ each: eachMonthOfInterval,
542
+ format: (d) => formatDate(d, "MMM yyyy"),
543
+ emphasize: (d) => d.getMonth() === 0,
544
+ },
545
+ {
546
+ px: pxPerDay * 90,
547
+ each: eachQuarterOfInterval,
548
+ format: (d) => formatDate(d, "QQQ yyyy"),
549
+ emphasize: (d) => d.getMonth() === 0,
550
+ },
551
+ {
552
+ px: pxPerDay * 365,
553
+ each: eachYearOfInterval,
554
+ format: (d) => formatDate(d, "yyyy"),
555
+ emphasize: () => true,
556
+ },
557
+ ];
558
+
559
+ const chosenIdx = units.findIndex((u) => u.px >= (u.minPx ?? TARGET_LABEL_PX));
560
+ const chosen = units[chosenIdx === -1 ? 0 : chosenIdx];
561
+
562
+ // One rung finer than the labelled unit, when it still has room to read.
563
+ const finer = chosenIdx > 0 ? units[chosenIdx - 1] : null;
564
+ const minor =
565
+ finer && finer.px >= MINOR_GRID_MIN_PX
566
+ ? finer.each(interval).filter(inWindow)
567
+ : [];
568
+
569
+ return {
570
+ major: chosen
571
+ .each(interval)
572
+ .filter(inWindow)
573
+ .map((d) => ({
574
+ date: d,
575
+ label: chosen.format(d),
576
+ emphasize: chosen.emphasize(d),
577
+ })),
578
+ minor,
579
+ };
580
+ });
581
+
582
+ const axisTicks = computed(() => axis.value.major);
583
+ const axisMinor = computed(() => axis.value.minor);
584
+
585
+ const todayIso = computed(() => {
586
+ if (!props.vm) return "";
587
+ const d = new Date(props.vm.meta.t0);
588
+ d.setDate(d.getDate() + props.vm.meta.todayDay);
589
+ return d.toLocaleDateString("en-US", {
590
+ month: "short",
591
+ day: "numeric",
592
+ year: "numeric",
593
+ });
594
+ });
595
+
596
+ const barCssClass = (b: Bar): string => {
597
+ const cls = ["bar", `kind-${b.kind}`];
598
+ if (b.synthetic) cls.push("synthetic");
599
+ return cls.join(" ");
600
+ };
601
+
602
+ // Icon edge in user units. Lucide components render as nested <svg>
603
+ // elements here (see .gantt-icon), so this is their viewport size, and the
604
+ // label maths needs it to leave room.
605
+ const GANTT_ICON = 14;
606
+
607
+ /**
608
+ * Horizontal room the TODAY label needs. Its text shares a baseline with the
609
+ * axis tick labels (y -8 vs -10) and is anchored the same way, so a tick
610
+ * landing near today's rule prints straight through it.
611
+ */
612
+ const TODAY_LABEL_W = 56;
613
+
614
+ /**
615
+ * Whether a tick's label would collide with the TODAY marker.
616
+ *
617
+ * Suppressing the tick rather than the marker loses nothing: the marker
618
+ * already prints the full date above itself, which is strictly more precise
619
+ * than the tick's month label.
620
+ */
621
+ function tickCollidesWithToday(
622
+ tick: { date: Date },
623
+ scaler: { scale: (v: number) => number },
624
+ ): boolean {
625
+ const meta = props.vm?.meta;
626
+ if (!meta) return false;
627
+ if (meta.todayDay < 0 || meta.todayDay > meta.totalDays) return false;
628
+ const tx = scaler.scale(tick.date.getTime());
629
+ const todayX = scaler.scale(dateAt(meta.todayDay).getTime());
630
+ return Math.abs(tx - todayX) < TODAY_LABEL_W;
631
+ }
632
+
633
+ // Terminal icons sit at arrowhead scale, where a stroked glyph reads far
634
+ // weaker than the filled triangle a <marker> used to draw. Bigger, heavier
635
+ // and at full opacity to carry the same weight.
636
+ //
637
+ // Full-graph mode draws every edge at once, so its terminals shrink: at that
638
+ // density a full-weight arrow per edge buries the bars it points at. Size is
639
+ // per-role and passed as a prop, because a nested <svg> ignores CSS width.
640
+ const DEP_ICON = 18;
641
+ const DEP_ICON_NEUTRAL = 12;
642
+
643
+ const depIconSize = (role: keyof typeof ROLE_ICON) =>
644
+ role === "neutral" ? DEP_ICON_NEUTRAL : DEP_ICON;
645
+
646
+ // ── noop reserveSpace: Axis wants to negotiate its tick-label margins,
647
+ // but we've already carved fixed AXIS_TOP/AXIS_BOTTOM room. Absorb the
648
+ // updates without letting them mutate our fixed layout.
649
+ const reserveSpace = () => () => {};
650
+
651
+ // ── hover / click interactions (bp-6y1) ───────────────────────────────────
652
+ // Hover any bar → resolve the bead's detail via the store and float a
653
+ // tooltip pinned to the entry cursor position (NOT following the mouse, so
654
+ // the user can move onto the tooltip to click its links). Both the bar and
655
+ // the tooltip are hover targets; a short grace period on leave lets the
656
+ // cursor traverse the gap between them. The state machine and the panel's
657
+ // derived rows live in useBeadTooltip, shared with the drawer's subgraph
658
+ // (bp-67g.42), so the two hovers say the same things about a bead.
659
+
660
+ const {
661
+ hoveredBeadId,
662
+ props: tipProps,
663
+ enter: tipEnter,
664
+ cancelLeave,
665
+ scheduleLeave,
666
+ dismiss: tipDismiss,
667
+ toggleWatch: onToggleWatch,
668
+ } = useBeadTooltip();
669
+
670
+ // ── row hover ────────────────────────────────────────────────────────────
671
+ // One band across the whole row — label column and plot together — so a
672
+ // pointer over a bar can be lined up with the id and title on the left.
673
+ // Driven by transparent hit rects (label band + plot), and falls back to the
674
+ // tooltip's bead so the row stays lit while the tooltip is up.
675
+ const hoveredRowId = ref<string | null>(null);
676
+ // The open bead's row stays lit while the drawer is up: the drawer is the
677
+ // keyboard cursor (bp-67g.45), and this is its mark in the view. Hover wins
678
+ // while the pointer is on another row.
679
+ const litRowId = computed(() => hoveredRowId.value ?? hoveredBeadId.value ?? store.openBeadId);
680
+
681
+ // The bar's own rect is the band the panel stays off (bp-67g.41).
682
+ function onBarEnter(bar: Bar, e: MouseEvent) {
683
+ tipEnter(bar.beadId, e);
684
+ }
685
+
686
+ function onOpenDetails(id: string) {
687
+ store.openBead(id);
688
+ tipDismiss();
689
+ }
690
+
691
+ // Right-click: the quick actions (bp-ocs). The hover tooltip goes first, or
692
+ // it sits over the menu.
693
+ function onBarMenu(bar: Bar, e: MouseEvent) {
694
+ tipDismiss();
695
+ store.openContextMenu(bar.beadId, e.clientX, e.clientY);
696
+ }
697
+
698
+ // ── copy-id flash ────────────────────────────────────────────────────────
699
+ // Clicking a row's id column copies the id to the clipboard and briefly
700
+ // swaps the display text to "copied ✓" before reverting. The flash state
701
+ // is keyed by bead id so simultaneous flashes across rows (rare, but the
702
+ // user could shift-click a batch later) don't stomp each other.
703
+ const copiedIds = ref<Set<string>>(new Set());
704
+ const copyTimers = new Map<string, ReturnType<typeof setTimeout>>();
705
+
706
+ function copyRowId(id: string, e: MouseEvent) {
707
+ e.stopPropagation();
708
+ void navigator.clipboard.writeText(id);
709
+ copiedIds.value = new Set([...copiedIds.value, id]);
710
+ const prev = copyTimers.get(id);
711
+ if (prev) clearTimeout(prev);
712
+ copyTimers.set(
713
+ id,
714
+ setTimeout(() => {
715
+ const next = new Set(copiedIds.value);
716
+ next.delete(id);
717
+ copiedIds.value = next;
718
+ copyTimers.delete(id);
719
+ }, 900),
720
+ );
721
+ }
722
+
723
+ // See BeadsGrid: an inferred match belongs in an assignee-filtered result
724
+ // but recedes, because it's the scheduler's guess and not recorded fact.
725
+ const assigneeFilterActive = computed(
726
+ () => (store.filters.assignees?.length ?? 0) > 0,
727
+ );
728
+ const isInferredMatch = (row: { syntheticAssignee: boolean }) =>
729
+ assigneeFilterActive.value && row.syntheticAssignee;
730
+
731
+ const watched = computed(() => store.watchedSet);
732
+ // Pins are a read-only reflection of the watch list; the drawer header owns
733
+ // adding and removing.
734
+ const isWatched = (id: string) => watched.value.has(id);
735
+
736
+ /**
737
+ * The bead behind a group, when the group *is* a bead. Only epic grouping
738
+ * yields real bead ids; every other mode groups by a facet value (an
739
+ * assignee handle, a status) and placeholder buckets start with "__".
740
+ * Returns null when there's nothing to open, so the label stays inert.
741
+ */
742
+ function groupBeadId(gid: string): string | null {
743
+ if (store.groupBy !== "epic") return null;
744
+ if (gid.startsWith("__")) return null;
745
+ return gid;
746
+ }
747
+
748
+ /** An assignee lane's header is a handle: it opens that person (bp-67g.43). */
749
+ function groupPerson(gid: string): string | null {
750
+ if (store.groupBy !== "assignee") return null;
751
+ if (gid.startsWith("__")) return null;
752
+ return gid;
753
+ }
754
+
755
+ const isGroupOpenable = (gid: string) => !!groupBeadId(gid) || !!groupPerson(gid);
756
+
757
+ function openGroupBead(gid: string) {
758
+ const id = groupBeadId(gid);
759
+ if (id) return store.openBead(id);
760
+ const who = groupPerson(gid);
761
+ if (who) store.openPerson(who);
762
+ }
763
+
764
+ // ── dim / highlight helpers ──────────────────────────────────────────────
765
+ const blockedIds = computed(() => store.blockedBeadIds);
766
+ const readyNowIds = computed(() => store.readyNowBeadIds);
767
+ const overCapAssignees = computed(() => store.overCapAssignees);
768
+
769
+ function isGroupOverCap(gid: string): boolean {
770
+ if (!store.highlights["over-cap"]) return false;
771
+ if (store.groupBy !== "assignee") return false;
772
+ return overCapAssignees.value.has(gid);
773
+ }
774
+
775
+ function barExtraClass(bar: Bar): string {
776
+ const cls: string[] = [];
777
+ if (blockedIds.value.has(bar.beadId)) cls.push("hl-blocked");
778
+ if (readyNowIds.value.has(bar.beadId)) cls.push("hl-ready-now");
779
+ return cls.join(" ");
780
+ }
781
+
782
+ // ── dependency arrows (bp-67g.1) ─────────────────────────────────────────
783
+ // Map every visible bead id → the pixel geometry of its bar (union of any
784
+ // split-bar segments the view-model emits). Recomputed with the view model.
785
+
786
+ interface BeadGeometry {
787
+ xStart: number;
788
+ xEnd: number;
789
+ yCenter: number;
790
+ }
791
+
792
+ // What-if ghosts (bp-q56): for each bead the what-if moved, the baseline's
793
+ // work span drawn dimmed under the live plan, with the delta beside it.
794
+ const rowIndexById = computed(() => new Map((props.vm?.rows ?? []).map((r) => [r.beadId, r.index] as const)));
795
+ const ghosts = computed(() => {
796
+ const d = store.whatIfDiff;
797
+ if (!d || !props.vm) return [];
798
+ const today = props.vm.meta.todayDay;
799
+ const total = props.vm.meta.totalDays;
800
+ const clampDay = (v: number) => Math.min(total, Math.max(0, today + v));
801
+ return d.moved
802
+ .filter((m) => rowIndexById.value.has(m.id))
803
+ .map((m) => ({
804
+ id: m.id,
805
+ rowIndex: rowIndexById.value.get(m.id)!,
806
+ startDay: clampDay(m.workBefore.start),
807
+ endDay: clampDay(m.workBefore.end),
808
+ afterStartDay: clampDay(m.workAfter.start),
809
+ label: describeDelta(m.deltaDays),
810
+ later: m.deltaDays > 0,
811
+ }));
812
+ });
813
+
814
+ const beadGeometryById = computed<Map<string, BeadGeometry>>(() => {
815
+ const out = new Map<string, BeadGeometry>();
816
+ if (!props.vm) return out;
817
+ // xScaler isn't available in a pure computed (it's a slot arg), so we
818
+ // build the geometry in scaled-day space and convert on render below.
819
+ // For now, capture only Y and raw day extents; the arrow path picks the
820
+ // scaler up when it renders.
821
+ for (const bar of props.vm.bars) {
822
+ const existing = out.get(bar.beadId);
823
+ const yCenter =
824
+ rowYByIndex.value[bar.rowIndex] + BAR_H / 2 + 3;
825
+ if (!existing) {
826
+ out.set(bar.beadId, {
827
+ xStart: bar.startDay,
828
+ xEnd: bar.endDay,
829
+ yCenter,
830
+ });
831
+ } else {
832
+ existing.xStart = Math.min(existing.xStart, bar.startDay);
833
+ existing.xEnd = Math.max(existing.xEnd, bar.endDay);
834
+ }
835
+ }
836
+ return out;
837
+ });
838
+
839
+ // A "focal" bead is what an edge is drawn relative to for coloring:
840
+ // - outgoing (green) → focal bead BLOCKS this dependent
841
+ // - incoming (red) → this blocker is what's blocking the focal
842
+ // When no focal bead exists and "show all" is on, edges use a neutral hue.
843
+ type EdgeRole = "outgoing" | "incoming" | "neutral";
844
+
845
+ interface DepEdge {
846
+ fromId: string;
847
+ toId: string;
848
+ fromX: number;
849
+ fromY: number;
850
+ toX: number;
851
+ toY: number;
852
+ role: EdgeRole;
853
+ }
854
+
855
+ // Which bead is the "focal" for arrow drawing? The hovered one, while the
856
+ // cursor is on it; otherwise none, and no arrows unless the user opted into
857
+ // "show all".
858
+ const focalBeadId = computed<string | null>(() => {
859
+ if (hoveredBeadId.value) return hoveredBeadId.value;
860
+ return null;
861
+ });
862
+
863
+ const dependencyEdges = computed<DepEdge[]>(() => {
864
+ if (!store.showDependencies) return [];
865
+ const beads = store.beads;
866
+ const geo = beadGeometryById.value;
867
+ const focal = focalBeadId.value;
868
+ const edges: DepEdge[] = [];
869
+ for (const b of beads) {
870
+ if (!geo.has(b.id)) continue;
871
+ for (const d of b.dependencies ?? []) {
872
+ if (d.type !== "blocks") continue;
873
+ const blockerGeo = geo.get(d.depends_on_id);
874
+ if (!blockerGeo) continue;
875
+ const dependentGeo = geo.get(b.id);
876
+ if (!dependentGeo) continue;
877
+
878
+ // Focal-only filter: full-graph arrows are unreadable at scale, so
879
+ // we default to "arrows only for the focal bead" and let the user
880
+ // opt into all edges via a separate toggle.
881
+ let role: EdgeRole = "neutral";
882
+ if (focal) {
883
+ if (d.depends_on_id === focal) role = "outgoing";
884
+ else if (b.id === focal) role = "incoming";
885
+ else continue;
886
+ } else if (!store.showAllDependencies) {
887
+ continue;
888
+ }
889
+
890
+ edges.push({
891
+ fromId: d.depends_on_id,
892
+ toId: b.id,
893
+ fromX: blockerGeo.xEnd,
894
+ fromY: blockerGeo.yCenter,
895
+ toX: dependentGeo.xStart,
896
+ toY: dependentGeo.yCenter,
897
+ role,
898
+ });
899
+ }
900
+ }
901
+ return edges;
902
+ });
903
+
904
+ /**
905
+ * Cubic-bezier arrow path from (x0,y0) to (x1,y1). The control points push
906
+ * the curve upward by ~row height so arrows arc above the bars they
907
+ * connect. If the destination sits to the LEFT of the source (dependent
908
+ * scheduled before blocker — should be rare), we still route the curve
909
+ * cleanly with a bigger arc.
910
+ */
911
+ function arrowGeometry(
912
+ x0: number,
913
+ y0: number,
914
+ x1: number,
915
+ y1: number,
916
+ iconSize: number,
917
+ ) {
918
+ const dx = x1 - x0;
919
+ const arcHeight = Math.max(ROW_H * 0.9, Math.min(80, Math.abs(dx) * 0.15));
920
+ const cy0 = y0 - arcHeight;
921
+ const cy1 = y1 - arcHeight;
922
+ const cx0 = x0 + (dx >= 0 ? Math.max(12, dx * 0.3) : -20);
923
+ const cx1 = x1 - (dx >= 0 ? Math.max(12, dx * 0.3) : -20);
924
+ // A cubic's tangent at t=1 is P3 - P2, so the terminal icon can be rotated
925
+ // to meet the curve instead of sitting at an arbitrary angle.
926
+ const rad = Math.atan2(y1 - cy1, x1 - cx1);
927
+ // Pull the glyph back along the tangent so it stops short of the bar
928
+ // instead of half-disappearing under it — the job <marker refX> used to do.
929
+ const pullback = iconSize * 0.45;
930
+ return {
931
+ d: `M ${x0} ${y0} C ${cx0} ${cy0}, ${cx1} ${cy1}, ${x1} ${y1}`,
932
+ tipX: x1 - Math.cos(rad) * pullback,
933
+ tipY: y1 - Math.sin(rad) * pullback,
934
+ angle: (rad * 180) / Math.PI,
935
+ };
936
+ }
937
+
938
+ /**
939
+ * Terminal icon per edge role. Markers can't do this: currentColor inside a
940
+ * <marker> resolves against the marker's own context in <defs>, not the
941
+ * referencing path, so a marker has to hardcode its fill (which is why this
942
+ * used to carry three near-identical marker defs). A nested <svg> placed at
943
+ * the tip inherits `color` from the edge's own class instead.
944
+ */
945
+ const ROLE_ICON = {
946
+ // ArrowRight rather than a chevron: terminals get rotated onto the curve's
947
+ // tangent, and a bare chevron at an oblique angle reads as a checkmark.
948
+ // An arrow has a shaft, so its orientation survives any rotation.
949
+ outgoing: ArrowRight, // focal blocks these — flow continues downstream
950
+ // Semantic glyphs are reserved for the focal bead's own edges, where there
951
+ // are few of them and they're worth reading. Full-graph mode (neutral) stays
952
+ // plain: at that density a stop sign per edge is noise, and colour already
953
+ // carries the role.
954
+ incoming: Ban, // these block the focal — a stop, not an onward arrow
955
+ neutral: ArrowRight,
956
+ } as const;
957
+ </script>
958
+
959
+ <template>
960
+ <div
961
+ ref="wrapperEl"
962
+ class="beads-gantt"
963
+ :class="{ 'is-resizing-labels': isResizingLabelCol }"
964
+ @wheel="onPlotWheel"
965
+ :style="{
966
+ minHeight: contentHeight + 'px',
967
+ // DDV's inner <svg class='outer'> hard-caps at min-height 250px unless
968
+ // we override --minHeight here. --aspectRatio has to be 'auto' or DDV
969
+ // will resize the SVG to whatever ratio the wrapper started at
970
+ // (short) and never grow to fit the row count.
971
+ '--minHeight': contentHeight + 'px',
972
+ '--aspectRatio': 'auto',
973
+ }"
974
+ >
975
+ <DDV :layout="layout" :mouse-state="mouseState" class="beads-gantt-ddv">
976
+ <template #default>
977
+ <CartesianScaler
978
+ :x-source="xDomain"
979
+ :y-source="yDomain"
980
+ :x-target="xScreenDomain"
981
+ :y-target="yScreenDomain"
982
+ >
983
+ <template #default="{ xScaler }">
984
+ <!-- Clip titles to the title column so long strings can't run
985
+ into the priority chip on the right. Row texts sit inside
986
+ translated <g>s at varying Y, so the clip rect spans the
987
+ full vertical range — horizontal is the only constraint. -->
988
+ <defs>
989
+ <!-- Group labels get their own clip: they start further left
990
+ than row titles and must stop short of the progress chip
991
+ rather than the priority gutter. -->
992
+ <clipPath
993
+ id="beads-gantt-group-label-clip"
994
+ clipPathUnits="userSpaceOnUse"
995
+ >
996
+ <rect
997
+ :x="ID_COL_X"
998
+ :y="0"
999
+ :width="Math.max(0, layout.margins.left - PROGRESS_CHIP_W - 8 - ID_COL_X - PIN_COL_W)"
1000
+ :height="contentHeight"
1001
+ />
1002
+ </clipPath>
1003
+ <clipPath id="beads-gantt-title-clip" clipPathUnits="userSpaceOnUse">
1004
+ <rect
1005
+ :x="TITLE_COL_X"
1006
+ :y="0"
1007
+ :width="Math.max(0, layout.margins.left - TITLE_COL_X - PRIORITY_COL_W - PIN_COL_W)"
1008
+ :height="contentHeight"
1009
+ />
1010
+ </clipPath>
1011
+ <!-- The viewport (bp-c6q): every layer the x scaler positions
1012
+ is clipped to the plot column, so a bar, arrow or tick
1013
+ panned out of view cannot draw over the label column. In
1014
+ the plot groups' own coordinates, which start at the
1015
+ margins; the axis rows above the plot are included. -->
1016
+ <clipPath id="beads-gantt-plot-clip" clipPathUnits="userSpaceOnUse">
1017
+ <rect
1018
+ :x="0"
1019
+ :y="-AXIS_TOP"
1020
+ :width="Math.max(0, layout.plotWidth)"
1021
+ :height="Math.max(0, layout.plotHeight) + AXIS_TOP + AXIS_BOTTOM"
1022
+ />
1023
+ </clipPath>
1024
+ <!-- Synthetic-assignee hatch (bp-67g.49). A luminance mask:
1025
+ the white stripes keep the bar and the gaps drop it, so
1026
+ the bar's own kind colour and opacity show through
1027
+ unchanged. The pattern tiles in user space, so stripes
1028
+ run continuously across neighbouring bars instead of
1029
+ restarting at each one's edge. The mask's content is in
1030
+ the referencing bar's coordinates — the plot's, since
1031
+ bars live in the translated plot group — and one rect
1032
+ the size of the plot covers every bar. -->
1033
+ <pattern
1034
+ id="beads-gantt-hatch"
1035
+ patternUnits="userSpaceOnUse"
1036
+ :width="HATCH_PITCH"
1037
+ :height="HATCH_PITCH"
1038
+ patternTransform="rotate(45)"
1039
+ >
1040
+ <rect :x="0" :y="0" :width="HATCH_STRIPE" :height="HATCH_PITCH" fill="#fff" />
1041
+ </pattern>
1042
+ <mask id="beads-gantt-synthetic-mask" maskContentUnits="userSpaceOnUse">
1043
+ <rect
1044
+ :x="-HATCH_PITCH"
1045
+ :y="-HATCH_PITCH"
1046
+ :width="layout.plotWidth + 2 * HATCH_PITCH"
1047
+ :height="layout.plotHeight + 2 * HATCH_PITCH"
1048
+ fill="url(#beads-gantt-hatch)"
1049
+ />
1050
+ </mask>
1051
+ </defs>
1052
+
1053
+ <!-- Plot background -->
1054
+ <rect
1055
+ v-if="layout.plotWidth > 0 && layout.plotHeight > 0"
1056
+ class="plot-bg"
1057
+ :x="layout.margins.left"
1058
+ :y="layout.margins.top"
1059
+ :width="layout.plotWidth"
1060
+ :height="layout.plotHeight"
1061
+ />
1062
+
1063
+ <!-- Top X-axis. Rendered manually (DDV's <Axis> hardcodes tick
1064
+ labels to the bottom of the plot). Ticks are auto-picked
1065
+ by the same date-fns unit progression DDV uses. -->
1066
+ <g
1067
+ v-if="xScaler && layout.plotWidth > 0"
1068
+ class="x-axis"
1069
+ clip-path="url(#beads-gantt-plot-clip)"
1070
+ :transform="`translate(${layout.margins.left} ${layout.margins.top})`"
1071
+ >
1072
+ <g class="gridlines">
1073
+ <!-- Finer boundaries first, so a labelled major line always
1074
+ paints over its minor counterpart at the same x. -->
1075
+ <line
1076
+ v-for="(d, i) in axisMinor"
1077
+ :key="`minor-${i}`"
1078
+ class="minor"
1079
+ :x1="xScaler.scale(d.getTime())"
1080
+ :x2="xScaler.scale(d.getTime())"
1081
+ :y1="0"
1082
+ :y2="layout.plotHeight"
1083
+ />
1084
+ <line
1085
+ v-for="(t, i) in axisTicks"
1086
+ :key="`grid-${i}`"
1087
+ :class="{ emphasize: t.emphasize }"
1088
+ :x1="xScaler.scale(t.date.getTime())"
1089
+ :x2="xScaler.scale(t.date.getTime())"
1090
+ :y1="0"
1091
+ :y2="layout.plotHeight"
1092
+ />
1093
+ </g>
1094
+ <g class="tick-labels">
1095
+ <text
1096
+ v-for="(t, i) in axisTicks"
1097
+ v-show="!tickCollidesWithToday(t, xScaler)"
1098
+ :key="`tick-${i}`"
1099
+ :class="{ emphasize: t.emphasize }"
1100
+ :x="xScaler.scale(t.date.getTime()) + 4"
1101
+ :y="-8"
1102
+ >{{ t.label }}</text>
1103
+ </g>
1104
+ </g>
1105
+
1106
+ <!-- Group header strips. The count sits at the RIGHT edge of
1107
+ the plot so it never collides with the (potentially long)
1108
+ epic label in the left column. -->
1109
+ <g
1110
+ v-if="vm"
1111
+ class="left-column"
1112
+ :transform="`translate(0 ${layout.margins.top})`"
1113
+ >
1114
+ <g
1115
+ v-for="g in groupBands"
1116
+ :key="g.id"
1117
+ :class="{
1118
+ 'group-band': true,
1119
+ 'is-over-cap': isGroupOverCap(g.id),
1120
+ }"
1121
+ :transform="`translate(0 ${g.top})`"
1122
+ >
1123
+ <rect
1124
+ v-if="!isUngrouped"
1125
+ class="group-header"
1126
+ :x="0"
1127
+ :y="0"
1128
+ :width="layout.elementWidth"
1129
+ :height="g.height"
1130
+ />
1131
+ <text
1132
+ v-if="!isUngrouped"
1133
+ class="group-label"
1134
+ :class="{ 'is-openable': isGroupOpenable(g.id) }"
1135
+ :x="ID_COL_X"
1136
+ :y="GROUP_H - 11"
1137
+ clip-path="url(#beads-gantt-group-label-clip)"
1138
+ @click.stop="openGroupBead(g.id)"
1139
+ >
1140
+ {{ g.label }}
1141
+ <title>{{ groupBeadId(g.id) ? `Open ${g.id}` : g.label }}</title>
1142
+ </text>
1143
+ <!-- Over-cap badge. The icon is a lucide component used as
1144
+ a NESTED <svg>: it renders its own <svg> root, so x/y
1145
+ (which fall through as attributes) place it, and it
1146
+ carries its own viewport rather than inheriting ours. -->
1147
+ <TriangleAlert
1148
+ v-if="!isUngrouped && isGroupOverCap(g.id)"
1149
+ class="gantt-icon over-cap-icon"
1150
+ :size="GANTT_ICON"
1151
+ :x="layout.margins.left + 8"
1152
+ :y="GROUP_H - 22"
1153
+ />
1154
+ <Pin
1155
+ v-if="!isUngrouped && isWatched(g.id)"
1156
+ class="gantt-icon watch-pin"
1157
+ :size="WATCH_PIN"
1158
+ :x="layout.margins.left - PROGRESS_CHIP_W - PIN_COL_W - 3"
1159
+ :y="(GROUP_H - WATCH_PIN) / 2"
1160
+ />
1161
+ <text
1162
+ v-if="!isUngrouped && isGroupOverCap(g.id)"
1163
+ class="over-cap-badge"
1164
+ :x="layout.margins.left + 8 + GANTT_ICON + 4"
1165
+ :y="GROUP_H - 11"
1166
+ >over cap</text>
1167
+ <!-- Progress chip: e.g. "3/17". Positioned to the right of
1168
+ the label, inside the label column. Filter-independent. -->
1169
+ <g
1170
+ v-if="!isUngrouped && g.progress.total > 0"
1171
+ class="group-progress"
1172
+ :transform="`translate(${layout.margins.left - PROGRESS_CHIP_W - 8} 0)`"
1173
+ >
1174
+ <rect
1175
+ class="progress-chip"
1176
+ :x="0"
1177
+ :y="4"
1178
+ :width="PROGRESS_CHIP_W"
1179
+ :height="12"
1180
+ rx="2"
1181
+ />
1182
+ <text
1183
+ class="progress-chip-text"
1184
+ :x="PROGRESS_CHIP_W / 2"
1185
+ :y="14"
1186
+ text-anchor="middle"
1187
+ >{{ g.progress.closed }}/{{ g.progress.total }}</text>
1188
+ </g>
1189
+ <!-- Progress bar: slim rule under the label spanning the
1190
+ label column, closed-fraction filled. -->
1191
+ <g
1192
+ v-if="!isUngrouped && g.progress.total > 0"
1193
+ class="progress-bar"
1194
+ :transform="`translate(10 ${GROUP_H - PROGRESS_BAR_H - PROGRESS_BAR_MARGIN_Y})`"
1195
+ >
1196
+ <rect
1197
+ class="progress-bar-bg"
1198
+ :x="0"
1199
+ :y="0"
1200
+ :width="layout.margins.left - 20"
1201
+ :height="PROGRESS_BAR_H"
1202
+ rx="1.5"
1203
+ />
1204
+ <rect
1205
+ class="progress-bar-fill"
1206
+ :x="0"
1207
+ :y="0"
1208
+ :width="(layout.margins.left - 20) * progressFraction(g.progress)"
1209
+ :height="PROGRESS_BAR_H"
1210
+ rx="1.5"
1211
+ />
1212
+ </g>
1213
+ <!-- Row-count / expand pill on the right side of the plot
1214
+ area. When the group is truncated, this becomes a
1215
+ clickable "show N more" pill; when expanded (or fully
1216
+ visible) it's a passive "N beads" tag. -->
1217
+ <g
1218
+ v-if="g.truncatedCount > 0"
1219
+ class="group-expand-btn"
1220
+ :transform="`translate(${layout.margins.left + layout.plotWidth - 128} 4)`"
1221
+ @click.stop="store.toggleExpandGroup(g.id)"
1222
+ >
1223
+ <rect
1224
+ class="expand-pill"
1225
+ :x="0"
1226
+ :y="0"
1227
+ :width="120"
1228
+ :height="16"
1229
+ rx="8"
1230
+ />
1231
+ <text
1232
+ class="expand-pill-text"
1233
+ :x="52"
1234
+ :y="12"
1235
+ text-anchor="middle"
1236
+ >show {{ g.truncatedCount }} more</text>
1237
+ <ChevronDown class="gantt-icon" :size="GANTT_ICON" :x="96" :y="1" />
1238
+ </g>
1239
+ <g
1240
+ v-else-if="store.expandedGroups[g.id]"
1241
+ class="group-expand-btn is-expanded"
1242
+ :transform="`translate(${layout.margins.left + layout.plotWidth - 128} 4)`"
1243
+ @click.stop="store.toggleExpandGroup(g.id)"
1244
+ >
1245
+ <rect
1246
+ class="expand-pill"
1247
+ :x="0"
1248
+ :y="0"
1249
+ :width="120"
1250
+ :height="16"
1251
+ rx="8"
1252
+ />
1253
+ <text
1254
+ class="expand-pill-text"
1255
+ :x="52"
1256
+ :y="12"
1257
+ text-anchor="middle"
1258
+ >collapse</text>
1259
+ <ChevronUp class="gantt-icon" :size="GANTT_ICON" :x="96" :y="1" />
1260
+ </g>
1261
+ <text
1262
+ v-else-if="!isUngrouped"
1263
+ class="group-count"
1264
+ :x="layout.margins.left + layout.plotWidth - 8"
1265
+ :y="GROUP_H - 9"
1266
+ text-anchor="end"
1267
+ >
1268
+ {{ g.rowCount }} bead{{ g.rowCount === 1 ? "" : "s" }}<template v-if="g.syntheticCount > 0"> · {{ g.syntheticCount }} inferred</template>
1269
+ <title v-if="g.syntheticCount > 0">{{ inferredTitle(g) }}</title>
1270
+ </text>
1271
+ </g>
1272
+ </g>
1273
+
1274
+ <!-- Divider between label column and plot. The visible line is
1275
+ thin, but the hit-target rect around it is 8px wide so
1276
+ dragging feels forgiving. Persists to localStorage. -->
1277
+ <line
1278
+ v-if="layout.plotWidth > 0"
1279
+ class="label-divider"
1280
+ :class="{ 'is-resizing': isResizingLabelCol }"
1281
+ :x1="layout.margins.left"
1282
+ :y1="0"
1283
+ :x2="layout.margins.left"
1284
+ :y2="layout.margins.top + layout.plotHeight + AXIS_BOTTOM"
1285
+ />
1286
+ <rect
1287
+ v-if="layout.plotWidth > 0"
1288
+ class="label-resize-handle"
1289
+ :x="layout.margins.left - 4"
1290
+ :y="0"
1291
+ :width="8"
1292
+ :height="layout.margins.top + layout.plotHeight + AXIS_BOTTOM"
1293
+ @mousedown="onLabelColResizeStart"
1294
+ />
1295
+
1296
+ <!-- Row hover band: one translucent rect per row across the full
1297
+ element width, behind the text and the bars. Always present so
1298
+ the opacity transition can run both ways. -->
1299
+ <g
1300
+ v-if="vm"
1301
+ class="row-hover-layer"
1302
+ :transform="`translate(0 ${layout.margins.top})`"
1303
+ >
1304
+ <rect
1305
+ v-for="(row, i) in vm.rows"
1306
+ :key="`hover-${row.beadId}`"
1307
+ class="row-hover"
1308
+ :class="{ 'is-lit': litRowId === row.beadId }"
1309
+ :x="0"
1310
+ :y="rowYByIndex[i]"
1311
+ :width="layout.elementWidth"
1312
+ :height="ROW_H"
1313
+ />
1314
+ </g>
1315
+
1316
+ <!-- Row labels (id + title + priority) -->
1317
+ <g
1318
+ v-if="vm"
1319
+ class="row-labels"
1320
+ :transform="`translate(0 ${layout.margins.top})`"
1321
+ >
1322
+ <g
1323
+ v-for="(row, i) in vm.rows"
1324
+ :key="row.beadId"
1325
+ :class="{
1326
+ 'row-label-band': true,
1327
+ 'is-inferred-match': isInferredMatch(row),
1328
+ 'is-lit': litRowId === row.beadId,
1329
+ }"
1330
+ :transform="`translate(0 ${rowYByIndex[i]})`"
1331
+ @mouseenter="hoveredRowId = row.beadId"
1332
+ @mouseleave="hoveredRowId = null"
1333
+ >
1334
+ <!-- Hit target for the empty parts of the band. Stops 4px
1335
+ short of the label-column resize handle. -->
1336
+ <rect
1337
+ class="row-hit"
1338
+ :x="0"
1339
+ :y="0"
1340
+ :width="Math.max(0, layout.margins.left - 4)"
1341
+ :height="ROW_H"
1342
+ />
1343
+ <!-- ID: click copies to clipboard with a brief "copied ✓"
1344
+ flash. Separate from the title so the two affordances
1345
+ don't collide. -->
1346
+ <text
1347
+ class="row-id"
1348
+ :class="{ 'is-copied': copiedIds.has(row.beadId) }"
1349
+ :x="ID_COL_X"
1350
+ :y="ROW_H - 6"
1351
+ @click="copyRowId(row.beadId, $event)"
1352
+ >{{ copiedIds.has(row.beadId) ? "copied ✓" : row.beadId }}</text>
1353
+ <!-- Title: click opens the detail drawer. Clipped to the
1354
+ title column so long strings can't collide with the
1355
+ priority chip on the right. -->
1356
+ <text
1357
+ class="row-title"
1358
+ :x="TITLE_COL_X"
1359
+ :y="ROW_H - 6"
1360
+ clip-path="url(#beads-gantt-title-clip)"
1361
+ @click="store.openBead(row.beadId)"
1362
+ >{{ row.title }}<title>{{ row.title }}</title></text>
1363
+ <Pin
1364
+ v-if="isWatched(row.beadId)"
1365
+ class="gantt-icon watch-pin"
1366
+ :size="WATCH_PIN"
1367
+ :x="layout.margins.left - PRIORITY_COL_W - PIN_COL_W + 5"
1368
+ :y="(ROW_H - WATCH_PIN) / 2"
1369
+ />
1370
+ <text
1371
+ class="row-priority"
1372
+ :x="layout.margins.left - 8"
1373
+ :y="ROW_H - 6"
1374
+ text-anchor="end"
1375
+ >P{{ row.priority }}</text>
1376
+ </g>
1377
+ </g>
1378
+
1379
+ <!-- Row hit targets in the plot, behind the bars, so empty row
1380
+ space lights the row too. Start 4px in, clear of the resize
1381
+ handle. -->
1382
+ <g
1383
+ v-if="vm && layout.plotWidth > 4"
1384
+ class="row-hit-layer"
1385
+ :transform="`translate(${layout.margins.left} ${layout.margins.top})`"
1386
+ >
1387
+ <rect
1388
+ v-for="(row, i) in vm.rows"
1389
+ :key="`hit-${row.beadId}`"
1390
+ class="row-hit"
1391
+ :x="4"
1392
+ :y="rowYByIndex[i]"
1393
+ :width="layout.plotWidth - 4"
1394
+ :height="ROW_H"
1395
+ @mouseenter="hoveredRowId = row.beadId"
1396
+ @mouseleave="hoveredRowId = null"
1397
+ />
1398
+ </g>
1399
+
1400
+ <!-- Bars. Hover resolves the tooltip; a bar is not a link. -->
1401
+ <g
1402
+ v-if="vm && xScaler && layout.plotWidth > 0"
1403
+ class="bars"
1404
+ clip-path="url(#beads-gantt-plot-clip)"
1405
+ :transform="`translate(${layout.margins.left} ${layout.margins.top})`"
1406
+ >
1407
+ <g
1408
+ v-for="(bar, i) in vm.bars"
1409
+ :key="`bar-${i}`"
1410
+ @mouseenter="onBarEnter(bar, $event)"
1411
+ @mouseleave="scheduleLeave"
1412
+ @contextmenu.prevent="onBarMenu(bar, $event)"
1413
+ >
1414
+ <!-- Wait segments (bp-6cg): dulled, hatched, cut short at
1415
+ the end that means something, with a dotted connector
1416
+ for what was cut. The work bars below stay to scale. -->
1417
+ <template v-if="isWaitKind(bar.kind)">
1418
+ <line
1419
+ v-if="waitGeo(bar, xScaler.scale).gap"
1420
+ class="wait-gap"
1421
+ :class="barCssClass(bar)"
1422
+ :x1="waitGeo(bar, xScaler.scale).gap!.x1"
1423
+ :x2="waitGeo(bar, xScaler.scale).gap!.x2"
1424
+ :y1="rowYByIndex[bar.rowIndex] + 3 + BAR_H / 2"
1425
+ :y2="rowYByIndex[bar.rowIndex] + 3 + BAR_H / 2"
1426
+ />
1427
+ <rect
1428
+ :class="[barCssClass(bar), 'is-wait', { 'is-hovered': hoveredBeadId === bar.beadId }]"
1429
+ :x="waitGeo(bar, xScaler.scale).x"
1430
+ :y="rowYByIndex[bar.rowIndex] + 3 + WAIT_INSET"
1431
+ :width="waitGeo(bar, xScaler.scale).w"
1432
+ :height="BAR_H - 2 * WAIT_INSET"
1433
+ rx="2"
1434
+ />
1435
+ </template>
1436
+ <rect
1437
+ v-else
1438
+ :class="[
1439
+ barCssClass(bar),
1440
+ barExtraClass(bar),
1441
+ { 'is-hovered': hoveredBeadId === bar.beadId },
1442
+ ]"
1443
+ :x="xScaler.scale(dateAt(bar.startDay))"
1444
+ :y="rowYByIndex[bar.rowIndex] + 3"
1445
+ :width="Math.max(MIN_BAR_PX, xScaler.scale(dateAt(bar.endDay)) - xScaler.scale(dateAt(bar.startDay)))"
1446
+ :height="BAR_H"
1447
+ rx="2"
1448
+ />
1449
+ </g>
1450
+ <circle
1451
+ v-for="(bar, i) in vm.bars.filter((b) => b.shipMarker)"
1452
+ :key="`ship-${i}`"
1453
+ class="ship-marker"
1454
+ :cx="xScaler.scale(dateAt(bar.endDay))"
1455
+ :cy="rowYByIndex[bar.rowIndex] + BAR_H / 2 + 3"
1456
+ r="3"
1457
+ />
1458
+ </g>
1459
+
1460
+ <!-- What-if ghosts (bp-q56): where the work WAS, dimmed, and the
1461
+ delta next to where it is now. -->
1462
+ <g
1463
+ v-if="xScaler && vm && ghosts.length"
1464
+ class="ghosts"
1465
+ clip-path="url(#beads-gantt-plot-clip)"
1466
+ :transform="`translate(${layout.margins.left} ${layout.margins.top})`"
1467
+ >
1468
+ <template v-for="g in ghosts" :key="`ghost-${g.id}`">
1469
+ <rect
1470
+ class="ghost"
1471
+ :x="xScaler.scale(dateAt(g.startDay))"
1472
+ :y="rowYByIndex[g.rowIndex] + 3"
1473
+ :width="Math.max(MIN_BAR_PX, xScaler.scale(dateAt(g.endDay)) - xScaler.scale(dateAt(g.startDay)))"
1474
+ :height="BAR_H"
1475
+ rx="2"
1476
+ />
1477
+ <text
1478
+ class="ghost-delta"
1479
+ :class="{ later: g.later, earlier: !g.later }"
1480
+ :x="xScaler.scale(dateAt(g.afterStartDay)) + 4"
1481
+ :y="rowYByIndex[g.rowIndex] + 3 - 2"
1482
+ >{{ g.label }}</text>
1483
+ </template>
1484
+ </g>
1485
+
1486
+ <!-- Dependency arrows (bp-67g.1). Rendered inside the plot's
1487
+ translate group so scaler pixels line up. Non-interactive
1488
+ so the underlying bars still receive hover/click. -->
1489
+ <g
1490
+ v-if="xScaler && vm && dependencyEdges.length"
1491
+ class="dep-arrows"
1492
+ clip-path="url(#beads-gantt-plot-clip)"
1493
+ :transform="`translate(${layout.margins.left} ${layout.margins.top})`"
1494
+ >
1495
+ <g
1496
+ v-for="(e, i) in dependencyEdges"
1497
+ :key="`dep-${i}`"
1498
+ :class="[
1499
+ 'dep-edge',
1500
+ `role-${e.role}`,
1501
+ ]"
1502
+ >
1503
+ <template
1504
+ v-for="geo in [arrowGeometry(
1505
+ xScaler.scale(dateAt(e.fromX)),
1506
+ e.fromY,
1507
+ xScaler.scale(dateAt(e.toX)),
1508
+ e.toY,
1509
+ depIconSize(e.role),
1510
+ )]"
1511
+ >
1512
+ <path class="dep-arrow" :d="geo.d" />
1513
+ <!-- Terminal icon: a nested <svg> rotated onto the curve's
1514
+ end tangent. Inherits `color` from .dep-edge, which a
1515
+ <marker> could not do. -->
1516
+ <g :transform="`translate(${geo.tipX} ${geo.tipY}) rotate(${geo.angle})`">
1517
+ <component
1518
+ :is="ROLE_ICON[e.role]"
1519
+ class="dep-terminal-icon"
1520
+ :size="depIconSize(e.role)"
1521
+ :x="-DEP_ICON / 2"
1522
+ :y="-DEP_ICON / 2"
1523
+ />
1524
+ </g>
1525
+ </template>
1526
+ </g>
1527
+ </g>
1528
+
1529
+ <!-- Today marker. ThresholdLine draws only the vertical rule
1530
+ (its `label` prop is inert — the primitive doesn't render
1531
+ text), so we position the TODAY label ourselves. Wrapped
1532
+ in the plot-area translate so the ThresholdLine's own
1533
+ `translate(scaler.scale(value) 0)` lands inside the plot. -->
1534
+ <g
1535
+ v-if="xScaler && vm && vm.meta.todayDay >= 0 && vm.meta.todayDay <= vm.meta.totalDays"
1536
+ class="today"
1537
+ clip-path="url(#beads-gantt-plot-clip)"
1538
+ :transform="`translate(${layout.margins.left} ${layout.margins.top})`"
1539
+ >
1540
+ <!-- ThresholdLine's `value` + `domain` props are typed as
1541
+ NumericDomain / number, but the scaler handles ms
1542
+ timestamps identically in a DateDomain. Pass epoch time
1543
+ + the numeric-typed alias so runtime + TS both agree. -->
1544
+ <ThresholdLine
1545
+ :layout="{ ...layout, plotHeight: layout.plotHeight }"
1546
+ :value="dateAt(vm.meta.todayDay).getTime()"
1547
+ :scaler="xScaler"
1548
+ :domain="xDomainAsNumeric"
1549
+ color="var(--c-today)"
1550
+ is-x
1551
+ />
1552
+ <!-- The date sits ON the tick row, taking the place of the
1553
+ tick this marker suppresses, so the row of dates across
1554
+ the top stays unbroken. TODAY floats above it. -->
1555
+ <text
1556
+ class="today-label"
1557
+ :x="xScaler.scale(dateAt(vm.meta.todayDay)) + 4"
1558
+ :y="-22"
1559
+ >TODAY</text>
1560
+ <text
1561
+ class="today-date"
1562
+ :x="xScaler.scale(dateAt(vm.meta.todayDay)) + 4"
1563
+ :y="-8"
1564
+ >{{ todayIso }}</text>
1565
+ </g>
1566
+ </template>
1567
+ </CartesianScaler>
1568
+ </template>
1569
+ </DDV>
1570
+ <!-- Horizontal viewport controls (bp-c6q). The strip over the axis is
1571
+ the pan and zoom handle — drag to pan, wheel to zoom — offset from
1572
+ the column divider so the label-width handle stays reachable. The
1573
+ scrollbar sits in the plot's bottom margin and is the discoverable
1574
+ way to scroll; both disappear when the window fits. -->
1575
+ <div
1576
+ v-if="hscroll.scrollable"
1577
+ class="axis-scrub"
1578
+ :style="{ left: layout.margins.left + 8 + 'px', width: Math.max(0, layout.plotWidth - 8) + 'px', height: AXIS_TOP + 'px' }"
1579
+ title="Drag to pan the timeline · wheel to zoom"
1580
+ @pointerdown="onScrubDown"
1581
+ @pointermove="onScrubMove"
1582
+ @pointerup="onScrubUp"
1583
+ @pointercancel="onScrubUp"
1584
+ @wheel="onScrubWheel"
1585
+ />
1586
+ <div
1587
+ v-else
1588
+ class="axis-scrub is-fit"
1589
+ :style="{ left: layout.margins.left + 8 + 'px', width: Math.max(0, layout.plotWidth - 8) + 'px', height: AXIS_TOP + 'px' }"
1590
+ title="Wheel to zoom the timeline"
1591
+ @wheel="onScrubWheel"
1592
+ />
1593
+ <div
1594
+ v-if="hscroll.scrollable"
1595
+ ref="hscrollEl"
1596
+ class="gantt-hscroll"
1597
+ :style="{ left: layout.margins.left + 'px', width: Math.max(0, layout.plotWidth) + 'px', height: AXIS_BOTTOM + 'px' }"
1598
+ aria-label="Scroll the timeline"
1599
+ @scroll="onHScroll"
1600
+ >
1601
+ <div :style="{ width: hscroll.contentWidth + 'px', height: '1px' }" />
1602
+ </div>
1603
+ <BeadTooltip
1604
+ v-bind="tipProps"
1605
+ @details="onOpenDetails"
1606
+ @watch="onToggleWatch"
1607
+ @tooltip-enter="cancelLeave"
1608
+ @tooltip-leave="scheduleLeave"
1609
+ />
1610
+ </div>
1611
+ </template>
1612
+
1613
+ <style>
1614
+ /* Colours come from tokens.css. The static SVG emitter in src/cli.mjs still
1615
+ carries its own copy of this palette — it renders outside a browser, where
1616
+ custom properties and light-dark() aren't available. */
1617
+ .beads-gantt {
1618
+
1619
+ /* DDV theme overrides — its Axis + gridlines default to light-theme colors
1620
+ (grey/white) that are invisible on our dark background. */
1621
+ --axisTickFontColor: var(--c-dim);
1622
+ --axisLabelColor: var(--c-dim);
1623
+ --axisTickFontFamily: ui-sans-serif, -apple-system, Segoe UI, Roboto, sans-serif;
1624
+ --axisTickFontSize: 10px;
1625
+ --axisTickFontWeight: 400;
1626
+ --axisLabelFontFamily: ui-sans-serif, -apple-system, Segoe UI, Roboto, sans-serif;
1627
+ --boundaryStroke: var(--c-border-strong);
1628
+ --boundaryStrokeWidth: 1;
1629
+ --tickStroke: var(--c-border-strong);
1630
+ --tickStrokeWidth: 1;
1631
+ --subTickStroke: var(--c-border);
1632
+ --subTickStrokeWidth: 0.5;
1633
+ --gridStroke: var(--c-border);
1634
+ --gridStrokeWidth: 0.5;
1635
+ --subGridStroke: var(--c-border);
1636
+ --subGridStrokeWidth: 0.5;
1637
+ --gridStrokeDashArray: none;
1638
+
1639
+ background: var(--c-bg);
1640
+ border: 1px solid var(--c-border);
1641
+ border-radius: 4px;
1642
+ overflow: hidden;
1643
+ width: 100%;
1644
+ display: block;
1645
+ position: relative; /* the viewport controls are positioned over the svg */
1646
+ }
1647
+
1648
+ /* Pan/zoom handle over the axis rows (bp-c6q). Transparent: the axis draws
1649
+ underneath, this only catches the pointer. */
1650
+ .beads-gantt .axis-scrub {
1651
+ position: absolute;
1652
+ top: 0;
1653
+ cursor: grab;
1654
+ touch-action: none;
1655
+ z-index: 2;
1656
+ }
1657
+
1658
+ .beads-gantt .axis-scrub:active {
1659
+ cursor: grabbing;
1660
+ }
1661
+
1662
+ .beads-gantt .axis-scrub.is-fit {
1663
+ cursor: default;
1664
+ }
1665
+
1666
+ /* The real scrollbar, in the plot's bottom margin. Thin, and in the
1667
+ panel's colours so it reads as part of the chart. */
1668
+ .beads-gantt .gantt-hscroll {
1669
+ position: absolute;
1670
+ bottom: 0;
1671
+ overflow-x: auto;
1672
+ overflow-y: hidden;
1673
+ scrollbar-width: thin;
1674
+ scrollbar-color: var(--c-border-strong) transparent;
1675
+ z-index: 2;
1676
+ }
1677
+
1678
+ .beads-gantt-ddv {
1679
+ display: block;
1680
+ width: 100%;
1681
+ height: 100%;
1682
+ }
1683
+
1684
+ .beads-gantt .plot-bg {
1685
+ fill: var(--c-panel);
1686
+ }
1687
+
1688
+ .beads-gantt .group-header {
1689
+ fill: var(--c-border);
1690
+ fill-opacity: 0.4;
1691
+ }
1692
+
1693
+ .beads-gantt .group-label {
1694
+ fill: var(--c-epic);
1695
+ font-size: 12px;
1696
+ font-weight: 600;
1697
+ }
1698
+
1699
+ .beads-gantt .group-count {
1700
+ fill: var(--c-dim);
1701
+ font-size: 10px;
1702
+ }
1703
+
1704
+ .beads-gantt .group-expand-btn {
1705
+ cursor: pointer;
1706
+ }
1707
+
1708
+ .beads-gantt .expand-pill {
1709
+ fill: var(--c-epic);
1710
+ fill-opacity: 0.18;
1711
+ stroke: var(--c-epic);
1712
+ stroke-width: 0.75;
1713
+ stroke-opacity: 0.5;
1714
+ transition: fill-opacity 0.08s ease-out;
1715
+ }
1716
+
1717
+ .beads-gantt .group-expand-btn:hover .expand-pill {
1718
+ fill-opacity: 0.35;
1719
+ }
1720
+
1721
+ .beads-gantt .group-expand-btn.is-expanded .expand-pill {
1722
+ fill: var(--c-border);
1723
+ fill-opacity: 0.5;
1724
+ stroke: var(--c-dim);
1725
+ stroke-opacity: 0.4;
1726
+ }
1727
+
1728
+ /* SVG text paints with `fill`; a lucide icon paints with stroke=currentColor,
1729
+ which resolves against `color`. Any rule that colours text next to an icon
1730
+ has to set both, or the icon renders in the inherited default and looks
1731
+ like a different control. */
1732
+ .beads-gantt .group-expand-btn {
1733
+ color: var(--c-epic);
1734
+ }
1735
+
1736
+ .beads-gantt .group-expand-btn.is-expanded {
1737
+ color: var(--c-dim);
1738
+ }
1739
+
1740
+ .beads-gantt .expand-pill-text {
1741
+ fill: var(--c-epic);
1742
+ font-size: 10px;
1743
+ font-weight: 600;
1744
+ dominant-baseline: middle;
1745
+ pointer-events: none;
1746
+ }
1747
+
1748
+ .beads-gantt .group-expand-btn.is-expanded .expand-pill-text {
1749
+ fill: var(--c-dim);
1750
+ font-weight: 400;
1751
+ }
1752
+
1753
+ .beads-gantt .progress-chip {
1754
+ fill: var(--c-border);
1755
+ }
1756
+
1757
+ .beads-gantt .progress-chip-text {
1758
+ fill: var(--c-dim);
1759
+ font-family: ui-monospace, monospace;
1760
+ font-size: 9px;
1761
+ dominant-baseline: alphabetic;
1762
+ }
1763
+
1764
+ .beads-gantt .progress-bar-bg {
1765
+ fill: var(--c-border);
1766
+ }
1767
+
1768
+ .beads-gantt .progress-bar-fill {
1769
+ fill: var(--c-shipped);
1770
+ fill-opacity: 0.8;
1771
+ }
1772
+
1773
+ /* Row hover band: the eye's ruler from a bar back to its id and title.
1774
+ A lift, not a selection, and faster in than out so sweeping the pointer
1775
+ down the chart reads as one moving band rather than a flicker. */
1776
+ .beads-gantt .row-hover {
1777
+ fill: var(--c-accent-wash);
1778
+ opacity: 0;
1779
+ pointer-events: none;
1780
+ transition: opacity 0.16s ease-out;
1781
+ }
1782
+
1783
+ .beads-gantt .row-hover.is-lit {
1784
+ opacity: 1;
1785
+ transition: opacity 0.06s ease-out;
1786
+ }
1787
+
1788
+ .beads-gantt .row-hit {
1789
+ fill: transparent;
1790
+ }
1791
+
1792
+ .beads-gantt .row-id,
1793
+ .beads-gantt .row-title {
1794
+ cursor: pointer;
1795
+ }
1796
+
1797
+ .beads-gantt .row-id:hover,
1798
+ .beads-gantt .row-title:hover {
1799
+ fill: var(--c-epic);
1800
+ }
1801
+
1802
+ .beads-gantt .row-id {
1803
+ fill: var(--c-dim);
1804
+ font-family: ui-monospace, monospace;
1805
+ font-size: 10px;
1806
+ transition: fill 0.08s ease-out;
1807
+ }
1808
+
1809
+ .beads-gantt .row-id.is-copied {
1810
+ fill: var(--c-shipped);
1811
+ font-weight: 600;
1812
+ }
1813
+
1814
+ .beads-gantt .row-title {
1815
+ fill: var(--c-text);
1816
+ font-size: 11px;
1817
+ }
1818
+
1819
+ .beads-gantt .row-priority {
1820
+ fill: var(--c-dim);
1821
+ font-size: 9px;
1822
+ transition: fill 0.08s ease-out;
1823
+ }
1824
+
1825
+ /* The lit row's dim text steps up one notch. Colour only: nothing moves. */
1826
+ .beads-gantt .row-label-band.is-lit .row-id:not(.is-copied),
1827
+ .beads-gantt .row-label-band.is-lit .row-priority {
1828
+ fill: var(--c-dim-bright);
1829
+ }
1830
+
1831
+ .beads-gantt .bar {
1832
+ stroke: none;
1833
+ fill-opacity: 0.9;
1834
+ cursor: pointer;
1835
+ transition: fill-opacity 0.08s ease-out;
1836
+ }
1837
+ .beads-gantt .bar.is-hovered {
1838
+ fill-opacity: 1;
1839
+ filter: brightness(1.2);
1840
+ }
1841
+ .beads-gantt .bar.kind-shipped {
1842
+ fill: var(--c-shipped);
1843
+ stroke: var(--c-shipped-stroke);
1844
+ stroke-width: 0.75;
1845
+ }
1846
+ /* 0.8, not 0.5. At 0.5 this washed into the panel and landed within 0.05
1847
+ OKLab of a futureBacklog bar (0.35 over the same panel) — two low-opacity
1848
+ fills over one background converge no matter how far apart their tokens
1849
+ are. A closed bead is a fact; the speculative projection is the faint
1850
+ one. Enforced by style-audit.test.mjs, which composites before comparing. */
1851
+ .beads-gantt .bar.kind-closedNoShip {
1852
+ fill: var(--c-closed-noship);
1853
+ fill-opacity: 0.8;
1854
+ }
1855
+ .beads-gantt .bar.kind-inProgressLive {
1856
+ fill: var(--c-in-progress);
1857
+ fill-opacity: 0.95;
1858
+ }
1859
+ .beads-gantt .bar.kind-future {
1860
+ fill: var(--c-future);
1861
+ fill-opacity: 0.75;
1862
+ }
1863
+ .beads-gantt .bar.kind-futureBacklog {
1864
+ fill: var(--c-backlog);
1865
+ fill-opacity: 0.35;
1866
+ }
1867
+ /* The wait clock (bp-6cg): the phase bar's own waiting and review colours,
1868
+ hatched like every projection that is not work, and faint. Their fills are
1869
+ deliberately NOT in the style audit's bar-kinds group: they are told apart
1870
+ from work by texture, height and cut, not by hue — a review wait sits in
1871
+ the in-progress hue family on purpose, as the drawer's phase bar does. */
1872
+ .beads-gantt .bar.kind-waitUnclaimed {
1873
+ fill: var(--c-phase-waiting);
1874
+ fill-opacity: 0.32;
1875
+ mask: url(#beads-gantt-synthetic-mask);
1876
+ }
1877
+ .beads-gantt .bar.kind-waitReview {
1878
+ fill: var(--c-phase-review);
1879
+ fill-opacity: 0.32;
1880
+ mask: url(#beads-gantt-synthetic-mask);
1881
+ }
1882
+ /* What-if ghosts (bp-q56): the plan as it was, under the plan as it would be. */
1883
+ .beads-gantt .ghost {
1884
+ fill: none;
1885
+ stroke: var(--c-dim);
1886
+ stroke-width: 1;
1887
+ stroke-dasharray: 3 2;
1888
+ pointer-events: none;
1889
+ }
1890
+ .beads-gantt .ghost-delta {
1891
+ font-size: 9px;
1892
+ font-weight: 600;
1893
+ pointer-events: none;
1894
+ }
1895
+ .beads-gantt .ghost-delta.later {
1896
+ fill: var(--c-danger);
1897
+ }
1898
+ .beads-gantt .ghost-delta.earlier {
1899
+ fill: var(--c-shipped);
1900
+ }
1901
+ .beads-gantt .wait-gap {
1902
+ stroke-width: 1;
1903
+ stroke-dasharray: 2 3;
1904
+ stroke-opacity: 0.45;
1905
+ pointer-events: none;
1906
+ }
1907
+ .beads-gantt .wait-gap.kind-waitUnclaimed {
1908
+ stroke: var(--c-phase-waiting);
1909
+ }
1910
+ .beads-gantt .wait-gap.kind-waitReview {
1911
+ stroke: var(--c-phase-review);
1912
+ }
1913
+ .beads-gantt .bar.kind-unassigned {
1914
+ fill: var(--c-future);
1915
+ fill-opacity: 0.35;
1916
+ stroke: oklch(from var(--c-future) calc(l - 0.35) c h);
1917
+ stroke-width: 0.75;
1918
+ }
1919
+ /* An inferred assignee is a texture, not a colour (bp-67g.49). The fill and
1920
+ opacity stay the bar's kind — a hatched future bar is the same violet at
1921
+ 0.75 — and the mask cuts stripes out of it, so it reads as "this kind,
1922
+ not settled" and never as a fourth violet. A fourth violet is what the
1923
+ dashed stroke this replaces amounted to: --c-epic at 0.75px on a
1924
+ --c-future fill, twelve degrees of hue apart, on a 12px bar. Texture
1925
+ holds in every style and mode and under colour-vision deficiency, and
1926
+ adds nothing to the opacity ladder the audit has to police. Geometry is
1927
+ HATCH_PITCH / HATCH_STRIPE in the script and the <defs> in the template;
1928
+ style-audit.test.mjs checks the stripes stay legible at BAR_H and that
1929
+ this rule never grows a fill or fill-opacity the painted-kinds check
1930
+ cannot see. */
1931
+ .beads-gantt .bar.synthetic {
1932
+ mask: url(#beads-gantt-synthetic-mask);
1933
+ }
1934
+
1935
+ .beads-gantt .row-label-band.is-inferred-match {
1936
+ opacity: var(--o-inferred);
1937
+ }
1938
+
1939
+ .beads-gantt .watch-pin {
1940
+ color: var(--c-epic);
1941
+ pointer-events: none;
1942
+ }
1943
+
1944
+ /* Only openable when the group is backed by a real bead — otherwise the
1945
+ label is a facet value and must not look like a link. */
1946
+ .beads-gantt .group-label.is-openable {
1947
+ cursor: pointer;
1948
+ }
1949
+
1950
+ .beads-gantt .group-label.is-openable:hover {
1951
+ text-decoration: underline;
1952
+ }
1953
+
1954
+ .beads-gantt .group-band.is-over-cap .group-label {
1955
+ fill: var(--c-today);
1956
+ }
1957
+
1958
+ /* Lucide icons inside the chart arrive as NESTED <svg> roots, and a nested
1959
+ <svg> does not take its width/height from CSS — unlike a top-level one in
1960
+ HTML, it keeps its width/height attributes and CSS is ignored. So size is
1961
+ passed as :size at each call site (see GANTT_ICON / DEP_ICON)
1962
+ and only the properties that really do cascade live here: stroke-width is
1963
+ a genuine SVG presentation property, and stroke="currentColor" means each
1964
+ icon takes the `color` of the band it sits in. */
1965
+ .beads-gantt .gantt-icon {
1966
+ stroke-width: 2;
1967
+ pointer-events: none;
1968
+ }
1969
+
1970
+ .beads-gantt .over-cap-icon {
1971
+ color: var(--c-today);
1972
+ }
1973
+
1974
+ .beads-gantt .over-cap-badge {
1975
+ fill: var(--c-today);
1976
+ font-size: 10px;
1977
+ font-weight: 600;
1978
+ }
1979
+
1980
+ .beads-gantt .bar.hl-blocked {
1981
+ stroke: var(--c-today);
1982
+ stroke-width: 1.5;
1983
+ }
1984
+
1985
+ .beads-gantt .bar.hl-ready-now {
1986
+ stroke: var(--c-epic);
1987
+ stroke-width: 1.5;
1988
+ filter: drop-shadow(0 0 3px oklch(from var(--c-epic) l c h / 60%));
1989
+ }
1990
+
1991
+ .beads-gantt .dep-arrow {
1992
+ fill: none;
1993
+ stroke-width: 1.25;
1994
+ pointer-events: none;
1995
+ }
1996
+
1997
+ .beads-gantt .dep-edge.role-outgoing {
1998
+ /* Focal bead BLOCKS these — green, "downstream." */
1999
+ color: var(--c-shipped);
2000
+ stroke: var(--c-shipped);
2001
+ stroke-opacity: 0.75;
2002
+ }
2003
+ .beads-gantt .dep-edge.role-incoming {
2004
+ /* These are what's blocking the focal bead — red, "upstream." */
2005
+ color: var(--c-today);
2006
+ stroke: var(--c-today);
2007
+ stroke-opacity: 0.75;
2008
+ }
2009
+ .beads-gantt .dep-edge.role-neutral {
2010
+ /* Full-graph mode — no focal, use dim violet so density stays readable. */
2011
+ color: var(--c-epic);
2012
+ stroke: var(--c-epic);
2013
+ stroke-opacity: 0.28;
2014
+ }
2015
+ .beads-gantt .dep-edge.role-neutral .dep-arrow {
2016
+ stroke-width: 1;
2017
+ }
2018
+
2019
+ /* Full-graph mode draws every edge at once, so its terminals shrink and fade:
2020
+ at that density a full-weight arrow per edge buries the bars it points at.
2021
+ Focal edges are few, so they keep full weight. */
2022
+ .beads-gantt .dep-edge.role-neutral .dep-terminal-icon {
2023
+ stroke-width: 2.5;
2024
+ stroke-opacity: 0.6;
2025
+ }
2026
+
2027
+ /* Terminal icon rides the curve's end tangent. stroke-opacity is inherited
2028
+ from the role above, so the icon fades with its edge. */
2029
+ .beads-gantt .dep-terminal-icon {
2030
+ stroke-width: 3;
2031
+ /* stroke-opacity inherits from the edge role, which is tuned for a hairline
2032
+ curve and leaves the glyph a smudge. Terminals carry the meaning, so they
2033
+ stay opaque. */
2034
+ stroke-opacity: 1;
2035
+ pointer-events: none;
2036
+ }
2037
+
2038
+
2039
+ .beads-gantt .ship-marker {
2040
+ /* Decoration on top of the bar: let the pointer through to the bar so the
2041
+ hover (and its tooltip) still fires at the centre of a short shipped bar. */
2042
+ pointer-events: none;
2043
+ fill: var(--c-shipped);
2044
+ stroke: oklch(from var(--c-shipped) calc(l - 0.55) c h);
2045
+ stroke-width: 1;
2046
+ }
2047
+
2048
+ .beads-gantt .threshold line {
2049
+ stroke: var(--c-today);
2050
+ stroke-width: 1.5;
2051
+ stroke-dasharray: 3, 3;
2052
+ }
2053
+
2054
+ .beads-gantt .today-label {
2055
+ fill: var(--c-today);
2056
+ font-size: 10px;
2057
+ font-weight: 600;
2058
+ }
2059
+
2060
+ .beads-gantt .today-date {
2061
+ fill: var(--c-dim);
2062
+ font-size: 9px;
2063
+ }
2064
+
2065
+ .beads-gantt .label-divider {
2066
+ stroke: var(--c-border);
2067
+ stroke-width: 1;
2068
+ pointer-events: none;
2069
+ transition: stroke 0.08s ease-out, stroke-width 0.08s ease-out;
2070
+ }
2071
+
2072
+ .beads-gantt .label-divider.is-resizing {
2073
+ stroke: var(--c-epic);
2074
+ stroke-width: 2;
2075
+ }
2076
+
2077
+ .beads-gantt .label-resize-handle {
2078
+ fill: transparent;
2079
+ cursor: col-resize;
2080
+ }
2081
+
2082
+ .beads-gantt .label-resize-handle:hover {
2083
+ fill: var(--c-epic);
2084
+ fill-opacity: 0.25;
2085
+ }
2086
+
2087
+ .beads-gantt.is-resizing-labels {
2088
+ cursor: col-resize;
2089
+ user-select: none;
2090
+ }
2091
+
2092
+ .beads-gantt.is-resizing-labels .label-resize-handle {
2093
+ fill: var(--c-epic);
2094
+ fill-opacity: 0.35;
2095
+ }
2096
+
2097
+ .beads-gantt .x-axis .gridlines line {
2098
+ stroke: var(--c-border);
2099
+ stroke-width: 0.5;
2100
+ }
2101
+
2102
+ /* Fainter than a labelled boundary: present enough to count weeks or days
2103
+ against, quiet enough not to compete with the bars. */
2104
+ .beads-gantt .x-axis .gridlines line.minor {
2105
+ stroke: var(--c-border);
2106
+ stroke-width: 0.5;
2107
+ stroke-opacity: 0.45;
2108
+ }
2109
+
2110
+ .beads-gantt .x-axis .gridlines line.emphasize {
2111
+ stroke: var(--c-border-strong);
2112
+ stroke-width: 1;
2113
+ }
2114
+
2115
+ .beads-gantt .x-axis .tick-labels text {
2116
+ fill: var(--c-dim);
2117
+ font-size: 10px;
2118
+ dominant-baseline: alphabetic;
2119
+ }
2120
+
2121
+ .beads-gantt .x-axis .tick-labels text.emphasize {
2122
+ fill: var(--c-text);
2123
+ font-weight: 600;
2124
+ }
2125
+ </style>