source_monitor 0.13.1 → 0.14.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 (95) hide show
  1. checksums.yaml +4 -4
  2. data/.claude/skills/sm-configuration-setting/reference/settings-catalog.md +1 -0
  3. data/.claude/skills/sm-configure/SKILL.md +8 -1
  4. data/.claude/skills/sm-configure/reference/configuration-reference.md +11 -0
  5. data/.claude/skills/sm-host-setup/SKILL.md +13 -3
  6. data/.claude/skills/sm-host-setup/reference/initializer-template.md +11 -0
  7. data/.claude/skills/sm-host-setup/reference/setup-checklist.md +9 -1
  8. data/.claude/skills/sm-upgrade/reference/version-history.md +12 -0
  9. data/CHANGELOG.md +15 -0
  10. data/Gemfile.lock +1 -1
  11. data/README.md +3 -3
  12. data/VERSION +1 -1
  13. data/app/controllers/source_monitor/application_controller.rb +73 -14
  14. data/app/views/layouts/source_monitor/application.html.erb +6 -0
  15. data/docs/configuration.md +18 -1
  16. data/docs/deployment.md +1 -1
  17. data/docs/goals/engine-hardening/.goalbuddy-board/app.js +543 -0
  18. data/docs/goals/engine-hardening/.goalbuddy-board/goalbuddy-mark.png +0 -0
  19. data/docs/goals/engine-hardening/.goalbuddy-board/index.html +111 -0
  20. data/docs/goals/engine-hardening/.goalbuddy-board/styles.css +991 -0
  21. data/docs/goals/engine-hardening/goal.md +97 -0
  22. data/docs/goals/engine-hardening/notes/T001-spec-validation.md +37 -0
  23. data/docs/goals/engine-hardening/state.yaml +324 -0
  24. data/docs/setup.md +3 -3
  25. data/docs/upgrade.md +27 -0
  26. data/lib/generators/source_monitor/install/templates/source_monitor.rb.tt +10 -0
  27. data/lib/source_monitor/configuration/authentication_settings.rb +5 -1
  28. data/lib/source_monitor/security/authentication.rb +10 -0
  29. data/lib/source_monitor/version.rb +1 -1
  30. data/source_monitor.gemspec +7 -2
  31. metadata +8 -65
  32. data/.claude/agent-memory/vbw-vbw-debugger/MEMORY.md +0 -15
  33. data/.claude/agent-memory/vbw-vbw-dev/MEMORY.md +0 -34
  34. data/.claude/agent-memory/vbw-vbw-lead/MEMORY.md +0 -49
  35. data/.claude/agents/rails-concern.md +0 -464
  36. data/.claude/agents/rails-controller.md +0 -424
  37. data/.claude/agents/rails-hotwire.md +0 -446
  38. data/.claude/agents/rails-implement.md +0 -374
  39. data/.claude/agents/rails-job.md +0 -334
  40. data/.claude/agents/rails-lint.md +0 -294
  41. data/.claude/agents/rails-mailer.md +0 -371
  42. data/.claude/agents/rails-migration.md +0 -449
  43. data/.claude/agents/rails-model.md +0 -420
  44. data/.claude/agents/rails-policy.md +0 -443
  45. data/.claude/agents/rails-presenter.md +0 -427
  46. data/.claude/agents/rails-query.md +0 -412
  47. data/.claude/agents/rails-review.md +0 -490
  48. data/.claude/agents/rails-service.md +0 -458
  49. data/.claude/agents/rails-state-records.md +0 -465
  50. data/.claude/agents/rails-tdd.md +0 -314
  51. data/.claude/agents/rails-test.md +0 -441
  52. data/.claude/agents/rails-view-component.md +0 -418
  53. data/.claude/commands/rails-audit.md +0 -77
  54. data/.claude/commands/release.md +0 -366
  55. data/.claude/hooks/block-secrets.sh +0 -52
  56. data/.claude/settings.json +0 -85
  57. data/.claude/skills/action-cable-patterns/SKILL.md +0 -296
  58. data/.claude/skills/action-mailer-patterns/SKILL.md +0 -295
  59. data/.claude/skills/active-storage-setup/SKILL.md +0 -311
  60. data/.claude/skills/api-versioning/SKILL.md +0 -294
  61. data/.claude/skills/authentication-flow/SKILL.md +0 -335
  62. data/.claude/skills/authentication-flow/reference/current.md +0 -248
  63. data/.claude/skills/authentication-flow/reference/passwordless.md +0 -253
  64. data/.claude/skills/authentication-flow/reference/sessions.md +0 -201
  65. data/.claude/skills/authorization-pundit/SKILL.md +0 -462
  66. data/.claude/skills/caching-strategies/SKILL.md +0 -350
  67. data/.claude/skills/database-migrations/SKILL.md +0 -354
  68. data/.claude/skills/form-object-patterns/SKILL.md +0 -399
  69. data/.claude/skills/hotwire-patterns/SKILL.md +0 -247
  70. data/.claude/skills/hotwire-patterns/reference/stimulus.md +0 -307
  71. data/.claude/skills/hotwire-patterns/reference/tailwind-integration.md +0 -112
  72. data/.claude/skills/hotwire-patterns/reference/turbo-frames.md +0 -158
  73. data/.claude/skills/hotwire-patterns/reference/turbo-streams.md +0 -218
  74. data/.claude/skills/i18n-patterns/SKILL.md +0 -320
  75. data/.claude/skills/install/SKILL.md +0 -367
  76. data/.claude/skills/performance-optimization/SKILL.md +0 -311
  77. data/.claude/skills/rails-architecture/SKILL.md +0 -259
  78. data/.claude/skills/rails-architecture/reference/error-handling.md +0 -333
  79. data/.claude/skills/rails-architecture/reference/event-tracking.md +0 -142
  80. data/.claude/skills/rails-architecture/reference/layer-interactions.md +0 -417
  81. data/.claude/skills/rails-architecture/reference/multi-tenancy.md +0 -152
  82. data/.claude/skills/rails-architecture/reference/query-patterns.md +0 -342
  83. data/.claude/skills/rails-architecture/reference/service-patterns.md +0 -286
  84. data/.claude/skills/rails-architecture/reference/state-records.md +0 -250
  85. data/.claude/skills/rails-architecture/reference/testing-strategy.md +0 -326
  86. data/.claude/skills/rails-concern/SKILL.md +0 -399
  87. data/.claude/skills/rails-controller/SKILL.md +0 -336
  88. data/.claude/skills/rails-model-generator/SKILL.md +0 -321
  89. data/.claude/skills/rails-model-generator/reference/validations.md +0 -298
  90. data/.claude/skills/rails-presenter/SKILL.md +0 -274
  91. data/.claude/skills/rails-query-object/SKILL.md +0 -289
  92. data/.claude/skills/rails-service-object/SKILL.md +0 -349
  93. data/.claude/skills/solid-queue-setup/SKILL.md +0 -307
  94. data/.claude/skills/tdd-cycle/SKILL.md +0 -359
  95. data/.claude/skills/viewcomponent-patterns/SKILL.md +0 -333
@@ -0,0 +1,543 @@
1
+ let currentBoard = null;
2
+ let eventSource = null;
3
+ let currentSettings = null;
4
+
5
+ const boardEl = document.getElementById("board");
6
+ const liveStateEl = document.getElementById("live-state");
7
+ const liveDotEl = document.getElementById("live-dot");
8
+ const boardSwitcherEl = document.getElementById("board-switcher");
9
+ const settingsButtonEl = document.getElementById("settings-button");
10
+ const settingsPopoverEl = document.getElementById("settings-popover");
11
+ const githubStarsEl = document.getElementById("github-stars");
12
+ const modalEl = document.getElementById("task-modal");
13
+ const modalTitleEl = document.getElementById("modal-title");
14
+ const modalKickerEl = document.getElementById("modal-kicker");
15
+ const modalBodyEl = document.getElementById("modal-body");
16
+ const settingsStorageKey = "goalbuddy.localBoardSettings.v1";
17
+ const settingsDefaults = {
18
+ theme: "system",
19
+ density: "comfortable",
20
+ completedVisibility: "show",
21
+ boardOpenBehavior: "last",
22
+ motion: "system",
23
+ lastBoardPath: "",
24
+ };
25
+ const settingsOptions = {
26
+ theme: new Set(["system", "light", "dark"]),
27
+ density: new Set(["comfortable", "compact"]),
28
+ completedVisibility: new Set(["show", "collapse"]),
29
+ boardOpenBehavior: new Set(["last", "newest"]),
30
+ motion: new Set(["system", "reduce", "allow"]),
31
+ };
32
+
33
+ document.addEventListener("click", (event) => {
34
+ const card = event.target.closest("[data-task-id]");
35
+ if (card) openTask(card.dataset.taskId);
36
+ if (event.target.matches("[data-close-modal]")) closeModal();
37
+ if (settingsPopoverEl.hidden) return;
38
+ if (!event.target.closest(".settings-wrap")) closeSettings();
39
+ });
40
+
41
+ document.addEventListener("keydown", (event) => {
42
+ if (event.key === "Escape") {
43
+ closeModal();
44
+ closeSettings();
45
+ }
46
+ });
47
+
48
+ boardSwitcherEl.addEventListener("change", () => {
49
+ if (boardSwitcherEl.value && boardSwitcherEl.value !== window.location.href) {
50
+ window.location.href = boardSwitcherEl.value;
51
+ }
52
+ });
53
+
54
+ settingsButtonEl.addEventListener("click", () => {
55
+ if (settingsPopoverEl.hidden) {
56
+ openSettings();
57
+ } else {
58
+ closeSettings();
59
+ }
60
+ });
61
+
62
+ settingsPopoverEl.addEventListener("change", (event) => {
63
+ const control = event.target.closest("[data-setting]");
64
+ if (!control) return;
65
+ saveSettings({ ...currentSettings, [control.dataset.setting]: control.value });
66
+ });
67
+
68
+ async function loadBoard() {
69
+ const response = await fetch("./api/board", { cache: "no-store" });
70
+ if (!response.ok) throw new Error("Board request failed");
71
+ renderBoard(await response.json());
72
+ }
73
+
74
+ async function loadBoardSwitcher() {
75
+ const response = await fetch("../api/boards", { cache: "no-store" });
76
+ if (!response.ok) return;
77
+ const payload = await response.json();
78
+ renderBoardSwitcher(payload.boards || []);
79
+ }
80
+
81
+ async function loadSettings() {
82
+ try {
83
+ const response = await fetch("../api/settings", { cache: "no-store" });
84
+ if (!response.ok) throw new Error("Settings request failed");
85
+ const payload = await response.json();
86
+ currentSettings = normalizeSettings(payload.settings);
87
+ window.localStorage?.setItem(settingsStorageKey, JSON.stringify(currentSettings));
88
+ } catch {
89
+ currentSettings = readStoredSettings();
90
+ }
91
+ applySettings(currentSettings);
92
+ }
93
+
94
+ async function saveSettings(nextSettings) {
95
+ currentSettings = normalizeSettings(nextSettings);
96
+ window.localStorage?.setItem(settingsStorageKey, JSON.stringify(currentSettings));
97
+ applySettings(currentSettings);
98
+ try {
99
+ const response = await fetch("../api/settings", {
100
+ method: "PUT",
101
+ headers: { "Content-Type": "application/json" },
102
+ body: JSON.stringify({ settings: currentSettings }),
103
+ });
104
+ if (!response.ok) throw new Error("Settings save failed");
105
+ const payload = await response.json();
106
+ currentSettings = normalizeSettings(payload.settings);
107
+ window.localStorage?.setItem(settingsStorageKey, JSON.stringify(currentSettings));
108
+ applySettings(currentSettings);
109
+ } catch {
110
+ // Keep the localStorage fallback active when the local settings API is unavailable.
111
+ }
112
+ return currentSettings;
113
+ }
114
+
115
+ function readStoredSettings() {
116
+ try {
117
+ return normalizeSettings(JSON.parse(window.localStorage?.getItem(settingsStorageKey) || "{}"));
118
+ } catch {
119
+ return { ...settingsDefaults };
120
+ }
121
+ }
122
+
123
+ function normalizeSettings(settings) {
124
+ const normalized = { ...settingsDefaults };
125
+ if (!settings || typeof settings !== "object" || Array.isArray(settings)) return normalized;
126
+ for (const [key, allowed] of Object.entries(settingsOptions)) {
127
+ if (allowed.has(settings[key])) normalized[key] = settings[key];
128
+ }
129
+ if (typeof settings.lastBoardPath === "string" && /^\/[a-z0-9][a-z0-9-]*\/$/.test(settings.lastBoardPath)) {
130
+ normalized.lastBoardPath = settings.lastBoardPath;
131
+ }
132
+ return normalized;
133
+ }
134
+
135
+ function applySettings(settings) {
136
+ const normalized = normalizeSettings(settings);
137
+ document.documentElement.dataset.theme = normalized.theme;
138
+ document.documentElement.dataset.density = normalized.density;
139
+ document.documentElement.dataset.completedVisibility = normalized.completedVisibility;
140
+ document.documentElement.dataset.boardOpenBehavior = normalized.boardOpenBehavior;
141
+ document.documentElement.dataset.motion = normalized.motion;
142
+ for (const control of settingsPopoverEl.querySelectorAll("[data-setting]")) {
143
+ control.value = normalized[control.dataset.setting] || settingsDefaults[control.dataset.setting];
144
+ }
145
+ }
146
+
147
+ function rememberCurrentBoard() {
148
+ const boardPath = normalizePath(window.location.pathname);
149
+ if (!/^\/[a-z0-9][a-z0-9-]*\/$/.test(boardPath)) return;
150
+ const nextSettings = normalizeSettings({ ...currentSettings, lastBoardPath: boardPath });
151
+ currentSettings = nextSettings;
152
+ window.localStorage?.setItem(settingsStorageKey, JSON.stringify(nextSettings));
153
+ fetch("../api/settings", {
154
+ method: "PUT",
155
+ headers: { "Content-Type": "application/json" },
156
+ body: JSON.stringify({ settings: nextSettings }),
157
+ }).catch(() => {});
158
+ }
159
+
160
+ function openSettings() {
161
+ settingsPopoverEl.hidden = false;
162
+ settingsButtonEl.setAttribute("aria-expanded", "true");
163
+ settingsPopoverEl.querySelector("[data-setting]")?.focus();
164
+ }
165
+
166
+ function closeSettings() {
167
+ settingsPopoverEl.hidden = true;
168
+ settingsButtonEl.setAttribute("aria-expanded", "false");
169
+ }
170
+
171
+ function formatStars(count) {
172
+ if (count >= 1000) return `${(count / 1000).toFixed(count >= 10000 ? 0 : 1)}k`;
173
+ return String(count);
174
+ }
175
+
176
+ async function loadGithubStars() {
177
+ if (!githubStarsEl) return;
178
+ try {
179
+ const response = await fetch("https://api.github.com/repos/tolibear/goalbuddy", {
180
+ headers: { Accept: "application/vnd.github+json" },
181
+ });
182
+ if (!response.ok) throw new Error("GitHub API unavailable");
183
+ const repo = await response.json();
184
+ githubStarsEl.textContent = `${formatStars(repo.stargazers_count)} stars`;
185
+ } catch {
186
+ githubStarsEl.textContent = "GitHub";
187
+ }
188
+ }
189
+
190
+ function connectEvents() {
191
+ eventSource = new EventSource("./events");
192
+ eventSource.addEventListener("board", (event) => {
193
+ setLiveState("Live", true);
194
+ renderBoard(JSON.parse(event.data));
195
+ });
196
+ eventSource.addEventListener("error", () => {
197
+ setLiveState("Reconnecting", false);
198
+ });
199
+ }
200
+
201
+ function renderBoard(board) {
202
+ const previousPositions = measureCards();
203
+ const previousColumns = new Map();
204
+ for (const column of currentBoard?.columns || []) {
205
+ for (const task of column.tasks) previousColumns.set(task.id, column.id);
206
+ }
207
+ const movingTaskIds = tasksChangingColumns(board, previousColumns);
208
+ if (movingTaskIds.size) highlightMovingCards(movingTaskIds);
209
+ currentBoard = board;
210
+ document.getElementById("goal-title").textContent = board.goal.title;
211
+ document.title = board.goal.title ? board.goal.title + " - GoalBuddy Board" : "GoalBuddy Board";
212
+ document.getElementById("goal-tranche").textContent = board.goal.tranche || "";
213
+ document.getElementById("goal-status").textContent = board.goal.status;
214
+ document.getElementById("goal-active").textContent = board.goal.activeTask || "None";
215
+ document.getElementById("goal-updated").textContent = new Date(board.generatedAt).toLocaleTimeString();
216
+
217
+ if (board.error) {
218
+ boardEl.replaceChildren(renderBoardError(board.error));
219
+ return;
220
+ }
221
+
222
+ const delay = movingTaskIds.size ? 260 : 0;
223
+ window.setTimeout(() => {
224
+ boardEl.replaceChildren(...board.columns.map(renderColumn));
225
+ animateCardMoves(previousPositions, movingTaskIds);
226
+ }, delay);
227
+ }
228
+
229
+ function renderBoardError(message) {
230
+ const node = el("section", "board-error");
231
+ node.append(
232
+ el("h2", "", "GoalBuddy could not parse this board"),
233
+ el("p", "", message),
234
+ );
235
+ return node;
236
+ }
237
+
238
+ function renderBoardSwitcher(boards) {
239
+ boardSwitcherEl.closest(".board-switcher").classList.toggle("is-empty", boards.length <= 1);
240
+ const currentPath = normalizePath(window.location.pathname);
241
+ const options = boards.map((board) => {
242
+ const option = document.createElement("option");
243
+ option.value = board.url;
244
+ option.textContent = boardOptionLabel(board);
245
+ const boardPath = normalizePath(new URL(board.url, window.location.href).pathname);
246
+ if (boardPath === currentPath) option.selected = true;
247
+ return option;
248
+ });
249
+ boardSwitcherEl.replaceChildren(...options);
250
+ }
251
+
252
+ function renderColumn(column) {
253
+ const section = el("section", "column");
254
+ section.dataset.columnId = column.id;
255
+ const header = el("header", "column-header");
256
+ const titleWrap = el("div");
257
+ titleWrap.append(el("h2", "", column.title), el("p", "", column.description));
258
+ header.append(titleWrap, el("span", "column-count", String(column.tasks.length)));
259
+
260
+ const list = el("div", "card-list");
261
+ if (column.tasks.length === 0) {
262
+ list.append(el("p", "empty", "No cards"));
263
+ } else {
264
+ for (const task of column.tasks) list.append(renderCard(task));
265
+ }
266
+
267
+ section.append(header, list);
268
+ return section;
269
+ }
270
+
271
+ function renderCard(task) {
272
+ const button = el("button", `task-card ${task.active ? "is-active" : ""}`);
273
+ button.type = "button";
274
+ button.dataset.taskId = task.id;
275
+ button.dataset.status = task.status;
276
+
277
+ const topline = el("div", "card-topline");
278
+ topline.append(el("span", "task-id", task.id), statusBadge(task.status));
279
+
280
+ const footer = el("div", "card-footer");
281
+ footer.append(el("span", "badge role", task.assignee || task.type || "PM"));
282
+ if (task.subgoal) footer.append(subgoalBadge(task.subgoal));
283
+ if (task.receipt?.present) footer.append(el("span", "badge status-done", "Receipt"));
284
+
285
+ button.append(topline, el("h3", "task-title", task.title), footer);
286
+ return button;
287
+ }
288
+
289
+ function measureCards() {
290
+ const positions = new Map();
291
+ for (const card of boardEl.querySelectorAll("[data-task-id]")) {
292
+ const rect = card.getBoundingClientRect();
293
+ positions.set(card.dataset.taskId, {
294
+ left: rect.left,
295
+ top: rect.top,
296
+ width: rect.width,
297
+ height: rect.height,
298
+ columnId: card.closest("[data-column-id]")?.dataset.columnId || "",
299
+ });
300
+ }
301
+ return positions;
302
+ }
303
+
304
+ function tasksChangingColumns(board, previousColumns) {
305
+ const moving = new Set();
306
+ for (const column of board.columns) {
307
+ for (const task of column.tasks) {
308
+ const previousColumn = previousColumns.get(task.id);
309
+ if (previousColumn && previousColumn !== column.id) moving.add(task.id);
310
+ }
311
+ }
312
+ return moving;
313
+ }
314
+
315
+ function highlightMovingCards(taskIds) {
316
+ if (window.matchMedia("(prefers-reduced-motion: reduce)").matches) return;
317
+ for (const card of boardEl.querySelectorAll("[data-task-id]")) {
318
+ if (!taskIds.has(card.dataset.taskId)) continue;
319
+ card.classList.add("is-moving");
320
+ card.animate([
321
+ { transform: "scale(1)", borderColor: "#eaeaea" },
322
+ { transform: "scale(1.025)", borderColor: "#9d8cff" },
323
+ { transform: "scale(1)", borderColor: "#c2b8ff" },
324
+ ], {
325
+ duration: 240,
326
+ easing: "cubic-bezier(0.16, 1, 0.3, 1)",
327
+ });
328
+ }
329
+ }
330
+
331
+ function animateCardMoves(previousPositions, movingTaskIds = new Set()) {
332
+ if (window.matchMedia("(prefers-reduced-motion: reduce)").matches) return;
333
+
334
+ for (const card of boardEl.querySelectorAll("[data-task-id]")) {
335
+ const previous = previousPositions.get(card.dataset.taskId);
336
+ const current = card.getBoundingClientRect();
337
+ const columnId = card.closest("[data-column-id]")?.dataset.columnId || "";
338
+
339
+ if (!previous) {
340
+ card.animate([
341
+ { opacity: 0, transform: "translateY(10px) scale(0.98)" },
342
+ { opacity: 1, transform: "translateY(0) scale(1)" },
343
+ ], {
344
+ duration: 260,
345
+ easing: "cubic-bezier(0.16, 1, 0.3, 1)",
346
+ });
347
+ continue;
348
+ }
349
+
350
+ const dx = previous.left - current.left;
351
+ const dy = previous.top - current.top;
352
+ if (Math.abs(dx) < 1 && Math.abs(dy) < 1) continue;
353
+
354
+ const changedColumn = previous.columnId !== columnId;
355
+ const wasSelected = movingTaskIds.has(card.dataset.taskId);
356
+ card.animate([
357
+ {
358
+ transform: `translate(${dx}px, ${dy}px) scale(${changedColumn ? "1.015" : "1"})`,
359
+ opacity: changedColumn ? 0.9 : 1,
360
+ borderColor: wasSelected ? "#9d8cff" : "#eaeaea",
361
+ },
362
+ {
363
+ transform: "translate(0, 0) scale(1)",
364
+ opacity: 1,
365
+ borderColor: "#eaeaea",
366
+ },
367
+ ], {
368
+ duration: changedColumn ? 980 : 520,
369
+ easing: "cubic-bezier(0.19, 1, 0.22, 1)",
370
+ });
371
+ }
372
+ }
373
+
374
+ function openTask(taskId) {
375
+ const task = currentBoard?.tasks.find((candidate) => candidate.id === taskId);
376
+ if (!task) return;
377
+
378
+ modalKickerEl.textContent = `${task.id} · ${task.status}`;
379
+ modalTitleEl.textContent = task.title;
380
+ modalBodyEl.replaceChildren(renderTaskDetail(task));
381
+ modalEl.hidden = false;
382
+ }
383
+
384
+ function closeModal() {
385
+ modalEl.hidden = true;
386
+ }
387
+
388
+ function renderTaskDetail(task) {
389
+ const root = el("div");
390
+ const grid = el("dl", "detail-grid");
391
+ for (const [label, value] of [
392
+ ["Status", task.status],
393
+ ["Assignee", task.assignee || "Unassigned"],
394
+ ["Type", task.type],
395
+ ["Receipt", task.receipt?.summary || "None"],
396
+ ]) {
397
+ const item = el("div", "detail-item");
398
+ item.append(el("dt", "", label), el("dd", "", value));
399
+ grid.append(item);
400
+ }
401
+ root.append(grid);
402
+ if (task.subgoal) root.append(renderSubgoal(task.subgoal));
403
+ root.append(detailText("Objective", task.objective));
404
+ root.append(detailList("Inputs", task.inputs));
405
+ root.append(detailList("Constraints", task.constraints));
406
+ root.append(detailList("Expected Output", task.expectedOutput));
407
+ root.append(detailList("Allowed Files", task.allowedFiles));
408
+ root.append(detailList("Verify", task.verify));
409
+ root.append(detailList("Stop If", task.stopIf));
410
+ if (task.receipt?.decision) root.append(detailText("Decision", task.receipt.decision));
411
+ if (task.receipt?.changedFiles?.length) root.append(detailList("Changed Files", task.receipt.changedFiles));
412
+ if (task.receipt?.commands?.length) {
413
+ root.append(detailList("Commands", task.receipt.commands.map((command) => command.status ? `${command.status}: ${command.cmd}` : command.cmd)));
414
+ }
415
+ if (task.note?.content) {
416
+ const section = el("section", "detail-section");
417
+ section.append(el("h3", "", task.note.title || task.note.path), el("pre", "note", task.note.content));
418
+ root.append(section);
419
+ }
420
+ return root;
421
+ }
422
+
423
+ function renderSubgoal(subgoal) {
424
+ const section = el("section", "detail-section subgoal-section");
425
+ const header = el("div", "subgoal-header");
426
+ const titleWrap = el("div");
427
+ const board = subgoal.board;
428
+ titleWrap.append(
429
+ el("h3", "subgoal-title", board?.goal?.title || "Sub-goal"),
430
+ el("p", "subgoal-meta", [
431
+ subgoal.path,
432
+ subgoal.owner ? `owner: ${subgoal.owner}` : "",
433
+ subgoal.depth ? `depth: ${subgoal.depth}` : "",
434
+ ].filter(Boolean).join(" · ")),
435
+ );
436
+ header.append(titleWrap, subgoalBadge(subgoal));
437
+ section.append(header);
438
+
439
+ if (!board?.columns?.length) {
440
+ section.append(el("p", "", "No child board payload."));
441
+ return section;
442
+ }
443
+
444
+ const boardEl = el("div", "subgoal-board");
445
+ for (const column of board.columns) {
446
+ const columnEl = el("section", "subgoal-column");
447
+ const columnHeader = el("header", "subgoal-column-header");
448
+ columnHeader.append(el("h4", "", column.title), el("span", "column-count", String(column.tasks.length)));
449
+ const list = el("div", "subgoal-card-list");
450
+ if (column.tasks.length === 0) {
451
+ list.append(el("p", "empty", "No cards"));
452
+ } else {
453
+ for (const task of column.tasks) list.append(renderSubgoalTask(task));
454
+ }
455
+ columnEl.append(columnHeader, list);
456
+ boardEl.append(columnEl);
457
+ }
458
+ section.append(boardEl);
459
+
460
+ if (subgoal.rollupReceipt) {
461
+ section.append(detailText("Roll-up Receipt", subgoal.rollupReceipt));
462
+ }
463
+
464
+ return section;
465
+ }
466
+
467
+ function renderSubgoalTask(task) {
468
+ const card = el("article", `subgoal-task-card ${task.active ? "is-active" : ""}`);
469
+ const topline = el("div", "card-topline");
470
+ topline.append(el("span", "task-id", task.id), statusBadge(task.status));
471
+ const footer = el("div", "card-footer");
472
+ footer.append(el("span", "badge role", task.assignee || task.type || "PM"));
473
+ if (task.receipt?.present) footer.append(el("span", "badge status-done", "Receipt"));
474
+ card.append(topline, el("h4", "subgoal-task-title", task.title), footer);
475
+ return card;
476
+ }
477
+
478
+ function detailText(title, value) {
479
+ const section = el("section", "detail-section");
480
+ section.append(el("h3", "", title), el("p", "", value || "None"));
481
+ return section;
482
+ }
483
+
484
+ function detailList(title, values) {
485
+ const section = el("section", "detail-section");
486
+ section.append(el("h3", "", title));
487
+ if (!values?.length) {
488
+ section.append(el("p", "", "None"));
489
+ return section;
490
+ }
491
+ const list = el("ul");
492
+ for (const value of values) list.append(el("li", "", value));
493
+ section.append(list);
494
+ return section;
495
+ }
496
+
497
+ function statusBadge(status) {
498
+ const label = status === "done" ? "Completed" : status === "active" ? "Active" : status === "blocked" ? "Blocked" : "Queued";
499
+ return el("span", `badge status-${status}`, label);
500
+ }
501
+
502
+ function subgoalBadge(subgoal) {
503
+ return el("span", `badge subgoal status-${subgoal.status}`, `Sub-goal ${subgoal.status || "linked"}`);
504
+ }
505
+
506
+ function setLiveState(text, live) {
507
+ liveStateEl.textContent = text;
508
+ liveDotEl.classList.toggle("offline", !live);
509
+ settingsButtonEl.setAttribute("aria-label", `Settings. Board status: ${text}`);
510
+ settingsButtonEl.title = `Settings · ${text}`;
511
+ }
512
+
513
+ function normalizePath(pathname) {
514
+ return pathname.endsWith("/") ? pathname : pathname + "/";
515
+ }
516
+
517
+ function boardOptionLabel(board) {
518
+ const title = board.title || board.slug || board.goalDir || "GoalBuddy board";
519
+ return /[/\\]subgoals[/\\]/.test(board.goalDir || "") ? `Child: ${title}` : title;
520
+ }
521
+
522
+ function el(tag, className = "", text = "") {
523
+ const node = document.createElement(tag);
524
+ if (className) node.className = className;
525
+ if (text !== "") node.textContent = text;
526
+ return node;
527
+ }
528
+
529
+ loadSettings()
530
+ .then(loadBoard)
531
+ .then(() => {
532
+ setLiveState("Live", true);
533
+ rememberCurrentBoard();
534
+ loadGithubStars();
535
+ loadBoardSwitcher();
536
+ window.setInterval(loadBoardSwitcher, 5000);
537
+ connectEvents();
538
+ })
539
+ .catch((error) => {
540
+ setLiveState("Offline", false);
541
+ boardEl.textContent = error.message;
542
+ });
543
+
@@ -0,0 +1,111 @@
1
+ <!doctype html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="utf-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1">
6
+ <title>GoalBuddy Board</title>
7
+ <link rel="stylesheet" href="./styles.css">
8
+ </head>
9
+ <body>
10
+ <header class="topbar">
11
+ <div class="topbar-primary">
12
+ <div class="brand" aria-label="Goal Buddy">
13
+ <img class="brand-mark" src="./goalbuddy-mark.png" alt="GoalBuddy">
14
+ <span class="brand-name">Goal Buddy</span>
15
+ <span class="live-dot" id="live-dot" aria-hidden="true"></span>
16
+ </div>
17
+ <nav class="board-switcher is-empty" aria-label="Local GoalBuddy boards">
18
+ <label for="board-switcher">Board</label>
19
+ <select id="board-switcher" aria-label="Switch local board"></select>
20
+ </nav>
21
+ </div>
22
+ <div class="header-tools">
23
+ <a class="github-stars" href="https://github.com/tolibear/goalbuddy" target="_blank" rel="noreferrer" aria-label="Open GoalBuddy on GitHub">
24
+ <svg viewBox="0 0 24 24" aria-hidden="true"><path d="m12 2.8 2.84 5.76 6.36.92-4.6 4.48 1.08 6.34L12 17.32 6.32 20.3l1.08-6.34-4.6-4.48 6.36-.92L12 2.8Z"></path></svg>
25
+ <span id="github-stars">Stars</span>
26
+ </a>
27
+ <div class="settings-wrap">
28
+ <button class="settings-button" id="settings-button" type="button" aria-expanded="false" aria-controls="settings-popover">
29
+ <svg viewBox="0 0 24 24" aria-hidden="true">
30
+ <path d="M12.2 2.75h-.4a1.6 1.6 0 0 0-1.58 1.36l-.18 1.18c-.46.16-.9.34-1.31.56l-1.02-.64a1.6 1.6 0 0 0-2.08.31l-.28.28a1.6 1.6 0 0 0-.31 2.08l.64 1.02c-.22.42-.4.86-.56 1.31l-1.18.18A1.6 1.6 0 0 0 2.58 12v.4A1.6 1.6 0 0 0 3.94 14l1.18.18c.16.46.34.9.56 1.31l-.64 1.02a1.6 1.6 0 0 0 .31 2.08l.28.28a1.6 1.6 0 0 0 2.08.31l1.02-.64c.42.22.86.4 1.31.56l.18 1.18a1.6 1.6 0 0 0 1.58 1.36h.4a1.6 1.6 0 0 0 1.58-1.36l.18-1.18c.46-.16.9-.34 1.31-.56l1.02.64a1.6 1.6 0 0 0 2.08-.31l.28-.28a1.6 1.6 0 0 0 .31-2.08l-.64-1.02c.22-.42.4-.86.56-1.31l1.18-.18a1.6 1.6 0 0 0 1.36-1.58V12a1.6 1.6 0 0 0-1.36-1.58l-1.18-.18a7.2 7.2 0 0 0-.56-1.31l.64-1.02a1.6 1.6 0 0 0-.31-2.08l-.28-.28a1.6 1.6 0 0 0-2.08-.31l-1.02.64c-.42-.22-.86-.4-1.31-.56l-.18-1.18a1.6 1.6 0 0 0-1.58-1.39Z"></path>
31
+ <circle cx="12" cy="12.2" r="3.15"></circle>
32
+ </svg>
33
+ <span class="visually-hidden" id="live-state">Connecting</span>
34
+ </button>
35
+ <section class="settings-popover" id="settings-popover" aria-label="Local board settings" hidden>
36
+ <div class="settings-heading">
37
+ <p class="eyebrow">Board settings</p>
38
+ <h2>Local preferences</h2>
39
+ </div>
40
+ <div class="setting-row">
41
+ <label for="setting-theme">Theme</label>
42
+ <select id="setting-theme" data-setting="theme">
43
+ <option value="system">System</option>
44
+ <option value="light">Light</option>
45
+ <option value="dark">Dark</option>
46
+ </select>
47
+ </div>
48
+ <div class="setting-row">
49
+ <label for="setting-density">Density</label>
50
+ <select id="setting-density" data-setting="density">
51
+ <option value="comfortable">Comfortable</option>
52
+ <option value="compact">Compact</option>
53
+ </select>
54
+ </div>
55
+ <div class="setting-row">
56
+ <label for="setting-completed">Completed</label>
57
+ <select id="setting-completed" data-setting="completedVisibility">
58
+ <option value="show">Show</option>
59
+ <option value="collapse">Collapse</option>
60
+ </select>
61
+ </div>
62
+ <div class="setting-row">
63
+ <label for="setting-board-open">Open boards</label>
64
+ <select id="setting-board-open" data-setting="boardOpenBehavior">
65
+ <option value="last">Last viewed</option>
66
+ <option value="newest">Newest active</option>
67
+ </select>
68
+ </div>
69
+ <div class="setting-row">
70
+ <label for="setting-motion">Motion</label>
71
+ <select id="setting-motion" data-setting="motion">
72
+ <option value="system">System</option>
73
+ <option value="reduce">Reduce</option>
74
+ <option value="allow">Allow</option>
75
+ </select>
76
+ </div>
77
+ </section>
78
+ </div>
79
+ </div>
80
+ </header>
81
+ <main class="shell">
82
+ <section class="goal-header" aria-labelledby="goal-title">
83
+ <div>
84
+ <p class="eyebrow">Local board</p>
85
+ <h1 id="goal-title">GoalBuddy Board</h1>
86
+ <p id="goal-tranche" class="goal-tranche"></p>
87
+ </div>
88
+ <dl class="goal-meta">
89
+ <div><dt>Status</dt><dd id="goal-status">Unknown</dd></div>
90
+ <div><dt>Active</dt><dd id="goal-active">None</dd></div>
91
+ <div><dt>Updated</dt><dd id="goal-updated">Waiting</dd></div>
92
+ </dl>
93
+ </section>
94
+ <section class="board" id="board" aria-label="Goal task board"></section>
95
+ </main>
96
+ <div class="modal" id="task-modal" hidden>
97
+ <button class="modal-scrim" type="button" data-close-modal aria-label="Close task detail"></button>
98
+ <article class="modal-panel" role="dialog" aria-modal="true" aria-labelledby="modal-title">
99
+ <header class="modal-header">
100
+ <div>
101
+ <p class="eyebrow" id="modal-kicker">Task</p>
102
+ <h2 id="modal-title">Task detail</h2>
103
+ </div>
104
+ <button class="icon-button" type="button" data-close-modal aria-label="Close task detail">x</button>
105
+ </header>
106
+ <div class="modal-body" id="modal-body"></div>
107
+ </article>
108
+ </div>
109
+ <script src="./app.js" type="module"></script>
110
+ </body>
111
+ </html>