beadcyte 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +73 -0
- package/LICENSE +661 -0
- package/README.md +386 -0
- package/THIRD_PARTY_NOTICES.md +56 -0
- package/bin/beadcyte.mjs +60 -0
- package/package.json +77 -0
- package/src/changelog-cli.mjs +105 -0
- package/src/changelog.mjs +196 -0
- package/src/cli.mjs +577 -0
- package/src/estimator.mjs +314 -0
- package/src/format.mjs +22 -0
- package/src/history-walk.mjs +170 -0
- package/src/index.mjs +5 -0
- package/src/mutate.mjs +193 -0
- package/src/projects.mjs +120 -0
- package/src/provenance.mjs +75 -0
- package/src/review-hours.mjs +117 -0
- package/src/roster-path.mjs +24 -0
- package/src/scheduler.mjs +424 -0
- package/src/serve.mjs +411 -0
- package/src/server-state.mjs +105 -0
- package/src/ship.mjs +178 -0
- package/src/stage-waits.mjs +69 -0
- package/src/start.mjs +111 -0
- package/src/stop.mjs +66 -0
- package/src/velocity.mjs +209 -0
- package/src/web/App.vue +691 -0
- package/src/web/app.css +54 -0
- package/src/web/assets/favicon.svg +12 -0
- package/src/web/avatar.ts +53 -0
- package/src/web/bead-detail.ts +240 -0
- package/src/web/changelog-view.ts +41 -0
- package/src/web/components/BeadDrawer.vue +1883 -0
- package/src/web/components/BeadSubGraph.vue +326 -0
- package/src/web/components/BeadSubGraphOverlay.vue +192 -0
- package/src/web/components/BeadTooltip.vue +516 -0
- package/src/web/components/BeadcyteMark.vue +64 -0
- package/src/web/components/BeadsGantt.vue +2125 -0
- package/src/web/components/BeadsGrid.vue +468 -0
- package/src/web/components/BeadsIncytes.vue +567 -0
- package/src/web/components/BeadsMine.vue +325 -0
- package/src/web/components/BeadsTable.vue +335 -0
- package/src/web/components/ChangelogOverlay.vue +198 -0
- package/src/web/components/ContextMenu.vue +386 -0
- package/src/web/components/ControlsPanel.vue +476 -0
- package/src/web/components/CostTrend.vue +206 -0
- package/src/web/components/FilterPopover.vue +245 -0
- package/src/web/components/GroupProgress.vue +274 -0
- package/src/web/components/LoadMeter.vue +144 -0
- package/src/web/components/MineRow.vue +28 -0
- package/src/web/components/OptionsMenu.vue +825 -0
- package/src/web/components/PriorityChip.vue +105 -0
- package/src/web/components/ScoreStrip.vue +131 -0
- package/src/web/components/SearchPalette.vue +210 -0
- package/src/web/components/ShipTrend.vue +510 -0
- package/src/web/components/ShortcutsOverlay.vue +164 -0
- package/src/web/components/Term.vue +177 -0
- package/src/web/components/Toast.vue +50 -0
- package/src/web/components/TriageMeters.vue +426 -0
- package/src/web/components/TypeChip.vue +96 -0
- package/src/web/components/Walkthrough.vue +209 -0
- package/src/web/components/WhatIfPanel.vue +206 -0
- package/src/web/components/WipBullets.vue +191 -0
- package/src/web/components/filter-option.ts +9 -0
- package/src/web/composables/url-codec.ts +136 -0
- package/src/web/composables/useBeadTooltip.ts +148 -0
- package/src/web/composables/useKeyboard.ts +97 -0
- package/src/web/composables/useLiveRefresh.ts +69 -0
- package/src/web/composables/useTheme.ts +125 -0
- package/src/web/composables/useUrlState.ts +208 -0
- package/src/web/controls-scope.ts +83 -0
- package/src/web/cost.ts +251 -0
- package/src/web/dep-headings.ts +62 -0
- package/src/web/economics.ts +440 -0
- package/src/web/env.d.ts +85 -0
- package/src/web/frontier.ts +208 -0
- package/src/web/gantt-viewport.ts +99 -0
- package/src/web/highlights.ts +124 -0
- package/src/web/index.html +46 -0
- package/src/web/insights.ts +107 -0
- package/src/web/keybindings.ts +200 -0
- package/src/web/load-meter.ts +72 -0
- package/src/web/main.ts +20 -0
- package/src/web/markdown.ts +14 -0
- package/src/web/mine.ts +137 -0
- package/src/web/mutations.ts +21 -0
- package/src/web/person.ts +102 -0
- package/src/web/projects-text.ts +15 -0
- package/src/web/projects.ts +188 -0
- package/src/web/refresh.ts +47 -0
- package/src/web/search.ts +50 -0
- package/src/web/shortcuts.ts +113 -0
- package/src/web/status-filter.ts +48 -0
- package/src/web/store.ts +1378 -0
- package/src/web/style-audit.mjs +346 -0
- package/src/web/styles-alt.css +111 -0
- package/src/web/styles-ported.css +270 -0
- package/src/web/subgraph.ts +362 -0
- package/src/web/table.ts +201 -0
- package/src/web/theme.ts +88 -0
- package/src/web/tokens.css +168 -0
- package/src/web/triage.ts +914 -0
- package/src/web/view-model.ts +717 -0
- package/src/web/walkthrough.ts +133 -0
- package/src/web/watchlist.ts +47 -0
- package/src/web/whatif.ts +291 -0
- package/src/web/window.ts +73 -0
- package/src/web/wip.ts +83 -0
|
@@ -0,0 +1,825 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
// OptionsMenu.vue — the app's one options surface.
|
|
3
|
+
//
|
|
4
|
+
// Owns the project registry (add / rename / forget / reorder / switch) and
|
|
5
|
+
// the refresh controls. bp-67g.14 moves the theme and style controls in
|
|
6
|
+
// here too, rather than each growing its own menu.
|
|
7
|
+
//
|
|
8
|
+
// Click-toggled rather than hover-opened, unlike the watch list: this holds
|
|
9
|
+
// text inputs and destructive-looking actions, so it must not vanish when
|
|
10
|
+
// the pointer strays.
|
|
11
|
+
//
|
|
12
|
+
// Paths are TYPED, not picked, and that is not an oversight. No browser API
|
|
13
|
+
// hands out an absolute filesystem path: showDirectoryPicker() returns a
|
|
14
|
+
// handle whose prototype has getDirectoryHandle/getFileHandle/removeEntry/
|
|
15
|
+
// resolve/entries/keys/values and no path at all (resolve() is relative to
|
|
16
|
+
// another handle), and <input webkitdirectory> gives webkitRelativePath,
|
|
17
|
+
// relative to whatever was picked. The server needs an absolute path, so a
|
|
18
|
+
// native picker cannot supply one. A real picker would mean a server-side
|
|
19
|
+
// directory-listing endpoint, which is precisely the filesystem-probing
|
|
20
|
+
// oracle the registration allowlist exists to close (see src/projects.mjs).
|
|
21
|
+
// Decided: keep typed paths, with validation and per-reason messages.
|
|
22
|
+
|
|
23
|
+
import { computed, nextTick, ref, watch } from "vue";
|
|
24
|
+
import {
|
|
25
|
+
Settings,
|
|
26
|
+
Plus,
|
|
27
|
+
X,
|
|
28
|
+
ChevronUp,
|
|
29
|
+
ChevronDown,
|
|
30
|
+
RefreshCw,
|
|
31
|
+
Check,
|
|
32
|
+
Sun,
|
|
33
|
+
Moon,
|
|
34
|
+
Monitor,
|
|
35
|
+
} from "@lucide/vue";
|
|
36
|
+
import { useBeadsStore } from "../store";
|
|
37
|
+
import { REFRESH_CHOICES } from "../refresh";
|
|
38
|
+
import { useTheme } from "../composables/useTheme";
|
|
39
|
+
import { STYLE_NAMES, STYLE_LABELS, THEME_MODES, type ThemeMode } from "../theme";
|
|
40
|
+
import { REFUSAL_TEXT } from "../projects-text";
|
|
41
|
+
|
|
42
|
+
const store = useBeadsStore();
|
|
43
|
+
|
|
44
|
+
const theme = useTheme();
|
|
45
|
+
|
|
46
|
+
// Get version from environment variables
|
|
47
|
+
const version = import.meta.env.VITE_BEADCYTE_VERSION || 'unknown';
|
|
48
|
+
|
|
49
|
+
// Explicit three-way rather than a cycling button: with 'system' in the set
|
|
50
|
+
// a single toggle cannot show which of the three is active, and "follows the
|
|
51
|
+
// OS" is a different statement from "dark".
|
|
52
|
+
const MODE_ICON: Record<ThemeMode, typeof Sun> = {
|
|
53
|
+
light: Sun,
|
|
54
|
+
dark: Moon,
|
|
55
|
+
system: Monitor,
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
const open = ref(false);
|
|
59
|
+
// "," asks for the menu through the store (bp-67g.45).
|
|
60
|
+
watch(() => store.optionsRequests, () => { open.value = true; });
|
|
61
|
+
const newPath = ref("");
|
|
62
|
+
const newLabel = ref("");
|
|
63
|
+
const addError = ref<string | null>(null);
|
|
64
|
+
const adding = ref(false);
|
|
65
|
+
const renamingId = ref<string | null>(null);
|
|
66
|
+
const renameText = ref("");
|
|
67
|
+
// The form is the rare action here; switching projects is the common one.
|
|
68
|
+
// Collapsed by default so the list is what the menu leads with.
|
|
69
|
+
const addOpen = ref(false);
|
|
70
|
+
|
|
71
|
+
const projects = computed(() => store.projects);
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Why a registry entry is unusable against the CURRENT server, if it is.
|
|
75
|
+
* The reconcile in loadProjects() re-registers everything the server has
|
|
76
|
+
* forgotten; anything that still fails validation is dead until the
|
|
77
|
+
* directory comes back, and saying so here is what keeps the row from
|
|
78
|
+
* being a click that 403s.
|
|
79
|
+
*/
|
|
80
|
+
function unavailableReason(path: string): string | null {
|
|
81
|
+
const code = store.unavailableProjects[path];
|
|
82
|
+
if (!code) return null;
|
|
83
|
+
return REFUSAL_TEXT[code] ?? code;
|
|
84
|
+
}
|
|
85
|
+
const activeId = computed(() => store.activeProject?.id ?? null);
|
|
86
|
+
|
|
87
|
+
async function submitAdd() {
|
|
88
|
+
const path = newPath.value.trim();
|
|
89
|
+
if (!path || adding.value) return;
|
|
90
|
+
adding.value = true;
|
|
91
|
+
addError.value = null;
|
|
92
|
+
const result = await store.addProjectByPath(path, newLabel.value);
|
|
93
|
+
adding.value = false;
|
|
94
|
+
if (result.ok) {
|
|
95
|
+
newPath.value = "";
|
|
96
|
+
newLabel.value = "";
|
|
97
|
+
addOpen.value = false;
|
|
98
|
+
} else {
|
|
99
|
+
addError.value = REFUSAL_TEXT[result.reason] ?? result.reason;
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function startRename(id: string, current: string) {
|
|
104
|
+
renamingId.value = id;
|
|
105
|
+
renameText.value = current;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function commitRename() {
|
|
109
|
+
if (renamingId.value) store.renameProject(renamingId.value, renameText.value);
|
|
110
|
+
renamingId.value = null;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// Closing discards a half-typed path rather than keeping it to reappear
|
|
114
|
+
// later with no context.
|
|
115
|
+
watch(open, (isOpen) => {
|
|
116
|
+
if (!isOpen) {
|
|
117
|
+
addError.value = null;
|
|
118
|
+
renamingId.value = null;
|
|
119
|
+
addOpen.value = false;
|
|
120
|
+
}
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
// Focus the path field when the form appears, so the button-then-type flow
|
|
124
|
+
// doesn't need a second click.
|
|
125
|
+
const pathInput = ref<HTMLInputElement | null>(null);
|
|
126
|
+
watch(addOpen, async (isOpen) => {
|
|
127
|
+
if (!isOpen) {
|
|
128
|
+
addError.value = null;
|
|
129
|
+
return;
|
|
130
|
+
}
|
|
131
|
+
await nextTick();
|
|
132
|
+
pathInput.value?.focus();
|
|
133
|
+
});
|
|
134
|
+
</script>
|
|
135
|
+
|
|
136
|
+
<template>
|
|
137
|
+
<div class="options-menu">
|
|
138
|
+
<button
|
|
139
|
+
type="button"
|
|
140
|
+
class="options-trigger"
|
|
141
|
+
:class="{ 'is-open': open }"
|
|
142
|
+
:aria-expanded="open"
|
|
143
|
+
aria-label="Options"
|
|
144
|
+
title="Options"
|
|
145
|
+
@click="open = !open"
|
|
146
|
+
>
|
|
147
|
+
<Settings class="icon" />
|
|
148
|
+
</button>
|
|
149
|
+
|
|
150
|
+
<!-- Backdrop closes on an outside click without swallowing the click
|
|
151
|
+
that opened it. -->
|
|
152
|
+
<div v-if="open" class="options-backdrop" @click="open = false" />
|
|
153
|
+
|
|
154
|
+
<section v-if="open" class="options-panel" aria-label="Options">
|
|
155
|
+
<h3>Projects</h3>
|
|
156
|
+
<p v-if="!projects.length" class="options-empty">
|
|
157
|
+
No projects registered yet.
|
|
158
|
+
</p>
|
|
159
|
+
<ul v-else class="project-list">
|
|
160
|
+
<li
|
|
161
|
+
v-for="(p, i) in projects"
|
|
162
|
+
:key="p.id"
|
|
163
|
+
:class="{
|
|
164
|
+
'is-active': p.id === activeId,
|
|
165
|
+
'is-unavailable': !!unavailableReason(p.path),
|
|
166
|
+
}"
|
|
167
|
+
>
|
|
168
|
+
<button
|
|
169
|
+
type="button"
|
|
170
|
+
class="project-pick"
|
|
171
|
+
:aria-current="p.id === activeId"
|
|
172
|
+
:disabled="!!unavailableReason(p.path)"
|
|
173
|
+
:title="unavailableReason(p.path) ?? p.path"
|
|
174
|
+
@click="store.setActiveProject(p.id)"
|
|
175
|
+
>
|
|
176
|
+
<Check v-if="p.id === activeId" class="icon pick-tick" />
|
|
177
|
+
<span v-else class="pick-tick-spacer" />
|
|
178
|
+
<span v-if="renamingId !== p.id" class="project-label">{{ p.label }}</span>
|
|
179
|
+
<input
|
|
180
|
+
v-else
|
|
181
|
+
v-model="renameText"
|
|
182
|
+
class="rename-input"
|
|
183
|
+
@click.stop
|
|
184
|
+
@keydown.enter="commitRename"
|
|
185
|
+
@keydown.esc="renamingId = null"
|
|
186
|
+
@blur="commitRename"
|
|
187
|
+
/>
|
|
188
|
+
<code class="project-path">{{ p.path }}</code>
|
|
189
|
+
<span v-if="unavailableReason(p.path)" class="project-unavailable">
|
|
190
|
+
unavailable — {{ unavailableReason(p.path) }}
|
|
191
|
+
</span>
|
|
192
|
+
</button>
|
|
193
|
+
<div class="project-actions">
|
|
194
|
+
<button
|
|
195
|
+
type="button"
|
|
196
|
+
:disabled="i === 0"
|
|
197
|
+
aria-label="Move up"
|
|
198
|
+
title="Move up"
|
|
199
|
+
@click="store.moveProject(p.id, -1)"
|
|
200
|
+
><ChevronUp class="icon" /></button>
|
|
201
|
+
<button
|
|
202
|
+
type="button"
|
|
203
|
+
:disabled="i === projects.length - 1"
|
|
204
|
+
aria-label="Move down"
|
|
205
|
+
title="Move down"
|
|
206
|
+
@click="store.moveProject(p.id, 1)"
|
|
207
|
+
><ChevronDown class="icon" /></button>
|
|
208
|
+
<button
|
|
209
|
+
type="button"
|
|
210
|
+
aria-label="Rename"
|
|
211
|
+
title="Rename"
|
|
212
|
+
@click="startRename(p.id, p.label)"
|
|
213
|
+
>✎</button>
|
|
214
|
+
<!-- Wording matters: this forgets a location. Nothing on disk
|
|
215
|
+
is touched, and "delete project" would read otherwise. -->
|
|
216
|
+
<button
|
|
217
|
+
type="button"
|
|
218
|
+
class="forget"
|
|
219
|
+
aria-label="Forget this location"
|
|
220
|
+
title="Forget this location (nothing on disk is deleted)"
|
|
221
|
+
@click="store.forgetProject(p.id)"
|
|
222
|
+
><X class="icon" /></button>
|
|
223
|
+
</div>
|
|
224
|
+
</li>
|
|
225
|
+
</ul>
|
|
226
|
+
|
|
227
|
+
<button
|
|
228
|
+
v-if="!addOpen"
|
|
229
|
+
type="button"
|
|
230
|
+
class="add-toggle"
|
|
231
|
+
@click="addOpen = true"
|
|
232
|
+
>
|
|
233
|
+
<Plus class="icon" /> Add beads project
|
|
234
|
+
</button>
|
|
235
|
+
|
|
236
|
+
<form v-else class="add-project" @submit.prevent="submitAdd">
|
|
237
|
+
<label class="sr-only" for="np">Project path</label>
|
|
238
|
+
<input
|
|
239
|
+
id="np"
|
|
240
|
+
ref="pathInput"
|
|
241
|
+
v-model="newPath"
|
|
242
|
+
class="add-path"
|
|
243
|
+
placeholder="/absolute/path/to/repo"
|
|
244
|
+
spellcheck="false"
|
|
245
|
+
autocomplete="off"
|
|
246
|
+
/>
|
|
247
|
+
<label class="sr-only" for="nl">Label</label>
|
|
248
|
+
<input
|
|
249
|
+
id="nl"
|
|
250
|
+
v-model="newLabel"
|
|
251
|
+
class="add-label"
|
|
252
|
+
placeholder="label (optional)"
|
|
253
|
+
autocomplete="off"
|
|
254
|
+
/>
|
|
255
|
+
<button type="submit" :disabled="adding || !newPath.trim()">
|
|
256
|
+
<Plus class="icon" /> Add
|
|
257
|
+
</button>
|
|
258
|
+
<button
|
|
259
|
+
type="button"
|
|
260
|
+
class="add-cancel"
|
|
261
|
+
aria-label="Cancel"
|
|
262
|
+
title="Cancel"
|
|
263
|
+
@click="addOpen = false"
|
|
264
|
+
><X class="icon" /></button>
|
|
265
|
+
</form>
|
|
266
|
+
<p v-if="addError" class="add-error">{{ addError }}</p>
|
|
267
|
+
<p v-if="addOpen" class="options-note">
|
|
268
|
+
A browser can't hand out absolute paths, so the path is typed.
|
|
269
|
+
</p>
|
|
270
|
+
<p class="options-note">
|
|
271
|
+
Forgetting a project removes it from this list only — nothing on disk
|
|
272
|
+
changes.
|
|
273
|
+
</p>
|
|
274
|
+
|
|
275
|
+
<!-- Who "mine" means (bp-egy). Listed from the assignees present in the
|
|
276
|
+
data, so the stored value is exactly a bd assignee string. -->
|
|
277
|
+
<h3>Current user</h3>
|
|
278
|
+
<div class="refresh-row">
|
|
279
|
+
<label class="sr-only" for="cu">Current user</label>
|
|
280
|
+
<select
|
|
281
|
+
id="cu"
|
|
282
|
+
class="refresh-select"
|
|
283
|
+
:value="store.currentUser ?? ''"
|
|
284
|
+
@change="store.setCurrentUser(($event.target as HTMLSelectElement).value || null)"
|
|
285
|
+
>
|
|
286
|
+
<option value="">not set</option>
|
|
287
|
+
<option v-for="a in store.presentAssignees" :key="a" :value="a">{{ a }}</option>
|
|
288
|
+
<option
|
|
289
|
+
v-if="store.currentUser && !store.presentAssignees.includes(store.currentUser)"
|
|
290
|
+
:value="store.currentUser"
|
|
291
|
+
>{{ store.currentUser }} (not an assignee here)</option>
|
|
292
|
+
</select>
|
|
293
|
+
</div>
|
|
294
|
+
<p class="options-note">
|
|
295
|
+
Who the MINE buttons mean. Stored in this browser; a shared link
|
|
296
|
+
carrying <code>?me=</code> overrides it for that load only<template v-if="store.urlUser">, and this page is showing <code>{{ store.urlUser }}</code></template>.
|
|
297
|
+
</p>
|
|
298
|
+
|
|
299
|
+
<h3>Appearance</h3>
|
|
300
|
+
<div class="mode-row">
|
|
301
|
+
<div class="segmented mode-toggle" role="group" aria-label="Colour mode">
|
|
302
|
+
<button
|
|
303
|
+
v-for="m in THEME_MODES"
|
|
304
|
+
:key="m"
|
|
305
|
+
type="button"
|
|
306
|
+
:class="{ active: theme.mode.value === m }"
|
|
307
|
+
:aria-pressed="theme.mode.value === m"
|
|
308
|
+
:aria-label="m"
|
|
309
|
+
:title="m === 'system' ? 'Follow the OS' : m"
|
|
310
|
+
@click="theme.setMode(m)"
|
|
311
|
+
>
|
|
312
|
+
<component :is="MODE_ICON[m]" class="icon" />
|
|
313
|
+
<span class="mode-name">{{ m }}</span>
|
|
314
|
+
</button>
|
|
315
|
+
</div>
|
|
316
|
+
</div>
|
|
317
|
+
|
|
318
|
+
<!-- Each style shows its OWN colours. The swatch container carries
|
|
319
|
+
data-preview, and the token blocks in tokens.css/styles-alt.css
|
|
320
|
+
match that as well as :root[data-style], so a preview renders in
|
|
321
|
+
a family's palette while a different family is active — and can
|
|
322
|
+
never drift from the style it claims to show. -->
|
|
323
|
+
<ul class="style-list">
|
|
324
|
+
<li v-for="s in STYLE_NAMES" :key="s">
|
|
325
|
+
<button
|
|
326
|
+
type="button"
|
|
327
|
+
class="style-pick"
|
|
328
|
+
:class="{ 'is-active': theme.style.value === s }"
|
|
329
|
+
:aria-current="theme.style.value === s"
|
|
330
|
+
@click="theme.setStyle(s)"
|
|
331
|
+
>
|
|
332
|
+
<span class="style-swatch" :data-preview="s" aria-hidden="true">
|
|
333
|
+
<span class="sw-panel">
|
|
334
|
+
<span class="sw-bar sw-shipped" />
|
|
335
|
+
<span class="sw-bar sw-progress" />
|
|
336
|
+
<span class="sw-bar sw-future" />
|
|
337
|
+
</span>
|
|
338
|
+
</span>
|
|
339
|
+
<span class="style-name">{{ STYLE_LABELS[s] }}</span>
|
|
340
|
+
<Check v-if="theme.style.value === s" class="icon pick-tick" />
|
|
341
|
+
</button>
|
|
342
|
+
</li>
|
|
343
|
+
</ul>
|
|
344
|
+
<p class="options-note">
|
|
345
|
+
Stored in this browser. <code>?theme=</code> and <code>?fx=</code>
|
|
346
|
+
override either for one visit without saving.
|
|
347
|
+
</p>
|
|
348
|
+
|
|
349
|
+
<h3>Refresh</h3>
|
|
350
|
+
<div class="refresh-row">
|
|
351
|
+
<label class="sr-only" for="ri">Refresh interval</label>
|
|
352
|
+
<select
|
|
353
|
+
id="ri"
|
|
354
|
+
class="refresh-select"
|
|
355
|
+
:value="String(store.pollIntervalMs)"
|
|
356
|
+
@change="store.setPollInterval(Number(($event.target as HTMLSelectElement).value))"
|
|
357
|
+
>
|
|
358
|
+
<option v-for="c in REFRESH_CHOICES" :key="c.ms" :value="String(c.ms)">
|
|
359
|
+
{{ c.label }}
|
|
360
|
+
</option>
|
|
361
|
+
</select>
|
|
362
|
+
<button
|
|
363
|
+
type="button"
|
|
364
|
+
class="refresh-now"
|
|
365
|
+
:disabled="store.loading"
|
|
366
|
+
@click="store.fetch()"
|
|
367
|
+
>
|
|
368
|
+
<RefreshCw class="icon" /> Refresh now
|
|
369
|
+
</button>
|
|
370
|
+
</div>
|
|
371
|
+
<p class="options-note">
|
|
372
|
+
Stored in this browser. Every poll shells out to <code>bd</code>, so
|
|
373
|
+
the default is slow.
|
|
374
|
+
</p>
|
|
375
|
+
|
|
376
|
+
<!-- The whole panel is one <section>, and every group is an <h3> plus
|
|
377
|
+
its own content — the version block arrived wrapped in a <div>
|
|
378
|
+
nested inside Refresh, which made "About" read as a sub-heading of
|
|
379
|
+
the poll interval. Flattened to match its siblings, and using the
|
|
380
|
+
panel's existing .options-note rather than an unstyled class. -->
|
|
381
|
+
<h3>Keyboard</h3>
|
|
382
|
+
<p class="options-note">
|
|
383
|
+
Press <kbd>?</kbd> anywhere for the shortcuts — single letters, and
|
|
384
|
+
nothing fires while you are typing.
|
|
385
|
+
<button type="button" class="link-btn" @click="open = false; store.openHelp()">Show them</button>
|
|
386
|
+
</p>
|
|
387
|
+
|
|
388
|
+
<h3>Walkthrough</h3>
|
|
389
|
+
<p class="options-note">
|
|
390
|
+
The first-run tour: bar kinds, the projection being a model, triage, the views.
|
|
391
|
+
<button type="button" class="link-btn open-walkthrough" @click="open = false; store.openWalkthrough()">Show it again</button>
|
|
392
|
+
</p>
|
|
393
|
+
|
|
394
|
+
<h3>About</h3>
|
|
395
|
+
<p class="options-note">
|
|
396
|
+
<button type="button" class="link-btn about-changelog" @click="open = false; store.openChangelog()">What changed</button>
|
|
397
|
+
— the changelog, generated from the tracker and shipped with this build.
|
|
398
|
+
</p>
|
|
399
|
+
<p class="options-note">
|
|
400
|
+
beadcyte <code>{{ version }}</code> — also available as
|
|
401
|
+
<code>beadcyte --version</code>.
|
|
402
|
+
</p>
|
|
403
|
+
</section>
|
|
404
|
+
</div>
|
|
405
|
+
</template>
|
|
406
|
+
|
|
407
|
+
<style>
|
|
408
|
+
.options-menu {
|
|
409
|
+
position: relative;
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
.options-trigger {
|
|
413
|
+
display: inline-flex;
|
|
414
|
+
align-items: center;
|
|
415
|
+
height: 24px;
|
|
416
|
+
box-sizing: border-box;
|
|
417
|
+
padding: 3px 7px;
|
|
418
|
+
line-height: 16px;
|
|
419
|
+
border: 1px solid var(--c-border);
|
|
420
|
+
border-radius: var(--radius-sm);
|
|
421
|
+
background: var(--c-panel-2);
|
|
422
|
+
color: var(--c-dim);
|
|
423
|
+
cursor: pointer;
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
.options-trigger .icon {
|
|
427
|
+
width: 16px;
|
|
428
|
+
height: 16px;
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
.options-trigger:hover,
|
|
432
|
+
.options-trigger.is-open {
|
|
433
|
+
color: var(--c-accent);
|
|
434
|
+
border-color: var(--c-accent);
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
/* Catches an outside click. Transparent rather than a scrim: this is a
|
|
438
|
+
settings popover, not a modal, and dimming the whole app for it would
|
|
439
|
+
overstate the interruption. */
|
|
440
|
+
.options-backdrop {
|
|
441
|
+
position: fixed;
|
|
442
|
+
inset: 0;
|
|
443
|
+
z-index: 450;
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
.options-panel {
|
|
447
|
+
position: absolute;
|
|
448
|
+
top: calc(100% + 6px);
|
|
449
|
+
right: 0;
|
|
450
|
+
z-index: 460;
|
|
451
|
+
width: min(92vw, 560px);
|
|
452
|
+
max-height: min(78vh, 640px);
|
|
453
|
+
overflow: auto;
|
|
454
|
+
overscroll-behavior: contain;
|
|
455
|
+
padding: 12px 14px 14px;
|
|
456
|
+
border: 1px solid var(--c-border);
|
|
457
|
+
border-radius: var(--radius);
|
|
458
|
+
background: var(--c-panel);
|
|
459
|
+
box-shadow: var(--shadow-pop);
|
|
460
|
+
text-align: left;
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
.options-panel h3 {
|
|
464
|
+
margin: 10px 0 8px;
|
|
465
|
+
font-size: 10px;
|
|
466
|
+
text-transform: uppercase;
|
|
467
|
+
letter-spacing: 0.05em;
|
|
468
|
+
color: var(--c-dim);
|
|
469
|
+
font-weight: 600;
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
.options-panel h3:first-child {
|
|
473
|
+
margin-top: 0;
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
.project-list {
|
|
477
|
+
list-style: none;
|
|
478
|
+
margin: 0 0 10px;
|
|
479
|
+
padding: 0;
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
.project-list li {
|
|
483
|
+
display: flex;
|
|
484
|
+
align-items: center;
|
|
485
|
+
gap: 4px;
|
|
486
|
+
border-radius: var(--radius-sm);
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
.project-list li.is-active {
|
|
490
|
+
background: var(--c-accent-wash);
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
.project-pick {
|
|
494
|
+
display: flex;
|
|
495
|
+
align-items: baseline;
|
|
496
|
+
gap: 7px;
|
|
497
|
+
flex: 1;
|
|
498
|
+
min-width: 0;
|
|
499
|
+
padding: 5px 7px;
|
|
500
|
+
border: none;
|
|
501
|
+
background: transparent;
|
|
502
|
+
color: var(--c-text);
|
|
503
|
+
font: inherit;
|
|
504
|
+
font-size: 11px;
|
|
505
|
+
text-align: left;
|
|
506
|
+
cursor: pointer;
|
|
507
|
+
border-radius: var(--radius-sm);
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
.project-pick:hover {
|
|
511
|
+
background: var(--c-panel-2);
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
/* A path the current server won't admit. Dimmed and not clickable, because
|
|
515
|
+
the only thing clicking it could do is 403 — but still listed, since the
|
|
516
|
+
directory may come back and forgetting it is the user's call. The row
|
|
517
|
+
keeps the forget/rename actions for exactly that reason. */
|
|
518
|
+
.project-list li.is-unavailable .project-label,
|
|
519
|
+
.project-list li.is-unavailable .project-path {
|
|
520
|
+
opacity: var(--o-dim-label);
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
.project-pick:disabled {
|
|
524
|
+
cursor: default;
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
.project-pick:disabled:hover {
|
|
528
|
+
background: transparent;
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
.project-unavailable {
|
|
532
|
+
color: var(--c-warning-strong);
|
|
533
|
+
font-size: 10px;
|
|
534
|
+
flex: none;
|
|
535
|
+
white-space: nowrap;
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
.pick-tick {
|
|
539
|
+
color: var(--c-accent);
|
|
540
|
+
flex: none;
|
|
541
|
+
align-self: center;
|
|
542
|
+
}
|
|
543
|
+
|
|
544
|
+
.pick-tick-spacer {
|
|
545
|
+
width: 1.15em;
|
|
546
|
+
flex: none;
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
.project-label {
|
|
550
|
+
font-weight: 600;
|
|
551
|
+
flex: none;
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
/* Plain LTR. `direction: rtl` would ellipsize from the start — keeping the
|
|
555
|
+
informative tail of a long path — but "/" is bidi-neutral, so RTL
|
|
556
|
+
reorders the leading slash to the end and "/home/anon/x" renders as
|
|
557
|
+
"home/anon/x/". The label carries identity and the row's title attribute
|
|
558
|
+
carries the full path, so a right-side ellipsis is the honest trade. */
|
|
559
|
+
.project-path {
|
|
560
|
+
color: var(--c-dim);
|
|
561
|
+
font-size: 10px;
|
|
562
|
+
overflow: hidden;
|
|
563
|
+
text-overflow: ellipsis;
|
|
564
|
+
white-space: nowrap;
|
|
565
|
+
min-width: 0;
|
|
566
|
+
}
|
|
567
|
+
|
|
568
|
+
.rename-input {
|
|
569
|
+
font: inherit;
|
|
570
|
+
font-size: 11px;
|
|
571
|
+
padding: 1px 4px;
|
|
572
|
+
border: 1px solid var(--c-accent);
|
|
573
|
+
border-radius: 2px;
|
|
574
|
+
background: var(--c-bg);
|
|
575
|
+
color: var(--c-text);
|
|
576
|
+
width: 12ch;
|
|
577
|
+
}
|
|
578
|
+
|
|
579
|
+
.project-actions {
|
|
580
|
+
display: flex;
|
|
581
|
+
gap: 1px;
|
|
582
|
+
flex: none;
|
|
583
|
+
}
|
|
584
|
+
|
|
585
|
+
.project-actions button {
|
|
586
|
+
display: inline-flex;
|
|
587
|
+
align-items: center;
|
|
588
|
+
padding: 3px 4px;
|
|
589
|
+
border: none;
|
|
590
|
+
background: transparent;
|
|
591
|
+
color: var(--c-dim);
|
|
592
|
+
font: inherit;
|
|
593
|
+
font-size: 11px;
|
|
594
|
+
cursor: pointer;
|
|
595
|
+
border-radius: 2px;
|
|
596
|
+
}
|
|
597
|
+
|
|
598
|
+
.project-actions button:hover:not(:disabled) {
|
|
599
|
+
color: var(--c-accent);
|
|
600
|
+
background: var(--c-panel-2);
|
|
601
|
+
}
|
|
602
|
+
|
|
603
|
+
.project-actions button:disabled {
|
|
604
|
+
opacity: 0.3;
|
|
605
|
+
cursor: default;
|
|
606
|
+
}
|
|
607
|
+
|
|
608
|
+
.project-actions .forget:hover {
|
|
609
|
+
color: var(--c-danger);
|
|
610
|
+
}
|
|
611
|
+
|
|
612
|
+
.add-toggle {
|
|
613
|
+
display: inline-flex;
|
|
614
|
+
align-items: center;
|
|
615
|
+
gap: 5px;
|
|
616
|
+
padding: 4px 9px;
|
|
617
|
+
border: 1px dashed var(--c-border-strong);
|
|
618
|
+
border-radius: var(--radius-sm);
|
|
619
|
+
background: transparent;
|
|
620
|
+
color: var(--c-dim);
|
|
621
|
+
font: inherit;
|
|
622
|
+
font-size: 11px;
|
|
623
|
+
cursor: pointer;
|
|
624
|
+
}
|
|
625
|
+
|
|
626
|
+
.add-toggle:hover {
|
|
627
|
+
color: var(--c-accent);
|
|
628
|
+
border-color: var(--c-accent);
|
|
629
|
+
border-style: solid;
|
|
630
|
+
}
|
|
631
|
+
|
|
632
|
+
.add-cancel {
|
|
633
|
+
padding: 4px 5px !important;
|
|
634
|
+
color: var(--c-dim) !important;
|
|
635
|
+
}
|
|
636
|
+
|
|
637
|
+
.add-project {
|
|
638
|
+
display: flex;
|
|
639
|
+
gap: 5px;
|
|
640
|
+
align-items: center;
|
|
641
|
+
}
|
|
642
|
+
|
|
643
|
+
.add-project input {
|
|
644
|
+
font: inherit;
|
|
645
|
+
font-size: 11px;
|
|
646
|
+
padding: 4px 6px;
|
|
647
|
+
border: 1px solid var(--c-border);
|
|
648
|
+
border-radius: var(--radius-sm);
|
|
649
|
+
background: var(--c-bg);
|
|
650
|
+
color: var(--c-text);
|
|
651
|
+
}
|
|
652
|
+
|
|
653
|
+
.add-path {
|
|
654
|
+
flex: 1;
|
|
655
|
+
min-width: 0;
|
|
656
|
+
font-family: var(--font-mono);
|
|
657
|
+
}
|
|
658
|
+
|
|
659
|
+
.add-label {
|
|
660
|
+
width: 13ch;
|
|
661
|
+
}
|
|
662
|
+
|
|
663
|
+
.add-project button,
|
|
664
|
+
.refresh-now {
|
|
665
|
+
display: inline-flex;
|
|
666
|
+
align-items: center;
|
|
667
|
+
gap: 4px;
|
|
668
|
+
padding: 4px 8px;
|
|
669
|
+
border: 1px solid var(--c-border);
|
|
670
|
+
border-radius: var(--radius-sm);
|
|
671
|
+
background: var(--c-panel-2);
|
|
672
|
+
color: var(--c-text);
|
|
673
|
+
font: inherit;
|
|
674
|
+
font-size: 11px;
|
|
675
|
+
cursor: pointer;
|
|
676
|
+
white-space: nowrap;
|
|
677
|
+
}
|
|
678
|
+
|
|
679
|
+
.add-project button:disabled,
|
|
680
|
+
.refresh-now:disabled {
|
|
681
|
+
opacity: 0.45;
|
|
682
|
+
cursor: default;
|
|
683
|
+
}
|
|
684
|
+
|
|
685
|
+
.add-error {
|
|
686
|
+
margin: 6px 0 0;
|
|
687
|
+
font-size: 11px;
|
|
688
|
+
color: var(--c-danger);
|
|
689
|
+
}
|
|
690
|
+
|
|
691
|
+
.options-empty,
|
|
692
|
+
.options-note {
|
|
693
|
+
margin: 6px 0 0;
|
|
694
|
+
font-size: 10px;
|
|
695
|
+
color: var(--c-dim);
|
|
696
|
+
line-height: 1.5;
|
|
697
|
+
}
|
|
698
|
+
|
|
699
|
+
.mode-row {
|
|
700
|
+
margin-bottom: 8px;
|
|
701
|
+
}
|
|
702
|
+
|
|
703
|
+
.mode-toggle button {
|
|
704
|
+
display: inline-flex;
|
|
705
|
+
align-items: center;
|
|
706
|
+
gap: 5px;
|
|
707
|
+
padding: 4px 9px;
|
|
708
|
+
}
|
|
709
|
+
|
|
710
|
+
.mode-name {
|
|
711
|
+
font-size: 11px;
|
|
712
|
+
}
|
|
713
|
+
|
|
714
|
+
/* Two columns: twelve styles as a single column was a menu-length scroll. The
|
|
715
|
+
swatch carries the identity, so a short label beside it is enough. */
|
|
716
|
+
.style-list {
|
|
717
|
+
list-style: none;
|
|
718
|
+
margin: 0 0 6px;
|
|
719
|
+
padding: 0;
|
|
720
|
+
display: grid;
|
|
721
|
+
grid-template-columns: 1fr 1fr;
|
|
722
|
+
gap: 3px 6px;
|
|
723
|
+
}
|
|
724
|
+
|
|
725
|
+
.style-pick {
|
|
726
|
+
display: flex;
|
|
727
|
+
align-items: center;
|
|
728
|
+
gap: 9px;
|
|
729
|
+
width: 100%;
|
|
730
|
+
padding: 4px 7px;
|
|
731
|
+
border: 1px solid transparent;
|
|
732
|
+
border-radius: var(--radius-sm);
|
|
733
|
+
background: transparent;
|
|
734
|
+
color: var(--c-text);
|
|
735
|
+
font: inherit;
|
|
736
|
+
font-size: 11px;
|
|
737
|
+
text-align: left;
|
|
738
|
+
cursor: pointer;
|
|
739
|
+
}
|
|
740
|
+
|
|
741
|
+
.style-pick:hover {
|
|
742
|
+
background: var(--c-panel-2);
|
|
743
|
+
}
|
|
744
|
+
|
|
745
|
+
.style-pick.is-active {
|
|
746
|
+
border-color: var(--c-accent);
|
|
747
|
+
background: var(--c-accent-wash);
|
|
748
|
+
}
|
|
749
|
+
|
|
750
|
+
.style-name {
|
|
751
|
+
font-weight: 600;
|
|
752
|
+
}
|
|
753
|
+
|
|
754
|
+
.style-pick .pick-tick {
|
|
755
|
+
margin-left: auto;
|
|
756
|
+
}
|
|
757
|
+
|
|
758
|
+
/* A miniature of the app: ground, panel, three encoding bars. Reads as
|
|
759
|
+
"this is what that looks like" far faster than a row of colour chips. */
|
|
760
|
+
.style-swatch {
|
|
761
|
+
display: block;
|
|
762
|
+
width: 62px;
|
|
763
|
+
height: 26px;
|
|
764
|
+
flex: none;
|
|
765
|
+
padding: 4px;
|
|
766
|
+
border: 1px solid var(--c-border);
|
|
767
|
+
border-radius: 3px;
|
|
768
|
+
background: var(--c-bg);
|
|
769
|
+
}
|
|
770
|
+
|
|
771
|
+
.sw-panel {
|
|
772
|
+
display: flex;
|
|
773
|
+
align-items: center;
|
|
774
|
+
gap: 2px;
|
|
775
|
+
height: 100%;
|
|
776
|
+
padding: 0 3px;
|
|
777
|
+
border-radius: 2px;
|
|
778
|
+
background: var(--c-panel);
|
|
779
|
+
}
|
|
780
|
+
|
|
781
|
+
.sw-bar {
|
|
782
|
+
height: 6px;
|
|
783
|
+
flex: 1;
|
|
784
|
+
border-radius: 2px;
|
|
785
|
+
}
|
|
786
|
+
|
|
787
|
+
.sw-shipped { background: var(--c-shipped); }
|
|
788
|
+
.sw-progress { background: var(--c-in-progress); }
|
|
789
|
+
.sw-future { background: var(--c-future); }
|
|
790
|
+
|
|
791
|
+
.refresh-row {
|
|
792
|
+
display: flex;
|
|
793
|
+
gap: 6px;
|
|
794
|
+
align-items: center;
|
|
795
|
+
}
|
|
796
|
+
|
|
797
|
+
.refresh-select {
|
|
798
|
+
font: inherit;
|
|
799
|
+
font-size: 11px;
|
|
800
|
+
padding: 4px 6px;
|
|
801
|
+
border: 1px solid var(--c-border);
|
|
802
|
+
border-radius: var(--radius-sm);
|
|
803
|
+
background: var(--c-panel-2);
|
|
804
|
+
color: var(--c-text);
|
|
805
|
+
}
|
|
806
|
+
|
|
807
|
+
.options-panel kbd {
|
|
808
|
+
display: inline-block;
|
|
809
|
+
padding: 0 5px;
|
|
810
|
+
border: 1px solid var(--c-border-strong);
|
|
811
|
+
border-bottom-width: 2px;
|
|
812
|
+
border-radius: 3px;
|
|
813
|
+
background: var(--c-lift);
|
|
814
|
+
font-family: ui-monospace, monospace;
|
|
815
|
+
font-size: 10px;
|
|
816
|
+
}
|
|
817
|
+
|
|
818
|
+
.options-panel .link-btn {
|
|
819
|
+
all: unset;
|
|
820
|
+
cursor: pointer;
|
|
821
|
+
color: var(--c-accent);
|
|
822
|
+
text-decoration: underline dotted;
|
|
823
|
+
margin-left: 4px;
|
|
824
|
+
}
|
|
825
|
+
</style>
|