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.
- package/CHANGELOG.md +73 -0
- package/LICENSE +661 -0
- package/README.md +386 -0
- package/THIRD_PARTY_NOTICES.md +56 -0
- package/bin/beadcyte.mjs +60 -0
- package/package.json +77 -0
- package/src/changelog-cli.mjs +105 -0
- package/src/changelog.mjs +196 -0
- package/src/cli.mjs +577 -0
- package/src/estimator.mjs +314 -0
- package/src/format.mjs +22 -0
- package/src/history-walk.mjs +170 -0
- package/src/index.mjs +5 -0
- package/src/mutate.mjs +193 -0
- package/src/projects.mjs +120 -0
- package/src/provenance.mjs +75 -0
- package/src/review-hours.mjs +117 -0
- package/src/roster-path.mjs +24 -0
- package/src/scheduler.mjs +424 -0
- package/src/serve.mjs +411 -0
- package/src/server-state.mjs +105 -0
- package/src/ship.mjs +178 -0
- package/src/stage-waits.mjs +69 -0
- package/src/start.mjs +111 -0
- package/src/stop.mjs +66 -0
- package/src/velocity.mjs +209 -0
- package/src/web/App.vue +691 -0
- package/src/web/app.css +54 -0
- package/src/web/assets/favicon.svg +12 -0
- package/src/web/avatar.ts +53 -0
- package/src/web/bead-detail.ts +240 -0
- package/src/web/changelog-view.ts +41 -0
- package/src/web/components/BeadDrawer.vue +1883 -0
- package/src/web/components/BeadSubGraph.vue +326 -0
- package/src/web/components/BeadSubGraphOverlay.vue +192 -0
- package/src/web/components/BeadTooltip.vue +516 -0
- package/src/web/components/BeadcyteMark.vue +64 -0
- package/src/web/components/BeadsGantt.vue +2125 -0
- package/src/web/components/BeadsGrid.vue +468 -0
- package/src/web/components/BeadsIncytes.vue +567 -0
- package/src/web/components/BeadsMine.vue +325 -0
- package/src/web/components/BeadsTable.vue +335 -0
- package/src/web/components/ChangelogOverlay.vue +198 -0
- package/src/web/components/ContextMenu.vue +386 -0
- package/src/web/components/ControlsPanel.vue +476 -0
- package/src/web/components/CostTrend.vue +206 -0
- package/src/web/components/FilterPopover.vue +245 -0
- package/src/web/components/GroupProgress.vue +274 -0
- package/src/web/components/LoadMeter.vue +144 -0
- package/src/web/components/MineRow.vue +28 -0
- package/src/web/components/OptionsMenu.vue +825 -0
- package/src/web/components/PriorityChip.vue +105 -0
- package/src/web/components/ScoreStrip.vue +131 -0
- package/src/web/components/SearchPalette.vue +210 -0
- package/src/web/components/ShipTrend.vue +510 -0
- package/src/web/components/ShortcutsOverlay.vue +164 -0
- package/src/web/components/Term.vue +177 -0
- package/src/web/components/Toast.vue +50 -0
- package/src/web/components/TriageMeters.vue +426 -0
- package/src/web/components/TypeChip.vue +96 -0
- package/src/web/components/Walkthrough.vue +209 -0
- package/src/web/components/WhatIfPanel.vue +206 -0
- package/src/web/components/WipBullets.vue +191 -0
- package/src/web/components/filter-option.ts +9 -0
- package/src/web/composables/url-codec.ts +136 -0
- package/src/web/composables/useBeadTooltip.ts +148 -0
- package/src/web/composables/useKeyboard.ts +97 -0
- package/src/web/composables/useLiveRefresh.ts +69 -0
- package/src/web/composables/useTheme.ts +125 -0
- package/src/web/composables/useUrlState.ts +208 -0
- package/src/web/controls-scope.ts +83 -0
- package/src/web/cost.ts +251 -0
- package/src/web/dep-headings.ts +62 -0
- package/src/web/economics.ts +440 -0
- package/src/web/env.d.ts +85 -0
- package/src/web/frontier.ts +208 -0
- package/src/web/gantt-viewport.ts +99 -0
- package/src/web/highlights.ts +124 -0
- package/src/web/index.html +46 -0
- package/src/web/insights.ts +107 -0
- package/src/web/keybindings.ts +200 -0
- package/src/web/load-meter.ts +72 -0
- package/src/web/main.ts +20 -0
- package/src/web/markdown.ts +14 -0
- package/src/web/mine.ts +137 -0
- package/src/web/mutations.ts +21 -0
- package/src/web/person.ts +102 -0
- package/src/web/projects-text.ts +15 -0
- package/src/web/projects.ts +188 -0
- package/src/web/refresh.ts +47 -0
- package/src/web/search.ts +50 -0
- package/src/web/shortcuts.ts +113 -0
- package/src/web/status-filter.ts +48 -0
- package/src/web/store.ts +1378 -0
- package/src/web/style-audit.mjs +346 -0
- package/src/web/styles-alt.css +111 -0
- package/src/web/styles-ported.css +270 -0
- package/src/web/subgraph.ts +362 -0
- package/src/web/table.ts +201 -0
- package/src/web/theme.ts +88 -0
- package/src/web/tokens.css +168 -0
- package/src/web/triage.ts +914 -0
- package/src/web/view-model.ts +717 -0
- package/src/web/walkthrough.ts +133 -0
- package/src/web/watchlist.ts +47 -0
- package/src/web/whatif.ts +291 -0
- package/src/web/window.ts +73 -0
- 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>
|