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
package/src/web/App.vue
ADDED
|
@@ -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>
|