goalbuddy 0.3.2 → 0.3.5

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 (51) hide show
  1. package/README.md +28 -3
  2. package/RELEASE-0.3.5.md +324 -0
  3. package/goalbuddy/SKILL.md +8 -2
  4. package/goalbuddy/agents/goal_judge.toml +29 -17
  5. package/goalbuddy/agents/goal_scout.toml +34 -14
  6. package/goalbuddy/agents/goal_worker.toml +32 -15
  7. package/goalbuddy/extend/local-goal-board/README.md +8 -4
  8. package/goalbuddy/extend/local-goal-board/examples/subgoal-parent/goal.md +3 -0
  9. package/goalbuddy/extend/local-goal-board/examples/subgoal-parent/notes/.gitkeep +1 -0
  10. package/goalbuddy/extend/local-goal-board/examples/subgoal-parent/state.yaml +60 -0
  11. package/goalbuddy/extend/local-goal-board/examples/subgoal-parent/subgoals/T004-board-view/goal.md +3 -0
  12. package/goalbuddy/extend/local-goal-board/examples/subgoal-parent/subgoals/T004-board-view/notes/.gitkeep +1 -0
  13. package/goalbuddy/extend/local-goal-board/examples/subgoal-parent/subgoals/T004-board-view/state.yaml +52 -0
  14. package/goalbuddy/extend/local-goal-board/extension.yaml +6 -4
  15. package/goalbuddy/extend/local-goal-board/scripts/lib/goal-board.mjs +940 -24
  16. package/goalbuddy/extend/local-goal-board/scripts/local-goal-board.mjs +389 -54
  17. package/goalbuddy/extend/local-goal-board/test/local-goal-board.test.mjs +420 -4
  18. package/goalbuddy/scripts/check-goal-state.mjs +116 -6
  19. package/goalbuddy/scripts/parallel-plan.mjs +191 -0
  20. package/goalbuddy/scripts/render-task-prompt.mjs +248 -0
  21. package/goalbuddy/templates/agents.md +2 -2
  22. package/goalbuddy/templates/state.yaml +8 -0
  23. package/internal/assets/goalbuddy-v0.3.5-release.png +0 -0
  24. package/internal/cli/goal-maker.mjs +64 -1
  25. package/package.json +3 -2
  26. package/plugins/goalbuddy/.claude-plugin/plugin.json +2 -2
  27. package/plugins/goalbuddy/.codex-plugin/plugin.json +4 -4
  28. package/plugins/goalbuddy/README.md +5 -3
  29. package/plugins/goalbuddy/agents/goal-judge.md +31 -16
  30. package/plugins/goalbuddy/agents/goal-scout.md +38 -13
  31. package/plugins/goalbuddy/agents/goal-worker.md +35 -14
  32. package/plugins/goalbuddy/skills/goalbuddy/SKILL.md +8 -2
  33. package/plugins/goalbuddy/skills/goalbuddy/agents/goal_judge.toml +29 -17
  34. package/plugins/goalbuddy/skills/goalbuddy/agents/goal_scout.toml +34 -14
  35. package/plugins/goalbuddy/skills/goalbuddy/agents/goal_worker.toml +32 -15
  36. package/plugins/goalbuddy/skills/goalbuddy/extend/local-goal-board/README.md +8 -4
  37. package/plugins/goalbuddy/skills/goalbuddy/extend/local-goal-board/examples/subgoal-parent/goal.md +3 -0
  38. package/plugins/goalbuddy/skills/goalbuddy/extend/local-goal-board/examples/subgoal-parent/notes/.gitkeep +1 -0
  39. package/plugins/goalbuddy/skills/goalbuddy/extend/local-goal-board/examples/subgoal-parent/state.yaml +60 -0
  40. package/plugins/goalbuddy/skills/goalbuddy/extend/local-goal-board/examples/subgoal-parent/subgoals/T004-board-view/goal.md +3 -0
  41. package/plugins/goalbuddy/skills/goalbuddy/extend/local-goal-board/examples/subgoal-parent/subgoals/T004-board-view/notes/.gitkeep +1 -0
  42. package/plugins/goalbuddy/skills/goalbuddy/extend/local-goal-board/examples/subgoal-parent/subgoals/T004-board-view/state.yaml +52 -0
  43. package/plugins/goalbuddy/skills/goalbuddy/extend/local-goal-board/extension.yaml +6 -4
  44. package/plugins/goalbuddy/skills/goalbuddy/extend/local-goal-board/scripts/lib/goal-board.mjs +940 -24
  45. package/plugins/goalbuddy/skills/goalbuddy/extend/local-goal-board/scripts/local-goal-board.mjs +389 -54
  46. package/plugins/goalbuddy/skills/goalbuddy/extend/local-goal-board/test/local-goal-board.test.mjs +420 -4
  47. package/plugins/goalbuddy/skills/goalbuddy/scripts/check-goal-state.mjs +116 -6
  48. package/plugins/goalbuddy/skills/goalbuddy/scripts/parallel-plan.mjs +191 -0
  49. package/plugins/goalbuddy/skills/goalbuddy/scripts/render-task-prompt.mjs +248 -0
  50. package/plugins/goalbuddy/skills/goalbuddy/templates/agents.md +2 -2
  51. package/plugins/goalbuddy/skills/goalbuddy/templates/state.yaml +8 -0
@@ -1,6 +1,6 @@
1
1
  import { copyFileSync, existsSync, mkdirSync, readFileSync, readdirSync, statSync, writeFileSync } from "node:fs";
2
2
  import { readFile } from "node:fs/promises";
3
- import { basename, dirname, join, resolve } from "node:path";
3
+ import { basename, dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
4
4
  import { fileURLToPath } from "node:url";
5
5
 
6
6
  const VALID_STATUSES = new Set(["queued", "active", "blocked", "done"]);
@@ -26,7 +26,8 @@ export async function loadGoalBoard(goalDir) {
26
26
  return normalizeGoalBoard(parseGoalStateText(text), root);
27
27
  }
28
28
 
29
- export function createBoardPayload(goalDir) {
29
+ export function createBoardPayload(goalDir, options = {}) {
30
+ const includeSubgoals = options.includeSubgoals !== false;
30
31
  const root = resolve(goalDir);
31
32
  const statePath = join(root, "state.yaml");
32
33
  if (!existsSync(statePath)) {
@@ -36,7 +37,9 @@ export function createBoardPayload(goalDir) {
36
37
  const document = parseGoalStateText(readFileSync(statePath, "utf8"));
37
38
  const board = normalizeGoalBoard(document, root);
38
39
  const noteIndex = loadNotes(root);
39
- const tasks = board.tasks.map((task) => attachTaskNote(task, noteIndex));
40
+ const tasks = board.tasks
41
+ .map((task) => attachTaskNote(task, noteIndex))
42
+ .map((task) => includeSubgoals ? attachTaskSubgoal(task, root) : task);
40
43
  const columns = buildColumns(tasks);
41
44
  const stateStat = statSync(statePath);
42
45
 
@@ -128,6 +131,7 @@ export function normalizeTask(task, index) {
128
131
  allowedFiles: normalizeStringList(task.allowed_files),
129
132
  verify: normalizeStringList(task.verify),
130
133
  stopIf: normalizeStringList(task.stop_if),
134
+ subgoal: normalizeSubgoal(task.subgoal),
131
135
  receipt: normalizeReceipt(task.receipt),
132
136
  };
133
137
  }
@@ -170,6 +174,42 @@ function attachTaskNote(task, noteIndex) {
170
174
  };
171
175
  }
172
176
 
177
+ function attachTaskSubgoal(task, goalDir) {
178
+ if (!task.subgoal) return task;
179
+ const childStatePath = resolve(goalDir, task.subgoal.path);
180
+ validateChildSubgoalPath(task, goalDir, childStatePath);
181
+ const childGoalDir = dirname(childStatePath);
182
+ if (!existsSync(childStatePath)) {
183
+ throw new GoalBoardError(`Missing sub-goal state for ${task.id}: ${task.subgoal.path}`);
184
+ }
185
+
186
+ return {
187
+ ...task,
188
+ subgoal: {
189
+ ...task.subgoal,
190
+ board: createBoardPayload(childGoalDir, { includeSubgoals: false }),
191
+ },
192
+ };
193
+ }
194
+
195
+ function validateChildSubgoalPath(task, goalDir, childStatePath) {
196
+ if (task.subgoal.depth !== 1) {
197
+ throw new GoalBoardError(`Invalid sub-goal depth for ${task.id}: only depth 1 is supported.`);
198
+ }
199
+ const childRelativePath = relative(goalDir, childStatePath);
200
+ if (!isInsideRoot(childRelativePath)) {
201
+ throw new GoalBoardError(`Invalid sub-goal path for ${task.id}: ${task.subgoal.path} must stay inside the goal root.`);
202
+ }
203
+ const parts = childRelativePath.split(/[\\/]+/);
204
+ if (parts.length !== 3 || parts[0] !== "subgoals" || parts[2] !== "state.yaml") {
205
+ throw new GoalBoardError(`Invalid sub-goal path for ${task.id}: ${task.subgoal.path} must be subgoals/<slug>/state.yaml.`);
206
+ }
207
+ }
208
+
209
+ function isInsideRoot(relativePath) {
210
+ return relativePath && relativePath !== ".." && !relativePath.startsWith(`..${sep}`) && !isAbsolute(relativePath);
211
+ }
212
+
173
213
  function loadNotes(goalDir) {
174
214
  const notesDir = join(goalDir, "notes");
175
215
  if (!existsSync(notesDir)) return {};
@@ -215,6 +255,19 @@ function normalizeReceipt(receipt) {
215
255
  };
216
256
  }
217
257
 
258
+ function normalizeSubgoal(subgoal) {
259
+ if (!subgoal || typeof subgoal !== "object" || Array.isArray(subgoal)) return null;
260
+ return {
261
+ status: cleanText(subgoal.status || ""),
262
+ path: cleanText(subgoal.path || ""),
263
+ owner: cleanText(subgoal.owner || ""),
264
+ createdFrom: cleanText(subgoal.created_from || ""),
265
+ depth: Number(subgoal.depth || 0),
266
+ rollupReceipt: cleanText(subgoal.rollup_receipt || ""),
267
+ board: null,
268
+ };
269
+ }
270
+
218
271
  function normalizeCommands(commands) {
219
272
  if (!commands) return [];
220
273
  if (!Array.isArray(commands)) return [cleanText(commands)].filter(Boolean).map((cmd) => ({ cmd, status: "" }));
@@ -228,8 +281,33 @@ function normalizeCommands(commands) {
228
281
  }
229
282
 
230
283
  function titleForTask(task) {
284
+ if (task.title) return compactTaskTitle(task.title);
231
285
  const objective = cleanText(task.objective || "Untitled task");
232
- return objective.replace(/\.$/, "");
286
+ return compactTaskTitle(objective);
287
+ }
288
+
289
+ function compactTaskTitle(value) {
290
+ const text = cleanText(value).replace(/\.$/, "");
291
+ const routeMatch = text.match(/^Implement\b.*?\s(\/[A-Za-z0-9_./:-]+)\s+(route|queue slice|slice)\b/i);
292
+ if (routeMatch) return truncateTitle(`Implement ${routeMatch[1]} ${routeMatch[2]}`);
293
+
294
+ const firstClause = text
295
+ .split(/(?<=[.!?])\s+|\s+(?:Use only|Add|Match|Render|Clearly label|Do not)\b/i)[0]
296
+ .replace(/\bas the next first-milestone slice\b/gi, "")
297
+ .replace(/\bblocker documentation\b/gi, "blocker docs")
298
+ .replace(/\benv\/setup notes\b/gi, "setup notes")
299
+ .replace(/\s+/g, " ")
300
+ .replace(/[.;:,]\s*$/, "")
301
+ .trim();
302
+
303
+ return truncateTitle(firstClause || text);
304
+ }
305
+
306
+ function truncateTitle(value, maxLength = 82) {
307
+ const text = cleanText(value).replace(/\.$/, "");
308
+ if (text.length <= maxLength) return text;
309
+ const shortened = text.slice(0, maxLength + 1).replace(/\s+\S*$/, "").trim();
310
+ return `${shortened || text.slice(0, maxLength).trim()}...`;
233
311
  }
234
312
 
235
313
  function columnForStatus(status) {
@@ -452,11 +530,75 @@ function boardHtml() {
452
530
  </head>
453
531
  <body>
454
532
  <header class="topbar">
455
- <div class="brand" aria-label="GoalBuddy">
456
- <img class="brand-mark" src="./goalbuddy-mark.png" alt="GoalBuddy">
457
- <span class="brand-name">GoalBuddy</span>
533
+ <div class="topbar-primary">
534
+ <div class="brand" aria-label="Goal Buddy">
535
+ <img class="brand-mark" src="./goalbuddy-mark.png" alt="GoalBuddy">
536
+ <span class="brand-name">Goal Buddy</span>
537
+ <span class="live-dot" id="live-dot" aria-hidden="true"></span>
538
+ </div>
539
+ <nav class="board-switcher is-empty" aria-label="Local GoalBuddy boards">
540
+ <label for="board-switcher">Board</label>
541
+ <select id="board-switcher" aria-label="Switch local board"></select>
542
+ </nav>
543
+ </div>
544
+ <div class="header-tools">
545
+ <a class="github-stars" href="https://github.com/tolibear/goalbuddy" target="_blank" rel="noreferrer" aria-label="Open GoalBuddy on GitHub">
546
+ <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>
547
+ <span id="github-stars">Stars</span>
548
+ </a>
549
+ <div class="settings-wrap">
550
+ <button class="settings-button" id="settings-button" type="button" aria-expanded="false" aria-controls="settings-popover">
551
+ <svg viewBox="0 0 24 24" aria-hidden="true">
552
+ <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>
553
+ <circle cx="12" cy="12.2" r="3.15"></circle>
554
+ </svg>
555
+ <span class="visually-hidden" id="live-state">Connecting</span>
556
+ </button>
557
+ <section class="settings-popover" id="settings-popover" aria-label="Local board settings" hidden>
558
+ <div class="settings-heading">
559
+ <p class="eyebrow">Board settings</p>
560
+ <h2>Local preferences</h2>
561
+ </div>
562
+ <div class="setting-row">
563
+ <label for="setting-theme">Theme</label>
564
+ <select id="setting-theme" data-setting="theme">
565
+ <option value="system">System</option>
566
+ <option value="light">Light</option>
567
+ <option value="dark">Dark</option>
568
+ </select>
569
+ </div>
570
+ <div class="setting-row">
571
+ <label for="setting-density">Density</label>
572
+ <select id="setting-density" data-setting="density">
573
+ <option value="comfortable">Comfortable</option>
574
+ <option value="compact">Compact</option>
575
+ </select>
576
+ </div>
577
+ <div class="setting-row">
578
+ <label for="setting-completed">Completed</label>
579
+ <select id="setting-completed" data-setting="completedVisibility">
580
+ <option value="show">Show</option>
581
+ <option value="collapse">Collapse</option>
582
+ </select>
583
+ </div>
584
+ <div class="setting-row">
585
+ <label for="setting-board-open">Open boards</label>
586
+ <select id="setting-board-open" data-setting="boardOpenBehavior">
587
+ <option value="last">Last viewed</option>
588
+ <option value="newest">Newest active</option>
589
+ </select>
590
+ </div>
591
+ <div class="setting-row">
592
+ <label for="setting-motion">Motion</label>
593
+ <select id="setting-motion" data-setting="motion">
594
+ <option value="system">System</option>
595
+ <option value="reduce">Reduce</option>
596
+ <option value="allow">Allow</option>
597
+ </select>
598
+ </div>
599
+ </section>
600
+ </div>
458
601
  </div>
459
- <div class="live-state" id="live-state">Connecting</div>
460
602
  </header>
461
603
  <main class="shell">
462
604
  <section class="goal-header" aria-labelledby="goal-title">
@@ -508,9 +650,94 @@ function boardCss() {
508
650
  --red-text: #9f2f2d;
509
651
  --yellow-bg: #fbf3db;
510
652
  --yellow-text: #956400;
653
+ --active-surface: #fbfdfe;
511
654
  font-family: "SF Pro Display", "Geist Sans", "Helvetica Neue", Arial, sans-serif;
512
655
  }
513
656
 
657
+ :root[data-theme="dark"] {
658
+ color-scheme: dark;
659
+ --canvas: #07101f;
660
+ --surface: #101a2d;
661
+ --surface-muted: #0c1525;
662
+ --ink: #f7f9fc;
663
+ --muted: #9aa7bf;
664
+ --line: #26334a;
665
+ --blue-bg: #173653;
666
+ --blue-text: #9ed8ff;
667
+ --green-bg: #143929;
668
+ --green-text: #a6e8bf;
669
+ --red-bg: #3a1d22;
670
+ --red-text: #ffb2b9;
671
+ --yellow-bg: #3a3014;
672
+ --yellow-text: #f6d878;
673
+ --active-surface: #0f2031;
674
+ }
675
+
676
+ @media (prefers-color-scheme: dark) {
677
+ :root[data-theme="system"] {
678
+ color-scheme: dark;
679
+ --canvas: #07101f;
680
+ --surface: #101a2d;
681
+ --surface-muted: #0c1525;
682
+ --ink: #f7f9fc;
683
+ --muted: #9aa7bf;
684
+ --line: #26334a;
685
+ --blue-bg: #173653;
686
+ --blue-text: #9ed8ff;
687
+ --green-bg: #143929;
688
+ --green-text: #a6e8bf;
689
+ --red-bg: #3a1d22;
690
+ --red-text: #ffb2b9;
691
+ --yellow-bg: #3a3014;
692
+ --yellow-text: #f6d878;
693
+ --active-surface: #0f2031;
694
+ }
695
+
696
+ :root[data-theme="system"] .topbar {
697
+ border-color: rgba(61, 76, 108, 0.86);
698
+ background: rgba(13, 23, 41, 0.84);
699
+ box-shadow: 0 18px 48px rgba(0, 0, 0, 0.28);
700
+ }
701
+
702
+ :root[data-theme="system"] .brand {
703
+ color: var(--ink);
704
+ }
705
+
706
+ :root[data-theme="system"] .board-switcher select,
707
+ :root[data-theme="system"] .github-stars,
708
+ :root[data-theme="system"] .settings-button {
709
+ border-color: rgba(61, 76, 108, 0.9);
710
+ background: rgba(16, 26, 45, 0.78);
711
+ color: var(--ink);
712
+ }
713
+
714
+ :root[data-theme="system"] .settings-popover {
715
+ border-color: rgba(61, 76, 108, 0.96);
716
+ background: rgba(16, 26, 45, 0.96);
717
+ box-shadow: 0 24px 64px rgba(0, 0, 0, 0.28);
718
+ }
719
+
720
+ :root[data-theme="system"] .setting-row select {
721
+ background: var(--surface);
722
+ color: var(--ink);
723
+ }
724
+
725
+ :root[data-theme="system"] .goal-tranche,
726
+ :root[data-theme="system"] .task-title,
727
+ :root[data-theme="system"] .setting-row select {
728
+ color: var(--ink);
729
+ }
730
+
731
+ :root[data-theme="system"] .task-card.is-active {
732
+ background: linear-gradient(var(--active-surface), var(--active-surface)) padding-box,
733
+ linear-gradient(110deg, #78d7ff, #6c63ff, #78f2b9, #78d7ff) border-box;
734
+ }
735
+
736
+ :root[data-theme="system"] .task-card.is-active::after {
737
+ background: var(--active-surface);
738
+ }
739
+ }
740
+
514
741
  * { box-sizing: border-box; }
515
742
 
516
743
  body {
@@ -526,18 +753,46 @@ textarea {
526
753
  font: inherit;
527
754
  }
528
755
 
756
+ a {
757
+ color: inherit;
758
+ text-decoration: none;
759
+ }
760
+
761
+ select,
762
+ button {
763
+ font: inherit;
764
+ }
765
+
529
766
  .topbar {
530
767
  position: sticky;
531
- top: 0;
768
+ top: 16px;
532
769
  z-index: 10;
533
770
  display: flex;
534
771
  align-items: center;
535
772
  justify-content: space-between;
536
773
  gap: 16px;
537
- padding: 14px 24px;
538
- background: rgba(247, 246, 243, 0.94);
539
- border-bottom: 1px solid var(--line);
540
- backdrop-filter: blur(10px);
774
+ width: min(1392px, calc(100% - 48px));
775
+ min-height: 64px;
776
+ margin: 0 auto;
777
+ padding: 10px 12px 10px 18px;
778
+ border: 1px solid rgba(219, 226, 240, 0.86);
779
+ border-radius: 999px;
780
+ background: rgba(255, 255, 255, 0.78);
781
+ box-shadow: 0 18px 48px rgba(30, 40, 72, 0.1);
782
+ backdrop-filter: blur(22px);
783
+ }
784
+
785
+ :root[data-theme="dark"] .topbar {
786
+ border-color: rgba(61, 76, 108, 0.86);
787
+ background: rgba(13, 23, 41, 0.84);
788
+ box-shadow: 0 18px 48px rgba(0, 0, 0, 0.28);
789
+ }
790
+
791
+ .topbar-primary {
792
+ display: inline-flex;
793
+ align-items: center;
794
+ gap: 24px;
795
+ min-width: 0;
541
796
  }
542
797
 
543
798
  .brand {
@@ -546,12 +801,18 @@ textarea {
546
801
  gap: 10px;
547
802
  color: #071236;
548
803
  font-weight: 800;
804
+ min-width: fit-content;
805
+ }
806
+
807
+ :root[data-theme="dark"] .brand {
808
+ color: var(--ink);
549
809
  }
550
810
 
551
811
  .brand-mark {
552
812
  display: block;
553
- width: 34px;
554
- height: 34px;
813
+ width: 38px;
814
+ height: 38px;
815
+ filter: drop-shadow(0 8px 13px rgba(87, 76, 210, 0.18));
555
816
  }
556
817
 
557
818
  .brand-name {
@@ -559,7 +820,198 @@ textarea {
559
820
  letter-spacing: 0;
560
821
  }
561
822
 
562
- .live-state,
823
+ .board-switcher {
824
+ display: flex;
825
+ align-items: center;
826
+ justify-content: flex-start;
827
+ gap: 8px;
828
+ min-width: 0;
829
+ }
830
+
831
+ .board-switcher label {
832
+ color: var(--muted);
833
+ font-size: 11px;
834
+ font-weight: 700;
835
+ letter-spacing: 0.08em;
836
+ text-transform: uppercase;
837
+ }
838
+
839
+ .board-switcher select {
840
+ width: min(280px, 100%);
841
+ min-width: 0;
842
+ min-height: 38px;
843
+ border: 1px solid rgba(219, 226, 240, 0.9);
844
+ border-radius: 999px;
845
+ padding: 0 34px 0 14px;
846
+ background: rgba(255, 255, 255, 0.72);
847
+ color: #2f3c59;
848
+ font-weight: 700;
849
+ font-size: 14px;
850
+ }
851
+
852
+ :root[data-theme="dark"] .board-switcher select,
853
+ :root[data-theme="dark"] .github-stars,
854
+ :root[data-theme="dark"] .settings-button {
855
+ border-color: rgba(61, 76, 108, 0.9);
856
+ background: rgba(16, 26, 45, 0.78);
857
+ color: var(--ink);
858
+ }
859
+
860
+ .board-switcher.is-empty {
861
+ display: none;
862
+ }
863
+
864
+ .header-tools {
865
+ display: inline-flex;
866
+ align-items: center;
867
+ justify-content: flex-end;
868
+ gap: 10px;
869
+ min-width: fit-content;
870
+ }
871
+
872
+ .github-stars,
873
+ .settings-button {
874
+ display: inline-flex;
875
+ align-items: center;
876
+ justify-content: center;
877
+ min-height: 44px;
878
+ border: 1px solid rgba(219, 226, 240, 0.9);
879
+ border-radius: 999px;
880
+ background: rgba(255, 255, 255, 0.72);
881
+ color: #2f3c59;
882
+ font-weight: 800;
883
+ transition: transform 180ms ease, color 180ms ease, border-color 180ms ease, background 180ms ease;
884
+ }
885
+
886
+ .github-stars {
887
+ gap: 7px;
888
+ padding: 0 15px;
889
+ font-size: 14px;
890
+ white-space: nowrap;
891
+ }
892
+
893
+ .github-stars:hover,
894
+ .settings-button:hover {
895
+ transform: translateY(-2px);
896
+ color: #071236;
897
+ border-color: rgba(79, 70, 216, 0.26);
898
+ background: #fff;
899
+ }
900
+
901
+ .github-stars svg {
902
+ width: 16px;
903
+ height: 16px;
904
+ color: #4f46d8;
905
+ fill: currentColor;
906
+ }
907
+
908
+ .settings-wrap {
909
+ position: relative;
910
+ }
911
+
912
+ .settings-button {
913
+ position: relative;
914
+ gap: 8px;
915
+ width: 44px;
916
+ padding: 0;
917
+ cursor: pointer;
918
+ }
919
+
920
+ .settings-button svg {
921
+ width: 18px;
922
+ height: 18px;
923
+ fill: none;
924
+ stroke: currentColor;
925
+ stroke-width: 1.8;
926
+ stroke-linejoin: round;
927
+ }
928
+
929
+ .live-dot {
930
+ width: 8px;
931
+ height: 8px;
932
+ border: 2px solid #fff;
933
+ border-radius: 999px;
934
+ background: #1f9d69;
935
+ box-shadow: 0 0 0 4px rgba(31, 157, 105, 0.12);
936
+ }
937
+
938
+ .live-dot.offline {
939
+ background: var(--yellow-text);
940
+ box-shadow: 0 0 0 4px rgba(149, 100, 0, 0.12);
941
+ }
942
+
943
+ .settings-popover {
944
+ position: absolute;
945
+ top: calc(100% + 10px);
946
+ right: 0;
947
+ width: min(320px, calc(100vw - 32px));
948
+ padding: 16px;
949
+ border: 1px solid rgba(219, 226, 240, 0.96);
950
+ border-radius: 18px;
951
+ background: rgba(255, 255, 255, 0.96);
952
+ box-shadow: 0 24px 64px rgba(30, 40, 72, 0.16);
953
+ backdrop-filter: blur(20px);
954
+ }
955
+
956
+ :root[data-theme="dark"] .settings-popover {
957
+ border-color: rgba(61, 76, 108, 0.96);
958
+ background: rgba(16, 26, 45, 0.96);
959
+ box-shadow: 0 24px 64px rgba(0, 0, 0, 0.28);
960
+ }
961
+
962
+ .settings-popover[hidden] {
963
+ display: none;
964
+ }
965
+
966
+ .settings-heading {
967
+ margin-bottom: 12px;
968
+ }
969
+
970
+ .settings-heading .eyebrow {
971
+ margin-bottom: 6px;
972
+ }
973
+
974
+ .settings-heading h2 {
975
+ margin: 0;
976
+ font-size: 20px;
977
+ letter-spacing: 0;
978
+ }
979
+
980
+ .setting-row {
981
+ display: grid;
982
+ gap: 6px;
983
+ margin-top: 12px;
984
+ }
985
+
986
+ .setting-row label {
987
+ color: var(--muted);
988
+ font-size: 12px;
989
+ font-weight: 800;
990
+ }
991
+
992
+ .setting-row select {
993
+ min-height: 38px;
994
+ border: 1px solid var(--line);
995
+ border-radius: 8px;
996
+ padding: 0 10px;
997
+ background: #fff;
998
+ color: #2f3437;
999
+ }
1000
+
1001
+ :root[data-theme="dark"] .setting-row select {
1002
+ background: var(--surface);
1003
+ color: var(--ink);
1004
+ }
1005
+
1006
+ .visually-hidden {
1007
+ position: absolute;
1008
+ width: 1px;
1009
+ height: 1px;
1010
+ overflow: hidden;
1011
+ clip: rect(0 0 0 0);
1012
+ white-space: nowrap;
1013
+ }
1014
+
563
1015
  .badge {
564
1016
  display: inline-flex;
565
1017
  align-items: center;
@@ -625,6 +1077,12 @@ h1 {
625
1077
  line-height: 1.55;
626
1078
  }
627
1079
 
1080
+ :root[data-theme="dark"] .goal-tranche,
1081
+ :root[data-theme="dark"] .task-title,
1082
+ :root[data-theme="dark"] .setting-row select {
1083
+ color: var(--ink);
1084
+ }
1085
+
628
1086
  .goal-meta {
629
1087
  display: grid;
630
1088
  grid-template-columns: repeat(3, minmax(94px, auto));
@@ -708,6 +1166,7 @@ h1 {
708
1166
  }
709
1167
 
710
1168
  .task-card {
1169
+ position: relative;
711
1170
  width: 100%;
712
1171
  min-height: 138px;
713
1172
  display: flex;
@@ -720,10 +1179,16 @@ h1 {
720
1179
  color: inherit;
721
1180
  text-align: left;
722
1181
  cursor: pointer;
1182
+ overflow: hidden;
723
1183
  transition: transform 160ms ease, border-color 160ms ease;
724
1184
  will-change: transform, opacity;
725
1185
  }
726
1186
 
1187
+ .task-card > * {
1188
+ position: relative;
1189
+ z-index: 1;
1190
+ }
1191
+
727
1192
  .task-card:hover {
728
1193
  border-color: #d1d0cc;
729
1194
  transform: translateY(-1px);
@@ -736,10 +1201,80 @@ h1 {
736
1201
  }
737
1202
 
738
1203
  .task-card.is-active {
739
- border-color: #a8cfe7;
1204
+ border-color: transparent;
1205
+ background: linear-gradient(#fbfdfe, #fbfdfe) padding-box,
1206
+ linear-gradient(110deg, #78d7ff, #4f46d8, #78f2b9, #78d7ff) border-box;
1207
+ box-shadow: 0 14px 38px rgba(31, 108, 159, 0.12);
1208
+ }
1209
+
1210
+ .task-card.is-active::before {
1211
+ position: absolute;
1212
+ inset: -2px;
1213
+ z-index: 0;
1214
+ content: "";
1215
+ background: conic-gradient(from 0deg, transparent 0 58%, rgba(79, 70, 216, 0.28), rgba(120, 215, 255, 0.44), transparent 78% 100%);
1216
+ opacity: 0.86;
1217
+ animation: active-card-orbit 2.8s linear infinite;
1218
+ }
1219
+
1220
+ .task-card.is-active::after {
1221
+ position: absolute;
1222
+ inset: 2px;
1223
+ z-index: 0;
1224
+ content: "";
1225
+ border-radius: 6px;
740
1226
  background: #fbfdfe;
741
1227
  }
742
1228
 
1229
+ :root[data-theme="dark"] .task-card.is-active {
1230
+ background: linear-gradient(var(--active-surface), var(--active-surface)) padding-box,
1231
+ linear-gradient(110deg, #78d7ff, #6c63ff, #78f2b9, #78d7ff) border-box;
1232
+ }
1233
+
1234
+ :root[data-theme="dark"] .task-card.is-active::after {
1235
+ background: var(--active-surface);
1236
+ }
1237
+
1238
+ :root[data-density="compact"] .shell {
1239
+ padding-top: 20px;
1240
+ }
1241
+
1242
+ :root[data-density="compact"] .board {
1243
+ gap: 12px;
1244
+ }
1245
+
1246
+ :root[data-density="compact"] .column-header {
1247
+ padding: 12px;
1248
+ }
1249
+
1250
+ :root[data-density="compact"] .card-list {
1251
+ gap: 8px;
1252
+ padding: 10px;
1253
+ }
1254
+
1255
+ :root[data-density="compact"] .task-card {
1256
+ min-height: 110px;
1257
+ gap: 9px;
1258
+ padding: 11px;
1259
+ }
1260
+
1261
+ :root[data-density="compact"] .task-title {
1262
+ font-size: 14px;
1263
+ }
1264
+
1265
+ :root[data-completed-visibility="collapse"] .column[data-column-id="completed"] .card-list {
1266
+ display: none;
1267
+ }
1268
+
1269
+ :root[data-completed-visibility="collapse"] .column[data-column-id="completed"] {
1270
+ max-height: 80px;
1271
+ overflow: hidden;
1272
+ }
1273
+
1274
+ @keyframes active-card-orbit {
1275
+ to { transform: rotate(360deg); }
1276
+ }
1277
+
743
1278
  .task-card.is-moving {
744
1279
  border-color: #c2b8ff;
745
1280
  }
@@ -776,6 +1311,14 @@ h1 {
776
1311
  .badge.status-done { background: var(--green-bg); color: var(--green-text); }
777
1312
  .badge.status-blocked { background: var(--red-bg); color: var(--red-text); }
778
1313
  .badge.role { background: var(--yellow-bg); color: var(--yellow-text); }
1314
+ .badge.subgoal { background: #ece8ff; color: #5c43c6; }
1315
+ .badge.subgoal.status-blocked { background: var(--red-bg); color: var(--red-text); }
1316
+ .badge.subgoal.status-done { background: var(--green-bg); color: var(--green-text); }
1317
+
1318
+ :root[data-theme="dark"] .badge.subgoal {
1319
+ background: #263052;
1320
+ color: #c7d2ff;
1321
+ }
779
1322
 
780
1323
  .empty {
781
1324
  padding: 18px;
@@ -784,9 +1327,27 @@ h1 {
784
1327
  }
785
1328
 
786
1329
  @media (prefers-reduced-motion: reduce) {
1330
+ .github-stars,
1331
+ .settings-button,
787
1332
  .task-card {
788
1333
  transition: none;
789
1334
  }
1335
+
1336
+ .task-card.is-active::before {
1337
+ animation: none;
1338
+ opacity: 0.26;
1339
+ }
1340
+ }
1341
+
1342
+ :root[data-motion="reduce"] .github-stars,
1343
+ :root[data-motion="reduce"] .settings-button,
1344
+ :root[data-motion="reduce"] .task-card {
1345
+ transition: none;
1346
+ }
1347
+
1348
+ :root[data-motion="reduce"] .task-card.is-active::before {
1349
+ animation: none;
1350
+ opacity: 0.26;
790
1351
  }
791
1352
 
792
1353
  .modal[hidden] {
@@ -811,7 +1372,7 @@ h1 {
811
1372
 
812
1373
  .modal-panel {
813
1374
  position: relative;
814
- width: min(760px, 100%);
1375
+ width: min(1080px, 100%);
815
1376
  max-height: min(760px, calc(100vh - 48px));
816
1377
  overflow: auto;
817
1378
  border: 1px solid var(--line);
@@ -896,10 +1457,94 @@ h1 {
896
1457
  .detail-section ul {
897
1458
  margin: 0;
898
1459
  padding-left: 18px;
899
- color: #2f3437;
1460
+ color: var(--ink);
900
1461
  line-height: 1.55;
901
1462
  }
902
1463
 
1464
+ .detail-section li {
1465
+ color: var(--ink);
1466
+ }
1467
+
1468
+ .subgoal-section {
1469
+ border: 1px solid var(--line);
1470
+ border-radius: 8px;
1471
+ padding: 14px;
1472
+ background: var(--surface-muted);
1473
+ }
1474
+
1475
+ .subgoal-header {
1476
+ display: flex;
1477
+ align-items: start;
1478
+ justify-content: space-between;
1479
+ gap: 12px;
1480
+ margin-bottom: 12px;
1481
+ }
1482
+
1483
+ .subgoal-title {
1484
+ margin: 0 0 4px;
1485
+ font-size: 15px;
1486
+ }
1487
+
1488
+ .subgoal-meta {
1489
+ margin: 0;
1490
+ color: var(--muted);
1491
+ font-size: 12px;
1492
+ line-height: 1.45;
1493
+ }
1494
+
1495
+ .subgoal-board {
1496
+ display: grid;
1497
+ grid-template-columns: repeat(4, minmax(0, 1fr));
1498
+ gap: 10px;
1499
+ }
1500
+
1501
+ .subgoal-column {
1502
+ min-width: 0;
1503
+ border: 1px solid var(--line);
1504
+ border-radius: 8px;
1505
+ background: var(--surface);
1506
+ }
1507
+
1508
+ .subgoal-column-header {
1509
+ display: flex;
1510
+ align-items: center;
1511
+ justify-content: space-between;
1512
+ gap: 8px;
1513
+ padding: 10px;
1514
+ border-bottom: 1px solid var(--line);
1515
+ }
1516
+
1517
+ .subgoal-column-header h4 {
1518
+ margin: 0;
1519
+ font-size: 12px;
1520
+ }
1521
+
1522
+ .subgoal-card-list {
1523
+ display: grid;
1524
+ gap: 8px;
1525
+ padding: 8px;
1526
+ }
1527
+
1528
+ .subgoal-task-card {
1529
+ min-height: 74px;
1530
+ border: 1px solid var(--line);
1531
+ border-radius: 7px;
1532
+ padding: 9px;
1533
+ background: var(--surface);
1534
+ }
1535
+
1536
+ .subgoal-task-card.is-active {
1537
+ border-color: #8e9cff;
1538
+ background: var(--active-surface);
1539
+ }
1540
+
1541
+ .subgoal-task-title {
1542
+ margin: 6px 0 0;
1543
+ color: var(--ink);
1544
+ font-size: 12px;
1545
+ line-height: 1.35;
1546
+ }
1547
+
903
1548
  pre.note {
904
1549
  overflow: auto;
905
1550
  margin: 0;
@@ -907,7 +1552,7 @@ pre.note {
907
1552
  border: 1px solid var(--line);
908
1553
  border-radius: 8px;
909
1554
  background: var(--canvas);
910
- color: #2f3437;
1555
+ color: var(--ink);
911
1556
  font-family: "Geist Mono", "SF Mono", monospace;
912
1557
  font-size: 12px;
913
1558
  line-height: 1.55;
@@ -926,10 +1571,27 @@ pre.note {
926
1571
  .board {
927
1572
  grid-template-columns: 1fr;
928
1573
  }
1574
+
1575
+ .subgoal-board {
1576
+ grid-template-columns: 1fr;
1577
+ }
929
1578
  }
930
1579
 
931
1580
  @media (max-width: 640px) {
932
- .topbar,
1581
+ .topbar {
1582
+ align-items: flex-start;
1583
+ }
1584
+
1585
+ .topbar-primary {
1586
+ flex: 1;
1587
+ flex-wrap: wrap;
1588
+ gap: 10px 14px;
1589
+ }
1590
+
1591
+ .board-switcher select {
1592
+ width: 100%;
1593
+ }
1594
+
933
1595
  .shell {
934
1596
  padding-left: 14px;
935
1597
  padding-right: 14px;
@@ -949,22 +1611,69 @@ pre.note {
949
1611
  function boardJs() {
950
1612
  return `let currentBoard = null;
951
1613
  let eventSource = null;
1614
+ let currentSettings = null;
952
1615
 
953
1616
  const boardEl = document.getElementById("board");
954
1617
  const liveStateEl = document.getElementById("live-state");
1618
+ const liveDotEl = document.getElementById("live-dot");
1619
+ const boardSwitcherEl = document.getElementById("board-switcher");
1620
+ const settingsButtonEl = document.getElementById("settings-button");
1621
+ const settingsPopoverEl = document.getElementById("settings-popover");
1622
+ const githubStarsEl = document.getElementById("github-stars");
955
1623
  const modalEl = document.getElementById("task-modal");
956
1624
  const modalTitleEl = document.getElementById("modal-title");
957
1625
  const modalKickerEl = document.getElementById("modal-kicker");
958
1626
  const modalBodyEl = document.getElementById("modal-body");
1627
+ const settingsStorageKey = "goalbuddy.localBoardSettings.v1";
1628
+ const settingsDefaults = {
1629
+ theme: "system",
1630
+ density: "comfortable",
1631
+ completedVisibility: "show",
1632
+ boardOpenBehavior: "last",
1633
+ motion: "system",
1634
+ lastBoardPath: "",
1635
+ };
1636
+ const settingsOptions = {
1637
+ theme: new Set(["system", "light", "dark"]),
1638
+ density: new Set(["comfortable", "compact"]),
1639
+ completedVisibility: new Set(["show", "collapse"]),
1640
+ boardOpenBehavior: new Set(["last", "newest"]),
1641
+ motion: new Set(["system", "reduce", "allow"]),
1642
+ };
959
1643
 
960
1644
  document.addEventListener("click", (event) => {
961
1645
  const card = event.target.closest("[data-task-id]");
962
1646
  if (card) openTask(card.dataset.taskId);
963
1647
  if (event.target.matches("[data-close-modal]")) closeModal();
1648
+ if (settingsPopoverEl.hidden) return;
1649
+ if (!event.target.closest(".settings-wrap")) closeSettings();
964
1650
  });
965
1651
 
966
1652
  document.addEventListener("keydown", (event) => {
967
- if (event.key === "Escape") closeModal();
1653
+ if (event.key === "Escape") {
1654
+ closeModal();
1655
+ closeSettings();
1656
+ }
1657
+ });
1658
+
1659
+ boardSwitcherEl.addEventListener("change", () => {
1660
+ if (boardSwitcherEl.value && boardSwitcherEl.value !== window.location.href) {
1661
+ window.location.href = boardSwitcherEl.value;
1662
+ }
1663
+ });
1664
+
1665
+ settingsButtonEl.addEventListener("click", () => {
1666
+ if (settingsPopoverEl.hidden) {
1667
+ openSettings();
1668
+ } else {
1669
+ closeSettings();
1670
+ }
1671
+ });
1672
+
1673
+ settingsPopoverEl.addEventListener("change", (event) => {
1674
+ const control = event.target.closest("[data-setting]");
1675
+ if (!control) return;
1676
+ saveSettings({ ...currentSettings, [control.dataset.setting]: control.value });
968
1677
  });
969
1678
 
970
1679
  async function loadBoard() {
@@ -973,6 +1682,122 @@ async function loadBoard() {
973
1682
  renderBoard(await response.json());
974
1683
  }
975
1684
 
1685
+ async function loadBoardSwitcher() {
1686
+ const response = await fetch("../api/boards", { cache: "no-store" });
1687
+ if (!response.ok) return;
1688
+ const payload = await response.json();
1689
+ renderBoardSwitcher(payload.boards || []);
1690
+ }
1691
+
1692
+ async function loadSettings() {
1693
+ try {
1694
+ const response = await fetch("../api/settings", { cache: "no-store" });
1695
+ if (!response.ok) throw new Error("Settings request failed");
1696
+ const payload = await response.json();
1697
+ currentSettings = normalizeSettings(payload.settings);
1698
+ window.localStorage?.setItem(settingsStorageKey, JSON.stringify(currentSettings));
1699
+ } catch {
1700
+ currentSettings = readStoredSettings();
1701
+ }
1702
+ applySettings(currentSettings);
1703
+ }
1704
+
1705
+ async function saveSettings(nextSettings) {
1706
+ currentSettings = normalizeSettings(nextSettings);
1707
+ window.localStorage?.setItem(settingsStorageKey, JSON.stringify(currentSettings));
1708
+ applySettings(currentSettings);
1709
+ try {
1710
+ const response = await fetch("../api/settings", {
1711
+ method: "PUT",
1712
+ headers: { "Content-Type": "application/json" },
1713
+ body: JSON.stringify({ settings: currentSettings }),
1714
+ });
1715
+ if (!response.ok) throw new Error("Settings save failed");
1716
+ const payload = await response.json();
1717
+ currentSettings = normalizeSettings(payload.settings);
1718
+ window.localStorage?.setItem(settingsStorageKey, JSON.stringify(currentSettings));
1719
+ applySettings(currentSettings);
1720
+ } catch {
1721
+ // Keep the localStorage fallback active when the local settings API is unavailable.
1722
+ }
1723
+ return currentSettings;
1724
+ }
1725
+
1726
+ function readStoredSettings() {
1727
+ try {
1728
+ return normalizeSettings(JSON.parse(window.localStorage?.getItem(settingsStorageKey) || "{}"));
1729
+ } catch {
1730
+ return { ...settingsDefaults };
1731
+ }
1732
+ }
1733
+
1734
+ function normalizeSettings(settings) {
1735
+ const normalized = { ...settingsDefaults };
1736
+ if (!settings || typeof settings !== "object" || Array.isArray(settings)) return normalized;
1737
+ for (const [key, allowed] of Object.entries(settingsOptions)) {
1738
+ if (allowed.has(settings[key])) normalized[key] = settings[key];
1739
+ }
1740
+ if (typeof settings.lastBoardPath === "string" && /^\\/[a-z0-9][a-z0-9-]*\\/$/.test(settings.lastBoardPath)) {
1741
+ normalized.lastBoardPath = settings.lastBoardPath;
1742
+ }
1743
+ return normalized;
1744
+ }
1745
+
1746
+ function applySettings(settings) {
1747
+ const normalized = normalizeSettings(settings);
1748
+ document.documentElement.dataset.theme = normalized.theme;
1749
+ document.documentElement.dataset.density = normalized.density;
1750
+ document.documentElement.dataset.completedVisibility = normalized.completedVisibility;
1751
+ document.documentElement.dataset.boardOpenBehavior = normalized.boardOpenBehavior;
1752
+ document.documentElement.dataset.motion = normalized.motion;
1753
+ for (const control of settingsPopoverEl.querySelectorAll("[data-setting]")) {
1754
+ control.value = normalized[control.dataset.setting] || settingsDefaults[control.dataset.setting];
1755
+ }
1756
+ }
1757
+
1758
+ function rememberCurrentBoard() {
1759
+ const boardPath = normalizePath(window.location.pathname);
1760
+ if (!/^\\/[a-z0-9][a-z0-9-]*\\/$/.test(boardPath)) return;
1761
+ const nextSettings = normalizeSettings({ ...currentSettings, lastBoardPath: boardPath });
1762
+ currentSettings = nextSettings;
1763
+ window.localStorage?.setItem(settingsStorageKey, JSON.stringify(nextSettings));
1764
+ fetch("../api/settings", {
1765
+ method: "PUT",
1766
+ headers: { "Content-Type": "application/json" },
1767
+ body: JSON.stringify({ settings: nextSettings }),
1768
+ }).catch(() => {});
1769
+ }
1770
+
1771
+ function openSettings() {
1772
+ settingsPopoverEl.hidden = false;
1773
+ settingsButtonEl.setAttribute("aria-expanded", "true");
1774
+ settingsPopoverEl.querySelector("[data-setting]")?.focus();
1775
+ }
1776
+
1777
+ function closeSettings() {
1778
+ settingsPopoverEl.hidden = true;
1779
+ settingsButtonEl.setAttribute("aria-expanded", "false");
1780
+ }
1781
+
1782
+ function formatStars(count) {
1783
+ if (count >= 1000) return \`\${(count / 1000).toFixed(count >= 10000 ? 0 : 1)}k\`;
1784
+ return String(count);
1785
+ }
1786
+
1787
+ async function loadGithubStars() {
1788
+ if (!githubStarsEl) return;
1789
+ try {
1790
+ const response = await fetch("https://api.github.com/repos/tolibear/goalbuddy", {
1791
+ headers: { Accept: "application/vnd.github+json" },
1792
+ });
1793
+ if (!response.ok) throw new Error("GitHub API unavailable");
1794
+ const repo = await response.json();
1795
+ githubStarsEl.textContent = \`\${formatStars(repo.stargazers_count)} stars\`;
1796
+ } catch {
1797
+ githubStarsEl.textContent = "GitHub";
1798
+ }
1799
+ }
1800
+
976
1801
  function connectEvents() {
977
1802
  eventSource = new EventSource("./events");
978
1803
  eventSource.addEventListener("board", (event) => {
@@ -1007,6 +1832,20 @@ function renderBoard(board) {
1007
1832
  }, delay);
1008
1833
  }
1009
1834
 
1835
+ function renderBoardSwitcher(boards) {
1836
+ boardSwitcherEl.closest(".board-switcher").classList.toggle("is-empty", boards.length <= 1);
1837
+ const currentPath = normalizePath(window.location.pathname);
1838
+ const options = boards.map((board) => {
1839
+ const option = document.createElement("option");
1840
+ option.value = board.url;
1841
+ option.textContent = boardOptionLabel(board);
1842
+ const boardPath = normalizePath(new URL(board.url, window.location.href).pathname);
1843
+ if (boardPath === currentPath) option.selected = true;
1844
+ return option;
1845
+ });
1846
+ boardSwitcherEl.replaceChildren(...options);
1847
+ }
1848
+
1010
1849
  function renderColumn(column) {
1011
1850
  const section = el("section", "column");
1012
1851
  section.dataset.columnId = column.id;
@@ -1037,6 +1876,7 @@ function renderCard(task) {
1037
1876
 
1038
1877
  const footer = el("div", "card-footer");
1039
1878
  footer.append(el("span", "badge role", task.assignee || task.type || "PM"));
1879
+ if (task.subgoal) footer.append(subgoalBadge(task.subgoal));
1040
1880
  if (task.receipt?.present) footer.append(el("span", "badge status-done", "Receipt"));
1041
1881
 
1042
1882
  button.append(topline, el("h3", "task-title", task.title), footer);
@@ -1156,6 +1996,7 @@ function renderTaskDetail(task) {
1156
1996
  grid.append(item);
1157
1997
  }
1158
1998
  root.append(grid);
1999
+ if (task.subgoal) root.append(renderSubgoal(task.subgoal));
1159
2000
  root.append(detailText("Objective", task.objective));
1160
2001
  root.append(detailList("Inputs", task.inputs));
1161
2002
  root.append(detailList("Constraints", task.constraints));
@@ -1176,6 +2017,61 @@ function renderTaskDetail(task) {
1176
2017
  return root;
1177
2018
  }
1178
2019
 
2020
+ function renderSubgoal(subgoal) {
2021
+ const section = el("section", "detail-section subgoal-section");
2022
+ const header = el("div", "subgoal-header");
2023
+ const titleWrap = el("div");
2024
+ const board = subgoal.board;
2025
+ titleWrap.append(
2026
+ el("h3", "subgoal-title", board?.goal?.title || "Sub-goal"),
2027
+ el("p", "subgoal-meta", [
2028
+ subgoal.path,
2029
+ subgoal.owner ? \`owner: \${subgoal.owner}\` : "",
2030
+ subgoal.depth ? \`depth: \${subgoal.depth}\` : "",
2031
+ ].filter(Boolean).join(" · ")),
2032
+ );
2033
+ header.append(titleWrap, subgoalBadge(subgoal));
2034
+ section.append(header);
2035
+
2036
+ if (!board?.columns?.length) {
2037
+ section.append(el("p", "", "No child board payload."));
2038
+ return section;
2039
+ }
2040
+
2041
+ const boardEl = el("div", "subgoal-board");
2042
+ for (const column of board.columns) {
2043
+ const columnEl = el("section", "subgoal-column");
2044
+ const columnHeader = el("header", "subgoal-column-header");
2045
+ columnHeader.append(el("h4", "", column.title), el("span", "column-count", String(column.tasks.length)));
2046
+ const list = el("div", "subgoal-card-list");
2047
+ if (column.tasks.length === 0) {
2048
+ list.append(el("p", "empty", "No cards"));
2049
+ } else {
2050
+ for (const task of column.tasks) list.append(renderSubgoalTask(task));
2051
+ }
2052
+ columnEl.append(columnHeader, list);
2053
+ boardEl.append(columnEl);
2054
+ }
2055
+ section.append(boardEl);
2056
+
2057
+ if (subgoal.rollupReceipt) {
2058
+ section.append(detailText("Roll-up Receipt", subgoal.rollupReceipt));
2059
+ }
2060
+
2061
+ return section;
2062
+ }
2063
+
2064
+ function renderSubgoalTask(task) {
2065
+ const card = el("article", \`subgoal-task-card \${task.active ? "is-active" : ""}\`);
2066
+ const topline = el("div", "card-topline");
2067
+ topline.append(el("span", "task-id", task.id), statusBadge(task.status));
2068
+ const footer = el("div", "card-footer");
2069
+ footer.append(el("span", "badge role", task.assignee || task.type || "PM"));
2070
+ if (task.receipt?.present) footer.append(el("span", "badge status-done", "Receipt"));
2071
+ card.append(topline, el("h4", "subgoal-task-title", task.title), footer);
2072
+ return card;
2073
+ }
2074
+
1179
2075
  function detailText(title, value) {
1180
2076
  const section = el("section", "detail-section");
1181
2077
  section.append(el("h3", "", title), el("p", "", value || "None"));
@@ -1200,9 +2096,24 @@ function statusBadge(status) {
1200
2096
  return el("span", \`badge status-\${status}\`, label);
1201
2097
  }
1202
2098
 
2099
+ function subgoalBadge(subgoal) {
2100
+ return el("span", \`badge subgoal status-\${subgoal.status}\`, \`Sub-goal \${subgoal.status || "linked"}\`);
2101
+ }
2102
+
1203
2103
  function setLiveState(text, live) {
1204
2104
  liveStateEl.textContent = text;
1205
- liveStateEl.classList.toggle("offline", !live);
2105
+ liveDotEl.classList.toggle("offline", !live);
2106
+ settingsButtonEl.setAttribute("aria-label", \`Settings. Board status: \${text}\`);
2107
+ settingsButtonEl.title = \`Settings · \${text}\`;
2108
+ }
2109
+
2110
+ function normalizePath(pathname) {
2111
+ return pathname.endsWith("/") ? pathname : pathname + "/";
2112
+ }
2113
+
2114
+ function boardOptionLabel(board) {
2115
+ const title = board.title || board.slug || board.goalDir || "GoalBuddy board";
2116
+ return /[/\\\\]subgoals[/\\\\]/.test(board.goalDir || "") ? \`Child: \${title}\` : title;
1206
2117
  }
1207
2118
 
1208
2119
  function el(tag, className = "", text = "") {
@@ -1212,9 +2123,14 @@ function el(tag, className = "", text = "") {
1212
2123
  return node;
1213
2124
  }
1214
2125
 
1215
- loadBoard()
2126
+ loadSettings()
2127
+ .then(loadBoard)
1216
2128
  .then(() => {
1217
2129
  setLiveState("Live", true);
2130
+ rememberCurrentBoard();
2131
+ loadGithubStars();
2132
+ loadBoardSwitcher();
2133
+ window.setInterval(loadBoardSwitcher, 5000);
1218
2134
  connectEvents();
1219
2135
  })
1220
2136
  .catch((error) => {