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,1883 @@
1
+ <script setup lang="ts">
2
+ // BeadDrawer.vue — right-side detail drawer. Opens when store.openBeadId
3
+ // is set (via row-label click, tooltip Details button, or URL hash) and
4
+ // stays sticky until ESC / click-away / close. Contains:
5
+ //
6
+ // - Full dossier: id, title, description, priority, status, assignee,
7
+ // labels, dependency list (both directions), MR link, ship metadata.
8
+ // - Triage score panel — the eight signals from triage.ts with their
9
+ // normalized values, unnormalized contributions, and a composite.
10
+ // - Compact subgraph (BeadSubGraph.vue) with upstream blockers +
11
+ // downstream dependents up to 4 hops.
12
+ // - Actions: Copy id, Copy JSON.
13
+
14
+ import { computed, onBeforeUnmount, onMounted, ref, watch } from "vue";
15
+ import { renderMarkdown } from "../markdown";
16
+ import {
17
+ X,
18
+ Copy,
19
+ Pin,
20
+ PinOff,
21
+ ChevronRight,
22
+ Maximize2,
23
+ MoreHorizontal,
24
+ } from "@lucide/vue";
25
+ import { useBeadsStore } from "../store";
26
+ import { formatEstimate, explainFinish, explainEstimate, EXPLAIN_STARTS } from "../bead-detail";
27
+ import Term from "./Term.vue";
28
+ import { explainScore } from "../triage";
29
+ import { SYNTHETIC_ASSIGNEE_NOTE } from "../view-model";
30
+ import { ref as vueRef } from "vue";
31
+ import BeadSubGraph from "./BeadSubGraph.vue";
32
+ import BeadSubGraphOverlay from "./BeadSubGraphOverlay.vue";
33
+ import TriageMeters from "./TriageMeters.vue";
34
+ import ScoreStrip from "./ScoreStrip.vue";
35
+ import {
36
+ blockedByHeading,
37
+ blocksHeading,
38
+ edgeIsSpent,
39
+ } from "../dep-headings";
40
+ import { buildEconomics, formatHours, PHASE_EXPLANATIONS, beadStages, stageSpans, STAGE_LABELS, STAGE_EXPLANATIONS, type EconomicsPhase } from "../economics";
41
+ import PriorityChip from "./PriorityChip.vue";
42
+ import TypeChip from "./TypeChip.vue";
43
+
44
+ const store = useBeadsStore();
45
+
46
+ // ── drawer sizing ────────────────────────────────────────────────────────
47
+ // Width is user-adjustable via the left-edge drag handle and persisted per
48
+ // browser. Below 600px viewport the drawer flips to fullscreen; the drag
49
+ // handle is hidden and the persisted width is ignored.
50
+
51
+ const DEFAULT_WIDTH = 560;
52
+ const MIN_WIDTH = 360;
53
+ const MAX_WIDTH_MARGIN = 80; // leave at least this much of the underlying page
54
+ const WIDTH_STORAGE_KEY = "beadcyte:drawer-width";
55
+ const FULLSCREEN_BREAKPOINT = 600;
56
+
57
+ const viewportWidth = ref(
58
+ typeof window !== "undefined" ? window.innerWidth : 1200,
59
+ );
60
+ const isFullscreen = computed(
61
+ () => viewportWidth.value < FULLSCREEN_BREAKPOINT,
62
+ );
63
+
64
+ function loadStoredWidth(): number {
65
+ if (typeof localStorage === "undefined") return DEFAULT_WIDTH;
66
+ const raw = localStorage.getItem(WIDTH_STORAGE_KEY);
67
+ const n = raw ? parseInt(raw, 10) : NaN;
68
+ return Number.isFinite(n) && n >= MIN_WIDTH ? n : DEFAULT_WIDTH;
69
+ }
70
+
71
+ const drawerWidth = ref(loadStoredWidth());
72
+
73
+ const effectiveWidth = computed(() => {
74
+ if (isFullscreen.value) return viewportWidth.value;
75
+ const maxW = Math.max(MIN_WIDTH, viewportWidth.value - MAX_WIDTH_MARGIN);
76
+ return Math.min(Math.max(drawerWidth.value, MIN_WIDTH), maxW);
77
+ });
78
+
79
+ const isResizing = ref(false);
80
+
81
+ function onResizeStart(e: MouseEvent) {
82
+ if (isFullscreen.value) return;
83
+ e.preventDefault();
84
+ isResizing.value = true;
85
+ const startX = e.clientX;
86
+ const startW = drawerWidth.value;
87
+ const onMove = (ev: MouseEvent) => {
88
+ const dx = startX - ev.clientX; // drag left → wider (drawer is anchored right)
89
+ const next = Math.min(
90
+ viewportWidth.value - MAX_WIDTH_MARGIN,
91
+ Math.max(MIN_WIDTH, startW + dx),
92
+ );
93
+ drawerWidth.value = next;
94
+ };
95
+ const onUp = () => {
96
+ isResizing.value = false;
97
+ localStorage.setItem(WIDTH_STORAGE_KEY, String(drawerWidth.value));
98
+ document.removeEventListener("mousemove", onMove);
99
+ document.removeEventListener("mouseup", onUp);
100
+ };
101
+ document.addEventListener("mousemove", onMove);
102
+ document.addEventListener("mouseup", onUp);
103
+ }
104
+
105
+ function onViewportResize() {
106
+ viewportWidth.value = window.innerWidth;
107
+ }
108
+
109
+ onMounted(() => {
110
+ window.addEventListener("resize", onViewportResize);
111
+ });
112
+ onBeforeUnmount(() => {
113
+ window.removeEventListener("resize", onViewportResize);
114
+ });
115
+
116
+ const bead = computed(() => store.drawerBead);
117
+
118
+ // The drawer header is the only place a bead is added to or removed from the
119
+ // watch list; the pins elsewhere are read-only reflections of it.
120
+ // Where the bead's time went. Measured across a real repo, unclaimed +
121
+ // in-review explains a median 99% of a bead's lifetime, so the phases are
122
+ // worth showing as proportions of the whole rather than as bare figures.
123
+ const econ = computed(() =>
124
+ bead.value ? buildEconomics(bead.value) : null,
125
+ );
126
+
127
+ // What if… (bp-q56): stage a change to this bead in the what-if plan. The
128
+ // controls write to the change set, never to bd; the panel shows the diff.
129
+ const whatIfHandles = computed(() => {
130
+ const roster = (store.calibratedRoster ?? store.roster)?.humans ?? [];
131
+ const seen = new Set<string>(roster.map((h) => h.handle));
132
+ for (const b of store.beads) if (b.assignee) seen.add(b.assignee);
133
+ return [...seen].sort();
134
+ });
135
+ const wiAssignee = vueRef<string>("");
136
+ const wiPriority = vueRef<number>(2);
137
+ watch(() => bead.value?.id, () => {
138
+ wiAssignee.value = bead.value?.assignee ?? "";
139
+ wiPriority.value = bead.value?.priority ?? 4;
140
+ }, { immediate: true });
141
+ const stagedHere = computed(() => store.whatIf.filter((c) => "id" in c && c.id === bead.value?.id));
142
+
143
+ // The stage boundaries (bp-7c9): dated or absent, each saying where it came
144
+ // from, and the hours between the ones that are known.
145
+ const stages = computed(() => (bead.value ? beadStages(bead.value) : []));
146
+ const spans = computed(() => stageSpans(stages.value));
147
+ const knownStages = computed(() => stages.value.filter((s) => s.at !== null).length);
148
+ const fmtStamp = (iso: string) => new Date(iso).toLocaleString(undefined, { day: "numeric", month: "short", hour: "2-digit", minute: "2-digit" });
149
+
150
+ // The estimate's source, explained by the estimator that wrote it (bp-67g.47).
151
+ const estimateNote = computed(() => (detail.value ? explainEstimate(detail.value) : null));
152
+
153
+ /** Segments of the flow bar, in lifecycle order, skipping what's absent. */
154
+ const flowSegments = computed(() => {
155
+ const e = econ.value;
156
+ if (!e || e.elapsedHours === null || e.elapsedHours <= 0) return [];
157
+ const parts: Array<{
158
+ key: string;
159
+ label: string;
160
+ hours: number;
161
+ /** "" when measured; otherwise the badge text for how it was obtained. */
162
+ tag: string;
163
+ derived: boolean;
164
+ /** Which phase this is, for its explanation (bp-67g.47). */
165
+ phase: EconomicsPhase;
166
+ }> = [];
167
+ // "derived" and "reconstructed" both mean not-measured and both get the
168
+ // hatch; the badge separates them, because one is arithmetic you can check
169
+ // and the other is a number another tool admits it inferred.
170
+ const mark = (p: { kind: string } | null) =>
171
+ !p || p.kind === "recorded"
172
+ ? { tag: "", derived: false }
173
+ : { tag: p.kind === "derived" ? "derived" : "reconstructed", derived: true };
174
+ if (e.unclaimedHours !== null)
175
+ parts.push({
176
+ key: "unclaimed",
177
+ phase: "unclaimed",
178
+ label: "unclaimed",
179
+ hours: e.unclaimedHours,
180
+ ...mark(e.provenance.unclaimedHours),
181
+ });
182
+ if (e.reviewHours !== null)
183
+ parts.push({
184
+ key: "review",
185
+ phase: "review",
186
+ label: "in review",
187
+ hours: e.reviewHours,
188
+ ...mark(e.provenance.reviewHours),
189
+ });
190
+ if (e.workedHours !== null)
191
+ parts.push({
192
+ key: "worked",
193
+ phase: "worked",
194
+ label: "worked",
195
+ hours: e.workedHours,
196
+ ...mark(e.provenance.workedHours),
197
+ });
198
+ // Never derived and never recorded — it is the arithmetic remainder, so it
199
+ // is neither measured nor reconstructed and carries no marker.
200
+ if (e.unaccountedHours !== null && e.unaccountedHours > 0.01)
201
+ parts.push({
202
+ key: "other",
203
+ phase: "unaccounted",
204
+ label: "unaccounted",
205
+ hours: e.unaccountedHours,
206
+ tag: "",
207
+ derived: false,
208
+ });
209
+ const total = e.elapsedHours;
210
+ return parts.map((p) => ({
211
+ ...p,
212
+ pct: Math.min(100, (p.hours / total) * 100),
213
+ }));
214
+ });
215
+
216
+ /**
217
+ * One line per distinct non-measured provenance, so a bead whose unclaimed is
218
+ * derived AND whose worked hours were reconstructed explains both. The
219
+ * record's own note is printed verbatim where it left one — a reconstruction
220
+ * is only judgeable if it says how it was made, and the writer knows that
221
+ * while beadcyte does not.
222
+ */
223
+ const provenanceNotes = computed(() => {
224
+ const e = econ.value;
225
+ if (!e) return [];
226
+ type Row = { fields: string[]; plural: boolean; kind: string; detail: string };
227
+ const rows: Row[] = [];
228
+ const seen = new Map<string, Row>();
229
+ // The verb travels with the label: "Work hours" is plural and "Unclaimed
230
+ // time" is not, so it cannot be inferred from how many fields merged.
231
+ const add = (
232
+ field: string,
233
+ plural: boolean,
234
+ p: { kind: string; method: string | null; note: string | null } | null,
235
+ ) => {
236
+ if (!p || p.kind === "recorded") return;
237
+ const detail = p.note ?? p.method ?? "";
238
+ const key = `${p.kind}|${detail}`;
239
+ const hit = seen.get(key);
240
+ if (hit) {
241
+ hit.fields.push(field);
242
+ hit.plural = true; // two subjects joined by "and" always take "were"
243
+ return;
244
+ }
245
+ const row: Row = { fields: [field], plural, kind: p.kind, detail };
246
+ seen.set(key, row);
247
+ rows.push(row);
248
+ };
249
+ add("Unclaimed time", false, e.provenance.unclaimedHours);
250
+ add("Review time", false, e.provenance.reviewHours);
251
+ add("Work hours", true, e.provenance.workedHours);
252
+ return rows;
253
+ });
254
+
255
+ /** The ship record's own note or method, whichever it left. */
256
+ const shipNote = computed(() => {
257
+ const p = detail.value?.ship?.provenance;
258
+ if (!p || p.kind === "recorded") return null;
259
+ return p.note ?? p.method;
260
+ });
261
+
262
+ const isWatched = computed(() =>
263
+ bead.value ? store.watchedSet.has(bead.value.id) : false,
264
+ );
265
+
266
+ // ── page scroll lock ──────────────────────────────────────────────────────
267
+ // The drawer is a fixed overlay, so the page behind it keeps its own
268
+ // scrollbar — a third one beside the drawer's, and a wheel that misses the
269
+ // drawer scrolls the Gantt underneath. Lock the page while the drawer is
270
+ // open, padding the width the scrollbar was occupying so the layout behind
271
+ // doesn't jump sideways as it disappears.
272
+ let lockedPadding = "";
273
+ let lockedOverflow = "";
274
+
275
+ function lockPageScroll() {
276
+ const el = document.documentElement;
277
+ if (el.style.overflow === "hidden") return;
278
+ const barWidth = window.innerWidth - el.clientWidth;
279
+ lockedOverflow = el.style.overflow;
280
+ lockedPadding = el.style.paddingRight;
281
+ el.style.overflow = "hidden";
282
+ if (barWidth > 0) el.style.paddingRight = `${barWidth}px`;
283
+ }
284
+
285
+ function unlockPageScroll() {
286
+ const el = document.documentElement;
287
+ if (el.style.overflow !== "hidden") return;
288
+ el.style.overflow = lockedOverflow;
289
+ el.style.paddingRight = lockedPadding;
290
+ }
291
+
292
+ watch(
293
+ () => !!bead.value,
294
+ (open) => (open ? lockPageScroll() : unlockPageScroll()),
295
+ { immediate: true },
296
+ );
297
+
298
+ // Unmounting with the drawer open would otherwise strand the page unable
299
+ // to scroll.
300
+ onBeforeUnmount(unlockPageScroll);
301
+
302
+ // ── triage panel collapse state ──────────────────────────────────────────
303
+ // Signal breakdown is dense and rarely the primary interest. Show the
304
+ // composite score by default; hide the full table behind an expand link.
305
+ const triageOpen = ref(false);
306
+
307
+ // ── description → HTML ───────────────────────────────────────────────────
308
+ // Through markdown.ts, the one renderer (shared with the changelog viewer);
309
+ // the trust argument for running without a sanitizer lives there.
310
+ const descriptionHtml = computed(() => {
311
+ const src = bead.value?.description;
312
+ if (!src) return "";
313
+ return renderMarkdown(src);
314
+ });
315
+ const triage = computed(() =>
316
+ bead.value ? store.triage.get(bead.value.id) ?? null : null,
317
+ );
318
+ const detail = computed(() =>
319
+ bead.value ? store.beadDetailFor(bead.value.id) : null,
320
+ );
321
+
322
+ const blockedByList = computed(() => {
323
+ if (!bead.value) return [];
324
+ const byId = new Map(store.beads.map((b) => [b.id, b] as const));
325
+ return (bead.value.dependencies ?? [])
326
+ .filter((d) => d.type === "blocks")
327
+ .map((d) => byId.get(d.depends_on_id))
328
+ .filter((b): b is NonNullable<typeof b> => !!b);
329
+ });
330
+
331
+ const blocksList = computed(() => {
332
+ // Reverse edges: who depends on THIS bead?
333
+ if (!bead.value) return [];
334
+ const rootId = bead.value.id;
335
+ return store.beads.filter((b) =>
336
+ (b.dependencies ?? []).some(
337
+ (d) => d.type === "blocks" && d.depends_on_id === rootId,
338
+ ),
339
+ );
340
+ });
341
+
342
+ const childrenList = computed(() => {
343
+ // Show child beads (beads that have this bead as their parent)
344
+ if (!bead.value) return [];
345
+ return store.beads.filter((b) => b.parent === bead.value?.id);
346
+ });
347
+
348
+ // An epic's own dependency neighbourhood is almost always the epic alone —
349
+ // epics carry parent/child edges, not blocks edges — so an epic's drawer
350
+ // shows the graph of its children instead: every child, with the blocks
351
+ // edges between them (bp-67g.59). The ±4 hop view survives on an epic only
352
+ // when the epic has blocks edges of its own, or has no children to show;
353
+ // otherwise it would be a second graph saying nothing.
354
+ const isEpic = computed(() => bead.value?.issue_type === "epic");
355
+ const showChildrenGraph = computed(
356
+ () => isEpic.value && childrenList.value.length > 0,
357
+ );
358
+ const showNeighbourhood = computed(
359
+ () =>
360
+ !showChildrenGraph.value ||
361
+ blockedByList.value.length > 0 ||
362
+ blocksList.value.length > 0,
363
+ );
364
+
365
+ // bp-67g.60: a child can zoom out from its own ±4 hops to its epic's whole
366
+ // children graph — the one the epic's own drawer shows — with itself kept as
367
+ // the focal node, so the switch reads as a zoom out rather than a change of
368
+ // subject. Offered only when there is something to zoom out to: a parent
369
+ // with more than one child. Resets to the bead-rooted view whenever the
370
+ // drawer moves to another bead, which is the safer default when a click on
371
+ // a node changes the subject.
372
+ const parentBead = computed(() =>
373
+ bead.value?.parent ? store.beads.find((b) => b.id === bead.value!.parent) ?? null : null,
374
+ );
375
+ const siblingCount = computed(() =>
376
+ parentBead.value ? store.beads.filter((b) => b.parent === parentBead.value!.id).length : 0,
377
+ );
378
+ const canZoomOut = computed(
379
+ () => showNeighbourhood.value && !showChildrenGraph.value && !!parentBead.value && siblingCount.value > 1,
380
+ );
381
+ // In the store since bp-67g.45, so the s key can flip it; openBead resets
382
+ // it to the bead-rooted view whenever the drawer moves to another bead.
383
+ const subgraphScope = computed({
384
+ get: () => store.drawerSubgraphScope,
385
+ set: (v: "bead" | "epic") => store.setDrawerSubgraphScope(v),
386
+ });
387
+ const subgraphHint = computed(() => {
388
+ if (showChildrenGraph.value) return "the epic's own ±4 hops";
389
+ if (canZoomOut.value && subgraphScope.value === "epic")
390
+ return `all ${siblingCount.value} children of ${parentBead.value!.id}`;
391
+ return "±4 hops";
392
+ });
393
+
394
+ // The children graph at full viewport, for epics too large for a drawer
395
+ // column. Closes whenever the drawer moves to another bead — including a
396
+ // click on one of its own nodes, which is the point of clicking one.
397
+ const childrenGraphFullscreen = ref(false);
398
+ watch(
399
+ () => bead.value?.id,
400
+ () => {
401
+ childrenGraphFullscreen.value = false;
402
+ },
403
+ );
404
+
405
+ function close() {
406
+ store.closeBead();
407
+ }
408
+
409
+ // Which action button just fired a copy — used to flash a brief "Copied ✓"
410
+ // label on the button. Keyed by action name so multiple buttons don't step
411
+ // on each other's flashes.
412
+ const copiedFlash = ref<string | null>(null);
413
+ let copiedTimer: ReturnType<typeof setTimeout> | null = null;
414
+
415
+ function flashCopied(name: string) {
416
+ if (copiedTimer) clearTimeout(copiedTimer);
417
+ copiedFlash.value = name;
418
+ copiedTimer = setTimeout(() => {
419
+ copiedFlash.value = null;
420
+ }, 900);
421
+ }
422
+
423
+ function copyId() {
424
+ if (!bead.value) return;
425
+ navigator.clipboard.writeText(bead.value.id);
426
+ flashCopied("id");
427
+ }
428
+
429
+ function copyIdFromHeader() {
430
+ if (!bead.value) return;
431
+ navigator.clipboard.writeText(bead.value.id);
432
+ flashCopied("head-id");
433
+ }
434
+
435
+ function copyJson() {
436
+ if (!bead.value) return;
437
+ navigator.clipboard.writeText(JSON.stringify(bead.value, null, 2));
438
+ flashCopied("json");
439
+ }
440
+
441
+ function selectFromSubgraph(id: string) {
442
+ store.openBead(id);
443
+ }
444
+
445
+ function formatDate(iso: string | null | undefined): string {
446
+ if (!iso) return "—";
447
+ return new Date(iso).toLocaleDateString("en-US", {
448
+ month: "short",
449
+ day: "numeric",
450
+ year: "numeric",
451
+ });
452
+ }
453
+
454
+ /** "Mon 8 Sep", with the year once it isn't this one. */
455
+ function formatDay(d: Date): string {
456
+ const sameYear = d.getFullYear() === new Date().getFullYear();
457
+ return d.toLocaleDateString(undefined, {
458
+ weekday: "short",
459
+ day: "numeric",
460
+ month: "short",
461
+ ...(sameYear ? {} : { year: "numeric" }),
462
+ });
463
+ }
464
+
465
+ function fmtPct(v: number): string {
466
+ return `${(v * 100).toFixed(1)}%`;
467
+ }
468
+
469
+ function fmtNum(v: number): string {
470
+ return v.toFixed(4);
471
+ }
472
+
473
+ </script>
474
+
475
+ <template>
476
+ <Teleport to="body">
477
+ <transition name="drawer">
478
+ <div
479
+ v-if="bead"
480
+ class="bead-drawer-root"
481
+ :class="{ 'is-fullscreen': isFullscreen, 'is-resizing': isResizing }"
482
+ >
483
+ <div class="drawer-backdrop" @click="close" />
484
+ <aside
485
+ class="drawer"
486
+ :style="{ width: effectiveWidth + 'px' }"
487
+ >
488
+ <div
489
+ v-if="!isFullscreen"
490
+ class="resize-handle"
491
+ @mousedown="onResizeStart"
492
+ title="Drag to resize"
493
+ />
494
+ <header class="drawer-header">
495
+ <div class="head-line">
496
+ <code
497
+ class="head-id"
498
+ :class="{ 'is-copied': copiedFlash === 'head-id' }"
499
+ :title="`Click to copy · ${bead.id}`"
500
+ @click="copyIdFromHeader"
501
+ >{{ copiedFlash === 'head-id' ? 'copied ✓' : bead.id }}</code>
502
+ <PriorityChip :priority="bead.priority" />
503
+ <span class="pill" :class="`status-${bead.status}`">
504
+ {{ bead.status.replace(/_/g, " ") }}
505
+ </span>
506
+ <TypeChip :type="bead.issue_type" />
507
+ <div class="spacer" />
508
+ <!-- The keyboard route to the quick actions (bp-ocs); the
509
+ menu anchors under this button. -->
510
+ <button
511
+ class="btn ghost actions-btn"
512
+ type="button"
513
+ aria-label="Actions: claim, assign, spec:ready, label, defer"
514
+ title="Actions (a)"
515
+ aria-haspopup="menu"
516
+ @click="(e) => { const r = (e.currentTarget as HTMLElement).getBoundingClientRect(); store.openContextMenu(bead!.id, r.left, r.bottom + 4); }"
517
+ >
518
+ <MoreHorizontal class="icon" />
519
+ </button>
520
+ <button
521
+ class="btn ghost watch-toggle"
522
+ :class="{ 'is-watched': isWatched }"
523
+ :aria-pressed="isWatched"
524
+ :aria-label="isWatched ? 'Unwatch this bead' : 'Watch this bead'"
525
+ :title="isWatched ? 'Remove from watch list' : 'Add to watch list'"
526
+ @click="store.toggleWatch(bead.id)"
527
+ >
528
+ <component :is="isWatched ? PinOff : Pin" class="icon" />
529
+ </button>
530
+ <button class="btn ghost" @click="close" aria-label="Close">
531
+ <X class="icon" />
532
+ </button>
533
+ </div>
534
+ <h2 class="drawer-title">{{ bead.title }}</h2>
535
+ </header>
536
+
537
+ <div class="drawer-body">
538
+ <!-- Actions row -->
539
+ <div class="action-row">
540
+ <button class="btn" @click="copyId">
541
+ <Copy class="icon" />
542
+ <span v-if="copiedFlash === 'id'" class="flash">Copied ✓</span>
543
+ <span v-else>Copy id</span>
544
+ </button>
545
+ <button class="btn" @click="copyJson">
546
+ <Copy class="icon" />
547
+ <span v-if="copiedFlash === 'json'" class="flash">Copied ✓</span>
548
+ <span v-else>Copy JSON</span>
549
+ </button>
550
+ </div>
551
+
552
+ <!-- Triage score panel — breakdown table hidden by default,
553
+ shown behind an expand link. -->
554
+ <section v-if="triage" class="section triage-section">
555
+ <div class="triage-summary">
556
+ <span class="score-value">{{ fmtPct(triage.score) }}</span>
557
+ <Term class="score-label" :note="explainScore()">triage score</Term>
558
+ <button
559
+ type="button"
560
+ class="btn btn-subtle triage-toggle"
561
+ @click="triageOpen = !triageOpen"
562
+ >
563
+ <ChevronRight
564
+ class="icon chevron"
565
+ :class="{ 'is-open': triageOpen }"
566
+ />
567
+ {{ triageOpen ? "hide breakdown" : "show breakdown" }}
568
+ </button>
569
+ </div>
570
+ <!-- The score in its population (bp-67g.28): percentile line and a
571
+ histogram of the open beads' scores with this bead marked.
572
+ Always shown; the per-signal breakdown stays behind the toggle. -->
573
+ <ScoreStrip :population="store.triageScorePopulation" :score="triage.score" />
574
+ <TriageMeters
575
+ v-if="triageOpen"
576
+ :breakdown="triage.breakdown"
577
+ :population="store.triageSignalPopulation"
578
+ />
579
+ </section>
580
+
581
+ <!-- Subgraph. An epic gets the graph of all its children; the
582
+ hop-bounded neighbourhood is for everything else, and for an
583
+ epic that has blocks edges of its own (see showChildrenGraph). -->
584
+ <section v-if="showChildrenGraph" class="section">
585
+ <h3 class="with-action">
586
+ Subgraph
587
+ <span class="hint">(all {{ childrenList.length }} children)</span>
588
+ <button
589
+ class="btn ghost expand-btn"
590
+ type="button"
591
+ title="Show full screen"
592
+ aria-label="Show the children graph full screen"
593
+ @click="childrenGraphFullscreen = true"
594
+ >
595
+ <Maximize2 class="icon" />
596
+ </button>
597
+ </h3>
598
+ <BeadSubGraphOverlay
599
+ v-if="childrenGraphFullscreen"
600
+ :epic="bead"
601
+ :beads="store.beads"
602
+ :child-count="childrenList.length"
603
+ @close="childrenGraphFullscreen = false"
604
+ @select="selectFromSubgraph"
605
+ />
606
+ <BeadSubGraph
607
+ :root-bead="bead"
608
+ :beads="store.beads"
609
+ mode="children"
610
+ @select="selectFromSubgraph"
611
+ />
612
+ </section>
613
+ <section v-if="showNeighbourhood" class="section">
614
+ <h3 :class="{ 'with-action': canZoomOut }">
615
+ Subgraph
616
+ <span class="hint">({{ subgraphHint }})</span>
617
+ <div
618
+ v-if="canZoomOut"
619
+ class="scope-toggle"
620
+ role="group"
621
+ aria-label="Subgraph scope"
622
+ >
623
+ <button
624
+ type="button"
625
+ :class="{ 'is-active': subgraphScope === 'bead' }"
626
+ :aria-pressed="subgraphScope === 'bead'"
627
+ title="This bead's blockers and dependents, up to 4 hops"
628
+ @click="subgraphScope = 'bead'"
629
+ >this bead</button>
630
+ <button
631
+ type="button"
632
+ :class="{ 'is-active': subgraphScope === 'epic' }"
633
+ :aria-pressed="subgraphScope === 'epic'"
634
+ title="Every child of the parent epic, with this bead marked"
635
+ @click="subgraphScope = 'epic'"
636
+ >whole epic</button>
637
+ </div>
638
+ </h3>
639
+ <BeadSubGraph
640
+ v-if="canZoomOut && subgraphScope === 'epic' && parentBead"
641
+ :root-bead="parentBead"
642
+ :beads="store.beads"
643
+ mode="children"
644
+ :focal-id="bead.id"
645
+ @select="selectFromSubgraph"
646
+ />
647
+ <BeadSubGraph
648
+ v-else
649
+ :root-bead="bead"
650
+ :beads="store.beads"
651
+ @select="selectFromSubgraph"
652
+ />
653
+ </section>
654
+
655
+ <!-- Dossier -->
656
+ <section class="section">
657
+ <h3>Details</h3>
658
+ <dl class="dossier">
659
+ <!-- The recorded assignee, or the scheduler's synthetic one
660
+ with a marker that explains itself (bp-67g.47). The Gantt
661
+ tooltip already showed the synthetic handle; the drawer
662
+ showing no assignee for the same bead read as the two
663
+ disagreeing. -->
664
+ <template v-if="bead.assignee || detail?.assignee">
665
+ <dt>Assignee</dt>
666
+ <dd>
667
+ <code>{{ bead.assignee ?? detail?.assignee }}</code>
668
+ <Term
669
+ v-if="!bead.assignee && detail?.syntheticAssignee"
670
+ class="hint synth-mark"
671
+ :note="SYNTHETIC_ASSIGNEE_NOTE"
672
+ >inferred by the scheduler</Term>
673
+ </dd>
674
+ </template>
675
+ <template v-if="bead.parent">
676
+ <dt>Parent</dt>
677
+ <dd>
678
+ <a href="#" @click.prevent="store.openBead(bead.parent!)">
679
+ <code>{{ bead.parent }}</code>
680
+ </a>
681
+ </dd>
682
+ </template>
683
+ <template v-if="(bead.labels?.length ?? 0) > 0">
684
+ <dt>Labels</dt>
685
+ <dd class="labels">
686
+ <span v-for="l in bead.labels" :key="l" class="label-pill">
687
+ {{ l }}
688
+ </span>
689
+ </dd>
690
+ </template>
691
+ <dt>Created</dt>
692
+ <dd>{{ formatDate(bead.created_at) }}</dd>
693
+ <dt>Updated</dt>
694
+ <dd>{{ formatDate(bead.updated_at) }}</dd>
695
+ <template v-if="bead.started_at">
696
+ <dt>Started</dt>
697
+ <dd>{{ formatDate(bead.started_at) }}</dd>
698
+ </template>
699
+ <template v-if="bead.closed_at">
700
+ <dt>Closed</dt>
701
+ <dd>{{ formatDate(bead.closed_at) }}</dd>
702
+ </template>
703
+ <!-- One row, not one per evidence form. A bead can carry an
704
+ MR and a commit, and it can carry a good MR alongside a
705
+ commit that was rejected — splitting those into separate
706
+ rows reads as separate events. -->
707
+ <template v-if="detail?.ship || detail?.shipProblem">
708
+ <dt>Ship</dt>
709
+ <dd class="ship-evidence">
710
+ <a
711
+ v-if="detail.ship?.mrUrl"
712
+ :href="detail.ship.mrUrl"
713
+ target="_blank"
714
+ rel="noopener"
715
+ >
716
+ {{ detail.ship.mrUrl }}
717
+ </a>
718
+ <code
719
+ v-if="detail.ship?.shortCommit"
720
+ :title="detail.ship.commit ?? undefined"
721
+ >
722
+ {{ detail.ship.shortCommit }}
723
+ </code>
724
+ <!-- Same badge vocabulary as the phase bar: a marker
725
+ means "not measured at close time", wherever it
726
+ appears. -->
727
+ <span
728
+ v-if="detail.ship && detail.ship.provenance.kind !== 'recorded'"
729
+ class="flow-derived"
730
+ >{{ detail.ship.provenance.kind }}</span>
731
+ <span v-if="detail.shipProblem" class="ship-problem">
732
+ {{ detail.shipProblem }}
733
+ </span>
734
+ </dd>
735
+ </template>
736
+ <template
737
+ v-if="detail?.ship && detail.ship.provenance.kind !== 'recorded'"
738
+ >
739
+ <dt></dt>
740
+ <dd class="ship-problem">
741
+ Ship evidence was
742
+ {{ detail.ship.provenance.kind }}, not recorded at
743
+ close<template v-if="shipNote">: {{ shipNote }}</template>.
744
+ </dd>
745
+ </template>
746
+ <template v-if="detail?.estimateSource">
747
+ <dt>Estimate</dt>
748
+ <dd>
749
+ <template v-if="detail.estimateDays !== null">
750
+ {{ formatEstimate(detail.estimateDays) }} ·
751
+ </template>
752
+ <Term v-if="estimateNote" class="hint" :note="estimateNote">{{ detail.estimateSource }}</Term>
753
+ <span v-else class="hint">{{ detail.estimateSource }}</span>
754
+ </dd>
755
+ </template>
756
+ <!-- Same dates the Gantt tooltip shows, from the same
757
+ buildBeadDetail call — a date in one place and a day
758
+ offset in the other is worse than either (bp-67g.38). -->
759
+ <template v-if="detail?.scheduled">
760
+ <!-- Only for work that has NOT begun. An in-flight bead
761
+ already has a measured `Started` row above, and the
762
+ scheduler's start for it is the same date — two rows
763
+ labelled "Started" showing the same day in two formats
764
+ read as a bug, which is what they were. -->
765
+ <template v-if="!detail.scheduled.inFlight">
766
+ <dt><Term :note="EXPLAIN_STARTS">Starts</Term></dt>
767
+ <dd>
768
+ {{ formatDay(detail.scheduled.startDate) }}
769
+ <span class="hint">{{ detail.scheduled.spanDays }}d</span>
770
+ </dd>
771
+ </template>
772
+ <dt>
773
+ <Term :note="explainFinish(detail.scheduled)">
774
+ {{ detail.scheduled.beyondHorizon ? "Finishes after" : "Projected finish" }}
775
+ </Term>
776
+ </dt>
777
+ <dd>
778
+ <template v-if="detail.scheduled.beyondHorizon">
779
+ <span class="beyond">
780
+ {{ formatDay(detail.scheduled.endDate) }} — the end of
781
+ the scheduling window, not a projection
782
+ </span>
783
+ </template>
784
+ <template v-else>
785
+ {{ formatDay(detail.scheduled.endDate) }}
786
+ </template>
787
+ <!-- The span moves here when the start row is gone, so
788
+ it appears exactly once either way. The separator is
789
+ load-bearing: "Thu, Sep 3" followed by "1d" reads as
790
+ "Sep 31" without it. -->
791
+ <template v-if="detail.scheduled.inFlight">
792
+ ·
793
+ <span class="hint">{{ detail.scheduled.spanDays }}d</span>
794
+ </template>
795
+ <!-- Two clocks (bp-m0g): the work, and the waiting the
796
+ landing includes. -->
797
+ <span v-if="detail.scheduled.workDays !== null" class="hint work-wait">
798
+ · work {{ formatEstimate(detail.scheduled.workDays) }}<template v-if="detail.scheduled.waitBeforeDays + detail.scheduled.waitAfterDays > 0.01">
799
+ · waiting {{ formatEstimate(detail.scheduled.waitBeforeDays + detail.scheduled.waitAfterDays) }}</template>
800
+ </span>
801
+ </dd>
802
+ </template>
803
+ </dl>
804
+ </section>
805
+
806
+ <!-- What if… (bp-q56): reassign, reprioritise, mark ready — in
807
+ the what-if plan, not in bd. -->
808
+ <section v-if="bead.status !== 'closed'" class="section whatif-section">
809
+ <h3>What if…</h3>
810
+ <div class="whatif-row">
811
+ <label>
812
+ <span>assign to</span>
813
+ <select v-model="wiAssignee">
814
+ <option value="">unassigned</option>
815
+ <option v-for="h in whatIfHandles" :key="h" :value="h">{{ h }}</option>
816
+ </select>
817
+ </label>
818
+ <button type="button" class="btn btn-subtle" :disabled="(wiAssignee || null) === (bead.assignee ?? null)" @click="store.addWhatIf({ kind: 'reassign', id: bead.id, assignee: wiAssignee || null })">stage</button>
819
+ </div>
820
+ <div class="whatif-row">
821
+ <label>
822
+ <span>priority</span>
823
+ <select v-model.number="wiPriority">
824
+ <option v-for="p in [0, 1, 2, 3, 4]" :key="p" :value="p">P{{ p }}</option>
825
+ </select>
826
+ </label>
827
+ <button type="button" class="btn btn-subtle" :disabled="wiPriority === (bead.priority ?? 4)" @click="store.addWhatIf({ kind: 'priority', id: bead.id, priority: wiPriority })">stage</button>
828
+ </div>
829
+ <div class="whatif-row">
830
+ <span class="whatif-label">spec:ready</span>
831
+ <button type="button" class="btn btn-subtle" @click="store.addWhatIf({ kind: 'spec-ready', id: bead.id, ready: !(bead.labels ?? []).includes('spec:ready') })">
832
+ {{ (bead.labels ?? []).includes('spec:ready') ? "stage: remove" : "stage: mark" }}
833
+ </button>
834
+ </div>
835
+ <p v-if="stagedHere.length" class="hint">{{ stagedHere.length }} change{{ stagedHere.length === 1 ? "" : "s" }} staged for this bead — see the what-if panel.</p>
836
+ <p v-else class="hint">Staged changes re-plan everything without writing to bd; the panel shows what moves.</p>
837
+ </section>
838
+
839
+ <!-- Economics. Recorded on a minority of beads, so the whole
840
+ section is absent rather than showing empty rows — but the
841
+ queue wait is derivable from timestamps the tracker already
842
+ holds, which is enough to draw on far more of them. -->
843
+ <section v-if="econ && (econ.hasAny || econ.hasDerived || knownStages > 1)" class="section">
844
+ <h3>Economics</h3>
845
+
846
+ <div v-if="flowSegments.length" class="flow">
847
+ <div class="flow-bar">
848
+ <div
849
+ v-for="seg in flowSegments"
850
+ :key="seg.key"
851
+ class="flow-seg"
852
+ :class="[`seg-${seg.key}`, { 'is-derived': seg.derived }]"
853
+ :style="{ width: seg.pct + '%' }"
854
+ :title="`${seg.label} — ${formatHours(seg.hours)}${
855
+ seg.derived ? ' (derived, not recorded)' : ''
856
+ }`"
857
+ />
858
+ </div>
859
+ <ul class="flow-key">
860
+ <li v-for="seg in flowSegments" :key="seg.key">
861
+ <span
862
+ class="flow-swatch"
863
+ :class="[`seg-${seg.key}`, { 'is-derived': seg.derived }]"
864
+ />
865
+ <Term class="flow-label" :note="PHASE_EXPLANATIONS[seg.phase]">{{ seg.label }}</Term>
866
+ <!-- Marked on the row, not only by the hatch, so the
867
+ distinction survives a colour-blind reader and a
868
+ greyscale print. -->
869
+ <span v-if="seg.tag" class="flow-derived">{{ seg.tag }}</span>
870
+ <span class="flow-hours">{{ formatHours(seg.hours) }}</span>
871
+ <span class="flow-pct">{{ seg.pct.toFixed(0) }}%</span>
872
+ </li>
873
+ </ul>
874
+ <p class="flow-total">
875
+ {{ formatHours(econ.elapsedHours ?? 0) }} from created to
876
+ {{ econ.elapsedOngoing ? "now, still open" : "closed" }}
877
+ <em v-if="econ.overAccounted">
878
+ · recorded phases exceed that span
879
+ </em>
880
+ </p>
881
+ <!-- What "derived" means, once, rather than per row. Nobody
882
+ measured this bead; the queue wait is the subtraction a
883
+ ship-record would have done at close time. -->
884
+ <p
885
+ v-for="(n, i) in provenanceNotes"
886
+ :key="i"
887
+ class="econ-note"
888
+ >
889
+ {{ n.fields.join(" and ") }}
890
+ {{ n.plural ? "were" : "was" }} {{ n.kind }}, not
891
+ measured<template v-if="n.detail">:
892
+ <code>{{ n.detail }}</code></template
893
+ >.
894
+ </p>
895
+ </div>
896
+
897
+ <!-- Stage boundaries (bp-7c9): filed, assigned, started, first
898
+ MR, first QA, merged. Shown whenever more than filed is
899
+ known; an absent boundary is printed as absent, because the
900
+ tracker never sees an MR open or QA begin unless the close
901
+ script writes it. -->
902
+ <dl v-if="knownStages > 1" class="stages">
903
+ <template v-for="st in stages" :key="st.id">
904
+ <dt><Term :note="STAGE_EXPLANATIONS[st.id]">{{ STAGE_LABELS[st.id] }}</Term></dt>
905
+ <dd>
906
+ <template v-if="st.at">
907
+ {{ fmtStamp(st.at) }}
908
+ <span v-if="st.provenance && st.provenance.kind !== 'recorded'" class="flow-derived">{{ st.provenance.kind }}</span>
909
+ </template>
910
+ <span v-else class="stage-absent">not recorded</span>
911
+ </dd>
912
+ </template>
913
+ </dl>
914
+ <p v-if="spans.length" class="stage-spans">
915
+ <template v-for="(sp, i) in spans" :key="sp.from + sp.to">
916
+ <template v-if="i"> · </template>{{ STAGE_LABELS[sp.from] }} → {{ STAGE_LABELS[sp.to] }} {{ formatHours(sp.hours) }}<template v-if="sp.derived"> (derived)</template>
917
+ </template>
918
+ </p>
919
+
920
+ <!-- Absent work data is stated, because a bead with no logged
921
+ sessions did not take zero hours. -->
922
+ <p v-if="econ.workedHours === null" class="econ-note">
923
+ Work hours not recorded for this bead.
924
+ </p>
925
+
926
+ <div v-if="econ.seats.length" class="econ-seats">
927
+ <div v-for="s in econ.seats" :key="s.seat" class="econ-seat">
928
+ <div class="econ-seat-head">
929
+ <code>{{ s.seat }}</code>
930
+ <span class="econ-seat-hours">{{ formatHours(s.hours) }}</span>
931
+ </div>
932
+ <div
933
+ v-for="(sess, i) in s.sessions"
934
+ :key="i"
935
+ class="econ-session"
936
+ >
937
+ <span class="econ-session-date">{{ sess.date ?? "undated" }}</span>
938
+ <span class="econ-session-human">{{ sess.human ?? "—" }}</span>
939
+ <span class="econ-session-hours">{{ formatHours(sess.hours) }}</span>
940
+ </div>
941
+ </div>
942
+ </div>
943
+
944
+ <dl
945
+ v-if="econ.totals.agentHours !== null || econ.totals.tokensIn !== null || econ.totals.tokensOut !== null"
946
+ class="econ-totals"
947
+ >
948
+ <template v-if="econ.totals.agentHours !== null">
949
+ <dt>AGENT HOURS</dt>
950
+ <dd>{{ formatHours(econ.totals.agentHours) }}</dd>
951
+ </template>
952
+ <template v-if="econ.totals.tokensIn !== null">
953
+ <dt>TOKENS IN</dt>
954
+ <dd>{{ econ.totals.tokensIn.toLocaleString() }}</dd>
955
+ </template>
956
+ <template v-if="econ.totals.tokensOut !== null">
957
+ <dt>TOKENS OUT</dt>
958
+ <dd>{{ econ.totals.tokensOut.toLocaleString() }}</dd>
959
+ </template>
960
+ </dl>
961
+ </section>
962
+
963
+ <!-- Dependencies -->
964
+ <section
965
+ v-if="blockedByList.length || blocksList.length || childrenList.length"
966
+ class="section"
967
+ >
968
+ <h3>Dependencies</h3>
969
+ <div v-if="blockedByList.length" class="dep-list">
970
+ <div class="dep-heading">
971
+ {{ blockedByHeading(blockedByList).prefix
972
+ }}<Term :note="blockedByHeading(blockedByList).explain"><em>{{ blockedByHeading(blockedByList).tense }}</em></Term>{{ blockedByHeading(blockedByList).suffix }}
973
+ </div>
974
+ <div
975
+ class="dep-row"
976
+ v-for="d in blockedByList"
977
+ :key="d.id"
978
+ :class="{ 'is-spent': edgeIsSpent(d) }"
979
+ >
980
+ <a href="#" @click.prevent="store.openBead(d.id)">
981
+ <code>{{ d.id }}</code>
982
+ </a>
983
+ <span class="dep-title">{{ d.title }}</span>
984
+ <span class="pill status" :class="`status-${d.status}`">
985
+ {{ d.status.replace(/_/g, " ") }}
986
+ </span>
987
+ </div>
988
+ </div>
989
+ <div v-if="childrenList.length" class="dep-list">
990
+ <div class="dep-heading">Children:</div>
991
+ <div class="dep-row" v-for="d in childrenList" :key="d.id">
992
+ <a href="#" @click.prevent="store.openBead(d.id)">
993
+ <code>{{ d.id }}</code>
994
+ </a>
995
+ <span class="dep-title">{{ d.title }}</span>
996
+ <span class="pill status" :class="`status-${d.status}`">
997
+ {{ d.status.replace(/_/g, " ") }}
998
+ </span>
999
+ </div>
1000
+ </div>
1001
+ <div v-if="blocksList.length" class="dep-list">
1002
+ <div class="dep-heading">
1003
+ {{ blocksHeading(bead.status).prefix
1004
+ }}<Term :note="blocksHeading(bead.status).explain"><em>{{ blocksHeading(bead.status).tense }}</em></Term>{{ blocksHeading(bead.status).suffix }}
1005
+ </div>
1006
+ <div
1007
+ class="dep-row"
1008
+ v-for="d in blocksList"
1009
+ :key="d.id"
1010
+ :class="{ 'is-spent': edgeIsSpent(d) }"
1011
+ >
1012
+ <a href="#" @click.prevent="store.openBead(d.id)">
1013
+ <code>{{ d.id }}</code>
1014
+ </a>
1015
+ <span class="dep-title">{{ d.title }}</span>
1016
+ <span class="pill status" :class="`status-${d.status}`">
1017
+ {{ d.status.replace(/_/g, " ") }}
1018
+ </span>
1019
+ </div>
1020
+ </div>
1021
+ </section>
1022
+
1023
+ <!-- Description rendered as markdown → HTML. Bead descriptions
1024
+ come from our own bd DB so we don't run a sanitizer; if the
1025
+ source ever becomes untrusted, pipe through DOMPurify. -->
1026
+ <section v-if="bead.description" class="section description-section">
1027
+ <h3>Description</h3>
1028
+ <div class="description markdown-body" v-html="descriptionHtml" />
1029
+ </section>
1030
+ </div>
1031
+ </aside>
1032
+ </div>
1033
+ </transition>
1034
+ </Teleport>
1035
+ </template>
1036
+
1037
+ <style>
1038
+ .bead-drawer-root {
1039
+ position: fixed;
1040
+ inset: 0;
1041
+ z-index: 500;
1042
+ pointer-events: none;
1043
+ }
1044
+
1045
+ .drawer-backdrop {
1046
+ position: absolute;
1047
+ inset: 0;
1048
+ background: var(--c-scrim);
1049
+ pointer-events: auto;
1050
+ }
1051
+
1052
+ .drawer {
1053
+ position: absolute;
1054
+ top: 0;
1055
+ right: 0;
1056
+ bottom: 0;
1057
+ background: var(--c-panel);
1058
+ border-left: 1px solid var(--c-border);
1059
+ color: var(--c-text);
1060
+ font-size: 12px;
1061
+ line-height: 1.5;
1062
+ overflow: hidden;
1063
+ display: flex;
1064
+ flex-direction: column;
1065
+ pointer-events: auto;
1066
+ box-shadow: var(--shadow-drawer);
1067
+ font-family:
1068
+ ui-sans-serif,
1069
+ -apple-system,
1070
+ Segoe UI,
1071
+ Roboto,
1072
+ sans-serif;
1073
+ }
1074
+
1075
+ .bead-drawer-root.is-fullscreen .drawer {
1076
+ width: 100vw !important;
1077
+ border-left: none;
1078
+ }
1079
+
1080
+ /* Resize handle: a 6px hit-target on the drawer's left edge. Widens on
1081
+ hover and while dragging so the affordance is discoverable. */
1082
+ .resize-handle {
1083
+ position: absolute;
1084
+ top: 0;
1085
+ left: 0;
1086
+ bottom: 0;
1087
+ width: 6px;
1088
+ cursor: col-resize;
1089
+ z-index: 2;
1090
+ background: transparent;
1091
+ transition: background 0.1s ease-out;
1092
+ }
1093
+
1094
+ .resize-handle:hover,
1095
+ .bead-drawer-root.is-resizing .resize-handle {
1096
+ background: var(--c-accent);
1097
+ opacity: 0.6;
1098
+ }
1099
+
1100
+ .bead-drawer-root.is-resizing {
1101
+ cursor: col-resize;
1102
+ user-select: none;
1103
+ }
1104
+
1105
+ .drawer-enter-active,
1106
+ .drawer-leave-active {
1107
+ transition: opacity 0.15s ease-out;
1108
+ }
1109
+ .drawer-enter-active .drawer,
1110
+ .drawer-leave-active .drawer {
1111
+ transition: transform 0.18s cubic-bezier(0.2, 0.9, 0.3, 1);
1112
+ }
1113
+ .drawer-enter-from,
1114
+ .drawer-leave-to {
1115
+ opacity: 0;
1116
+ }
1117
+ .drawer-enter-from .drawer,
1118
+ .drawer-leave-to .drawer {
1119
+ transform: translateX(100%);
1120
+ }
1121
+
1122
+ .watch-toggle.is-watched {
1123
+ color: var(--c-accent);
1124
+ }
1125
+
1126
+ .drawer-header {
1127
+ padding: 14px 18px 12px;
1128
+ border-bottom: 1px solid var(--c-border);
1129
+ background: var(--c-panel);
1130
+ }
1131
+
1132
+ .head-line {
1133
+ display: flex;
1134
+ align-items: center;
1135
+ gap: 6px;
1136
+ flex-wrap: wrap;
1137
+ }
1138
+
1139
+ .spacer {
1140
+ flex: 1;
1141
+ }
1142
+
1143
+ .head-id {
1144
+ color: var(--c-accent);
1145
+ font-family: ui-monospace, monospace;
1146
+ font-size: 12px;
1147
+ cursor: pointer;
1148
+ padding: 1px 4px;
1149
+ border-radius: 3px;
1150
+ transition: background 0.08s ease-out;
1151
+ }
1152
+
1153
+ .head-id:hover {
1154
+ background: var(--c-accent-wash-strong);
1155
+ }
1156
+
1157
+ .head-id.is-copied {
1158
+ color: var(--c-shipped);
1159
+ font-weight: 600;
1160
+ }
1161
+
1162
+ .pill {
1163
+ font-size: 10px;
1164
+ padding: 1px 6px;
1165
+ border-radius: 2px;
1166
+ border: 1px solid var(--c-border);
1167
+ color: var(--c-dim);
1168
+ text-transform: lowercase;
1169
+ }
1170
+ .pill.pri-p0,
1171
+ .pill.pri-p1 { color: var(--c-danger-bright); border-color: var(--c-danger-bright); }
1172
+ .pill.pri-p2 { color: var(--c-warning); border-color: var(--c-warning); }
1173
+ .pill.status-closed { color: var(--c-shipped); border-color: var(--c-shipped-stroke); }
1174
+ .pill.status-in_progress { color: var(--c-in-progress); border-color: var(--c-in-progress); }
1175
+ .pill.status-deferred { color: var(--c-dim); }
1176
+
1177
+ .drawer-title {
1178
+ margin: 8px 0 0;
1179
+ font-size: 15px;
1180
+ font-weight: 600;
1181
+ color: var(--c-text);
1182
+ overflow-wrap: anywhere;
1183
+ }
1184
+
1185
+ /* The drawer's single scroll container. The description used to be a second
1186
+ one nested inside it (max-height 480 + overflow auto), so the wheel went to
1187
+ whichever box was under the cursor and a long description was read through
1188
+ a porthole while the fields above it stayed put. Now everything flows and
1189
+ the metadata scrolls away, leaving the description the full height.
1190
+
1191
+ overscroll-behavior stops a wheel past either end from chaining to the
1192
+ Gantt behind the drawer. */
1193
+ .drawer-body {
1194
+ overflow: auto;
1195
+ overscroll-behavior: contain;
1196
+ padding: 16px 18px 24px;
1197
+ flex: 1;
1198
+ }
1199
+
1200
+ /* Keeps "Description" visible once the metadata has scrolled off, so a long
1201
+ body still says what it is. Sticks to the top of .drawer-body's viewport;
1202
+ the negative offsets let it span the body's padding so text doesn't show
1203
+ through beside it. */
1204
+ .section.description-section > h3 {
1205
+ position: sticky;
1206
+ top: -16px;
1207
+ z-index: 1;
1208
+ margin: -16px -18px 10px;
1209
+ padding: 16px 18px 8px;
1210
+ background: var(--c-panel);
1211
+ }
1212
+
1213
+ .action-row {
1214
+ display: flex;
1215
+ gap: 6px;
1216
+ flex-wrap: wrap;
1217
+ margin-bottom: 18px;
1218
+ }
1219
+
1220
+ /* Real button styling — filled background, visible border, hover state
1221
+ with slight lift. The tooltip's action row uses the same class so
1222
+ affordances read the same way. */
1223
+ .btn {
1224
+ background: var(--c-panel-2);
1225
+ border: 1px solid var(--c-border);
1226
+ color: var(--c-text);
1227
+ font-size: 11px;
1228
+ font-weight: 500;
1229
+ padding: 6px 12px;
1230
+ border-radius: 4px;
1231
+ cursor: pointer;
1232
+ font-family: inherit;
1233
+ text-decoration: none;
1234
+ display: inline-flex;
1235
+ align-items: center;
1236
+ gap: 5px;
1237
+ line-height: 1.2;
1238
+ transition: background 0.08s ease-out, border-color 0.08s ease-out, color 0.08s ease-out;
1239
+ }
1240
+ .btn:hover {
1241
+ background: var(--c-accent-wash-strong);
1242
+ border-color: var(--c-accent);
1243
+ color: var(--c-accent);
1244
+ }
1245
+ .btn:active {
1246
+ background: var(--c-accent-wash-strong);
1247
+ }
1248
+ .btn.btn-primary {
1249
+ background: var(--c-accent);
1250
+ color: var(--c-bg);
1251
+ border-color: var(--c-accent);
1252
+ }
1253
+ .btn.btn-primary:hover {
1254
+ background: var(--c-accent);
1255
+ color: var(--c-bg);
1256
+ opacity: 0.85;
1257
+ }
1258
+ .btn.btn-subtle {
1259
+ background: transparent;
1260
+ border-color: transparent;
1261
+ color: var(--c-dim);
1262
+ padding: 4px 8px;
1263
+ font-weight: 400;
1264
+ }
1265
+ .btn.btn-subtle:hover {
1266
+ background: var(--c-accent-wash);
1267
+ color: var(--c-accent);
1268
+ border-color: transparent;
1269
+ }
1270
+ .btn.ghost {
1271
+ background: transparent;
1272
+ border-color: transparent;
1273
+ color: var(--c-dim);
1274
+ padding: 4px;
1275
+ }
1276
+ .btn.ghost:hover {
1277
+ background: var(--c-dim-wash);
1278
+ color: var(--c-text);
1279
+ border-color: transparent;
1280
+ }
1281
+
1282
+ .btn .flash {
1283
+ color: var(--c-shipped);
1284
+ font-weight: 600;
1285
+ }
1286
+
1287
+ .chevron {
1288
+ transition: transform 0.15s ease-out;
1289
+ }
1290
+ .chevron.is-open {
1291
+ transform: rotate(90deg);
1292
+ }
1293
+
1294
+ .section {
1295
+ margin-bottom: 22px;
1296
+ }
1297
+
1298
+ .section h3 {
1299
+ font-size: 11px;
1300
+ text-transform: uppercase;
1301
+ letter-spacing: 0.05em;
1302
+ color: var(--c-dim);
1303
+ margin: 0 0 10px;
1304
+ font-weight: 600;
1305
+ }
1306
+
1307
+ .section h3 .hint {
1308
+ color: var(--c-dim);
1309
+ font-weight: 400;
1310
+ text-transform: none;
1311
+ letter-spacing: 0;
1312
+ font-size: 10px;
1313
+ margin-left: 4px;
1314
+ }
1315
+
1316
+ /* A section heading that carries a control at its right edge. */
1317
+ .section h3.with-action {
1318
+ display: flex;
1319
+ align-items: center;
1320
+ }
1321
+
1322
+ /* bp-67g.60: the scope toggle sits where the epic's expand button does. */
1323
+ .section h3 .scope-toggle {
1324
+ margin-left: auto;
1325
+ display: inline-flex;
1326
+ gap: 2px;
1327
+ font-weight: 400;
1328
+ text-transform: none;
1329
+ letter-spacing: 0;
1330
+ }
1331
+
1332
+ .section h3 .scope-toggle button {
1333
+ font: inherit;
1334
+ font-size: 10px;
1335
+ padding: 1px 7px;
1336
+ border: 1px solid var(--c-border);
1337
+ border-radius: 3px;
1338
+ background: transparent;
1339
+ color: var(--c-dim);
1340
+ cursor: pointer;
1341
+ }
1342
+
1343
+ .section h3 .scope-toggle button.is-active {
1344
+ color: var(--c-accent);
1345
+ border-color: var(--c-accent);
1346
+ background: var(--c-accent-wash);
1347
+ }
1348
+
1349
+ .section h3 .expand-btn {
1350
+ margin-left: auto;
1351
+ }
1352
+
1353
+ .section h3 .expand-btn .icon {
1354
+ width: 13px;
1355
+ height: 13px;
1356
+ }
1357
+
1358
+ .triage-section {
1359
+ margin-bottom: 18px;
1360
+ }
1361
+
1362
+ .triage-summary {
1363
+ display: flex;
1364
+ align-items: baseline;
1365
+ gap: 10px;
1366
+ flex-wrap: wrap;
1367
+ }
1368
+
1369
+ .score-value {
1370
+ font-size: 20px;
1371
+ font-weight: 700;
1372
+ color: var(--c-accent);
1373
+ font-variant-numeric: tabular-nums;
1374
+ }
1375
+
1376
+ .score-label {
1377
+ color: var(--c-dim);
1378
+ font-size: 11px;
1379
+ text-transform: uppercase;
1380
+ letter-spacing: 0.05em;
1381
+ }
1382
+
1383
+ .triage-toggle {
1384
+ background: transparent;
1385
+ border: none;
1386
+ color: var(--c-accent);
1387
+ font-size: 11px;
1388
+ cursor: pointer;
1389
+ padding: 0;
1390
+ margin-left: auto;
1391
+ font-family: inherit;
1392
+ }
1393
+ .triage-toggle:hover { text-decoration: underline; }
1394
+
1395
+ /* The breakdown itself is TriageMeters.vue's; nothing of the old table's
1396
+ styling survived it. */
1397
+
1398
+ /* A finish clamped to the horizon, not projected. */
1399
+ .beyond {
1400
+ color: var(--c-warning-strong);
1401
+ line-height: 1.4;
1402
+ }
1403
+
1404
+ .dossier {
1405
+ display: grid;
1406
+ grid-template-columns: 90px 1fr;
1407
+ gap: 4px 12px;
1408
+ margin: 0;
1409
+ }
1410
+
1411
+ /* Sits after the handle; the template newline between the two inline
1412
+ elements is condensed away, so the gap has to be a margin. */
1413
+ .dossier .synth-mark {
1414
+ margin-left: 6px;
1415
+ }
1416
+
1417
+ /* What if… (bp-q56) */
1418
+ .whatif-section .whatif-row {
1419
+ display: flex;
1420
+ align-items: center;
1421
+ gap: 8px;
1422
+ margin: 4px 0;
1423
+ font-size: 12px;
1424
+ }
1425
+ .whatif-section label {
1426
+ display: flex;
1427
+ align-items: center;
1428
+ gap: 6px;
1429
+ flex: 1;
1430
+ }
1431
+ .whatif-section label span,
1432
+ .whatif-section .whatif-label {
1433
+ font-size: 10px;
1434
+ text-transform: uppercase;
1435
+ letter-spacing: 0.05em;
1436
+ color: var(--c-dim);
1437
+ min-width: 70px;
1438
+ }
1439
+ .whatif-section .whatif-label {
1440
+ flex: 1;
1441
+ }
1442
+ .whatif-section select {
1443
+ font: inherit;
1444
+ padding: 3px 6px;
1445
+ border: 1px solid var(--c-border-strong);
1446
+ border-radius: 4px;
1447
+ background: var(--c-bg);
1448
+ color: var(--c-text);
1449
+ }
1450
+
1451
+ /* Stage boundaries (bp-7c9): the dossier's own grid, under the phase bar. */
1452
+ .stages {
1453
+ display: grid;
1454
+ grid-template-columns: 90px 1fr;
1455
+ gap: 2px 12px;
1456
+ margin: 10px 0 0;
1457
+ font-size: 12px;
1458
+ }
1459
+
1460
+ .stages dt {
1461
+ color: var(--c-dim);
1462
+ font-size: 10px;
1463
+ text-transform: uppercase;
1464
+ letter-spacing: 0.05em;
1465
+ padding-top: 2px;
1466
+ }
1467
+
1468
+ .stages dd {
1469
+ margin: 0;
1470
+ }
1471
+
1472
+ .stages .stage-absent {
1473
+ color: var(--c-dim);
1474
+ font-style: italic;
1475
+ }
1476
+
1477
+ .stage-spans {
1478
+ margin: 6px 0 0;
1479
+ font-size: 11px;
1480
+ color: var(--c-dim);
1481
+ line-height: 1.5;
1482
+ }
1483
+
1484
+ .dossier dt {
1485
+ color: var(--c-dim);
1486
+ font-size: 10px;
1487
+ text-transform: uppercase;
1488
+ letter-spacing: 0.05em;
1489
+ padding-top: 2px;
1490
+ }
1491
+
1492
+ .dossier dd {
1493
+ margin: 0;
1494
+ color: var(--c-text);
1495
+ overflow-wrap: anywhere;
1496
+ }
1497
+
1498
+ .dossier a {
1499
+ color: var(--c-accent);
1500
+ text-decoration: none;
1501
+ }
1502
+ .dossier a:hover { text-decoration: underline; }
1503
+
1504
+ .ship-evidence {
1505
+ display: flex;
1506
+ flex-wrap: wrap;
1507
+ align-items: baseline;
1508
+ gap: 4px 8px;
1509
+ }
1510
+
1511
+ /* The same green the Gantt paints a shipped bar with, so the SHA and the bar
1512
+ it explains agree. Every named style redefines --c-shipped at a comparable
1513
+ lightness, which is what makes it safe as text and not only as a fill. */
1514
+ .ship-evidence code {
1515
+ color: var(--c-shipped);
1516
+ }
1517
+
1518
+ /* A record that was written and did not count. Dim rather than alarming —
1519
+ it is a note to whoever writes the close command, not a problem with the
1520
+ bead. */
1521
+ .ship-problem {
1522
+ color: var(--c-dim);
1523
+ font-size: 11px;
1524
+ }
1525
+
1526
+ .labels {
1527
+ display: flex;
1528
+ flex-wrap: wrap;
1529
+ gap: 4px;
1530
+ }
1531
+
1532
+ .label-pill {
1533
+ background: var(--c-border);
1534
+ color: var(--c-dim);
1535
+ font-size: 10px;
1536
+ padding: 1px 6px;
1537
+ border-radius: 2px;
1538
+ }
1539
+
1540
+ .dep-list {
1541
+ margin-bottom: 12px;
1542
+ }
1543
+
1544
+ /* The tense word carries the meaning, so it's emphasised rather than
1545
+ buried in the sentence — and as markup, not asterisks in a string. */
1546
+ /* ── economics ────────────────────────────────────────────────────────────
1547
+ Phase colours are semantic, not decorative: green is time spent doing the
1548
+ work, blue is time in review, amber is time nobody had picked it up, grey
1549
+ is time the data doesn't explain. Amber dominating is the normal case and
1550
+ the point of showing this at all. */
1551
+ .flow-bar {
1552
+ display: flex;
1553
+ height: 10px;
1554
+ border-radius: 5px;
1555
+ overflow: hidden;
1556
+ background: var(--c-panel-2);
1557
+ margin-bottom: 10px;
1558
+ }
1559
+
1560
+ .flow-seg {
1561
+ height: 100%;
1562
+ /* A phase can round to a sliver; keep it visible rather than vanishing. */
1563
+ min-width: 2px;
1564
+ }
1565
+
1566
+ .flow-seg.seg-worked,
1567
+ .flow-swatch.seg-worked {
1568
+ background: var(--c-phase-worked);
1569
+ }
1570
+ .flow-seg.seg-review,
1571
+ .flow-swatch.seg-review {
1572
+ background: var(--c-phase-review);
1573
+ }
1574
+ .flow-seg.seg-unclaimed,
1575
+ .flow-swatch.seg-unclaimed {
1576
+ background: var(--c-phase-waiting);
1577
+ }
1578
+ .flow-seg.seg-other,
1579
+ .flow-swatch.seg-other {
1580
+ background: var(--c-phase-other);
1581
+ }
1582
+
1583
+ /* A derived figure, hatched rather than recoloured. The phase colours are
1584
+ semantic — amber IS queue wait — so provenance has to ride on a second
1585
+ channel or it would collide with the meaning already in the hue. The
1586
+ stripes are the panel colour showing through, so they read as "not solid"
1587
+ against every phase colour and in every named style without needing a
1588
+ variant per phase. Backed up by the word "derived" on the legend row,
1589
+ since a 10px bar's texture is not something to rely on alone.
1590
+
1591
+ Deliberately understated — at full strength the stripes read as hazard
1592
+ tape, and "we did this subtraction for you" is not a warning. */
1593
+ .flow-seg.is-derived,
1594
+ .flow-swatch.is-derived {
1595
+ background-image: repeating-linear-gradient(
1596
+ 135deg,
1597
+ color-mix(in oklch, var(--c-panel-2) 55%, transparent) 0 2px,
1598
+ transparent 2px 6px
1599
+ );
1600
+ }
1601
+
1602
+ .flow-derived {
1603
+ font-size: 9px;
1604
+ text-transform: uppercase;
1605
+ letter-spacing: 0.06em;
1606
+ color: var(--c-dim);
1607
+ border: 1px solid var(--c-border);
1608
+ border-radius: 3px;
1609
+ padding: 0 3px;
1610
+ line-height: 1.5;
1611
+ }
1612
+
1613
+ .flow-key {
1614
+ list-style: none;
1615
+ margin: 0 0 8px;
1616
+ padding: 0;
1617
+ display: flex;
1618
+ flex-direction: column;
1619
+ gap: 3px;
1620
+ }
1621
+
1622
+ .flow-key li {
1623
+ display: flex;
1624
+ align-items: center;
1625
+ gap: 8px;
1626
+ font-size: 11px;
1627
+ }
1628
+
1629
+ .flow-swatch {
1630
+ width: 9px;
1631
+ height: 9px;
1632
+ border-radius: 2px;
1633
+ flex: none;
1634
+ }
1635
+
1636
+ .flow-label {
1637
+ color: var(--c-dim);
1638
+ }
1639
+
1640
+ .flow-hours {
1641
+ margin-left: auto;
1642
+ font-variant-numeric: tabular-nums;
1643
+ color: var(--c-text);
1644
+ }
1645
+
1646
+ .flow-pct {
1647
+ width: 34px;
1648
+ text-align: right;
1649
+ font-variant-numeric: tabular-nums;
1650
+ color: var(--c-dim);
1651
+ }
1652
+
1653
+ .flow-total {
1654
+ margin: 0 0 10px;
1655
+ font-size: 11px;
1656
+ color: var(--c-dim);
1657
+ }
1658
+
1659
+ .flow-total em {
1660
+ font-style: normal;
1661
+ color: var(--c-warning);
1662
+ }
1663
+
1664
+ .econ-note {
1665
+ margin: 0 0 10px;
1666
+ font-size: 11px;
1667
+ color: var(--c-dim);
1668
+ font-style: italic;
1669
+ }
1670
+
1671
+ .econ-seat {
1672
+ margin-bottom: 8px;
1673
+ }
1674
+
1675
+ .econ-seat-head {
1676
+ display: flex;
1677
+ align-items: baseline;
1678
+ gap: 8px;
1679
+ font-size: 11px;
1680
+ padding-bottom: 2px;
1681
+ border-bottom: 1px solid var(--c-border);
1682
+ }
1683
+
1684
+ .econ-seat-head code {
1685
+ color: var(--c-accent);
1686
+ }
1687
+
1688
+ .econ-seat-hours {
1689
+ margin-left: auto;
1690
+ font-variant-numeric: tabular-nums;
1691
+ }
1692
+
1693
+ .econ-session {
1694
+ display: flex;
1695
+ gap: 8px;
1696
+ font-size: 11px;
1697
+ color: var(--c-dim);
1698
+ padding: 2px 0 2px 10px;
1699
+ }
1700
+
1701
+ .econ-session-hours {
1702
+ margin-left: auto;
1703
+ font-variant-numeric: tabular-nums;
1704
+ }
1705
+
1706
+ /* Was `class="detail-grid econ-totals"`, and .detail-grid is defined nowhere
1707
+ — so this dl rendered as a browser-default definition list, label above
1708
+ indented value. It never showed: only 3 beads in a 558-bead repo carried
1709
+ totals. Reconstructed hours put it on 31 of 78, and it had to be built. */
1710
+ .econ-totals {
1711
+ display: grid;
1712
+ grid-template-columns: auto 1fr;
1713
+ gap: 3px 12px;
1714
+ margin: 8px 0 0;
1715
+ font-size: 11px;
1716
+ }
1717
+
1718
+ .econ-totals dt {
1719
+ color: var(--c-dim);
1720
+ text-transform: uppercase;
1721
+ letter-spacing: 0.05em;
1722
+ font-size: 10px;
1723
+ align-self: baseline;
1724
+ }
1725
+
1726
+ .econ-totals dd {
1727
+ margin: 0;
1728
+ text-align: right;
1729
+ font-variant-numeric: tabular-nums;
1730
+ color: var(--c-text);
1731
+ }
1732
+
1733
+ .dep-heading em {
1734
+ font-style: normal;
1735
+ font-weight: 700;
1736
+ color: var(--c-text);
1737
+ }
1738
+
1739
+ /* An edge whose far end has closed no longer binds. Recedes so the live
1740
+ constraints are what stand out in a partially-cleared list. */
1741
+ .dep-row.is-spent {
1742
+ opacity: var(--o-spent-edge);
1743
+ }
1744
+
1745
+ .dep-heading {
1746
+ color: var(--c-dim);
1747
+ font-size: 10px;
1748
+ text-transform: uppercase;
1749
+ letter-spacing: 0.05em;
1750
+ margin-bottom: 6px;
1751
+ }
1752
+
1753
+ .dep-row {
1754
+ display: flex;
1755
+ align-items: center;
1756
+ gap: 8px;
1757
+ padding: 4px 0;
1758
+ border-bottom: 1px solid var(--c-border);
1759
+ }
1760
+
1761
+ .dep-row:last-child {
1762
+ border-bottom: none;
1763
+ }
1764
+
1765
+ .dep-row a {
1766
+ color: var(--c-accent);
1767
+ text-decoration: none;
1768
+ }
1769
+ .dep-row a:hover { text-decoration: underline; }
1770
+
1771
+ .dep-title {
1772
+ flex: 1;
1773
+ color: var(--c-text);
1774
+ overflow: hidden;
1775
+ text-overflow: ellipsis;
1776
+ white-space: nowrap;
1777
+ }
1778
+
1779
+ .description.markdown-body {
1780
+ background: var(--c-bg);
1781
+ border: 1px solid var(--c-border);
1782
+ border-radius: 3px;
1783
+ padding: 14px 16px;
1784
+ color: var(--c-text);
1785
+ font-size: 12px;
1786
+ line-height: 1.55;
1787
+ word-wrap: break-word;
1788
+ }
1789
+
1790
+ .markdown-body p {
1791
+ margin: 0 0 10px;
1792
+ }
1793
+ .markdown-body p:last-child { margin-bottom: 0; }
1794
+
1795
+ .markdown-body h1,
1796
+ .markdown-body h2,
1797
+ .markdown-body h3,
1798
+ .markdown-body h4 {
1799
+ color: var(--c-text);
1800
+ margin: 14px 0 6px;
1801
+ font-weight: 600;
1802
+ line-height: 1.3;
1803
+ }
1804
+ .markdown-body h1 { font-size: 15px; }
1805
+ .markdown-body h2 { font-size: 14px; }
1806
+ .markdown-body h3 { font-size: 13px; text-transform: none; letter-spacing: 0; color: var(--c-text); }
1807
+ .markdown-body h4 { font-size: 12px; color: var(--c-dim); }
1808
+
1809
+ .markdown-body ul,
1810
+ .markdown-body ol {
1811
+ margin: 0 0 10px;
1812
+ padding-left: 20px;
1813
+ }
1814
+
1815
+ .markdown-body li {
1816
+ margin-bottom: 3px;
1817
+ }
1818
+
1819
+ .markdown-body code {
1820
+ background: var(--c-panel-2);
1821
+ padding: 1px 5px;
1822
+ border-radius: 2px;
1823
+ font-family: ui-monospace, monospace;
1824
+ font-size: 11px;
1825
+ color: var(--c-accent);
1826
+ }
1827
+
1828
+ .markdown-body pre {
1829
+ background: var(--c-panel-2);
1830
+ border: 1px solid var(--c-border);
1831
+ border-radius: 3px;
1832
+ padding: 10px 12px;
1833
+ overflow: auto;
1834
+ margin: 0 0 10px;
1835
+ }
1836
+
1837
+ .markdown-body pre code {
1838
+ background: transparent;
1839
+ padding: 0;
1840
+ color: var(--c-text);
1841
+ font-size: 11px;
1842
+ }
1843
+
1844
+ .markdown-body blockquote {
1845
+ border-left: 3px solid var(--c-border);
1846
+ padding: 0 12px;
1847
+ color: var(--c-dim);
1848
+ margin: 0 0 10px;
1849
+ }
1850
+
1851
+ .markdown-body a {
1852
+ color: var(--c-accent);
1853
+ text-decoration: none;
1854
+ }
1855
+ .markdown-body a:hover { text-decoration: underline; }
1856
+
1857
+ .markdown-body strong { color: var(--c-text); font-weight: 600; }
1858
+ .markdown-body em { color: var(--c-text); font-style: italic; }
1859
+
1860
+ .markdown-body hr {
1861
+ border: none;
1862
+ border-top: 1px solid var(--c-border);
1863
+ margin: 12px 0;
1864
+ }
1865
+
1866
+ .markdown-body table {
1867
+ border-collapse: collapse;
1868
+ margin: 0 0 10px;
1869
+ font-size: 11px;
1870
+ }
1871
+
1872
+ .markdown-body th,
1873
+ .markdown-body td {
1874
+ padding: 4px 8px;
1875
+ border: 1px solid var(--c-border);
1876
+ }
1877
+
1878
+ .markdown-body th {
1879
+ background: var(--c-panel-2);
1880
+ color: var(--c-dim);
1881
+ text-align: left;
1882
+ }
1883
+ </style>