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,177 @@
1
+ <script setup lang="ts">
2
+ // Term.vue — an explainable term (bp-67g.47).
3
+ //
4
+ // Every derived number in the drawer can explain itself. This is the ONE
5
+ // mechanism for that, decided once so it does not vary field by field:
6
+ //
7
+ // - The term is drawn with a dotted underline and is focusable
8
+ // (tabindex 0, role button), so the explanation is reachable by keyboard
9
+ // and announced through aria-describedby while it is shown.
10
+ // - Hover shows the note; so does focus (Tab lands on it).
11
+ // - Click, tap, Enter or Space PIN it, so it survives the pointer leaving
12
+ // and works on touch, where there is no hover. A second click, Escape,
13
+ // a tap or click anywhere else, or any scroll dismisses it.
14
+ // - The note is teleported to <body> and fixed to the viewport, placed
15
+ // under the term (above it when the viewport runs out), so a scrolling
16
+ // drawer cannot clip it. Scroll dismisses rather than re-anchors, because
17
+ // a note pointing at where a term used to be is worse than none.
18
+ //
19
+ // A `title` attribute would have been cheaper and is unreachable by keyboard
20
+ // and on touch; that is why this exists. The sentence itself must come from
21
+ // the module that computes the figure (see triage.ts explainSignal,
22
+ // bead-detail.ts explainFinish, economics.ts PHASE_EXPLANATIONS), never be
23
+ // typed here, so a formula change cannot leave a stale description behind.
24
+
25
+ import { computed, nextTick, onBeforeUnmount, ref, watch } from "vue";
26
+
27
+ const props = defineProps<{ note: string }>();
28
+
29
+ const id = `term-note-${Math.random().toString(36).slice(2, 9)}`;
30
+ const termEl = ref<HTMLElement | null>(null);
31
+ const noteEl = ref<HTMLElement | null>(null);
32
+ const hover = ref(false);
33
+ const focused = ref(false);
34
+ const pinned = ref(false);
35
+ const visible = computed(() => hover.value || focused.value || pinned.value);
36
+ const pos = ref({ left: "0px", top: "0px" });
37
+
38
+ async function place() {
39
+ await nextTick();
40
+ const t = termEl.value?.getBoundingClientRect();
41
+ const n = noteEl.value;
42
+ if (!t || !n) return;
43
+ const pad = 12;
44
+ const gap = 6;
45
+ const w = n.offsetWidth;
46
+ const h = n.offsetHeight;
47
+ const left = Math.min(Math.max(pad, t.left), window.innerWidth - w - pad);
48
+ let top = t.bottom + gap;
49
+ if (top + h + pad > window.innerHeight) top = Math.max(pad, t.top - gap - h);
50
+ pos.value = { left: `${left}px`, top: `${top}px` };
51
+ }
52
+
53
+ function dismiss() {
54
+ hover.value = false;
55
+ focused.value = false;
56
+ pinned.value = false;
57
+ }
58
+
59
+ function onOutside(e: Event) {
60
+ const target = e.target as Node | null;
61
+ if (target && (termEl.value?.contains(target) || noteEl.value?.contains(target))) return;
62
+ dismiss();
63
+ }
64
+
65
+ function onClick() {
66
+ if (pinned.value) dismiss();
67
+ else pinned.value = true;
68
+ }
69
+
70
+ function onKeydown(e: KeyboardEvent) {
71
+ if (e.key === "Escape") {
72
+ if (!visible.value) return;
73
+ // Ours, not the drawer's: one Escape closes the note, the next closes
74
+ // the drawer.
75
+ e.stopPropagation();
76
+ dismiss();
77
+ termEl.value?.blur();
78
+ } else if (e.key === "Enter" || e.key === " ") {
79
+ e.preventDefault();
80
+ onClick();
81
+ }
82
+ }
83
+
84
+ function listen(on: boolean) {
85
+ const m = on ? document.addEventListener : document.removeEventListener;
86
+ m.call(document, "scroll", dismiss, { capture: true, passive: true } as AddEventListenerOptions);
87
+ m.call(document, "pointerdown", onOutside, true);
88
+ }
89
+
90
+ watch(visible, (v) => {
91
+ listen(v);
92
+ if (v) void place();
93
+ });
94
+
95
+ onBeforeUnmount(() => listen(false));
96
+ </script>
97
+
98
+ <template>
99
+ <span class="term-wrap">
100
+ <span
101
+ ref="termEl"
102
+ class="term"
103
+ :class="{ 'is-open': visible }"
104
+ tabindex="0"
105
+ role="button"
106
+ :aria-expanded="visible"
107
+ :aria-describedby="visible ? id : undefined"
108
+ @mouseenter="hover = true"
109
+ @mouseleave="hover = false"
110
+ @focus="focused = true"
111
+ @blur="focused = false"
112
+ @click="onClick"
113
+ @keydown="onKeydown"
114
+ ><slot /></span>
115
+ <Teleport to="body">
116
+ <span
117
+ v-if="visible"
118
+ ref="noteEl"
119
+ :id="id"
120
+ class="term-note"
121
+ role="tooltip"
122
+ :style="{ left: pos.left, top: pos.top }"
123
+ >{{ props.note }}</span>
124
+ </Teleport>
125
+ </span>
126
+ </template>
127
+
128
+ <style>
129
+ .term {
130
+ text-decoration: underline dotted;
131
+ text-decoration-color: var(--c-dim-bright);
132
+ text-decoration-thickness: 1px;
133
+ text-underline-offset: 2px;
134
+ cursor: help;
135
+ border-radius: 2px;
136
+ }
137
+
138
+ .term:hover,
139
+ .term.is-open {
140
+ text-decoration-color: var(--c-accent);
141
+ }
142
+
143
+ .term:focus-visible {
144
+ outline: 2px solid var(--c-accent);
145
+ outline-offset: 1px;
146
+ }
147
+
148
+ /* The note resets every inherited text style: a term inside an uppercase,
149
+ letter-spaced <dt> must not produce an uppercase, letter-spaced sentence. */
150
+ .term-note {
151
+ position: fixed;
152
+ z-index: 9998;
153
+ display: block;
154
+ max-width: 300px;
155
+ padding: 8px 10px;
156
+ background: var(--c-panel);
157
+ color: var(--c-text);
158
+ border: 1px solid var(--c-border-strong);
159
+ border-radius: 6px;
160
+ box-shadow: var(--shadow-pop);
161
+ font-size: 12px;
162
+ line-height: 1.45;
163
+ font-weight: 400;
164
+ font-style: normal;
165
+ text-transform: none;
166
+ letter-spacing: normal;
167
+ text-align: left;
168
+ white-space: normal;
169
+ font-family:
170
+ ui-sans-serif,
171
+ -apple-system,
172
+ Segoe UI,
173
+ Roboto,
174
+ sans-serif;
175
+ pointer-events: none;
176
+ }
177
+ </style>
@@ -0,0 +1,50 @@
1
+ <script setup lang="ts">
2
+ // Toast.vue — one line at the bottom of the screen for the outcome of a
3
+ // write (bp-ocs): what happened, or what failed and why. Announced as a status,
4
+ // dismissed by its own timer or a click.
5
+ import { useBeadsStore } from "../store";
6
+ const store = useBeadsStore();
7
+ </script>
8
+
9
+ <template>
10
+ <Teleport to="body">
11
+ <div v-if="store.notice" class="toast" :class="`is-${store.notice.kind}`" role="status" @click="store.clearNotice()">
12
+ <span class="toast-text">{{ store.notice.text }}</span>
13
+ </div>
14
+ </Teleport>
15
+ </template>
16
+
17
+ <style>
18
+ .toast {
19
+ position: fixed;
20
+ left: 50%;
21
+ bottom: 24px;
22
+ transform: translateX(-50%);
23
+ z-index: 9995;
24
+ max-width: min(720px, calc(100vw - 32px));
25
+ padding: 8px 12px;
26
+ border-radius: 6px;
27
+ background: var(--c-panel);
28
+ color: var(--c-text);
29
+ border: 1px solid var(--c-border-strong);
30
+ box-shadow: var(--shadow-pop);
31
+ font-size: 12px;
32
+ display: flex;
33
+ gap: 10px;
34
+ align-items: baseline;
35
+ cursor: pointer;
36
+ }
37
+
38
+ .toast.is-error {
39
+ border-color: var(--c-danger);
40
+ }
41
+
42
+ .toast .toast-cmd {
43
+ font-family: ui-monospace, monospace;
44
+ font-size: 11px;
45
+ color: var(--c-dim);
46
+ white-space: nowrap;
47
+ overflow: hidden;
48
+ text-overflow: ellipsis;
49
+ }
50
+ </style>
@@ -0,0 +1,426 @@
1
+ <script setup lang="ts">
2
+ // TriageMeters.vue — the eight triage signals as contribution meters.
3
+ //
4
+ // Replaces the four-decimal breakdown table (bp-67g.27). The table is still
5
+ // here, behind a disclosure, because no value should be reachable only by
6
+ // reading a bar.
7
+ //
8
+ // The encoding is the whole point, and it is what bp-67g.16 rejected a
9
+ // PolarChart radar in favour of:
10
+ //
11
+ // TRACK the signal's declared weight, scaled against the largest. An
12
+ // axis that can only ever reach 0.05 gets a quarter of the width
13
+ // of one that can reach 0.22.
14
+ // CARVE the part of the weight the signal can NEVER spend, where its
15
+ // norm is capped below 1 (bp-67g.23). risk declares 0.10 and can
16
+ // reach 0.002, so 98% of its track is cut away.
17
+ // FILL what this bead actually contributed.
18
+ // P90 where the 90th percentile of the open population sits, so a
19
+ // fill can be read against what "high" means on this repo. The
20
+ // absolute scales are meaningful but narrow in practice —
21
+ // nothing healthy is 30 days stale — so without this marker
22
+ // every bar looks empty and the panel says nothing.
23
+ //
24
+ // The carve-out started life as a notch at the achievable ceiling and was
25
+ // invisible: both capped signals have norms that sit AT their maximum, so
26
+ // the notch landed exactly on the fill edge every time. Removing the
27
+ // unreachable region instead makes it an area rather than a line, and one
28
+ // that cannot coincide with anything.
29
+ //
30
+ // A radar cannot express any of that: it has one magnitude domain, so it
31
+ // plots the norm and makes a full-radius staleness spike (weight 0.05) look
32
+ // identical to a full-radius pagerank spike (weight 0.22) while the two
33
+ // contribute 4.4x different amounts.
34
+ //
35
+ // Rows are in fixed weight order, never sorted by this bead's own values —
36
+ // fixed order is what lets two beads be compared by glancing down the same
37
+ // rows, and weight order puts the signals that could move the score first.
38
+
39
+ import { computed } from "vue";
40
+ import {
41
+ TRIAGE_WEIGHTS,
42
+ SIGNAL_FIDELITY,
43
+ achievableContribution,
44
+ type SignalPopulation,
45
+ type TriageBreakdown,
46
+ type TriageSignal,
47
+ explainSignal,
48
+ } from "../triage";
49
+ import Term from "./Term.vue";
50
+
51
+ const props = defineProps<{
52
+ breakdown: TriageBreakdown;
53
+ /** Spread across the open population, for the flat markers. */
54
+ population: SignalPopulation[];
55
+ }>();
56
+
57
+ const LABELS: Record<TriageSignal, string> = {
58
+ pagerank: "PageRank",
59
+ betweenness: "Betweenness",
60
+ blocker_ratio: "Blocker ratio",
61
+ priority_boost: "Priority",
62
+ time_to_impact: "Time to impact",
63
+ urgency: "Urgency",
64
+ staleness: "Staleness",
65
+ risk: "Risk",
66
+ };
67
+
68
+ const maxWeight = computed(() =>
69
+ Math.max(...Object.values(TRIAGE_WEIGHTS)),
70
+ );
71
+
72
+ interface MeterRow {
73
+ signal: TriageSignal;
74
+ label: string;
75
+ norm: number;
76
+ weight: number;
77
+ contribution: number;
78
+ /** Contribution as a share of this bead's whole score. */
79
+ share: number;
80
+ /** Fill width, as a percentage OF THE TRACK. */
81
+ fillPct: number;
82
+ /** Notch position, as a percentage of the track. 100 = no cap. */
83
+ ceilingPct: number;
84
+ capped: boolean;
85
+ fidelity: string;
86
+ note?: string;
87
+ degenerate: boolean;
88
+ /** Population p90 as a percentage of the track. Null when flat. */
89
+ p90Pct: number | null;
90
+ }
91
+
92
+ const rows = computed<MeterRow[]>(() => {
93
+ const byName = new Map(props.population.map((p) => [p.signal, p] as const));
94
+ const score = Object.keys(TRIAGE_WEIGHTS).reduce(
95
+ (a, k) => a + props.breakdown[k as TriageSignal],
96
+ 0,
97
+ );
98
+ return (Object.keys(TRIAGE_WEIGHTS) as TriageSignal[])
99
+ .map((signal) => {
100
+ const weight = TRIAGE_WEIGHTS[signal];
101
+ const ceiling = achievableContribution(signal);
102
+ const contribution = props.breakdown[signal];
103
+ const fid = SIGNAL_FIDELITY[signal];
104
+ return {
105
+ signal,
106
+ label: LABELS[signal],
107
+ norm: props.breakdown[`${signal}_norm` as keyof TriageBreakdown],
108
+ weight,
109
+ contribution,
110
+ share: score > 0 ? contribution / score : 0,
111
+ // Against the WEIGHT, not the ceiling: a fill that cannot leave the
112
+ // left edge of a full-width track is the shape of a signal whose
113
+ // declared weight it can never spend.
114
+ fillPct: weight > 0 ? Math.min(100, (contribution / weight) * 100) : 0,
115
+ ceilingPct: weight > 0 ? Math.min(100, (ceiling / weight) * 100) : 100,
116
+ capped: fid.max < 1,
117
+ fidelity: fid.fidelity,
118
+ note: fid.note,
119
+ degenerate: byName.get(signal)?.degenerate ?? false,
120
+ // Skip the marker on a flat signal: with no spread, p90 sits on top
121
+ // of everything else and the marker would imply a comparison the
122
+ // data cannot support.
123
+ p90Pct: (() => {
124
+ const pop = byName.get(signal);
125
+ return pop && !pop.degenerate ? Math.min(100, pop.p90 * 100) : null;
126
+ })(),
127
+ };
128
+ })
129
+ .sort((a, b) => b.weight - a.weight);
130
+ });
131
+
132
+ const flatCount = computed(() => rows.value.filter((r) => r.degenerate).length);
133
+ const cappedCount = computed(() => rows.value.filter((r) => r.capped).length);
134
+ const populationSize = computed(() => props.population[0]?.nonzero ?? 0);
135
+
136
+ /** The one signal that contributed most — the answer to "why this rank". */
137
+ const topContributor = computed(() =>
138
+ rows.value.reduce((a, r) => (r.contribution > a.contribution ? r : a), rows.value[0]),
139
+ );
140
+
141
+ const fmt4 = (v: number) => v.toFixed(4);
142
+
143
+ const byNameP90 = (signal: TriageSignal) =>
144
+ props.population.find((p) => p.signal === signal)?.p90;
145
+ const fmtWeight = (v: number) => v.toFixed(2).slice(1);
146
+ </script>
147
+
148
+ <template>
149
+ <div class="meters-wrap">
150
+ <p class="meters-lede">
151
+ Track is the signal's weight; the fill is what this bead contributed.
152
+ <template v-if="topContributor">
153
+ Most of this score is
154
+ <strong>{{ topContributor.label.toLowerCase() }}</strong>
155
+ ({{ Math.round(topContributor.share * 100) }}%).
156
+ </template>
157
+ <template v-if="cappedCount">
158
+ A cut-off track is weight the signal cannot reach.
159
+ </template>
160
+ The tick is this repo's p90 — what a high value looks like here.
161
+ </p>
162
+
163
+ <ul class="meters">
164
+ <li
165
+ v-for="r in rows"
166
+ :key="r.signal"
167
+ :class="{ 'is-flat': r.degenerate, 'is-caveated': r.fidelity !== 'faithful' }"
168
+ >
169
+ <!-- Each name explains what it measures, from triage.ts itself
170
+ (bp-67g.47); the flat marker uses the same mechanism. -->
171
+ <span class="m-label">
172
+ <Term :note="explainSignal(r.signal)">{{ r.label }}</Term>
173
+ <Term
174
+ v-if="r.degenerate"
175
+ class="flat"
176
+ note="Three or fewer distinct values across the open beads in this repo — this axis is not separating anything here"
177
+ >flat</Term>
178
+ </span>
179
+ <span class="m-weight" :title="`weight ${fmt4(r.weight)}`">
180
+ {{ fmtWeight(r.weight) }}
181
+ </span>
182
+ <span
183
+ class="m-track"
184
+ :style="{ width: `${(r.weight / maxWeight) * 100}%` }"
185
+ >
186
+ <i class="m-fill" :style="{ width: `${r.fillPct}%` }" />
187
+ <!-- The weight this signal cannot spend, cut out of the track. -->
188
+ <!-- What a high value looks like on this repo. -->
189
+ <i
190
+ v-if="r.p90Pct !== null"
191
+ class="m-p90"
192
+ :style="{ left: `${r.p90Pct}%` }"
193
+ :title="`p90 of the open population is ${fmt4((byNameP90(r.signal) ?? 0))} — this bar is read against that`"
194
+ />
195
+ <i
196
+ v-if="r.capped"
197
+ class="m-unreachable"
198
+ :style="{ left: `${r.ceilingPct}%` }"
199
+ :title="`can only reach ${fmt4(achievableContribution(r.signal))} of its ${fmt4(r.weight)} weight — the rest is unspendable`"
200
+ />
201
+ </span>
202
+ <span class="m-value">{{ fmt4(r.contribution) }}</span>
203
+ </li>
204
+ </ul>
205
+
206
+ <!-- No longer claims urgency is staleness x priority: bp-67g.37
207
+ replaced that formula with urgency labels plus an age decay, so it
208
+ is an independent axis now. -->
209
+ <p v-if="flatCount" class="meters-note">
210
+ {{ flatCount }} of {{ rows.length }} signals are flat across this
211
+ repo's open beads, so they separate nothing here regardless of their
212
+ weight — usually because the dependency graph is too shallow to give
213
+ them anything to measure.
214
+ </p>
215
+
216
+ <details class="meters-table">
217
+ <summary>Table — norm, weight, ceiling and contribution</summary>
218
+ <table>
219
+ <thead>
220
+ <tr>
221
+ <th>Signal</th>
222
+ <th class="num">Norm</th>
223
+ <th class="num">Weight</th>
224
+ <th class="num">Max</th>
225
+ <th class="num">Contrib</th>
226
+ <th>vs bv</th>
227
+ </tr>
228
+ </thead>
229
+ <tbody>
230
+ <tr v-for="r in rows" :key="r.signal" :class="{ 'is-flat': r.degenerate }">
231
+ <td>{{ r.label }}</td>
232
+ <td class="num">{{ fmt4(r.norm) }}</td>
233
+ <td class="num">{{ fmt4(r.weight) }}</td>
234
+ <td class="num" :class="{ 'is-capped': r.capped }">
235
+ {{ fmt4(achievableContribution(r.signal)) }}
236
+ </td>
237
+ <td class="num">{{ fmt4(r.contribution) }}</td>
238
+ <td class="fid" :title="r.note">{{ r.fidelity }}</td>
239
+ </tr>
240
+ </tbody>
241
+ </table>
242
+ </details>
243
+ </div>
244
+ </template>
245
+
246
+ <style scoped>
247
+ .meters-lede,
248
+ .meters-note {
249
+ font-size: 10px;
250
+ line-height: 1.5;
251
+ color: var(--c-dim);
252
+ margin: 0 0 10px;
253
+ }
254
+
255
+ .meters-lede strong {
256
+ color: var(--c-text);
257
+ font-weight: 600;
258
+ }
259
+
260
+ .meters-note {
261
+ margin: 10px 0 0;
262
+ }
263
+
264
+ .meters-note code {
265
+ font-family: var(--font-mono);
266
+ }
267
+
268
+ .meters {
269
+ list-style: none;
270
+ margin: 0;
271
+ padding: 0;
272
+ }
273
+
274
+ .meters li {
275
+ display: grid;
276
+ grid-template-columns: 96px 24px 1fr 52px;
277
+ align-items: center;
278
+ gap: 8px;
279
+ padding: 2px 0;
280
+ }
281
+
282
+ .m-label {
283
+ font-size: 11px;
284
+ color: var(--c-text);
285
+ white-space: nowrap;
286
+ }
287
+
288
+ .meters li.is-flat .m-label {
289
+ color: var(--c-dim);
290
+ }
291
+
292
+ .m-label .flat {
293
+ font-size: 8px;
294
+ text-transform: uppercase;
295
+ letter-spacing: 0.06em;
296
+ color: var(--c-warning-strong);
297
+ border: 1px solid currentColor;
298
+ border-radius: 2px;
299
+ padding: 0 2px;
300
+ margin-left: 4px;
301
+ text-decoration: none;
302
+ cursor: help;
303
+ vertical-align: 1px;
304
+ }
305
+
306
+ .m-weight {
307
+ font-size: 10px;
308
+ color: var(--c-dim);
309
+ font-variant-numeric: tabular-nums;
310
+ text-align: right;
311
+ }
312
+
313
+ /* The track IS the weight. Width is set inline per row, so a 0.05 signal is
314
+ visibly a quarter the reach of a 0.22 one before any value is read. */
315
+ .m-track {
316
+ display: block;
317
+ height: 12px;
318
+ background: var(--c-dim-wash);
319
+ border-radius: 2px;
320
+ position: relative;
321
+ overflow: hidden;
322
+ min-width: 3px;
323
+ }
324
+
325
+ .m-fill {
326
+ position: absolute;
327
+ left: 0;
328
+ top: 0;
329
+ height: 12px;
330
+ background: var(--c-accent);
331
+ border-radius: 2px 0 0 2px;
332
+ display: block;
333
+ /* A hairline of surface where a full fill meets the track end, so 100%
334
+ still reads as a fill rather than as a recoloured track. */
335
+ min-width: 1px;
336
+ }
337
+
338
+ /* The 90th percentile of the open population. Sits ON the fill rather than
339
+ beside it, because the comparison being made is "how far along this track
340
+ compared with a high bead", and two separate bars would be a chart of two
341
+ different things. */
342
+ .m-p90 {
343
+ position: absolute;
344
+ top: -2px;
345
+ width: 1px;
346
+ height: 16px;
347
+ background: var(--c-dim-bright);
348
+ transform: translateX(-0.5px);
349
+ display: block;
350
+ z-index: 1;
351
+ }
352
+
353
+ .meters li.is-flat .m-p90 {
354
+ display: none;
355
+ }
356
+
357
+ /* Weight the signal cannot spend, carved back out of the track: the panel
358
+ shows through, with a hairline where capacity stops. Not a stroke around
359
+ a mark — the track's own absence is the encoding. */
360
+ .m-unreachable {
361
+ position: absolute;
362
+ top: 0;
363
+ right: 0;
364
+ height: 12px;
365
+ background: var(--c-panel);
366
+ border-left: 1px solid var(--c-warning-strong);
367
+ display: block;
368
+ }
369
+
370
+ .m-value {
371
+ font-size: 10px;
372
+ color: var(--c-dim);
373
+ font-variant-numeric: tabular-nums;
374
+ text-align: right;
375
+ }
376
+
377
+ /* ── table view ───────────────────────────────────────────────────────── */
378
+
379
+ .meters-table {
380
+ margin-top: 12px;
381
+ font-size: 10px;
382
+ }
383
+
384
+ .meters-table summary {
385
+ cursor: pointer;
386
+ color: var(--c-dim);
387
+ padding: 2px 0;
388
+ }
389
+
390
+ .meters-table table {
391
+ border-collapse: collapse;
392
+ margin-top: 6px;
393
+ width: 100%;
394
+ }
395
+
396
+ .meters-table th,
397
+ .meters-table td {
398
+ text-align: left;
399
+ padding: 2px 6px 2px 0;
400
+ border-bottom: 1px solid var(--c-border);
401
+ white-space: nowrap;
402
+ }
403
+
404
+ .meters-table thead th {
405
+ color: var(--c-dim);
406
+ font-weight: 600;
407
+ }
408
+
409
+ .meters-table .num {
410
+ text-align: right;
411
+ font-variant-numeric: tabular-nums;
412
+ }
413
+
414
+ .meters-table td.is-capped {
415
+ color: var(--c-warning-strong);
416
+ }
417
+
418
+ .meters-table td.fid {
419
+ color: var(--c-dim);
420
+ cursor: help;
421
+ }
422
+
423
+ .meters-table tr.is-flat td {
424
+ color: var(--c-dim);
425
+ }
426
+ </style>