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,245 @@
1
+ <script setup lang="ts" generic="T extends string | number">
2
+ // FilterPopover.vue — small multi-select popover for the controls panel.
3
+ //
4
+ // Renders a chip-style trigger button that opens a floating checkbox list.
5
+ // Options come from live data (parent enumerates unique values from the
6
+ // current beads); selecting nothing means "no filter." Closes on
7
+ // outside-click and Escape. Keyboard-navigable (tab into the button, enter/
8
+ // space to open, tab through options).
9
+
10
+ import { computed, onBeforeUnmount, onMounted, ref, watch } from "vue";
11
+ import type { FilterOption } from "./filter-option";
12
+
13
+ const props = defineProps<{
14
+ label: string;
15
+ options: FilterOption<T>[];
16
+ modelValue: T[] | null;
17
+ /** The active view does not act on this filter (bp-67g.36): inert trigger, menu shut. */
18
+ disabled?: boolean;
19
+ }>();
20
+
21
+ const emit = defineEmits<{
22
+ "update:modelValue": [T[] | null];
23
+ }>();
24
+
25
+ const open = ref(false);
26
+ const rootEl = ref<HTMLElement | null>(null);
27
+
28
+ const selected = computed(() => new Set(props.modelValue ?? []));
29
+
30
+ const summary = computed(() => {
31
+ const n = props.modelValue?.length ?? 0;
32
+ if (!n) return "any";
33
+ if (n === 1) {
34
+ const opt = props.options.find((o) => o.value === props.modelValue![0]);
35
+ return opt?.label ?? String(props.modelValue![0]);
36
+ }
37
+ return `${n} selected`;
38
+ });
39
+
40
+ function toggle(v: T) {
41
+ const cur = props.modelValue ?? [];
42
+ const next = cur.includes(v)
43
+ ? cur.filter((x) => x !== v)
44
+ : [...cur, v];
45
+ emit("update:modelValue", next.length ? next : null);
46
+ }
47
+
48
+ function clear() {
49
+ emit("update:modelValue", null);
50
+ }
51
+
52
+ function onClickOutside(e: MouseEvent) {
53
+ if (!rootEl.value) return;
54
+ if (!rootEl.value.contains(e.target as Node)) open.value = false;
55
+ }
56
+
57
+ function onKeydown(e: KeyboardEvent) {
58
+ if (e.key === "Escape") open.value = false;
59
+ }
60
+
61
+ onMounted(() => {
62
+ document.addEventListener("mousedown", onClickOutside);
63
+ document.addEventListener("keydown", onKeydown);
64
+ });
65
+ onBeforeUnmount(() => {
66
+ document.removeEventListener("mousedown", onClickOutside);
67
+ document.removeEventListener("keydown", onKeydown);
68
+ });
69
+
70
+ // Auto-scroll list to selected item when opened.
71
+ watch(open, (v) => {
72
+ if (!v) return;
73
+ });
74
+ </script>
75
+
76
+ <template>
77
+ <div ref="rootEl" class="filter-popover" :class="{ open }">
78
+ <button
79
+ type="button"
80
+ class="filter-trigger"
81
+ :class="{ 'has-selection': (modelValue?.length ?? 0) > 0 }"
82
+ :disabled="disabled"
83
+ @click="open = !open"
84
+ >
85
+ <span class="label">{{ label }}</span>
86
+ <span class="summary">{{ summary }}</span>
87
+ <span class="caret">▾</span>
88
+ </button>
89
+
90
+ <div v-if="open && !disabled" class="filter-menu" role="listbox">
91
+ <div class="filter-menu-header">
92
+ <span>{{ label }}</span>
93
+ <button
94
+ v-if="(modelValue?.length ?? 0) > 0"
95
+ type="button"
96
+ class="clear"
97
+ @click="clear"
98
+ >clear</button>
99
+ </div>
100
+ <ul>
101
+ <li v-for="opt in options" :key="String(opt.value)">
102
+ <label>
103
+ <input
104
+ type="checkbox"
105
+ :checked="selected.has(opt.value)"
106
+ @change="toggle(opt.value)"
107
+ />
108
+ <span class="option-label">{{ opt.label }}</span>
109
+ <span v-if="opt.hint" class="option-hint">{{ opt.hint }}</span>
110
+ </label>
111
+ </li>
112
+ <li v-if="!options.length" class="empty">(no options in data)</li>
113
+ </ul>
114
+ </div>
115
+ </div>
116
+ </template>
117
+
118
+ <style>
119
+ .filter-popover {
120
+ position: relative;
121
+ display: inline-block;
122
+ }
123
+
124
+ .filter-trigger {
125
+ display: inline-flex;
126
+ align-items: center;
127
+ gap: 6px;
128
+ background: transparent;
129
+ border: 1px solid var(--c-border);
130
+ color: var(--c-text);
131
+ padding: 4px 8px 4px 10px;
132
+ border-radius: 3px;
133
+ font-size: 11px;
134
+ cursor: pointer;
135
+ font-family: inherit;
136
+ }
137
+
138
+ .filter-trigger:hover,
139
+ .filter-popover.open .filter-trigger {
140
+ border-color: var(--c-accent);
141
+ }
142
+
143
+ .filter-trigger .label {
144
+ color: var(--c-dim);
145
+ text-transform: uppercase;
146
+ letter-spacing: 0.05em;
147
+ }
148
+
149
+ .filter-trigger .summary {
150
+ color: var(--c-text);
151
+ }
152
+
153
+ .filter-trigger.has-selection .summary {
154
+ color: var(--c-accent);
155
+ font-weight: 600;
156
+ }
157
+
158
+ .filter-trigger .caret {
159
+ color: var(--c-dim);
160
+ font-size: 9px;
161
+ }
162
+
163
+ .filter-menu {
164
+ position: absolute;
165
+ top: calc(100% + 4px);
166
+ left: 0;
167
+ z-index: 100;
168
+ min-width: 200px;
169
+ max-height: 320px;
170
+ overflow: auto;
171
+ background: var(--c-panel);
172
+ border: 1px solid var(--c-border);
173
+ border-radius: 4px;
174
+ box-shadow: var(--shadow-panel);
175
+ color: var(--c-text);
176
+ font-size: 11px;
177
+ }
178
+
179
+ .filter-menu-header {
180
+ display: flex;
181
+ align-items: center;
182
+ justify-content: space-between;
183
+ padding: 8px 12px;
184
+ border-bottom: 1px solid var(--c-border);
185
+ color: var(--c-dim);
186
+ text-transform: uppercase;
187
+ letter-spacing: 0.05em;
188
+ font-size: 10px;
189
+ }
190
+
191
+ .filter-menu-header .clear {
192
+ background: none;
193
+ border: none;
194
+ color: var(--c-accent);
195
+ cursor: pointer;
196
+ font-size: 10px;
197
+ padding: 0;
198
+ }
199
+
200
+ .filter-menu ul {
201
+ list-style: none;
202
+ padding: 4px 0;
203
+ margin: 0;
204
+ }
205
+
206
+ .filter-menu li {
207
+ padding: 0;
208
+ }
209
+
210
+ .filter-menu label {
211
+ display: flex;
212
+ align-items: center;
213
+ gap: 6px;
214
+ padding: 4px 12px;
215
+ cursor: pointer;
216
+ }
217
+
218
+ .filter-menu label:hover {
219
+ background: var(--c-accent-wash);
220
+ }
221
+
222
+ .filter-menu input[type="checkbox"] {
223
+ accent-color: var(--c-accent);
224
+ }
225
+
226
+ .option-label {
227
+ flex: 1;
228
+ }
229
+
230
+ .option-hint {
231
+ color: var(--c-dim);
232
+ font-size: 10px;
233
+ }
234
+
235
+ .filter-menu li.empty {
236
+ padding: 8px 12px;
237
+ color: var(--c-dim);
238
+ font-style: italic;
239
+ }
240
+
241
+ /* Dimmed with its group when the view cannot act on it (bp-67g.36). */
242
+ .filter-popover .filter-trigger:disabled {
243
+ cursor: not-allowed;
244
+ }
245
+ </style>
@@ -0,0 +1,274 @@
1
+ <script setup lang="ts">
2
+ // GroupProgress.vue — group sizes and completion, replacing the Groups text
3
+ // list in app-main (bp-67g.30).
4
+ //
5
+ // The list this replaces was ordered by group ID, so the outlier group was
6
+ // not merely unobvious, it was unfindable. Sorted by population with the
7
+ // closed run drawn inside the bar, "which epic is huge" and "how far along
8
+ // is it" are both answered without reading a number.
9
+ //
10
+ // Horizontal rows rather than columns: group labels are long (an epic's ID
11
+ // plus its title) and a horizontal form never rotates a label.
12
+
13
+ import { computed } from "vue";
14
+ import type { GroupProgressRow } from "../insights";
15
+
16
+ const props = defineProps<{
17
+ rows: GroupProgressRow[];
18
+ /** What the rows are grouped by, for the heading. */
19
+ groupBy: string;
20
+ }>();
21
+
22
+ /**
23
+ * The placeholder bucket is pulled out of the bars. It is the remainder
24
+ * rather than a peer — on a 556-bead repo it held 305 of them — and left in
25
+ * the scale it put every real group on a tenth of the width.
26
+ */
27
+ const groups = computed(() => props.rows.filter((r) => !r.synthetic));
28
+ const remainder = computed(() => props.rows.filter((r) => r.synthetic));
29
+
30
+ const maxTotal = computed(() =>
31
+ Math.max(1, ...groups.value.map((r) => r.total)),
32
+ );
33
+
34
+ const totals = computed(() => ({
35
+ closed: props.rows.reduce((a, r) => a + r.closed, 0),
36
+ total: props.rows.reduce((a, r) => a + r.total, 0),
37
+ }));
38
+
39
+ const overall = computed(() =>
40
+ totals.value.total > 0 ? totals.value.closed / totals.value.total : 0,
41
+ );
42
+
43
+ const pct = (v: number) => Math.round(v * 100);
44
+ </script>
45
+
46
+ <template>
47
+ <section class="stat-block progress-block">
48
+ <h2>
49
+ Progress
50
+ <span class="hint">by {{ groupBy }}</span>
51
+ </h2>
52
+
53
+ <div class="progress-head">
54
+ <span class="progress-value">{{ pct(overall) }}%</span>
55
+ <span class="progress-sub">
56
+ {{ totals.closed }} of {{ totals.total }} closed ·
57
+ {{ groups.length }} {{ groups.length === 1 ? "group" : "groups" }}
58
+ <!-- Phrased rather than interpolating the label, which is already
59
+ parenthesised ("(no epic)") and read as a double bracket. -->
60
+ <template v-for="r in remainder" :key="r.id">
61
+ · {{ r.total }} ungrouped, {{ pct(r.fraction) }}% closed
62
+ </template>
63
+ </span>
64
+ </div>
65
+
66
+ <div class="legend">
67
+ <span class="key"><i class="swatch is-closed" />closed</span>
68
+ <span class="key"><i class="swatch is-open" />open</span>
69
+ </div>
70
+
71
+ <ul v-if="groups.length" class="rows">
72
+ <li v-for="r in groups" :key="r.id">
73
+ <span class="r-label" :title="r.label">{{ r.label }}</span>
74
+ <span
75
+ class="r-track"
76
+ :style="{ width: `${(r.total / maxTotal) * 100}%` }"
77
+ :aria-label="`${r.closed} of ${r.total} closed`"
78
+ >
79
+ <i class="r-closed" :style="{ width: `${r.fraction * 100}%` }" />
80
+ </span>
81
+ <span class="r-value">
82
+ {{ r.closed }}/{{ r.total }}
83
+ <em>{{ pct(r.fraction) }}%</em>
84
+ </span>
85
+ </li>
86
+ </ul>
87
+ <p v-else class="progress-empty">Nothing grouped to compare.</p>
88
+
89
+ <!-- rowCount is deliberately absent from the bars — it is what the Gantt
90
+ DRAWS, clamped at maxPerGroup, not how big the group is. It appears
91
+ here only so the difference is inspectable. -->
92
+ <details class="table-view">
93
+ <summary>Table</summary>
94
+ <table>
95
+ <thead>
96
+ <tr>
97
+ <th>group</th>
98
+ <th class="num">closed</th>
99
+ <th class="num">total</th>
100
+ <th class="num">%</th>
101
+ </tr>
102
+ </thead>
103
+ <tbody>
104
+ <tr v-for="r in rows" :key="r.id">
105
+ <td><code>{{ r.id }}</code></td>
106
+ <td class="num">{{ r.closed }}</td>
107
+ <td class="num">{{ r.total }}</td>
108
+ <td class="num">{{ pct(r.fraction) }}%</td>
109
+ </tr>
110
+ </tbody>
111
+ </table>
112
+ </details>
113
+ </section>
114
+ </template>
115
+
116
+ <style scoped>
117
+ .progress-block h2 .hint {
118
+ text-transform: none;
119
+ letter-spacing: 0;
120
+ font-weight: 400;
121
+ color: var(--c-dim);
122
+ margin-left: 6px;
123
+ }
124
+
125
+ .progress-head {
126
+ display: flex;
127
+ align-items: baseline;
128
+ gap: 8px;
129
+ flex-wrap: wrap;
130
+ margin-bottom: 8px;
131
+ }
132
+
133
+ /* Proportional figures: tabular-nums makes a 24px standalone value read
134
+ loose. The table below gets tabular, where digits align. */
135
+ .progress-value {
136
+ font-size: 24px;
137
+ font-weight: 600;
138
+ line-height: 1.1;
139
+ }
140
+
141
+ .progress-sub {
142
+ font-size: 11px;
143
+ color: var(--c-dim);
144
+ }
145
+
146
+ .legend {
147
+ display: flex;
148
+ gap: 12px;
149
+ font-size: 10px;
150
+ color: var(--c-dim);
151
+ margin-bottom: 8px;
152
+ }
153
+
154
+ .key {
155
+ display: inline-flex;
156
+ align-items: center;
157
+ gap: 5px;
158
+ }
159
+
160
+ .swatch {
161
+ width: 9px;
162
+ height: 9px;
163
+ border-radius: 2px;
164
+ display: inline-block;
165
+ }
166
+
167
+ .swatch.is-closed {
168
+ background: var(--c-shipped);
169
+ }
170
+
171
+ .swatch.is-open {
172
+ background: var(--c-future);
173
+ }
174
+
175
+ .rows {
176
+ list-style: none;
177
+ margin: 0;
178
+ padding: 0;
179
+ }
180
+
181
+ .rows li {
182
+ display: grid;
183
+ grid-template-columns: minmax(80px, 30%) 1fr 76px;
184
+ align-items: center;
185
+ gap: 8px;
186
+ padding: 2px 0;
187
+ /* The shared .stat-block li rule adds a divider and space-between; these
188
+ rows carry their own grid, so neither applies. */
189
+ border-bottom: none;
190
+ }
191
+
192
+ .r-label {
193
+ font-size: 11px;
194
+ color: var(--c-dim-bright);
195
+ overflow: hidden;
196
+ text-overflow: ellipsis;
197
+ white-space: nowrap;
198
+ }
199
+
200
+ /* Track length is the group's population; the fill is what's closed. */
201
+ .r-track {
202
+ display: block;
203
+ height: 11px;
204
+ background: var(--c-future);
205
+ border-radius: 2px;
206
+ overflow: hidden;
207
+ min-width: 3px;
208
+ }
209
+
210
+ /* A 2px gap in the surface colour separates closed from open, rather than a
211
+ stroke — a border would add data-weight ink that isn't data. */
212
+ .r-closed {
213
+ display: block;
214
+ height: 11px;
215
+ background: var(--c-shipped);
216
+ border-right: 2px solid var(--c-panel);
217
+ border-radius: 2px 0 0 2px;
218
+ }
219
+
220
+ .r-value {
221
+ font-size: 10px;
222
+ color: var(--c-dim);
223
+ font-variant-numeric: tabular-nums;
224
+ text-align: right;
225
+ white-space: nowrap;
226
+ }
227
+
228
+ .r-value em {
229
+ font-style: normal;
230
+ color: var(--c-text);
231
+ margin-left: 4px;
232
+ }
233
+
234
+ .progress-empty {
235
+ color: var(--c-dim);
236
+ font-size: 12px;
237
+ margin: 12px 0;
238
+ }
239
+
240
+ .table-view {
241
+ margin-top: 10px;
242
+ font-size: 11px;
243
+ }
244
+
245
+ .table-view summary {
246
+ cursor: pointer;
247
+ color: var(--c-dim);
248
+ padding: 2px 0;
249
+ }
250
+
251
+ .table-view table {
252
+ border-collapse: collapse;
253
+ margin-top: 6px;
254
+ width: 100%;
255
+ }
256
+
257
+ .table-view th,
258
+ .table-view td {
259
+ text-align: left;
260
+ padding: 2px 8px 2px 0;
261
+ border-bottom: 1px solid var(--c-border);
262
+ }
263
+
264
+ .table-view thead th {
265
+ color: var(--c-dim);
266
+ font-size: 10px;
267
+ font-weight: 600;
268
+ }
269
+
270
+ .table-view .num {
271
+ text-align: right;
272
+ font-variant-numeric: tabular-nums;
273
+ }
274
+ </style>
@@ -0,0 +1,144 @@
1
+ <script setup lang="ts">
2
+ // LoadMeter.vue — shown in place of the chart while there is nothing to
3
+ // show: a first load, or a project switch. A background poll never reaches
4
+ // here; it refreshes in place behind a thin activity hint.
5
+ //
6
+ // The bar fills against how long THIS project took last time, because the
7
+ // current run has no progress to report — the `bd` subprocess is ~90% of
8
+ // the wall time and says nothing while it works. That makes it an estimate,
9
+ // so it is labelled as one and gives up honestly when outlasted rather than
10
+ // creeping to 99%.
11
+
12
+ import { computed, onBeforeUnmount, onMounted, ref } from "vue";
13
+ import { useBeadsStore } from "../store";
14
+ import { formatElapsed, meterState } from "../load-meter";
15
+
16
+ const store = useBeadsStore();
17
+
18
+ // Ticks the elapsed clock. 100ms is smooth enough for a bar and cheap.
19
+ const now = ref(Date.now());
20
+ let timer: ReturnType<typeof setInterval> | null = null;
21
+ onMounted(() => {
22
+ timer = setInterval(() => (now.value = Date.now()), 100);
23
+ });
24
+ onBeforeUnmount(() => {
25
+ if (timer !== null) clearInterval(timer);
26
+ });
27
+
28
+ const elapsedMs = computed(() =>
29
+ store.fetchStartedAt === null ? 0 : now.value - store.fetchStartedAt,
30
+ );
31
+ const expectedMs = computed(() => store.expectedFetchMs);
32
+ const state = computed(() => meterState(elapsedMs.value, expectedMs.value));
33
+ const projectLabel = computed(() => store.activeProject?.label ?? null);
34
+ </script>
35
+
36
+ <template>
37
+ <section class="load-meter" role="status" aria-live="polite">
38
+ <div class="meter-head">
39
+ <!-- Built as one expression: a leading space inside a <template> tag
40
+ is collapsed by the compiler, which ran "Loading" into the
41
+ project name. -->
42
+ <span class="meter-what">{{
43
+ projectLabel ? `Loading ${projectLabel}…` : "Loading…"
44
+ }}</span>
45
+ <span class="meter-elapsed">{{ formatElapsed(elapsedMs) }}</span>
46
+ </div>
47
+
48
+ <div
49
+ class="meter-track"
50
+ role="progressbar"
51
+ :aria-valuemin="0"
52
+ :aria-valuemax="100"
53
+ :aria-valuenow="state.pct === null ? undefined : Math.round(state.pct)"
54
+ >
55
+ <div
56
+ v-if="state.mode === 'determinate'"
57
+ class="meter-fill"
58
+ :style="{ width: state.pct + '%' }"
59
+ />
60
+ <div v-else class="meter-fill is-indeterminate" />
61
+ </div>
62
+
63
+ <!-- Says where the number came from. A bar filling against a past run
64
+ is an estimate, and presenting it as measurement would be a lie. -->
65
+ <p class="meter-note">
66
+ <template v-if="state.overrun">
67
+ Taking longer than last time ({{ formatElapsed(expectedMs ?? 0) }}) —
68
+ no estimate to give.
69
+ </template>
70
+ <template v-else-if="state.mode === 'determinate'">
71
+ Estimated from the last load of this project
72
+ ({{ formatElapsed(expectedMs ?? 0) }}).
73
+ </template>
74
+ <template v-else>
75
+ First load of this project — no previous timing to estimate from.
76
+ </template>
77
+ </p>
78
+ </section>
79
+ </template>
80
+
81
+ <style>
82
+ .load-meter {
83
+ padding: 22px 20px;
84
+ border: 1px solid var(--c-border);
85
+ border-radius: var(--radius);
86
+ background: var(--c-bg);
87
+ }
88
+
89
+ .meter-head {
90
+ display: flex;
91
+ align-items: baseline;
92
+ gap: 10px;
93
+ margin-bottom: 9px;
94
+ font-size: 12px;
95
+ color: var(--c-text);
96
+ }
97
+
98
+ .meter-what {
99
+ font-weight: 600;
100
+ }
101
+
102
+ .meter-elapsed {
103
+ margin-left: auto;
104
+ font-variant-numeric: tabular-nums;
105
+ color: var(--c-dim);
106
+ }
107
+
108
+ .meter-track {
109
+ height: 6px;
110
+ border-radius: 3px;
111
+ background: var(--c-panel-2);
112
+ overflow: hidden;
113
+ }
114
+
115
+ .meter-fill {
116
+ height: 100%;
117
+ background: var(--c-accent);
118
+ border-radius: 3px;
119
+ transition: width 0.1s linear;
120
+ }
121
+
122
+ /* A sweep rather than a fill, so it reads as "working" and not as an
123
+ amount completed. */
124
+ .meter-fill.is-indeterminate {
125
+ width: 34%;
126
+ animation: meter-sweep 1.1s ease-in-out infinite;
127
+ transition: none;
128
+ }
129
+
130
+ @keyframes meter-sweep {
131
+ 0% {
132
+ transform: translateX(-100%);
133
+ }
134
+ 100% {
135
+ transform: translateX(295%);
136
+ }
137
+ }
138
+
139
+ .meter-note {
140
+ margin: 9px 0 0;
141
+ font-size: 10px;
142
+ color: var(--c-dim);
143
+ }
144
+ </style>
@@ -0,0 +1,28 @@
1
+ <script setup lang="ts">
2
+ // MineRow.vue — one bead as a dense list row in the mine view. Click opens
3
+ // the drawer, as everywhere else. The optional note carries the relationship
4
+ // a section is about ("waiting on bp-x · bob").
5
+
6
+ import type { Bead } from "../store";
7
+ import { useBeadsStore } from "../store";
8
+ import PriorityChip from "./PriorityChip.vue";
9
+ import TypeChip from "./TypeChip.vue";
10
+
11
+ defineProps<{ bead: Bead; note?: string | null }>();
12
+
13
+ const store = useBeadsStore();
14
+ const statusClass = (s: string) => `status-${s.replace(/_/g, "-")}`;
15
+ </script>
16
+
17
+ <template>
18
+ <li class="mine-row" @click="store.openBead(bead.id)" @contextmenu.prevent="store.openContextMenu(bead.id, $event.clientX, $event.clientY)">
19
+ <code class="mine-id">{{ bead.id }}</code>
20
+ <PriorityChip :priority="bead.priority ?? 4" />
21
+ <span class="mine-title" :title="bead.title">{{ bead.title }}</span>
22
+ <span v-if="note" class="mine-note" :title="note">{{ note }}</span>
23
+ <TypeChip :type="bead.issue_type" />
24
+ <span class="pill" :class="statusClass(bead.status)">{{ bead.status.replace(/_/g, " ") }}</span>
25
+ <!-- A trailing action, when a section has one (the frontier's "stage", bp-wfe). -->
26
+ <slot />
27
+ </li>
28
+ </template>