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,691 @@
1
+ <script setup lang="ts">
2
+ import { onMounted, onBeforeUnmount, computed, ref, watch } from "vue";
3
+ import { useBeadsStore } from "./store";
4
+ import { useUrlState } from "./composables/useUrlState";
5
+ import { useKeyboard } from "./composables/useKeyboard";
6
+ import ShortcutsOverlay from "./components/ShortcutsOverlay.vue";
7
+ import ChangelogOverlay from "./components/ChangelogOverlay.vue";
8
+ import Walkthrough from "./components/Walkthrough.vue";
9
+ import ContextMenu from "./components/ContextMenu.vue";
10
+ import Toast from "./components/Toast.vue";
11
+ import WhatIfPanel from "./components/WhatIfPanel.vue";
12
+ import Term from "./components/Term.vue";
13
+ import { walkthroughSeen } from "./walkthrough";
14
+ import { BAR_KIND_NOTES, isWaitKind, type BarKind } from "./view-model";
15
+ import { Eye, RefreshCw } from "@lucide/vue";
16
+ import BeadsMine from "./components/BeadsMine.vue";
17
+ import BeadsGantt from "./components/BeadsGantt.vue";
18
+ import BeadsGrid from "./components/BeadsGrid.vue";
19
+ import BeadsTable from "./components/BeadsTable.vue";
20
+ import BeadDrawer from "./components/BeadDrawer.vue";
21
+ import ControlsPanel from "./components/ControlsPanel.vue";
22
+ import SearchPalette from "./components/SearchPalette.vue";
23
+ import OptionsMenu from "./components/OptionsMenu.vue";
24
+ import BeadsIncytes from "./components/BeadsIncytes.vue";
25
+ import BeadcyteMark from "./components/BeadcyteMark.vue";
26
+ import LoadMeter from "./components/LoadMeter.vue";
27
+ import { useLiveRefresh } from "./composables/useLiveRefresh";
28
+ import { formatAge } from "./load-meter";
29
+
30
+ const store = useBeadsStore();
31
+ useUrlState();
32
+
33
+ // Get version from environment variables
34
+ const version = import.meta.env.VITE_BEADCYTE_VERSION || 'unknown';
35
+
36
+ // Every key the app answers to — Escape for the drawer, ⌘K for search, the
37
+ // letters — is one document listener in useKeyboard, driven by the table in
38
+ // keybindings.ts, which the ? overlay is generated from (bp-67g.45).
39
+ useKeyboard();
40
+
41
+ // URL hash → drawer. `#bp-abc` opens the drawer for that bead. Two-way:
42
+ // opening the drawer sets the hash so pages are deep-linkable, and manual
43
+ // hash changes (back button, paste) reopen the corresponding drawer.
44
+ function readHashBeadId(): string | null {
45
+ const raw = window.location.hash.replace(/^#/, "");
46
+ return /^[a-z0-9-]+(\.[a-z0-9-]+)*$/i.test(raw) ? raw : null;
47
+ }
48
+ function onHashChange() {
49
+ const id = readHashBeadId();
50
+ if (id) store.openBead(id);
51
+ else if (store.openBeadId) store.closeBead();
52
+ }
53
+
54
+ onMounted(() => {
55
+ // Learn the launch directory first so it seeds an empty registry, then
56
+ // fetch — otherwise the first request goes out with no project and the
57
+ // menu shows nothing to switch between.
58
+ void store.loadProjects().then(() => store.fetch());
59
+ window.addEventListener("hashchange", onHashChange);
60
+ // First run only (bp-67g.48): the tour opens once per browser and never
61
+ // again unasked; the options menu re-opens it.
62
+ if (!walkthroughSeen()) store.openWalkthrough();
63
+ // Deep-link on first load — but wait for the payload so drawerBead
64
+ // resolves properly. The store.fetch() promise handles that.
65
+ const startId = readHashBeadId();
66
+ if (startId) {
67
+ const unwatch = watch(
68
+ () => store.beads.length > 0,
69
+ (loaded) => {
70
+ if (loaded) {
71
+ store.openBead(startId);
72
+ unwatch();
73
+ }
74
+ },
75
+ { immediate: true },
76
+ );
77
+ }
78
+ });
79
+ onBeforeUnmount(() => {
80
+ window.removeEventListener("hashchange", onHashChange);
81
+ });
82
+
83
+ // Reflect drawer open state into the URL hash (without triggering the
84
+ // hashchange listener above — comparison against current hash short-
85
+ // circuits the cycle).
86
+ watch(
87
+ () => store.openBeadId,
88
+ (id) => {
89
+ const desired = id ? `#${id}` : "";
90
+ if (window.location.hash !== desired) {
91
+ // Replace, not push — avoid polluting browser history for every
92
+ // drawer open/close.
93
+ history.replaceState(null, "", desired || window.location.pathname);
94
+ }
95
+ },
96
+ );
97
+
98
+ const vm = computed(() => store.viewModel);
99
+
100
+ // Bar kinds as a count list, deliberately text (bp-67g.16 rejected charting
101
+ // a six-way split that is a two-way split at default filters). It is a fact
102
+ // about the RENDERER — "future" vs "futureBacklog" is inside or beyond the
103
+ // scheduling horizon, and the count is of bars drawn in this window — so it
104
+ // sits under the Gantt with the other counts, not on the project-wide view
105
+ // (bp-67g.36).
106
+ // Wait segments (bp-6cg) are the calendar around the work, not bars of
107
+ // work, so the footer counts neither them nor their kinds.
108
+ const workBars = computed(() => (vm.value?.bars ?? []).filter((b) => !isWaitKind(b.kind)));
109
+ const barKindCounts = computed(() => {
110
+ const m = new Map<string, number>();
111
+ for (const b of workBars.value) m.set(b.kind, (m.get(b.kind) ?? 0) + 1);
112
+ return [...m.entries()].sort((a, b) => b[1] - a[1]);
113
+ });
114
+
115
+ // A watched id can outlive its bead — deleted, renamed, or belonging to
116
+ // another repo. Those are counted and mentioned rather than silently
117
+ // dropped, so a shrinking badge isn't a mystery.
118
+ useLiveRefresh();
119
+
120
+ // Ticks so the age label counts up on its own rather than freezing at
121
+ // whatever it said when the payload landed. 1s: the label's smallest unit.
122
+ const clock = ref(Date.now());
123
+ let clockTimer: ReturnType<typeof setInterval> | null = null;
124
+ onMounted(() => {
125
+ clockTimer = setInterval(() => (clock.value = Date.now()), 1000);
126
+ });
127
+ onBeforeUnmount(() => {
128
+ if (clockTimer !== null) clearInterval(clockTimer);
129
+ });
130
+
131
+ /**
132
+ * "updated 12s ago". This is what makes polling "off" a safe choice rather
133
+ * than a trap: stale data stays visible AS stale.
134
+ */
135
+ const ageLabel = computed(() =>
136
+ store.lastFetched
137
+ ? formatAge(clock.value - store.lastFetched.getTime())
138
+ : null,
139
+ );
140
+
141
+ const staleWatchCount = computed(
142
+ () => store.watched.length - store.watchedBeads.length,
143
+ );
144
+
145
+ // The calibration strip that sat here on every view (bp-67g.29) moved into
146
+ // the incytes view (bp-dgq): it is a project-wide fact, and that is the
147
+ // project-wide surface. See BeadsIncytes.vue's Allocation block.
148
+
149
+ </script>
150
+
151
+ <template>
152
+ <div class="app">
153
+ <SearchPalette />
154
+ <ShortcutsOverlay />
155
+ <ChangelogOverlay />
156
+ <Walkthrough />
157
+ <ContextMenu />
158
+ <Toast />
159
+ <WhatIfPanel />
160
+ <header class="app-header">
161
+ <!-- The mark replaces the wordmark as TEXT, so the accessible name
162
+ has to come from somewhere: the SVG is aria-hidden and the h1
163
+ keeps a visually-hidden label. -->
164
+ <h1 class="app-title">
165
+ <BeadcyteMark />
166
+ <span class="visually-hidden">beadcyte</span>
167
+ </h1>
168
+ <!-- Outside the h1 deliberately. The version arrived inside it, which
169
+ made the page's main heading announce as "beadcytev0.1.0" — the
170
+ visually-hidden label and the tag concatenate with no separator.
171
+ A version is not part of the product's name. -->
172
+ <!-- "What changed" is the question a version number provokes, so the
173
+ tag is the way in to the changelog (bp-67g.46). -->
174
+ <button
175
+ type="button"
176
+ class="version-btn"
177
+ :title="`beadcyte ${version} — what changed`"
178
+ aria-label="What changed in this version"
179
+ @click="store.openChangelog()"
180
+ ><span class="version-tag">v{{ version }}</span></button>
181
+ <div class="app-meta">
182
+ <!-- A background refresh keeps the chart and says so quietly here;
183
+ only a first load or a project switch shows the meter. -->
184
+ <span v-if="store.loading && store.refreshingInPlace" class="refreshing">
185
+ <RefreshCw class="icon spin" /> refreshing
186
+ </span>
187
+ <span v-else-if="store.loading">loading…</span>
188
+ <span v-else-if="store.error" class="error">error: {{ store.error }}</span>
189
+ <span v-else-if="store.generatedAt">
190
+ {{ store.beads.length }} beads<template v-if="ageLabel"> · {{ ageLabel }}</template>
191
+ </span>
192
+ <!-- A failed refresh keeps the last good data on screen; the error
193
+ belongs beside the age, not in place of the chart. -->
194
+ <span v-if="store.error && store.payload" class="error stale-warn">
195
+ · refresh failed
196
+ </span>
197
+ </div>
198
+ <!-- Watch list: count badge with a hover list. Read-only here — the
199
+ drawer header owns adding and removing. Sits AFTER the load status
200
+ so the ticking "updated Ns ago" text cannot shove it around. -->
201
+ <div v-if="store.watched.length" class="watch-menu">
202
+ <button
203
+ type="button"
204
+ class="watch-trigger"
205
+ :aria-label="`${store.watchedBeads.length} watched`"
206
+ >
207
+ <Eye class="icon" />
208
+ <span class="watch-count">{{ store.watchedBeads.length }}</span>
209
+ </button>
210
+ <div class="watch-popover">
211
+ <p v-if="!store.watchedBeads.length" class="watch-empty">
212
+ Nothing watched is in view.
213
+ </p>
214
+ <button
215
+ v-for="b in store.watchedBeads"
216
+ :key="b.id"
217
+ type="button"
218
+ class="watch-item"
219
+ @click="store.openBead(b.id)"
220
+ >
221
+ <code>{{ b.id }}</code>
222
+ <span class="watch-item-title">{{ b.title }}</span>
223
+ </button>
224
+ <p v-if="staleWatchCount" class="watch-stale">
225
+ {{ staleWatchCount }} watched
226
+ {{ staleWatchCount === 1 ? "bead is" : "beads are" }} no longer in
227
+ this repo.
228
+ </p>
229
+ </div>
230
+ </div>
231
+ <!-- The gear comes last, right of the load status and the watch badge:
232
+ appearance and
233
+ projects live in its menu, and its panel opens leftward from the
234
+ header edge. -->
235
+ <div class="theme-controls">
236
+ <OptionsMenu />
237
+ </div>
238
+ </header>
239
+
240
+ <ControlsPanel />
241
+
242
+ <BeadDrawer />
243
+
244
+ <section class="chart-wrap">
245
+ <!-- The meter replaces the view only when there is nothing to show —
246
+ a first load or a project switch. A background poll leaves the
247
+ chart up and swaps data in behind the header's activity hint. -->
248
+ <LoadMeter v-if="!vm && store.loading" />
249
+ <!-- v-if, not v-show: the unselected view is unmounted so it costs
250
+ nothing on every filter/window change. -->
251
+ <BeadsMine v-else-if="store.view === 'mine'" :vm="vm" />
252
+ <BeadsGantt v-else-if="store.view === 'gantt'" :vm="vm" />
253
+ <BeadsIncytes v-else-if="store.view === 'incytes'" :vm="vm" />
254
+ <!-- Reads store.tableViewModel itself: the same set with the per-group
255
+ cap lifted (bp-67g.44), so it does not take the Gantt's vm. -->
256
+ <BeadsTable v-else-if="store.view === 'table'" />
257
+ <BeadsGrid v-else :vm="vm" />
258
+ <!-- Describes the view model the gantt and grid are drawing. Incytes
259
+ carries its own totals, and row/bar counts are not what it is
260
+ about, so it doesn't inherit this line. -->
261
+ <p v-if="store.view === 'gantt' || store.view === 'grid'" class="counts">
262
+ <code>{{ vm?.rows.length ?? 0 }}</code> rows ·
263
+ <!-- Bars are a timeline concept; the grid doesn't draw any. -->
264
+ <template v-if="store.view === 'gantt'">
265
+ <code>{{ workBars.length }}</code> bars<template v-if="barKindCounts.length">
266
+ (<template v-for="([kind, n], i) in barKindCounts" :key="kind"><template v-if="i"> · </template><Term class="kind-term" :note="BAR_KIND_NOTES[kind as BarKind]"><code class="kind">{{ kind }}</code></Term> {{ n }}</template>)</template> ·
267
+ </template>
268
+ <code>{{ vm?.groups.length ?? 0 }}</code> groups
269
+ </p>
270
+ </section>
271
+
272
+ </div>
273
+ </template>
274
+
275
+ <style>
276
+ body {
277
+ margin: 0;
278
+ background: var(--c-bg);
279
+ color: var(--c-text);
280
+ font-family:
281
+ ui-sans-serif,
282
+ -apple-system,
283
+ Segoe UI,
284
+ Roboto,
285
+ sans-serif;
286
+ font-size: 14px;
287
+ line-height: 1.5;
288
+ }
289
+
290
+ .app {
291
+ display: flex;
292
+ flex-direction: column;
293
+ min-height: 100vh;
294
+ }
295
+
296
+ .app-meta .refreshing {
297
+ display: inline-flex;
298
+ align-items: center;
299
+ gap: 5px;
300
+ color: var(--c-dim);
301
+ }
302
+
303
+ .app-meta .spin {
304
+ animation: bp-spin 0.9s linear infinite;
305
+ }
306
+
307
+ @keyframes bp-spin {
308
+ to {
309
+ transform: rotate(360deg);
310
+ }
311
+ }
312
+
313
+ .app-meta .stale-warn {
314
+ color: var(--c-warning);
315
+ }
316
+
317
+ .theme-controls {
318
+ display: flex;
319
+ align-items: center;
320
+ gap: 8px;
321
+ margin-left: 12px;
322
+ }
323
+
324
+
325
+ /* Watch menu. Hover-opened rather than click-toggled: it's a glance at a
326
+ short list, not a mode to enter. */
327
+ .watch-menu {
328
+ position: relative;
329
+ margin-left: 12px;
330
+ }
331
+
332
+ /* Same geometry as the gear next to it (OptionsMenu.vue .options-trigger):
333
+ 16px icon, 3px/7px padding, one radius token, so the pair reads as one
334
+ control group and shares a 24px height. */
335
+ .watch-trigger {
336
+ display: inline-flex;
337
+ align-items: center;
338
+ gap: 5px;
339
+ height: 24px;
340
+ box-sizing: border-box;
341
+ padding: 3px 7px;
342
+ line-height: 16px;
343
+ border: 1px solid var(--c-border);
344
+ border-radius: var(--radius-sm);
345
+ background: var(--c-panel-2);
346
+ color: var(--c-accent);
347
+ font: inherit;
348
+ font-size: 11px;
349
+ cursor: default;
350
+ }
351
+
352
+ .watch-trigger .icon {
353
+ width: 16px;
354
+ height: 16px;
355
+ }
356
+
357
+ .watch-count {
358
+ font-weight: 700;
359
+ font-variant-numeric: tabular-nums;
360
+ }
361
+
362
+ .watch-popover {
363
+ position: absolute;
364
+ top: calc(100% + 4px);
365
+ right: 0;
366
+ z-index: 400;
367
+ min-width: 280px;
368
+ max-width: 420px;
369
+ max-height: 320px;
370
+ overflow: auto;
371
+ overscroll-behavior: contain;
372
+ padding: 4px;
373
+ border: 1px solid var(--c-border);
374
+ border-radius: 4px;
375
+ background: var(--c-panel);
376
+ box-shadow: var(--shadow-pop);
377
+
378
+ /* Not display:none — that can't be transitioned, so the list vanished the
379
+ instant the pointer left the trigger, before it could reach the list.
380
+ Closing is delayed and only then fades; opening is immediate. */
381
+ visibility: hidden;
382
+ opacity: 0;
383
+ transition:
384
+ opacity 0.15s ease-out 0.35s,
385
+ visibility 0s linear 0.5s;
386
+ }
387
+
388
+ /* Bridges the 4px gap between trigger and list. Without it that strip
389
+ belongs to neither element, so travelling down to the list crossed dead
390
+ ground and dismissed it. As a child of the popover it keeps
391
+ .watch-menu:hover true, and it spans the full width so a diagonal path to
392
+ an entry stays covered. */
393
+ .watch-popover::before {
394
+ content: "";
395
+ position: absolute;
396
+ left: 0;
397
+ right: 0;
398
+ top: -6px;
399
+ height: 6px;
400
+ }
401
+
402
+ .watch-menu:hover .watch-popover,
403
+ .watch-menu:focus-within .watch-popover {
404
+ visibility: visible;
405
+ opacity: 1;
406
+ transition:
407
+ opacity 0.1s ease-out,
408
+ visibility 0s;
409
+ }
410
+
411
+ .watch-item {
412
+ display: flex;
413
+ align-items: baseline;
414
+ gap: 8px;
415
+ width: 100%;
416
+ padding: 5px 8px;
417
+ border: none;
418
+ border-radius: 3px;
419
+ background: transparent;
420
+ color: var(--c-text);
421
+ font: inherit;
422
+ font-size: 11px;
423
+ text-align: left;
424
+ cursor: pointer;
425
+ }
426
+
427
+ .watch-item:hover,
428
+ .watch-item:focus-visible {
429
+ background: var(--c-panel-2);
430
+ }
431
+
432
+ .watch-item code {
433
+ color: var(--c-accent);
434
+ flex: none;
435
+ }
436
+
437
+ .watch-item-title {
438
+ overflow: hidden;
439
+ text-overflow: ellipsis;
440
+ white-space: nowrap;
441
+ }
442
+
443
+ .watch-empty,
444
+ .watch-stale {
445
+ margin: 0;
446
+ padding: 6px 8px;
447
+ font-size: 11px;
448
+ color: var(--c-dim);
449
+ }
450
+
451
+ .version-tag {
452
+ font-size: 10px;
453
+ color: var(--c-dim);
454
+ font-variant-numeric: tabular-nums;
455
+ }
456
+
457
+ .app-header {
458
+ display: flex;
459
+ align-items: center;
460
+ padding: 12px 20px;
461
+ border-bottom: 1px solid var(--c-border);
462
+ background: var(--c-panel);
463
+ }
464
+
465
+ /* The mark paints with currentColor, so this rule is what colours it.
466
+ --c-accent, because that is what the text wordmark it replaces used, and
467
+ a named style recolouring its accent recolours the mark with it — which
468
+ is the point of asking for a theme-responsive logo. font-size drives the
469
+ height, since the mark is sized in em.
470
+
471
+ One rule, not two: the version tag arrived with its own .app-title block
472
+ ahead of this one, whose font-size was already dead because this one sits
473
+ later in the file. `gap` and `font-weight` are the parts of it that were
474
+ actually taking effect, folded in here. */
475
+ .app-title {
476
+ display: flex;
477
+ align-items: center;
478
+ margin: 0;
479
+ font-size: 15px;
480
+ font-weight: 600;
481
+ color: var(--c-accent);
482
+ }
483
+
484
+ /* Reachable by a screen reader, absent to everyone else. */
485
+ .visually-hidden {
486
+ position: absolute;
487
+ width: 1px;
488
+ height: 1px;
489
+ margin: -1px;
490
+ padding: 0;
491
+ overflow: hidden;
492
+ clip-path: inset(50%);
493
+ white-space: nowrap;
494
+ border: 0;
495
+ }
496
+
497
+
498
+
499
+ .app-meta {
500
+ font-size: 12px;
501
+ color: var(--c-dim);
502
+ }
503
+
504
+ .app-meta .error {
505
+ color: var(--c-danger);
506
+ }
507
+
508
+ .controls {
509
+ display: flex;
510
+ gap: 24px;
511
+ flex-wrap: wrap;
512
+ padding: 12px 20px;
513
+ border-bottom: 1px solid var(--c-border);
514
+ background: var(--c-panel);
515
+ align-items: center;
516
+ }
517
+
518
+ .control {
519
+ display: flex;
520
+ align-items: center;
521
+ gap: 8px;
522
+ }
523
+
524
+ .control > label {
525
+ font-size: 11px;
526
+ text-transform: uppercase;
527
+ letter-spacing: 0.05em;
528
+ color: var(--c-dim);
529
+ }
530
+
531
+ .segmented {
532
+ display: inline-flex;
533
+ border: 1px solid var(--c-border);
534
+ border-radius: 4px;
535
+ overflow: hidden;
536
+ }
537
+
538
+ .segmented button {
539
+ background: transparent;
540
+ color: var(--c-dim);
541
+ border: none;
542
+ padding: 4px 10px;
543
+ font-size: 11px;
544
+ cursor: pointer;
545
+ border-right: 1px solid var(--c-border);
546
+ }
547
+
548
+ .segmented button:last-child {
549
+ border-right: none;
550
+ }
551
+
552
+ .segmented button.active {
553
+ background: var(--c-accent);
554
+ color: var(--c-bg);
555
+ }
556
+
557
+ .chart-wrap {
558
+ padding: 20px 20px 4px;
559
+ }
560
+
561
+ /* The gantt and grid are followed by the counts line, which supplies the
562
+ bottom gap. Incytes isn't, so it needs its own. */
563
+ .chart-wrap:has(.incytes),
564
+ .chart-wrap:has(.mine),
565
+ .chart-wrap:has(.beads-table) {
566
+ padding-bottom: 24px;
567
+ }
568
+
569
+
570
+ /* The version tag is a button now; keep the tag's own look and add the
571
+ affordance on hover only, so the header stays quiet. */
572
+ .version-btn {
573
+ all: unset;
574
+ display: inline-flex;
575
+ cursor: pointer;
576
+ border-radius: 3px;
577
+ margin-left: 10px;
578
+ margin-right: auto; /* everything after the tag sits at the right edge */
579
+ }
580
+
581
+ .version-btn:hover .version-tag,
582
+ .version-btn:focus-visible .version-tag {
583
+ color: var(--c-accent);
584
+ text-decoration: underline dotted;
585
+ text-underline-offset: 2px;
586
+ }
587
+
588
+ .version-btn:focus-visible {
589
+ outline: 2px solid var(--c-accent);
590
+ outline-offset: 1px;
591
+ }
592
+
593
+ .chart-wrap .counts {
594
+ color: var(--c-dim);
595
+ font-size: 11px;
596
+ margin: 6px 4px 0;
597
+ }
598
+
599
+ .chart-wrap .counts code {
600
+ color: var(--c-accent);
601
+ }
602
+
603
+ /* Kind names are the renderer's own vocabulary; keep them quieter than the
604
+ totals they qualify. */
605
+ .chart-wrap .counts code.kind {
606
+ color: var(--c-dim-bright);
607
+ }
608
+
609
+ .skeleton-note {
610
+ grid-column: 1 / -1;
611
+ padding: 12px 16px;
612
+ background: var(--c-panel);
613
+ border: 1px solid var(--c-border);
614
+ border-radius: 4px;
615
+ color: var(--c-dim);
616
+ font-size: 12px;
617
+ }
618
+
619
+ .skeleton-note code {
620
+ color: var(--c-accent);
621
+ }
622
+
623
+ .stat-block {
624
+ background: var(--c-panel);
625
+ border: 1px solid var(--c-border);
626
+ border-radius: 4px;
627
+ padding: 16px;
628
+ }
629
+
630
+ .stat-block h2 {
631
+ margin: 0 0 12px;
632
+ font-size: 12px;
633
+ text-transform: uppercase;
634
+ letter-spacing: 0.05em;
635
+ color: var(--c-dim);
636
+ font-weight: 600;
637
+ }
638
+
639
+ .stat-block ul {
640
+ list-style: none;
641
+ padding: 0;
642
+ margin: 0;
643
+ }
644
+
645
+ .stat-block li {
646
+ display: flex;
647
+ justify-content: space-between;
648
+ padding: 4px 0;
649
+ border-bottom: 1px solid var(--c-border);
650
+ gap: 8px;
651
+ }
652
+
653
+ .stat-block li:last-child {
654
+ border-bottom: none;
655
+ }
656
+
657
+ /* Scoped to the stat block: bare .group-label is a name three components
658
+ use, and leaving it unqualified is how BeadsGrid's copy ended up
659
+ restyling this one. Size and weight are stated rather than inherited
660
+ from that accident. */
661
+ .stat-block .group-label {
662
+ font-size: 12px;
663
+ font-weight: 600;
664
+ overflow: hidden;
665
+ text-overflow: ellipsis;
666
+ white-space: nowrap;
667
+ }
668
+
669
+ .stat-count {
670
+ color: var(--c-dim);
671
+ font-variant-numeric: tabular-nums;
672
+ white-space: nowrap;
673
+ }
674
+
675
+ .stat-count em {
676
+ font-style: normal;
677
+ color: var(--c-danger);
678
+ margin-left: 6px;
679
+ }
680
+
681
+ .hint {
682
+ font-size: 11px;
683
+ color: var(--c-dim);
684
+ margin: -4px 0 8px;
685
+ }
686
+
687
+ code {
688
+ font-family: ui-monospace, monospace;
689
+ font-size: 12px;
690
+ }
691
+ </style>