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,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
+ >&#9998;</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>