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,191 @@
1
+ <script setup lang="ts">
2
+ // WipBullets.vue — per-person work in flight against the calibrated cap, as
3
+ // bullet rows on one shared scale (bp-67g.29).
4
+ //
5
+ // The bar is in-flight work in --c-in-progress up to the cap; the run past
6
+ // the cap continues in --c-danger at its real length; the notch at the cap
7
+ // is --c-text. Those three are the "wip bullet" group the style audit
8
+ // already guards, so a named style cannot collapse them. The value sits at
9
+ // the tip and the cap/rate text at the end of the row, where the old chips
10
+ // put it.
11
+
12
+ import { computed } from "vue";
13
+ import { capBlurb, rateBlurb, wipScaleMax, type WipRow } from "../wip";
14
+
15
+ const props = defineProps<{
16
+ rows: WipRow[];
17
+ /** The person the incytes view is scoped to; their row is emphasised (bp-dgq). */
18
+ selected?: string | null;
19
+ }>();
20
+ // The handle is a way in to that person's picture (bp-67g.43); the parent
21
+ // owns the navigation, like everything else in this presentational strip.
22
+ const emit = defineEmits<{ select: [handle: string] }>();
23
+
24
+ const scaleMax = computed(() => wipScaleMax(props.rows));
25
+ const pos = (v: number) => `${Math.min(100, (v / scaleMax.value) * 100)}%`;
26
+ </script>
27
+
28
+ <template>
29
+ <ul class="wip-bullets" :aria-label="`Work in flight against calibrated cap, scale 0 to ${scaleMax}`">
30
+ <li
31
+ v-for="r in rows"
32
+ :key="r.handle"
33
+ class="wip-row"
34
+ :class="{ 'is-over': r.over > 0, 'is-idle': r.inFlight === 0, 'is-nudged': r.rawCap !== r.cap, 'is-selected': selected === r.handle }"
35
+ >
36
+ <button type="button" class="who" :title="`Show ${r.handle}`" @click="emit('select', r.handle)"><code>{{ r.handle }}</code></button>
37
+ <div
38
+ class="track"
39
+ role="img"
40
+ :aria-label="`${r.handle}: ${r.inFlight} in flight against a cap of ${r.cap}${r.over ? `, ${r.over} over` : ''}`"
41
+ >
42
+ <i class="fill" :style="{ width: pos(Math.min(r.inFlight, r.cap)) }" />
43
+ <i v-if="r.over > 0" class="over" :style="{ left: pos(r.cap), width: pos(r.over) }" />
44
+ <i class="cap" :style="{ left: pos(r.cap) }" />
45
+ <span class="tip" :style="{ left: pos(r.inFlight) }">{{ r.inFlight }}</span>
46
+ </div>
47
+ <span class="blurb">
48
+ <span class="cap-blurb">{{ capBlurb(r) }}</span>
49
+ <span class="rate">{{ rateBlurb(r) }}</span>
50
+ </span>
51
+ </li>
52
+ </ul>
53
+ </template>
54
+
55
+ <style>
56
+ .wip-bullets {
57
+ list-style: none;
58
+ margin: 0;
59
+ padding: 0;
60
+ display: flex;
61
+ flex-direction: column;
62
+ gap: 4px;
63
+ flex: 1 1 100%;
64
+ min-width: 0;
65
+ }
66
+
67
+ .wip-row {
68
+ display: grid;
69
+ grid-template-columns: minmax(64px, 110px) minmax(160px, 1fr) auto;
70
+ align-items: center;
71
+ gap: 10px;
72
+ font-size: 11px;
73
+ }
74
+
75
+ .wip-row .who {
76
+ all: unset;
77
+ cursor: pointer;
78
+ font-family: ui-monospace, monospace;
79
+ font-size: 11px;
80
+ color: var(--c-accent);
81
+ overflow: hidden;
82
+ text-overflow: ellipsis;
83
+ white-space: nowrap;
84
+ }
85
+
86
+ .wip-row .who code {
87
+ font: inherit;
88
+ color: inherit;
89
+ }
90
+
91
+ .wip-row .who:hover code,
92
+ .wip-row .who:focus-visible code {
93
+ text-decoration: underline dotted;
94
+ text-underline-offset: 2px;
95
+ }
96
+
97
+ .wip-row.is-idle .who,
98
+ .wip-row.is-idle .blurb {
99
+ opacity: 0.6;
100
+ }
101
+
102
+ /* The selected person, when incytes is scoped to one: the comparison with
103
+ everyone else is the point of a shared scale, so the others stay. */
104
+ .wip-row.is-selected {
105
+ box-shadow: inset 3px 0 0 var(--c-accent);
106
+ padding-left: 6px;
107
+ margin-left: -9px;
108
+ }
109
+
110
+ .wip-row.is-selected .who,
111
+ .wip-row.is-selected .blurb {
112
+ opacity: 1;
113
+ color: var(--c-text);
114
+ }
115
+
116
+ /* The measure. Padding-right leaves room for a tip value at 100%. */
117
+ .wip-row .track {
118
+ position: relative;
119
+ height: 10px;
120
+ margin-right: 28px;
121
+ background: var(--c-lift);
122
+ border-radius: 2px;
123
+ }
124
+
125
+ .wip-row .fill,
126
+ .wip-row .over {
127
+ position: absolute;
128
+ top: 0;
129
+ bottom: 0;
130
+ border-radius: 2px;
131
+ }
132
+
133
+ .wip-row .fill {
134
+ left: 0;
135
+ background: var(--c-in-progress);
136
+ opacity: 0.9;
137
+ }
138
+
139
+ .wip-row .over {
140
+ background: var(--c-danger);
141
+ opacity: 0.9;
142
+ border-radius: 0 2px 2px 0;
143
+ }
144
+
145
+ /* The cap is a notch ON the measure, not the end of the scale. */
146
+ .wip-row .cap {
147
+ position: absolute;
148
+ top: -3px;
149
+ bottom: -3px;
150
+ width: 2px;
151
+ background: var(--c-text);
152
+ transform: translateX(-1px);
153
+ }
154
+
155
+ .wip-row .tip {
156
+ position: absolute;
157
+ top: -3px;
158
+ margin-left: 5px;
159
+ font-size: 10px;
160
+ line-height: 16px;
161
+ color: var(--c-text);
162
+ font-variant-numeric: tabular-nums;
163
+ white-space: nowrap;
164
+ }
165
+
166
+ .wip-row.is-over .tip {
167
+ color: var(--c-danger);
168
+ font-weight: 600;
169
+ }
170
+
171
+ .wip-row .blurb {
172
+ display: inline-flex;
173
+ gap: 8px;
174
+ white-space: nowrap;
175
+ font-variant-numeric: tabular-nums;
176
+ }
177
+
178
+ .wip-row .cap-blurb {
179
+ color: var(--c-text);
180
+ }
181
+
182
+ .wip-row.is-nudged .cap-blurb {
183
+ color: var(--c-accent);
184
+ font-weight: 600;
185
+ }
186
+
187
+ .wip-row .rate {
188
+ color: var(--c-dim);
189
+ font-size: 10px;
190
+ }
191
+ </style>
@@ -0,0 +1,9 @@
1
+ // Shared row shape for FilterPopover.vue. Kept out of the SFC so consumers
2
+ // can `import type { FilterOption } from "./filter-option"` without pulling
3
+ // the whole component's runtime.
4
+
5
+ export interface FilterOption<T> {
6
+ value: T;
7
+ label: string;
8
+ hint?: string;
9
+ }
@@ -0,0 +1,136 @@
1
+ // url-codec.ts — query-string encoding for shareable view state.
2
+ //
3
+ // Also owns the enum guards for the query string's closed-set params.
4
+ //
5
+ // Pure: no store, no window, no Vue. useUrlState.ts owns the reactive
6
+ // wiring; this owns the wire format, so the format is testable on its own
7
+ // (node:test can't resolve the composable's store import).
8
+
9
+ import type { GroupBy } from "../view-model";
10
+
11
+ /**
12
+ * Serialize an array of values to a comma-separated string.
13
+ * Empty/absent arrays serialize to "" so callers can drop the param.
14
+ */
15
+ export function serializeArray(
16
+ arr: (string | number | null)[] | null | undefined,
17
+ ): string {
18
+ if (!arr || arr.length === 0) return "";
19
+ return arr.map((v) => (v === null ? "" : String(v))).join(",");
20
+ }
21
+
22
+ /**
23
+ * Deserialize a comma-separated string to a string array.
24
+ *
25
+ * Deliberately does NOT coerce numeric-looking segments to numbers: ids,
26
+ * labels, statuses and assignees are opaque strings, and a label like "2026"
27
+ * must round-trip as a string or it stops matching the bead it came from.
28
+ */
29
+ export function deserializeStrings(str: string): string[] {
30
+ if (!str) return [];
31
+ return str
32
+ .split(",")
33
+ .map((s) => s.trim())
34
+ .filter((s) => s.length > 0);
35
+ }
36
+
37
+ /**
38
+ * Deserialize a comma-separated string to a number array, dropping any
39
+ * segment that is not a number (a hand-edited URL is untrusted input).
40
+ */
41
+ export function deserializeNumbers(str: string): number[] {
42
+ if (!str) return [];
43
+ return str
44
+ .split(",")
45
+ .map((s) => Number(s.trim()))
46
+ .filter((n) => Number.isFinite(n));
47
+ }
48
+
49
+ /**
50
+ * Serialize a date to a plain ISO day (YYYY-MM-DD).
51
+ */
52
+ export function serializeDate(date: Date | null | undefined): string {
53
+ if (!date) return "";
54
+ const t = date.getTime();
55
+ if (!Number.isFinite(t)) return "";
56
+ return date.toISOString().split("T")[0];
57
+ }
58
+
59
+ /**
60
+ * Parse a date string: ISO (2026-09-01) or relative ("-30d", "+90d").
61
+ * Returns null for anything unparseable -- `new Date(garbage)` yields an
62
+ * Invalid Date rather than throwing, so the result must be range-checked.
63
+ */
64
+ export function parseDate(str: string): Date | null {
65
+ if (!str) return null;
66
+ const rel = str.match(/^([+-])(\d+)d$/);
67
+ if (rel) {
68
+ const sign = rel[1] === "+" ? 1 : -1;
69
+ const date = new Date();
70
+ date.setDate(date.getDate() + sign * Number(rel[2]));
71
+ return date;
72
+ }
73
+ const date = new Date(str);
74
+ return Number.isFinite(date.getTime()) ? date : null;
75
+ }
76
+
77
+ /**
78
+ * Which view is mounted. Part of the URL rather than localStorage: a shared
79
+ * link should land the recipient on the view the sender was looking at.
80
+ */
81
+ export type ViewMode = "mine" | "gantt" | "grid" | "table" | "incytes";
82
+
83
+ const VIEW_MODES: readonly ViewMode[] = ["mine", "gantt", "grid", "table", "incytes"];
84
+
85
+ const GROUP_MODES: readonly GroupBy[] = [
86
+ "epic",
87
+ "assignee",
88
+ "seat",
89
+ "track",
90
+ "status",
91
+ "priority",
92
+ "none",
93
+ ];
94
+
95
+ /**
96
+ * Parse `?view=`, falling back to the mine view (the default tab). The query string is
97
+ * user-editable, so an unrecognised value is ignored rather than trusted —
98
+ * which is also what keeps links written before a view existed working.
99
+ */
100
+ export function parseView(str: string | null | undefined): ViewMode {
101
+ return str && (VIEW_MODES as readonly string[]).includes(str)
102
+ ? (str as ViewMode)
103
+ : "mine";
104
+ }
105
+
106
+ /**
107
+ * Parse `?group=`, returning null when the value isn't a real mode so the
108
+ * caller can leave the store's default alone. An unrecognised value used to
109
+ * be cast straight through, landing the view model on its "__all" fallback
110
+ * with no mode showing as active in the controls.
111
+ */
112
+ export function parseGroupBy(str: string | null | undefined): GroupBy | null {
113
+ return str && (GROUP_MODES as readonly string[]).includes(str)
114
+ ? (str as GroupBy)
115
+ : null;
116
+ }
117
+
118
+ /**
119
+ * Which normally-hidden statuses a pre-bp-67g.40 link was asking for.
120
+ *
121
+ * `?showClosed=true` and `?includeDeferred=true` were separate booleans that
122
+ * silently overrode `?status=`. They are no longer written, but old links are
123
+ * shareable state and have to keep resolving to the same view, so they are
124
+ * still read — as statuses to add to the default selection.
125
+ *
126
+ * Returns [] when neither is present or both are false, which is also the
127
+ * shape a modern link produces. An explicit `?status=` in the same URL is the
128
+ * newer and more specific instruction and should win outright; that call
129
+ * belongs to the caller, which has the parsed status list.
130
+ */
131
+ export function legacyHiddenStatuses(params: URLSearchParams): string[] {
132
+ const out: string[] = [];
133
+ if (params.get("showClosed") === "true") out.push("closed");
134
+ if (params.get("includeDeferred") === "true") out.push("deferred");
135
+ return out;
136
+ }
@@ -0,0 +1,148 @@
1
+ // useBeadTooltip.ts — one hover state machine for every BeadTooltip host
2
+ // (bp-67g.42).
3
+ //
4
+ // The Gantt grew this for its bars (bp-6y1, bp-67g.41): hovering a mark pins
5
+ // the panel at the entry point, a short grace period on leave lets the pointer
6
+ // cross the gap onto the panel, and the panel's rows — detail, triage score
7
+ // and rank, time spent, watched — are resolved through the store. The drawer's
8
+ // dependency subgraph needs exactly the same behaviour for its nodes, so the
9
+ // machine lives here and both hosts render `<BeadTooltip v-bind="tipProps">`.
10
+ // One definition, so the two hovers cannot drift apart in what they say.
11
+
12
+ import { computed, onUnmounted, ref } from "vue";
13
+ import { useBeadsStore } from "../store";
14
+ import { describeRank } from "../triage";
15
+ import { buildEconomics } from "../economics";
16
+
17
+ export interface BeadTooltipOptions {
18
+ /** Grace period in ms between leaving the mark and the panel closing. */
19
+ leaveDelayMs?: number;
20
+ /**
21
+ * Close the panel when anything scrolls — the host's own overflow, the
22
+ * drawer body, the page. The panel is fixed to the viewport while the mark
23
+ * it describes scrolls away, so a scroll would otherwise leave it stranded.
24
+ */
25
+ dismissOnScroll?: boolean;
26
+ /**
27
+ * The horizontal extent, in viewport px, the panel must stay inside. A host
28
+ * that lives in a column (the drawer) passes its own edges; the Gantt has
29
+ * the whole viewport and passes nothing. Read at hover time.
30
+ */
31
+ bounds?: () => { left: number; right: number } | null;
32
+ }
33
+
34
+ export function useBeadTooltip(opts: BeadTooltipOptions = {}) {
35
+ const store = useBeadsStore();
36
+ const leaveDelay = opts.leaveDelayMs ?? 180;
37
+
38
+ const hoveredBeadId = ref<string | null>(null);
39
+ const hoverX = ref(0);
40
+ const hoverY = ref(0);
41
+ // The hovered mark's band in viewport px, so the panel can stay off it.
42
+ const hoverBand = ref<{ top: number; bottom: number } | null>(null);
43
+ const hoverBounds = ref<{ left: number; right: number } | null>(null);
44
+ let leaveTimer: ReturnType<typeof setTimeout> | null = null;
45
+
46
+ const detail = computed(() =>
47
+ hoveredBeadId.value ? store.beadDetailFor(hoveredBeadId.value) : null,
48
+ );
49
+ const watched = computed(
50
+ () => !!hoveredBeadId.value && store.watchedSet.has(hoveredBeadId.value),
51
+ );
52
+ // Reading store.triage from a hover was the cost bp-67g.41 flagged;
53
+ // bp-67g.25 took it from ~300ms to a few ms on a 600-bead graph, so the
54
+ // first hover pays a blink, not a stall. Economics is a per-bead derivation
55
+ // over its own metadata and timestamps.
56
+ const triage = computed(() =>
57
+ hoveredBeadId.value ? store.triage.get(hoveredBeadId.value) ?? null : null,
58
+ );
59
+ const rank = computed(() =>
60
+ triage.value ? describeRank(store.triageScorePopulation, triage.value.score) : null,
61
+ );
62
+ const economics = computed(() => {
63
+ const id = hoveredBeadId.value;
64
+ const b = id ? store.beads.find((x) => x.id === id) : undefined;
65
+ return b ? buildEconomics(b) : null;
66
+ });
67
+
68
+ /** Everything BeadTooltip takes, ready for `v-bind`. */
69
+ const props = computed(() => ({
70
+ detail: detail.value,
71
+ x: hoverX.value,
72
+ y: hoverY.value,
73
+ watched: watched.value,
74
+ bandTop: hoverBand.value?.top,
75
+ bandBottom: hoverBand.value?.bottom,
76
+ boundsLeft: hoverBounds.value?.left,
77
+ boundsRight: hoverBounds.value?.right,
78
+ triage: triage.value,
79
+ rank: rank.value,
80
+ economics: economics.value,
81
+ }));
82
+
83
+ let listeningForScroll = false;
84
+ const onAnyScroll = () => dismiss();
85
+ function listenForScroll() {
86
+ if (listeningForScroll || !opts.dismissOnScroll) return;
87
+ // Scroll events do not bubble, but they do pass document in the capture
88
+ // phase, so one listener sees every scroll container on the page.
89
+ document.addEventListener("scroll", onAnyScroll, { capture: true, passive: true });
90
+ listeningForScroll = true;
91
+ }
92
+ function stopListeningForScroll() {
93
+ if (!listeningForScroll) return;
94
+ document.removeEventListener("scroll", onAnyScroll, { capture: true });
95
+ listeningForScroll = false;
96
+ }
97
+
98
+ function cancelLeave() {
99
+ if (leaveTimer) {
100
+ clearTimeout(leaveTimer);
101
+ leaveTimer = null;
102
+ }
103
+ }
104
+
105
+ /** The mark or the panel was left: close after the grace period. */
106
+ function scheduleLeave() {
107
+ cancelLeave();
108
+ leaveTimer = setTimeout(() => dismiss(), leaveDelay);
109
+ }
110
+
111
+ /** Close now: the bead was opened, the picture changed, something scrolled. */
112
+ function dismiss() {
113
+ cancelLeave();
114
+ hoveredBeadId.value = null;
115
+ stopListeningForScroll();
116
+ }
117
+
118
+ /**
119
+ * A mark for `id` was entered. The panel is pinned where the pointer came
120
+ * in (it does not follow the mouse, so the pointer can leave the mark for
121
+ * the panel) and anchored to `mark`'s band — the event's currentTarget
122
+ * unless the host names a tighter element, as the subgraph does with its
123
+ * node box inside a larger hit area.
124
+ */
125
+ function enter(id: string, e: MouseEvent, mark?: Element | null) {
126
+ cancelLeave();
127
+ hoveredBeadId.value = id;
128
+ hoverX.value = e.clientX;
129
+ hoverY.value = e.clientY;
130
+ const r = (mark ?? (e.currentTarget as Element | null))?.getBoundingClientRect();
131
+ hoverBand.value = r ? { top: r.top - 4, bottom: r.bottom + 4 } : null;
132
+ hoverBounds.value = opts.bounds?.() ?? null;
133
+ listenForScroll();
134
+ }
135
+
136
+ /**
137
+ * Pin from the panel. The panel stays open: the user is looking at the
138
+ * bead they just pinned, and the button flips to its unwatch state.
139
+ */
140
+ function toggleWatch(id: string) {
141
+ store.toggleWatch(id);
142
+ cancelLeave();
143
+ }
144
+
145
+ onUnmounted(dismiss);
146
+
147
+ return { hoveredBeadId, props, enter, scheduleLeave, cancelLeave, dismiss, toggleWatch };
148
+ }
@@ -0,0 +1,97 @@
1
+ // useKeyboard.ts — listens for the shortcuts keybindings.ts declares and
2
+ // carries each out (bp-67g.45). One document listener, installed by App.vue.
3
+ //
4
+ // The table decides WHAT is bound; this file decides what each action does,
5
+ // against the store and the theme. Nothing here adds a key the table does
6
+ // not list, so the ? overlay stays the whole truth.
7
+
8
+ import { onBeforeUnmount, onMounted } from "vue";
9
+ import { useBeadsStore } from "../store";
10
+ import { useTheme } from "./useTheme";
11
+ import { STYLE_NAMES, type StyleName } from "../theme";
12
+ import { WINDOW_PRESETS } from "../window";
13
+ import { isTypingTarget, matchBinding, type KeyAction } from "../keybindings";
14
+ import { markWalkthroughSeen } from "../walkthrough";
15
+ import type { GroupBy } from "../view-model";
16
+
17
+ const GROUP_CYCLE: readonly GroupBy[] = ["epic", "assignee", "seat", "track", "status", "priority", "none"];
18
+
19
+ export function useKeyboard() {
20
+ const store = useBeadsStore();
21
+ const theme = useTheme();
22
+
23
+ function next<T>(list: readonly T[], current: T): T {
24
+ const i = list.indexOf(current);
25
+ return list[(i + 1) % list.length]!;
26
+ }
27
+
28
+ function run(action: KeyAction) {
29
+ switch (action) {
30
+ case "view-mine": return store.setView("mine");
31
+ case "view-gantt": return store.setView("gantt");
32
+ case "view-grid": return store.setView("grid");
33
+ case "view-table": return store.setView("table");
34
+ case "view-incytes": return store.setView("incytes");
35
+ case "cycle-group": return store.setGroupBy(next(GROUP_CYCLE, store.groupBy));
36
+ case "cycle-window": {
37
+ const ids = WINDOW_PRESETS.map((p) => p.id);
38
+ const cur = store.activeWindowPreset;
39
+ return store.setWindowFromPreset(cur ? next(ids, cur) : ids[0]!);
40
+ }
41
+ case "today":
42
+ store.setWindowFromPreset("90d");
43
+ return store.requestToday();
44
+ case "next-bead": return store.moveCursor(1);
45
+ case "prev-bead": return store.moveCursor(-1);
46
+ case "open-first": return store.openBeadId ? undefined : store.moveCursor(1);
47
+ case "close":
48
+ if (store.contextMenu) return store.closeContextMenu();
49
+ if (store.helpOpen) return store.closeHelp();
50
+ if (store.changelogOpen) return store.closeChangelog();
51
+ if (store.openBeadId) return store.closeBead();
52
+ if (store.walkthroughOpen) {
53
+ markWalkthroughSeen();
54
+ return store.closeWalkthrough();
55
+ }
56
+ return;
57
+ case "toggle-watch": return store.openBeadId ? store.toggleWatch(store.openBeadId) : undefined;
58
+ case "toggle-scope": return store.toggleDrawerSubgraphScope();
59
+ case "actions": {
60
+ if (!store.openBeadId) return;
61
+ // Anchor at the drawer's Actions button when it is on screen.
62
+ const r = document.querySelector(".drawer .actions-btn")?.getBoundingClientRect();
63
+ return store.openContextMenu(store.openBeadId, r ? r.left : 24, r ? r.bottom + 4 : 24);
64
+ }
65
+ case "copy-id":
66
+ if (store.openBeadId) void navigator.clipboard?.writeText(store.openBeadId);
67
+ return;
68
+ case "hl-blocked": return store.toggleHighlight("blocked");
69
+ case "hl-over-cap": return store.toggleHighlight("over-cap");
70
+ case "hl-ready-now": return store.toggleHighlight("ready-now");
71
+ case "toggle-deps": return store.toggleShowDependencies();
72
+ case "clear-filters": return store.resetFilters();
73
+ case "toggle-closed": return store.toggleStatusInFilter("closed");
74
+ case "cycle-theme": return theme.cycleMode();
75
+ case "cycle-style": return theme.setStyle(next(STYLE_NAMES as readonly StyleName[], theme.style.value));
76
+ case "options": return store.requestOptions();
77
+ case "search": return store.requestSearch();
78
+ case "help": return store.helpOpen ? store.closeHelp() : store.openHelp();
79
+ }
80
+ }
81
+
82
+ function onKeydown(e: KeyEvent) {
83
+ if (e.defaultPrevented) return;
84
+ if (isTypingTarget(e.target as HTMLElement | null)) return;
85
+ const b = matchBinding(e);
86
+ if (!b) return;
87
+ // Escape with nothing to close is the browser's; everything else is ours.
88
+ if (b.action === "close" && !store.contextMenu && !store.helpOpen && !store.changelogOpen && !store.openBeadId && !store.walkthroughOpen) return;
89
+ e.preventDefault();
90
+ run(b.action);
91
+ }
92
+
93
+ type KeyEvent = KeyboardEvent;
94
+
95
+ onMounted(() => document.addEventListener("keydown", onKeydown));
96
+ onBeforeUnmount(() => document.removeEventListener("keydown", onKeydown));
97
+ }
@@ -0,0 +1,69 @@
1
+ // useLiveRefresh.ts — the poll timer.
2
+ //
3
+ // The interval and the "off" choice are settings owned by the options menu
4
+ // (refresh.ts); this acts on them.
5
+ //
6
+ // Three rules, each for a reason:
7
+ //
8
+ // - Paused while the tab is hidden. Every tick shells out to `bd`, and a
9
+ // backgrounded tab burning that for nobody is pure waste.
10
+ // - No backfill on return. A tab hidden for an hour owes one fetch, not
11
+ // sixty; setInterval would queue them, so the timer is torn down on hide
12
+ // and rebuilt on show.
13
+ // - Refetches on becoming visible even when polling is OFF. Off means "no
14
+ // timer", not "show me stale data without saying so" — and returning to
15
+ // a tab is exactly when stale data is most likely to be acted on.
16
+
17
+ import { onBeforeUnmount, onMounted, watch } from "vue";
18
+ import { useBeadsStore } from "../store";
19
+ import { isRefreshOff } from "../refresh";
20
+
21
+ export function useLiveRefresh() {
22
+ const store = useBeadsStore();
23
+ let timer: ReturnType<typeof setInterval> | null = null;
24
+
25
+ const stop = () => {
26
+ if (timer !== null) {
27
+ clearInterval(timer);
28
+ timer = null;
29
+ }
30
+ };
31
+
32
+ const start = () => {
33
+ stop();
34
+ const ms = store.pollIntervalMs;
35
+ if (isRefreshOff(ms)) return;
36
+ if (typeof document !== "undefined" && document.hidden) return;
37
+ timer = setInterval(() => {
38
+ // Skip a tick that lands while a fetch is still in flight rather than
39
+ // stacking requests on a slow repo.
40
+ if (!store.loading) void store.fetch();
41
+ }, ms);
42
+ };
43
+
44
+ function onVisibilityChange() {
45
+ if (document.hidden) {
46
+ stop();
47
+ return;
48
+ }
49
+ // One catch-up fetch, then resume ticking. This runs whatever the
50
+ // interval is, including off.
51
+ if (!store.loading) void store.fetch();
52
+ start();
53
+ }
54
+
55
+ onMounted(() => {
56
+ start();
57
+ document.addEventListener("visibilitychange", onVisibilityChange);
58
+ });
59
+
60
+ onBeforeUnmount(() => {
61
+ stop();
62
+ document.removeEventListener("visibilitychange", onVisibilityChange);
63
+ });
64
+
65
+ // Changing the interval takes effect now, not after the old one fires.
66
+ watch(() => store.pollIntervalMs, start);
67
+
68
+ return { restart: start, stop };
69
+ }