goalbuddy 0.3.2 → 0.3.6

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 (55) hide show
  1. package/README.md +55 -5
  2. package/RELEASE-0.3.5.md +324 -0
  3. package/goalbuddy/SKILL.md +40 -13
  4. package/goalbuddy/agents/README.md +1 -1
  5. package/goalbuddy/agents/goal_judge.toml +33 -17
  6. package/goalbuddy/agents/goal_scout.toml +34 -14
  7. package/goalbuddy/agents/goal_worker.toml +36 -16
  8. package/goalbuddy/extend/local-goal-board/README.md +8 -4
  9. package/goalbuddy/extend/local-goal-board/examples/subgoal-parent/goal.md +3 -0
  10. package/goalbuddy/extend/local-goal-board/examples/subgoal-parent/notes/.gitkeep +1 -0
  11. package/goalbuddy/extend/local-goal-board/examples/subgoal-parent/state.yaml +60 -0
  12. package/goalbuddy/extend/local-goal-board/examples/subgoal-parent/subgoals/T004-board-view/goal.md +3 -0
  13. package/goalbuddy/extend/local-goal-board/examples/subgoal-parent/subgoals/T004-board-view/notes/.gitkeep +1 -0
  14. package/goalbuddy/extend/local-goal-board/examples/subgoal-parent/subgoals/T004-board-view/state.yaml +52 -0
  15. package/goalbuddy/extend/local-goal-board/extension.yaml +6 -4
  16. package/goalbuddy/extend/local-goal-board/scripts/lib/goal-board.mjs +1188 -31
  17. package/goalbuddy/extend/local-goal-board/scripts/local-goal-board.mjs +389 -54
  18. package/goalbuddy/extend/local-goal-board/test/local-goal-board.test.mjs +479 -5
  19. package/goalbuddy/scripts/check-goal-state.mjs +192 -6
  20. package/goalbuddy/scripts/parallel-plan.mjs +191 -0
  21. package/goalbuddy/scripts/render-task-prompt.mjs +305 -0
  22. package/goalbuddy/templates/agents.md +5 -4
  23. package/goalbuddy/templates/goal.md +18 -4
  24. package/goalbuddy/templates/state.yaml +14 -1
  25. package/internal/assets/goalbuddy-v0.3.5-release.png +0 -0
  26. package/internal/cli/goal-maker.mjs +172 -9
  27. package/package.json +3 -2
  28. package/plugins/goalbuddy/.claude-plugin/plugin.json +2 -2
  29. package/plugins/goalbuddy/.codex-plugin/plugin.json +4 -4
  30. package/plugins/goalbuddy/README.md +5 -3
  31. package/plugins/goalbuddy/agents/goal-judge.md +35 -16
  32. package/plugins/goalbuddy/agents/goal-scout.md +38 -13
  33. package/plugins/goalbuddy/agents/goal-worker.md +37 -14
  34. package/plugins/goalbuddy/skills/goalbuddy/SKILL.md +40 -13
  35. package/plugins/goalbuddy/skills/goalbuddy/agents/README.md +1 -1
  36. package/plugins/goalbuddy/skills/goalbuddy/agents/goal_judge.toml +33 -17
  37. package/plugins/goalbuddy/skills/goalbuddy/agents/goal_scout.toml +34 -14
  38. package/plugins/goalbuddy/skills/goalbuddy/agents/goal_worker.toml +36 -16
  39. package/plugins/goalbuddy/skills/goalbuddy/extend/local-goal-board/README.md +8 -4
  40. package/plugins/goalbuddy/skills/goalbuddy/extend/local-goal-board/examples/subgoal-parent/goal.md +3 -0
  41. package/plugins/goalbuddy/skills/goalbuddy/extend/local-goal-board/examples/subgoal-parent/notes/.gitkeep +1 -0
  42. package/plugins/goalbuddy/skills/goalbuddy/extend/local-goal-board/examples/subgoal-parent/state.yaml +60 -0
  43. package/plugins/goalbuddy/skills/goalbuddy/extend/local-goal-board/examples/subgoal-parent/subgoals/T004-board-view/goal.md +3 -0
  44. package/plugins/goalbuddy/skills/goalbuddy/extend/local-goal-board/examples/subgoal-parent/subgoals/T004-board-view/notes/.gitkeep +1 -0
  45. package/plugins/goalbuddy/skills/goalbuddy/extend/local-goal-board/examples/subgoal-parent/subgoals/T004-board-view/state.yaml +52 -0
  46. package/plugins/goalbuddy/skills/goalbuddy/extend/local-goal-board/extension.yaml +6 -4
  47. package/plugins/goalbuddy/skills/goalbuddy/extend/local-goal-board/scripts/lib/goal-board.mjs +1188 -31
  48. package/plugins/goalbuddy/skills/goalbuddy/extend/local-goal-board/scripts/local-goal-board.mjs +389 -54
  49. package/plugins/goalbuddy/skills/goalbuddy/extend/local-goal-board/test/local-goal-board.test.mjs +479 -5
  50. package/plugins/goalbuddy/skills/goalbuddy/scripts/check-goal-state.mjs +192 -6
  51. package/plugins/goalbuddy/skills/goalbuddy/scripts/parallel-plan.mjs +191 -0
  52. package/plugins/goalbuddy/skills/goalbuddy/scripts/render-task-prompt.mjs +305 -0
  53. package/plugins/goalbuddy/skills/goalbuddy/templates/agents.md +5 -4
  54. package/plugins/goalbuddy/skills/goalbuddy/templates/goal.md +18 -4
  55. package/plugins/goalbuddy/skills/goalbuddy/templates/state.yaml +14 -1
@@ -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
 
@@ -107,7 +110,7 @@ export function normalizeTask(task, index) {
107
110
  }
108
111
 
109
112
  const id = cleanText(task.id);
110
- const status = cleanText(task.status);
113
+ const status = normalizeTaskStatus(task.status);
111
114
  if (!id) throw new GoalBoardError(`Task ${index + 1} is missing id.`);
112
115
  if (!VALID_STATUSES.has(status)) {
113
116
  throw new GoalBoardError(`Task ${id} has unsupported status "${status}".`);
@@ -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) {
@@ -254,14 +332,222 @@ function cleanText(value) {
254
332
  return String(value ?? "").trim();
255
333
  }
256
334
 
335
+ function normalizeTaskStatus(value) {
336
+ const status = cleanText(value);
337
+ if (status === "complete" || status === "completed") return "done";
338
+ return status;
339
+ }
340
+
257
341
  export function parseGoalStateText(text) {
258
- const lines = tokenizeYaml(text);
259
- if (!lines.length) throw new GoalBoardError("Goal state is empty.");
260
- const [value, nextIndex] = parseBlock(lines, 0, lines[0].indent);
261
- if (nextIndex < lines.length) {
262
- throw new GoalBoardError(`Could not parse line ${lines[nextIndex].number}.`);
342
+ try {
343
+ const lines = tokenizeYaml(text);
344
+ if (!lines.length) throw new GoalBoardError("Goal state is empty.");
345
+ const [value, nextIndex] = parseBlock(lines, 0, lines[0].indent);
346
+ if (nextIndex < lines.length) {
347
+ throw new GoalBoardError(`Could not parse line ${lines[nextIndex].number}.`);
348
+ }
349
+ return value;
350
+ } catch (error) {
351
+ if (error instanceof GoalBoardError && canRecoverBoardSubset(error)) {
352
+ return parseGoalBoardSubset(text);
353
+ }
354
+ throw error;
355
+ }
356
+ }
357
+
358
+ function canRecoverBoardSubset(error) {
359
+ return /Could not parse line|Expected key\/value pair|Expected mapping|Block scalar YAML/.test(error.message);
360
+ }
361
+
362
+ function parseGoalBoardSubset(text) {
363
+ const tasks = parseTaskSubsets(text);
364
+ if (!tasks.length) throw new GoalBoardError("Missing non-empty tasks list.");
365
+ return {
366
+ version: parseYamlScalar(findTopLevelScalar(text, "version") || "2"),
367
+ goal: {
368
+ title: parseYamlScalar(findNestedScalar(text, "goal", "title") || "Untitled goal"),
369
+ slug: parseYamlScalar(findNestedScalar(text, "goal", "slug") || "untitled-goal"),
370
+ kind: parseYamlScalar(findNestedScalar(text, "goal", "kind") || "open_ended"),
371
+ tranche: parseYamlScalar(findNestedScalar(text, "goal", "tranche") || ""),
372
+ status: parseYamlScalar(findNestedScalar(text, "goal", "status") || "active"),
373
+ },
374
+ active_task: parseYamlScalar(findTopLevelScalar(text, "active_task") || ""),
375
+ tasks,
376
+ };
377
+ }
378
+
379
+ function parseTaskSubsets(text) {
380
+ const tasksText = findTopLevelSection(text, "tasks");
381
+ if (!tasksText) return [];
382
+ const taskBlocks = [];
383
+ let current = [];
384
+ for (const line of tasksText.split("\n")) {
385
+ if (/^ - id:/.test(line)) {
386
+ if (current.length) taskBlocks.push(current.join("\n"));
387
+ current = [line];
388
+ } else if (current.length) {
389
+ current.push(line);
390
+ }
391
+ }
392
+ if (current.length) taskBlocks.push(current.join("\n"));
393
+ return taskBlocks.map((block) => ({
394
+ id: parseYamlScalar(findTaskScalar(block, "id") || ""),
395
+ type: parseYamlScalar(findTaskScalar(block, "type") || "pm"),
396
+ assignee: parseYamlScalar(findTaskScalar(block, "assignee") || ""),
397
+ status: parseYamlScalar(findTaskScalar(block, "status") || "queued"),
398
+ title: parseYamlScalar(findTaskScalar(block, "title") || ""),
399
+ objective: parseYamlScalar(findTaskScalar(block, "objective") || ""),
400
+ inputs: findTaskList(block, "inputs"),
401
+ constraints: findTaskList(block, "constraints"),
402
+ expected_output: findTaskList(block, "expected_output"),
403
+ allowed_files: findTaskList(block, "allowed_files"),
404
+ verify: findTaskList(block, "verify"),
405
+ stop_if: findTaskList(block, "stop_if"),
406
+ subgoal: findTaskSubgoal(block),
407
+ receipt: findTaskReceipt(block),
408
+ }));
409
+ }
410
+
411
+ function findTopLevelScalar(text, key) {
412
+ return findScalar(text, new RegExp(`^${escapeRegExp(key)}:\\s*(.*?)\\s*$`, "m"));
413
+ }
414
+
415
+ function findNestedScalar(text, section, key) {
416
+ return findScalar(findTopLevelSection(text, section), new RegExp(`^ ${escapeRegExp(key)}:\\s*(.*?)\\s*$`, "m"));
417
+ }
418
+
419
+ function findTaskScalar(text, key) {
420
+ if (key === "id") return findScalar(text, /^ - id:\s*(.*?)\s*$/m);
421
+ return findScalar(text, new RegExp(`^ ${escapeRegExp(key)}:\\s*(.*?)\\s*$`, "m"));
422
+ }
423
+
424
+ function findScalar(text, pattern) {
425
+ const match = String(text || "").match(pattern);
426
+ return match ? match[1] : "";
427
+ }
428
+
429
+ function findTopLevelSection(text, key) {
430
+ const lines = String(text || "").replace(/\r\n/g, "\n").split("\n");
431
+ const start = lines.findIndex((line) => line.trim() === `${key}:`);
432
+ if (start === -1) return "";
433
+ const section = [];
434
+ for (let index = start + 1; index < lines.length; index += 1) {
435
+ const line = lines[index];
436
+ if (/^\S/.test(line)) break;
437
+ section.push(line);
438
+ }
439
+ return section.join("\n");
440
+ }
441
+
442
+ function findIndentedSection(text, key, indent) {
443
+ const lines = String(text || "").replace(/\r\n/g, "\n").split("\n");
444
+ const prefix = " ".repeat(indent);
445
+ const start = lines.findIndex((line) => line.trim() === `${key}:` && line.startsWith(prefix));
446
+ if (start === -1) return "";
447
+ const section = [];
448
+ for (let index = start + 1; index < lines.length; index += 1) {
449
+ const line = lines[index];
450
+ if (line.trim() && !line.startsWith(`${prefix} `)) break;
451
+ section.push(line);
452
+ }
453
+ return section.join("\n");
454
+ }
455
+
456
+ function findTaskList(text, key) {
457
+ const inline = findTaskScalar(text, key);
458
+ if (inline) {
459
+ const parsed = parseYamlScalar(inline);
460
+ if (Array.isArray(parsed)) return parsed.map(cleanText).filter(Boolean);
461
+ return cleanText(parsed) ? [cleanText(parsed)] : [];
462
+ }
463
+ const section = findIndentedSection(text, key, 4);
464
+ return section
465
+ .split("\n")
466
+ .map((line) => line.match(/^ -\s*(.*?)\s*$/)?.[1] || "")
467
+ .map(parseYamlScalar)
468
+ .map(cleanText)
469
+ .filter(Boolean);
470
+ }
471
+
472
+ function findTaskSubgoal(text) {
473
+ const inline = findTaskScalar(text, "subgoal");
474
+ if (inline && parseYamlScalar(inline) === null) return null;
475
+ const section = findIndentedSection(text, "subgoal", 4);
476
+ if (!section) return null;
477
+ return {
478
+ status: parseYamlScalar(findScalar(section, /^ status:\s*(.*?)\s*$/m) || "active"),
479
+ path: parseYamlScalar(findScalar(section, /^ path:\s*(.*?)\s*$/m) || ""),
480
+ owner: parseYamlScalar(findScalar(section, /^ owner:\s*(.*?)\s*$/m) || ""),
481
+ created_from: parseYamlScalar(findScalar(section, /^ created_from:\s*(.*?)\s*$/m) || ""),
482
+ depth: parseYamlScalar(findScalar(section, /^ depth:\s*(.*?)\s*$/m) || "1"),
483
+ rollup_receipt: parseYamlScalar(findScalar(section, /^ rollup_receipt:\s*(.*?)\s*$/m) || "null"),
484
+ };
485
+ }
486
+
487
+ function findTaskReceipt(text) {
488
+ const inline = findTaskScalar(text, "receipt");
489
+ if (inline && parseYamlScalar(inline) === null) return null;
490
+ const section = findIndentedSection(text, "receipt", 4);
491
+ if (!section) return null;
492
+ return {
493
+ result: parseYamlScalar(findScalar(section, /^ result:\s*(.*?)\s*$/m) || ""),
494
+ summary: parseYamlScalar(findScalar(section, /^ summary:\s*(.*?)\s*$/m) || ""),
495
+ decision: parseYamlScalar(findScalar(section, /^ decision:\s*(.*?)\s*$/m) || ""),
496
+ note: parseYamlScalar(findScalar(section, /^ note:\s*(.*?)\s*$/m) || ""),
497
+ changed_files: findReceiptList(section, "changed_files"),
498
+ commands: findReceiptCommands(section),
499
+ evidence: [],
500
+ };
501
+ }
502
+
503
+ function findReceiptList(text, key) {
504
+ const section = findIndentedSection(text, key, 6);
505
+ return section
506
+ .split("\n")
507
+ .map((line) => line.match(/^ -\s*(.*?)\s*$/)?.[1] || "")
508
+ .map(parseYamlScalar)
509
+ .map(cleanText)
510
+ .filter(Boolean);
511
+ }
512
+
513
+ function findReceiptCommands(text) {
514
+ const section = findIndentedSection(text, "commands", 6);
515
+ const blocks = [];
516
+ let current = [];
517
+ for (const line of section.split("\n")) {
518
+ if (/^ - cmd:/.test(line)) {
519
+ if (current.length) blocks.push(current.join("\n"));
520
+ current = [line];
521
+ } else if (current.length) {
522
+ current.push(line);
523
+ }
263
524
  }
264
- return value;
525
+ if (current.length) blocks.push(current.join("\n"));
526
+ return blocks.map((block) => ({
527
+ cmd: parseYamlScalar(findScalar(block, /^ - cmd:\s*(.*?)\s*$/m) || ""),
528
+ status: parseYamlScalar(findScalar(block, /^ status:\s*(.*?)\s*$/m) || ""),
529
+ note: parseYamlScalar(findScalar(block, /^ note:\s*(.*?)\s*$/m) || ""),
530
+ }));
531
+ }
532
+
533
+ function parseYamlScalar(value) {
534
+ const text = stripComment(String(value ?? "")).trim();
535
+ if (!text) return "";
536
+ try {
537
+ return parseScalar(text);
538
+ } catch {
539
+ if (
540
+ (text.startsWith("\"") && text.endsWith("\"")) ||
541
+ (text.startsWith("'") && text.endsWith("'"))
542
+ ) {
543
+ return text.slice(1, -1);
544
+ }
545
+ return text;
546
+ }
547
+ }
548
+
549
+ function escapeRegExp(value) {
550
+ return String(value).replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
265
551
  }
266
552
 
267
553
  function tokenizeYaml(text) {
@@ -452,11 +738,75 @@ function boardHtml() {
452
738
  </head>
453
739
  <body>
454
740
  <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>
741
+ <div class="topbar-primary">
742
+ <div class="brand" aria-label="Goal Buddy">
743
+ <img class="brand-mark" src="./goalbuddy-mark.png" alt="GoalBuddy">
744
+ <span class="brand-name">Goal Buddy</span>
745
+ <span class="live-dot" id="live-dot" aria-hidden="true"></span>
746
+ </div>
747
+ <nav class="board-switcher is-empty" aria-label="Local GoalBuddy boards">
748
+ <label for="board-switcher">Board</label>
749
+ <select id="board-switcher" aria-label="Switch local board"></select>
750
+ </nav>
751
+ </div>
752
+ <div class="header-tools">
753
+ <a class="github-stars" href="https://github.com/tolibear/goalbuddy" target="_blank" rel="noreferrer" aria-label="Open GoalBuddy on GitHub">
754
+ <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>
755
+ <span id="github-stars">Stars</span>
756
+ </a>
757
+ <div class="settings-wrap">
758
+ <button class="settings-button" id="settings-button" type="button" aria-expanded="false" aria-controls="settings-popover">
759
+ <svg viewBox="0 0 24 24" aria-hidden="true">
760
+ <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>
761
+ <circle cx="12" cy="12.2" r="3.15"></circle>
762
+ </svg>
763
+ <span class="visually-hidden" id="live-state">Connecting</span>
764
+ </button>
765
+ <section class="settings-popover" id="settings-popover" aria-label="Local board settings" hidden>
766
+ <div class="settings-heading">
767
+ <p class="eyebrow">Board settings</p>
768
+ <h2>Local preferences</h2>
769
+ </div>
770
+ <div class="setting-row">
771
+ <label for="setting-theme">Theme</label>
772
+ <select id="setting-theme" data-setting="theme">
773
+ <option value="system">System</option>
774
+ <option value="light">Light</option>
775
+ <option value="dark">Dark</option>
776
+ </select>
777
+ </div>
778
+ <div class="setting-row">
779
+ <label for="setting-density">Density</label>
780
+ <select id="setting-density" data-setting="density">
781
+ <option value="comfortable">Comfortable</option>
782
+ <option value="compact">Compact</option>
783
+ </select>
784
+ </div>
785
+ <div class="setting-row">
786
+ <label for="setting-completed">Completed</label>
787
+ <select id="setting-completed" data-setting="completedVisibility">
788
+ <option value="show">Show</option>
789
+ <option value="collapse">Collapse</option>
790
+ </select>
791
+ </div>
792
+ <div class="setting-row">
793
+ <label for="setting-board-open">Open boards</label>
794
+ <select id="setting-board-open" data-setting="boardOpenBehavior">
795
+ <option value="last">Last viewed</option>
796
+ <option value="newest">Newest active</option>
797
+ </select>
798
+ </div>
799
+ <div class="setting-row">
800
+ <label for="setting-motion">Motion</label>
801
+ <select id="setting-motion" data-setting="motion">
802
+ <option value="system">System</option>
803
+ <option value="reduce">Reduce</option>
804
+ <option value="allow">Allow</option>
805
+ </select>
806
+ </div>
807
+ </section>
808
+ </div>
458
809
  </div>
459
- <div class="live-state" id="live-state">Connecting</div>
460
810
  </header>
461
811
  <main class="shell">
462
812
  <section class="goal-header" aria-labelledby="goal-title">
@@ -508,9 +858,94 @@ function boardCss() {
508
858
  --red-text: #9f2f2d;
509
859
  --yellow-bg: #fbf3db;
510
860
  --yellow-text: #956400;
861
+ --active-surface: #fbfdfe;
511
862
  font-family: "SF Pro Display", "Geist Sans", "Helvetica Neue", Arial, sans-serif;
512
863
  }
513
864
 
865
+ :root[data-theme="dark"] {
866
+ color-scheme: dark;
867
+ --canvas: #07101f;
868
+ --surface: #101a2d;
869
+ --surface-muted: #0c1525;
870
+ --ink: #f7f9fc;
871
+ --muted: #9aa7bf;
872
+ --line: #26334a;
873
+ --blue-bg: #173653;
874
+ --blue-text: #9ed8ff;
875
+ --green-bg: #143929;
876
+ --green-text: #a6e8bf;
877
+ --red-bg: #3a1d22;
878
+ --red-text: #ffb2b9;
879
+ --yellow-bg: #3a3014;
880
+ --yellow-text: #f6d878;
881
+ --active-surface: #0f2031;
882
+ }
883
+
884
+ @media (prefers-color-scheme: dark) {
885
+ :root[data-theme="system"] {
886
+ color-scheme: dark;
887
+ --canvas: #07101f;
888
+ --surface: #101a2d;
889
+ --surface-muted: #0c1525;
890
+ --ink: #f7f9fc;
891
+ --muted: #9aa7bf;
892
+ --line: #26334a;
893
+ --blue-bg: #173653;
894
+ --blue-text: #9ed8ff;
895
+ --green-bg: #143929;
896
+ --green-text: #a6e8bf;
897
+ --red-bg: #3a1d22;
898
+ --red-text: #ffb2b9;
899
+ --yellow-bg: #3a3014;
900
+ --yellow-text: #f6d878;
901
+ --active-surface: #0f2031;
902
+ }
903
+
904
+ :root[data-theme="system"] .topbar {
905
+ border-color: rgba(61, 76, 108, 0.86);
906
+ background: rgba(13, 23, 41, 0.84);
907
+ box-shadow: 0 18px 48px rgba(0, 0, 0, 0.28);
908
+ }
909
+
910
+ :root[data-theme="system"] .brand {
911
+ color: var(--ink);
912
+ }
913
+
914
+ :root[data-theme="system"] .board-switcher select,
915
+ :root[data-theme="system"] .github-stars,
916
+ :root[data-theme="system"] .settings-button {
917
+ border-color: rgba(61, 76, 108, 0.9);
918
+ background: rgba(16, 26, 45, 0.78);
919
+ color: var(--ink);
920
+ }
921
+
922
+ :root[data-theme="system"] .settings-popover {
923
+ border-color: rgba(61, 76, 108, 0.96);
924
+ background: rgba(16, 26, 45, 0.96);
925
+ box-shadow: 0 24px 64px rgba(0, 0, 0, 0.28);
926
+ }
927
+
928
+ :root[data-theme="system"] .setting-row select {
929
+ background: var(--surface);
930
+ color: var(--ink);
931
+ }
932
+
933
+ :root[data-theme="system"] .goal-tranche,
934
+ :root[data-theme="system"] .task-title,
935
+ :root[data-theme="system"] .setting-row select {
936
+ color: var(--ink);
937
+ }
938
+
939
+ :root[data-theme="system"] .task-card.is-active {
940
+ background: linear-gradient(var(--active-surface), var(--active-surface)) padding-box,
941
+ linear-gradient(110deg, #78d7ff, #6c63ff, #78f2b9, #78d7ff) border-box;
942
+ }
943
+
944
+ :root[data-theme="system"] .task-card.is-active::after {
945
+ background: var(--active-surface);
946
+ }
947
+ }
948
+
514
949
  * { box-sizing: border-box; }
515
950
 
516
951
  body {
@@ -526,18 +961,46 @@ textarea {
526
961
  font: inherit;
527
962
  }
528
963
 
964
+ a {
965
+ color: inherit;
966
+ text-decoration: none;
967
+ }
968
+
969
+ select,
970
+ button {
971
+ font: inherit;
972
+ }
973
+
529
974
  .topbar {
530
975
  position: sticky;
531
- top: 0;
976
+ top: 16px;
532
977
  z-index: 10;
533
978
  display: flex;
534
979
  align-items: center;
535
980
  justify-content: space-between;
536
981
  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);
982
+ width: min(1392px, calc(100% - 48px));
983
+ min-height: 64px;
984
+ margin: 0 auto;
985
+ padding: 10px 12px 10px 18px;
986
+ border: 1px solid rgba(219, 226, 240, 0.86);
987
+ border-radius: 999px;
988
+ background: rgba(255, 255, 255, 0.78);
989
+ box-shadow: 0 18px 48px rgba(30, 40, 72, 0.1);
990
+ backdrop-filter: blur(22px);
991
+ }
992
+
993
+ :root[data-theme="dark"] .topbar {
994
+ border-color: rgba(61, 76, 108, 0.86);
995
+ background: rgba(13, 23, 41, 0.84);
996
+ box-shadow: 0 18px 48px rgba(0, 0, 0, 0.28);
997
+ }
998
+
999
+ .topbar-primary {
1000
+ display: inline-flex;
1001
+ align-items: center;
1002
+ gap: 24px;
1003
+ min-width: 0;
541
1004
  }
542
1005
 
543
1006
  .brand {
@@ -546,12 +1009,18 @@ textarea {
546
1009
  gap: 10px;
547
1010
  color: #071236;
548
1011
  font-weight: 800;
1012
+ min-width: fit-content;
1013
+ }
1014
+
1015
+ :root[data-theme="dark"] .brand {
1016
+ color: var(--ink);
549
1017
  }
550
1018
 
551
1019
  .brand-mark {
552
1020
  display: block;
553
- width: 34px;
554
- height: 34px;
1021
+ width: 38px;
1022
+ height: 38px;
1023
+ filter: drop-shadow(0 8px 13px rgba(87, 76, 210, 0.18));
555
1024
  }
556
1025
 
557
1026
  .brand-name {
@@ -559,7 +1028,198 @@ textarea {
559
1028
  letter-spacing: 0;
560
1029
  }
561
1030
 
562
- .live-state,
1031
+ .board-switcher {
1032
+ display: flex;
1033
+ align-items: center;
1034
+ justify-content: flex-start;
1035
+ gap: 8px;
1036
+ min-width: 0;
1037
+ }
1038
+
1039
+ .board-switcher label {
1040
+ color: var(--muted);
1041
+ font-size: 11px;
1042
+ font-weight: 700;
1043
+ letter-spacing: 0.08em;
1044
+ text-transform: uppercase;
1045
+ }
1046
+
1047
+ .board-switcher select {
1048
+ width: min(280px, 100%);
1049
+ min-width: 0;
1050
+ min-height: 38px;
1051
+ border: 1px solid rgba(219, 226, 240, 0.9);
1052
+ border-radius: 999px;
1053
+ padding: 0 34px 0 14px;
1054
+ background: rgba(255, 255, 255, 0.72);
1055
+ color: #2f3c59;
1056
+ font-weight: 700;
1057
+ font-size: 14px;
1058
+ }
1059
+
1060
+ :root[data-theme="dark"] .board-switcher select,
1061
+ :root[data-theme="dark"] .github-stars,
1062
+ :root[data-theme="dark"] .settings-button {
1063
+ border-color: rgba(61, 76, 108, 0.9);
1064
+ background: rgba(16, 26, 45, 0.78);
1065
+ color: var(--ink);
1066
+ }
1067
+
1068
+ .board-switcher.is-empty {
1069
+ display: none;
1070
+ }
1071
+
1072
+ .header-tools {
1073
+ display: inline-flex;
1074
+ align-items: center;
1075
+ justify-content: flex-end;
1076
+ gap: 10px;
1077
+ min-width: fit-content;
1078
+ }
1079
+
1080
+ .github-stars,
1081
+ .settings-button {
1082
+ display: inline-flex;
1083
+ align-items: center;
1084
+ justify-content: center;
1085
+ min-height: 44px;
1086
+ border: 1px solid rgba(219, 226, 240, 0.9);
1087
+ border-radius: 999px;
1088
+ background: rgba(255, 255, 255, 0.72);
1089
+ color: #2f3c59;
1090
+ font-weight: 800;
1091
+ transition: transform 180ms ease, color 180ms ease, border-color 180ms ease, background 180ms ease;
1092
+ }
1093
+
1094
+ .github-stars {
1095
+ gap: 7px;
1096
+ padding: 0 15px;
1097
+ font-size: 14px;
1098
+ white-space: nowrap;
1099
+ }
1100
+
1101
+ .github-stars:hover,
1102
+ .settings-button:hover {
1103
+ transform: translateY(-2px);
1104
+ color: #071236;
1105
+ border-color: rgba(79, 70, 216, 0.26);
1106
+ background: #fff;
1107
+ }
1108
+
1109
+ .github-stars svg {
1110
+ width: 16px;
1111
+ height: 16px;
1112
+ color: #4f46d8;
1113
+ fill: currentColor;
1114
+ }
1115
+
1116
+ .settings-wrap {
1117
+ position: relative;
1118
+ }
1119
+
1120
+ .settings-button {
1121
+ position: relative;
1122
+ gap: 8px;
1123
+ width: 44px;
1124
+ padding: 0;
1125
+ cursor: pointer;
1126
+ }
1127
+
1128
+ .settings-button svg {
1129
+ width: 18px;
1130
+ height: 18px;
1131
+ fill: none;
1132
+ stroke: currentColor;
1133
+ stroke-width: 1.8;
1134
+ stroke-linejoin: round;
1135
+ }
1136
+
1137
+ .live-dot {
1138
+ width: 8px;
1139
+ height: 8px;
1140
+ border: 2px solid #fff;
1141
+ border-radius: 999px;
1142
+ background: #1f9d69;
1143
+ box-shadow: 0 0 0 4px rgba(31, 157, 105, 0.12);
1144
+ }
1145
+
1146
+ .live-dot.offline {
1147
+ background: var(--yellow-text);
1148
+ box-shadow: 0 0 0 4px rgba(149, 100, 0, 0.12);
1149
+ }
1150
+
1151
+ .settings-popover {
1152
+ position: absolute;
1153
+ top: calc(100% + 10px);
1154
+ right: 0;
1155
+ width: min(320px, calc(100vw - 32px));
1156
+ padding: 16px;
1157
+ border: 1px solid rgba(219, 226, 240, 0.96);
1158
+ border-radius: 18px;
1159
+ background: rgba(255, 255, 255, 0.96);
1160
+ box-shadow: 0 24px 64px rgba(30, 40, 72, 0.16);
1161
+ backdrop-filter: blur(20px);
1162
+ }
1163
+
1164
+ :root[data-theme="dark"] .settings-popover {
1165
+ border-color: rgba(61, 76, 108, 0.96);
1166
+ background: rgba(16, 26, 45, 0.96);
1167
+ box-shadow: 0 24px 64px rgba(0, 0, 0, 0.28);
1168
+ }
1169
+
1170
+ .settings-popover[hidden] {
1171
+ display: none;
1172
+ }
1173
+
1174
+ .settings-heading {
1175
+ margin-bottom: 12px;
1176
+ }
1177
+
1178
+ .settings-heading .eyebrow {
1179
+ margin-bottom: 6px;
1180
+ }
1181
+
1182
+ .settings-heading h2 {
1183
+ margin: 0;
1184
+ font-size: 20px;
1185
+ letter-spacing: 0;
1186
+ }
1187
+
1188
+ .setting-row {
1189
+ display: grid;
1190
+ gap: 6px;
1191
+ margin-top: 12px;
1192
+ }
1193
+
1194
+ .setting-row label {
1195
+ color: var(--muted);
1196
+ font-size: 12px;
1197
+ font-weight: 800;
1198
+ }
1199
+
1200
+ .setting-row select {
1201
+ min-height: 38px;
1202
+ border: 1px solid var(--line);
1203
+ border-radius: 8px;
1204
+ padding: 0 10px;
1205
+ background: #fff;
1206
+ color: #2f3437;
1207
+ }
1208
+
1209
+ :root[data-theme="dark"] .setting-row select {
1210
+ background: var(--surface);
1211
+ color: var(--ink);
1212
+ }
1213
+
1214
+ .visually-hidden {
1215
+ position: absolute;
1216
+ width: 1px;
1217
+ height: 1px;
1218
+ overflow: hidden;
1219
+ clip: rect(0 0 0 0);
1220
+ white-space: nowrap;
1221
+ }
1222
+
563
1223
  .badge {
564
1224
  display: inline-flex;
565
1225
  align-items: center;
@@ -625,6 +1285,12 @@ h1 {
625
1285
  line-height: 1.55;
626
1286
  }
627
1287
 
1288
+ :root[data-theme="dark"] .goal-tranche,
1289
+ :root[data-theme="dark"] .task-title,
1290
+ :root[data-theme="dark"] .setting-row select {
1291
+ color: var(--ink);
1292
+ }
1293
+
628
1294
  .goal-meta {
629
1295
  display: grid;
630
1296
  grid-template-columns: repeat(3, minmax(94px, auto));
@@ -708,6 +1374,7 @@ h1 {
708
1374
  }
709
1375
 
710
1376
  .task-card {
1377
+ position: relative;
711
1378
  width: 100%;
712
1379
  min-height: 138px;
713
1380
  display: flex;
@@ -720,10 +1387,16 @@ h1 {
720
1387
  color: inherit;
721
1388
  text-align: left;
722
1389
  cursor: pointer;
1390
+ overflow: hidden;
723
1391
  transition: transform 160ms ease, border-color 160ms ease;
724
1392
  will-change: transform, opacity;
725
1393
  }
726
1394
 
1395
+ .task-card > * {
1396
+ position: relative;
1397
+ z-index: 1;
1398
+ }
1399
+
727
1400
  .task-card:hover {
728
1401
  border-color: #d1d0cc;
729
1402
  transform: translateY(-1px);
@@ -736,10 +1409,80 @@ h1 {
736
1409
  }
737
1410
 
738
1411
  .task-card.is-active {
739
- border-color: #a8cfe7;
1412
+ border-color: transparent;
1413
+ background: linear-gradient(#fbfdfe, #fbfdfe) padding-box,
1414
+ linear-gradient(110deg, #78d7ff, #4f46d8, #78f2b9, #78d7ff) border-box;
1415
+ box-shadow: 0 14px 38px rgba(31, 108, 159, 0.12);
1416
+ }
1417
+
1418
+ .task-card.is-active::before {
1419
+ position: absolute;
1420
+ inset: -2px;
1421
+ z-index: 0;
1422
+ content: "";
1423
+ background: conic-gradient(from 0deg, transparent 0 58%, rgba(79, 70, 216, 0.28), rgba(120, 215, 255, 0.44), transparent 78% 100%);
1424
+ opacity: 0.86;
1425
+ animation: active-card-orbit 2.8s linear infinite;
1426
+ }
1427
+
1428
+ .task-card.is-active::after {
1429
+ position: absolute;
1430
+ inset: 2px;
1431
+ z-index: 0;
1432
+ content: "";
1433
+ border-radius: 6px;
740
1434
  background: #fbfdfe;
741
1435
  }
742
1436
 
1437
+ :root[data-theme="dark"] .task-card.is-active {
1438
+ background: linear-gradient(var(--active-surface), var(--active-surface)) padding-box,
1439
+ linear-gradient(110deg, #78d7ff, #6c63ff, #78f2b9, #78d7ff) border-box;
1440
+ }
1441
+
1442
+ :root[data-theme="dark"] .task-card.is-active::after {
1443
+ background: var(--active-surface);
1444
+ }
1445
+
1446
+ :root[data-density="compact"] .shell {
1447
+ padding-top: 20px;
1448
+ }
1449
+
1450
+ :root[data-density="compact"] .board {
1451
+ gap: 12px;
1452
+ }
1453
+
1454
+ :root[data-density="compact"] .column-header {
1455
+ padding: 12px;
1456
+ }
1457
+
1458
+ :root[data-density="compact"] .card-list {
1459
+ gap: 8px;
1460
+ padding: 10px;
1461
+ }
1462
+
1463
+ :root[data-density="compact"] .task-card {
1464
+ min-height: 110px;
1465
+ gap: 9px;
1466
+ padding: 11px;
1467
+ }
1468
+
1469
+ :root[data-density="compact"] .task-title {
1470
+ font-size: 14px;
1471
+ }
1472
+
1473
+ :root[data-completed-visibility="collapse"] .column[data-column-id="completed"] .card-list {
1474
+ display: none;
1475
+ }
1476
+
1477
+ :root[data-completed-visibility="collapse"] .column[data-column-id="completed"] {
1478
+ max-height: 80px;
1479
+ overflow: hidden;
1480
+ }
1481
+
1482
+ @keyframes active-card-orbit {
1483
+ to { transform: rotate(360deg); }
1484
+ }
1485
+
743
1486
  .task-card.is-moving {
744
1487
  border-color: #c2b8ff;
745
1488
  }
@@ -776,6 +1519,14 @@ h1 {
776
1519
  .badge.status-done { background: var(--green-bg); color: var(--green-text); }
777
1520
  .badge.status-blocked { background: var(--red-bg); color: var(--red-text); }
778
1521
  .badge.role { background: var(--yellow-bg); color: var(--yellow-text); }
1522
+ .badge.subgoal { background: #ece8ff; color: #5c43c6; }
1523
+ .badge.subgoal.status-blocked { background: var(--red-bg); color: var(--red-text); }
1524
+ .badge.subgoal.status-done { background: var(--green-bg); color: var(--green-text); }
1525
+
1526
+ :root[data-theme="dark"] .badge.subgoal {
1527
+ background: #263052;
1528
+ color: #c7d2ff;
1529
+ }
779
1530
 
780
1531
  .empty {
781
1532
  padding: 18px;
@@ -783,10 +1534,47 @@ h1 {
783
1534
  font-size: 14px;
784
1535
  }
785
1536
 
1537
+ .board-error {
1538
+ grid-column: 1 / -1;
1539
+ padding: 18px;
1540
+ border: 1px solid var(--red-border);
1541
+ border-radius: 8px;
1542
+ background: var(--red-bg);
1543
+ color: var(--text);
1544
+ }
1545
+
1546
+ .board-error h2 {
1547
+ margin: 0 0 8px;
1548
+ font-size: 16px;
1549
+ }
1550
+
1551
+ .board-error p {
1552
+ margin: 0;
1553
+ color: var(--muted);
1554
+ }
1555
+
786
1556
  @media (prefers-reduced-motion: reduce) {
1557
+ .github-stars,
1558
+ .settings-button,
787
1559
  .task-card {
788
1560
  transition: none;
789
1561
  }
1562
+
1563
+ .task-card.is-active::before {
1564
+ animation: none;
1565
+ opacity: 0.26;
1566
+ }
1567
+ }
1568
+
1569
+ :root[data-motion="reduce"] .github-stars,
1570
+ :root[data-motion="reduce"] .settings-button,
1571
+ :root[data-motion="reduce"] .task-card {
1572
+ transition: none;
1573
+ }
1574
+
1575
+ :root[data-motion="reduce"] .task-card.is-active::before {
1576
+ animation: none;
1577
+ opacity: 0.26;
790
1578
  }
791
1579
 
792
1580
  .modal[hidden] {
@@ -811,7 +1599,7 @@ h1 {
811
1599
 
812
1600
  .modal-panel {
813
1601
  position: relative;
814
- width: min(760px, 100%);
1602
+ width: min(1080px, 100%);
815
1603
  max-height: min(760px, calc(100vh - 48px));
816
1604
  overflow: auto;
817
1605
  border: 1px solid var(--line);
@@ -896,10 +1684,94 @@ h1 {
896
1684
  .detail-section ul {
897
1685
  margin: 0;
898
1686
  padding-left: 18px;
899
- color: #2f3437;
1687
+ color: var(--ink);
900
1688
  line-height: 1.55;
901
1689
  }
902
1690
 
1691
+ .detail-section li {
1692
+ color: var(--ink);
1693
+ }
1694
+
1695
+ .subgoal-section {
1696
+ border: 1px solid var(--line);
1697
+ border-radius: 8px;
1698
+ padding: 14px;
1699
+ background: var(--surface-muted);
1700
+ }
1701
+
1702
+ .subgoal-header {
1703
+ display: flex;
1704
+ align-items: start;
1705
+ justify-content: space-between;
1706
+ gap: 12px;
1707
+ margin-bottom: 12px;
1708
+ }
1709
+
1710
+ .subgoal-title {
1711
+ margin: 0 0 4px;
1712
+ font-size: 15px;
1713
+ }
1714
+
1715
+ .subgoal-meta {
1716
+ margin: 0;
1717
+ color: var(--muted);
1718
+ font-size: 12px;
1719
+ line-height: 1.45;
1720
+ }
1721
+
1722
+ .subgoal-board {
1723
+ display: grid;
1724
+ grid-template-columns: repeat(4, minmax(0, 1fr));
1725
+ gap: 10px;
1726
+ }
1727
+
1728
+ .subgoal-column {
1729
+ min-width: 0;
1730
+ border: 1px solid var(--line);
1731
+ border-radius: 8px;
1732
+ background: var(--surface);
1733
+ }
1734
+
1735
+ .subgoal-column-header {
1736
+ display: flex;
1737
+ align-items: center;
1738
+ justify-content: space-between;
1739
+ gap: 8px;
1740
+ padding: 10px;
1741
+ border-bottom: 1px solid var(--line);
1742
+ }
1743
+
1744
+ .subgoal-column-header h4 {
1745
+ margin: 0;
1746
+ font-size: 12px;
1747
+ }
1748
+
1749
+ .subgoal-card-list {
1750
+ display: grid;
1751
+ gap: 8px;
1752
+ padding: 8px;
1753
+ }
1754
+
1755
+ .subgoal-task-card {
1756
+ min-height: 74px;
1757
+ border: 1px solid var(--line);
1758
+ border-radius: 7px;
1759
+ padding: 9px;
1760
+ background: var(--surface);
1761
+ }
1762
+
1763
+ .subgoal-task-card.is-active {
1764
+ border-color: #8e9cff;
1765
+ background: var(--active-surface);
1766
+ }
1767
+
1768
+ .subgoal-task-title {
1769
+ margin: 6px 0 0;
1770
+ color: var(--ink);
1771
+ font-size: 12px;
1772
+ line-height: 1.35;
1773
+ }
1774
+
903
1775
  pre.note {
904
1776
  overflow: auto;
905
1777
  margin: 0;
@@ -907,7 +1779,7 @@ pre.note {
907
1779
  border: 1px solid var(--line);
908
1780
  border-radius: 8px;
909
1781
  background: var(--canvas);
910
- color: #2f3437;
1782
+ color: var(--ink);
911
1783
  font-family: "Geist Mono", "SF Mono", monospace;
912
1784
  font-size: 12px;
913
1785
  line-height: 1.55;
@@ -926,10 +1798,27 @@ pre.note {
926
1798
  .board {
927
1799
  grid-template-columns: 1fr;
928
1800
  }
1801
+
1802
+ .subgoal-board {
1803
+ grid-template-columns: 1fr;
1804
+ }
929
1805
  }
930
1806
 
931
1807
  @media (max-width: 640px) {
932
- .topbar,
1808
+ .topbar {
1809
+ align-items: flex-start;
1810
+ }
1811
+
1812
+ .topbar-primary {
1813
+ flex: 1;
1814
+ flex-wrap: wrap;
1815
+ gap: 10px 14px;
1816
+ }
1817
+
1818
+ .board-switcher select {
1819
+ width: 100%;
1820
+ }
1821
+
933
1822
  .shell {
934
1823
  padding-left: 14px;
935
1824
  padding-right: 14px;
@@ -949,22 +1838,69 @@ pre.note {
949
1838
  function boardJs() {
950
1839
  return `let currentBoard = null;
951
1840
  let eventSource = null;
1841
+ let currentSettings = null;
952
1842
 
953
1843
  const boardEl = document.getElementById("board");
954
1844
  const liveStateEl = document.getElementById("live-state");
1845
+ const liveDotEl = document.getElementById("live-dot");
1846
+ const boardSwitcherEl = document.getElementById("board-switcher");
1847
+ const settingsButtonEl = document.getElementById("settings-button");
1848
+ const settingsPopoverEl = document.getElementById("settings-popover");
1849
+ const githubStarsEl = document.getElementById("github-stars");
955
1850
  const modalEl = document.getElementById("task-modal");
956
1851
  const modalTitleEl = document.getElementById("modal-title");
957
1852
  const modalKickerEl = document.getElementById("modal-kicker");
958
1853
  const modalBodyEl = document.getElementById("modal-body");
1854
+ const settingsStorageKey = "goalbuddy.localBoardSettings.v1";
1855
+ const settingsDefaults = {
1856
+ theme: "system",
1857
+ density: "comfortable",
1858
+ completedVisibility: "show",
1859
+ boardOpenBehavior: "last",
1860
+ motion: "system",
1861
+ lastBoardPath: "",
1862
+ };
1863
+ const settingsOptions = {
1864
+ theme: new Set(["system", "light", "dark"]),
1865
+ density: new Set(["comfortable", "compact"]),
1866
+ completedVisibility: new Set(["show", "collapse"]),
1867
+ boardOpenBehavior: new Set(["last", "newest"]),
1868
+ motion: new Set(["system", "reduce", "allow"]),
1869
+ };
959
1870
 
960
1871
  document.addEventListener("click", (event) => {
961
1872
  const card = event.target.closest("[data-task-id]");
962
1873
  if (card) openTask(card.dataset.taskId);
963
1874
  if (event.target.matches("[data-close-modal]")) closeModal();
1875
+ if (settingsPopoverEl.hidden) return;
1876
+ if (!event.target.closest(".settings-wrap")) closeSettings();
964
1877
  });
965
1878
 
966
1879
  document.addEventListener("keydown", (event) => {
967
- if (event.key === "Escape") closeModal();
1880
+ if (event.key === "Escape") {
1881
+ closeModal();
1882
+ closeSettings();
1883
+ }
1884
+ });
1885
+
1886
+ boardSwitcherEl.addEventListener("change", () => {
1887
+ if (boardSwitcherEl.value && boardSwitcherEl.value !== window.location.href) {
1888
+ window.location.href = boardSwitcherEl.value;
1889
+ }
1890
+ });
1891
+
1892
+ settingsButtonEl.addEventListener("click", () => {
1893
+ if (settingsPopoverEl.hidden) {
1894
+ openSettings();
1895
+ } else {
1896
+ closeSettings();
1897
+ }
1898
+ });
1899
+
1900
+ settingsPopoverEl.addEventListener("change", (event) => {
1901
+ const control = event.target.closest("[data-setting]");
1902
+ if (!control) return;
1903
+ saveSettings({ ...currentSettings, [control.dataset.setting]: control.value });
968
1904
  });
969
1905
 
970
1906
  async function loadBoard() {
@@ -973,6 +1909,122 @@ async function loadBoard() {
973
1909
  renderBoard(await response.json());
974
1910
  }
975
1911
 
1912
+ async function loadBoardSwitcher() {
1913
+ const response = await fetch("../api/boards", { cache: "no-store" });
1914
+ if (!response.ok) return;
1915
+ const payload = await response.json();
1916
+ renderBoardSwitcher(payload.boards || []);
1917
+ }
1918
+
1919
+ async function loadSettings() {
1920
+ try {
1921
+ const response = await fetch("../api/settings", { cache: "no-store" });
1922
+ if (!response.ok) throw new Error("Settings request failed");
1923
+ const payload = await response.json();
1924
+ currentSettings = normalizeSettings(payload.settings);
1925
+ window.localStorage?.setItem(settingsStorageKey, JSON.stringify(currentSettings));
1926
+ } catch {
1927
+ currentSettings = readStoredSettings();
1928
+ }
1929
+ applySettings(currentSettings);
1930
+ }
1931
+
1932
+ async function saveSettings(nextSettings) {
1933
+ currentSettings = normalizeSettings(nextSettings);
1934
+ window.localStorage?.setItem(settingsStorageKey, JSON.stringify(currentSettings));
1935
+ applySettings(currentSettings);
1936
+ try {
1937
+ const response = await fetch("../api/settings", {
1938
+ method: "PUT",
1939
+ headers: { "Content-Type": "application/json" },
1940
+ body: JSON.stringify({ settings: currentSettings }),
1941
+ });
1942
+ if (!response.ok) throw new Error("Settings save failed");
1943
+ const payload = await response.json();
1944
+ currentSettings = normalizeSettings(payload.settings);
1945
+ window.localStorage?.setItem(settingsStorageKey, JSON.stringify(currentSettings));
1946
+ applySettings(currentSettings);
1947
+ } catch {
1948
+ // Keep the localStorage fallback active when the local settings API is unavailable.
1949
+ }
1950
+ return currentSettings;
1951
+ }
1952
+
1953
+ function readStoredSettings() {
1954
+ try {
1955
+ return normalizeSettings(JSON.parse(window.localStorage?.getItem(settingsStorageKey) || "{}"));
1956
+ } catch {
1957
+ return { ...settingsDefaults };
1958
+ }
1959
+ }
1960
+
1961
+ function normalizeSettings(settings) {
1962
+ const normalized = { ...settingsDefaults };
1963
+ if (!settings || typeof settings !== "object" || Array.isArray(settings)) return normalized;
1964
+ for (const [key, allowed] of Object.entries(settingsOptions)) {
1965
+ if (allowed.has(settings[key])) normalized[key] = settings[key];
1966
+ }
1967
+ if (typeof settings.lastBoardPath === "string" && /^\\/[a-z0-9][a-z0-9-]*\\/$/.test(settings.lastBoardPath)) {
1968
+ normalized.lastBoardPath = settings.lastBoardPath;
1969
+ }
1970
+ return normalized;
1971
+ }
1972
+
1973
+ function applySettings(settings) {
1974
+ const normalized = normalizeSettings(settings);
1975
+ document.documentElement.dataset.theme = normalized.theme;
1976
+ document.documentElement.dataset.density = normalized.density;
1977
+ document.documentElement.dataset.completedVisibility = normalized.completedVisibility;
1978
+ document.documentElement.dataset.boardOpenBehavior = normalized.boardOpenBehavior;
1979
+ document.documentElement.dataset.motion = normalized.motion;
1980
+ for (const control of settingsPopoverEl.querySelectorAll("[data-setting]")) {
1981
+ control.value = normalized[control.dataset.setting] || settingsDefaults[control.dataset.setting];
1982
+ }
1983
+ }
1984
+
1985
+ function rememberCurrentBoard() {
1986
+ const boardPath = normalizePath(window.location.pathname);
1987
+ if (!/^\\/[a-z0-9][a-z0-9-]*\\/$/.test(boardPath)) return;
1988
+ const nextSettings = normalizeSettings({ ...currentSettings, lastBoardPath: boardPath });
1989
+ currentSettings = nextSettings;
1990
+ window.localStorage?.setItem(settingsStorageKey, JSON.stringify(nextSettings));
1991
+ fetch("../api/settings", {
1992
+ method: "PUT",
1993
+ headers: { "Content-Type": "application/json" },
1994
+ body: JSON.stringify({ settings: nextSettings }),
1995
+ }).catch(() => {});
1996
+ }
1997
+
1998
+ function openSettings() {
1999
+ settingsPopoverEl.hidden = false;
2000
+ settingsButtonEl.setAttribute("aria-expanded", "true");
2001
+ settingsPopoverEl.querySelector("[data-setting]")?.focus();
2002
+ }
2003
+
2004
+ function closeSettings() {
2005
+ settingsPopoverEl.hidden = true;
2006
+ settingsButtonEl.setAttribute("aria-expanded", "false");
2007
+ }
2008
+
2009
+ function formatStars(count) {
2010
+ if (count >= 1000) return \`\${(count / 1000).toFixed(count >= 10000 ? 0 : 1)}k\`;
2011
+ return String(count);
2012
+ }
2013
+
2014
+ async function loadGithubStars() {
2015
+ if (!githubStarsEl) return;
2016
+ try {
2017
+ const response = await fetch("https://api.github.com/repos/tolibear/goalbuddy", {
2018
+ headers: { Accept: "application/vnd.github+json" },
2019
+ });
2020
+ if (!response.ok) throw new Error("GitHub API unavailable");
2021
+ const repo = await response.json();
2022
+ githubStarsEl.textContent = \`\${formatStars(repo.stargazers_count)} stars\`;
2023
+ } catch {
2024
+ githubStarsEl.textContent = "GitHub";
2025
+ }
2026
+ }
2027
+
976
2028
  function connectEvents() {
977
2029
  eventSource = new EventSource("./events");
978
2030
  eventSource.addEventListener("board", (event) => {
@@ -1000,6 +2052,11 @@ function renderBoard(board) {
1000
2052
  document.getElementById("goal-active").textContent = board.goal.activeTask || "None";
1001
2053
  document.getElementById("goal-updated").textContent = new Date(board.generatedAt).toLocaleTimeString();
1002
2054
 
2055
+ if (board.error) {
2056
+ boardEl.replaceChildren(renderBoardError(board.error));
2057
+ return;
2058
+ }
2059
+
1003
2060
  const delay = movingTaskIds.size ? 260 : 0;
1004
2061
  window.setTimeout(() => {
1005
2062
  boardEl.replaceChildren(...board.columns.map(renderColumn));
@@ -1007,6 +2064,29 @@ function renderBoard(board) {
1007
2064
  }, delay);
1008
2065
  }
1009
2066
 
2067
+ function renderBoardError(message) {
2068
+ const node = el("section", "board-error");
2069
+ node.append(
2070
+ el("h2", "", "GoalBuddy could not parse this board"),
2071
+ el("p", "", message),
2072
+ );
2073
+ return node;
2074
+ }
2075
+
2076
+ function renderBoardSwitcher(boards) {
2077
+ boardSwitcherEl.closest(".board-switcher").classList.toggle("is-empty", boards.length <= 1);
2078
+ const currentPath = normalizePath(window.location.pathname);
2079
+ const options = boards.map((board) => {
2080
+ const option = document.createElement("option");
2081
+ option.value = board.url;
2082
+ option.textContent = boardOptionLabel(board);
2083
+ const boardPath = normalizePath(new URL(board.url, window.location.href).pathname);
2084
+ if (boardPath === currentPath) option.selected = true;
2085
+ return option;
2086
+ });
2087
+ boardSwitcherEl.replaceChildren(...options);
2088
+ }
2089
+
1010
2090
  function renderColumn(column) {
1011
2091
  const section = el("section", "column");
1012
2092
  section.dataset.columnId = column.id;
@@ -1037,6 +2117,7 @@ function renderCard(task) {
1037
2117
 
1038
2118
  const footer = el("div", "card-footer");
1039
2119
  footer.append(el("span", "badge role", task.assignee || task.type || "PM"));
2120
+ if (task.subgoal) footer.append(subgoalBadge(task.subgoal));
1040
2121
  if (task.receipt?.present) footer.append(el("span", "badge status-done", "Receipt"));
1041
2122
 
1042
2123
  button.append(topline, el("h3", "task-title", task.title), footer);
@@ -1156,6 +2237,7 @@ function renderTaskDetail(task) {
1156
2237
  grid.append(item);
1157
2238
  }
1158
2239
  root.append(grid);
2240
+ if (task.subgoal) root.append(renderSubgoal(task.subgoal));
1159
2241
  root.append(detailText("Objective", task.objective));
1160
2242
  root.append(detailList("Inputs", task.inputs));
1161
2243
  root.append(detailList("Constraints", task.constraints));
@@ -1176,6 +2258,61 @@ function renderTaskDetail(task) {
1176
2258
  return root;
1177
2259
  }
1178
2260
 
2261
+ function renderSubgoal(subgoal) {
2262
+ const section = el("section", "detail-section subgoal-section");
2263
+ const header = el("div", "subgoal-header");
2264
+ const titleWrap = el("div");
2265
+ const board = subgoal.board;
2266
+ titleWrap.append(
2267
+ el("h3", "subgoal-title", board?.goal?.title || "Sub-goal"),
2268
+ el("p", "subgoal-meta", [
2269
+ subgoal.path,
2270
+ subgoal.owner ? \`owner: \${subgoal.owner}\` : "",
2271
+ subgoal.depth ? \`depth: \${subgoal.depth}\` : "",
2272
+ ].filter(Boolean).join(" · ")),
2273
+ );
2274
+ header.append(titleWrap, subgoalBadge(subgoal));
2275
+ section.append(header);
2276
+
2277
+ if (!board?.columns?.length) {
2278
+ section.append(el("p", "", "No child board payload."));
2279
+ return section;
2280
+ }
2281
+
2282
+ const boardEl = el("div", "subgoal-board");
2283
+ for (const column of board.columns) {
2284
+ const columnEl = el("section", "subgoal-column");
2285
+ const columnHeader = el("header", "subgoal-column-header");
2286
+ columnHeader.append(el("h4", "", column.title), el("span", "column-count", String(column.tasks.length)));
2287
+ const list = el("div", "subgoal-card-list");
2288
+ if (column.tasks.length === 0) {
2289
+ list.append(el("p", "empty", "No cards"));
2290
+ } else {
2291
+ for (const task of column.tasks) list.append(renderSubgoalTask(task));
2292
+ }
2293
+ columnEl.append(columnHeader, list);
2294
+ boardEl.append(columnEl);
2295
+ }
2296
+ section.append(boardEl);
2297
+
2298
+ if (subgoal.rollupReceipt) {
2299
+ section.append(detailText("Roll-up Receipt", subgoal.rollupReceipt));
2300
+ }
2301
+
2302
+ return section;
2303
+ }
2304
+
2305
+ function renderSubgoalTask(task) {
2306
+ const card = el("article", \`subgoal-task-card \${task.active ? "is-active" : ""}\`);
2307
+ const topline = el("div", "card-topline");
2308
+ topline.append(el("span", "task-id", task.id), statusBadge(task.status));
2309
+ const footer = el("div", "card-footer");
2310
+ footer.append(el("span", "badge role", task.assignee || task.type || "PM"));
2311
+ if (task.receipt?.present) footer.append(el("span", "badge status-done", "Receipt"));
2312
+ card.append(topline, el("h4", "subgoal-task-title", task.title), footer);
2313
+ return card;
2314
+ }
2315
+
1179
2316
  function detailText(title, value) {
1180
2317
  const section = el("section", "detail-section");
1181
2318
  section.append(el("h3", "", title), el("p", "", value || "None"));
@@ -1200,9 +2337,24 @@ function statusBadge(status) {
1200
2337
  return el("span", \`badge status-\${status}\`, label);
1201
2338
  }
1202
2339
 
2340
+ function subgoalBadge(subgoal) {
2341
+ return el("span", \`badge subgoal status-\${subgoal.status}\`, \`Sub-goal \${subgoal.status || "linked"}\`);
2342
+ }
2343
+
1203
2344
  function setLiveState(text, live) {
1204
2345
  liveStateEl.textContent = text;
1205
- liveStateEl.classList.toggle("offline", !live);
2346
+ liveDotEl.classList.toggle("offline", !live);
2347
+ settingsButtonEl.setAttribute("aria-label", \`Settings. Board status: \${text}\`);
2348
+ settingsButtonEl.title = \`Settings · \${text}\`;
2349
+ }
2350
+
2351
+ function normalizePath(pathname) {
2352
+ return pathname.endsWith("/") ? pathname : pathname + "/";
2353
+ }
2354
+
2355
+ function boardOptionLabel(board) {
2356
+ const title = board.title || board.slug || board.goalDir || "GoalBuddy board";
2357
+ return /[/\\\\]subgoals[/\\\\]/.test(board.goalDir || "") ? \`Child: \${title}\` : title;
1206
2358
  }
1207
2359
 
1208
2360
  function el(tag, className = "", text = "") {
@@ -1212,9 +2364,14 @@ function el(tag, className = "", text = "") {
1212
2364
  return node;
1213
2365
  }
1214
2366
 
1215
- loadBoard()
2367
+ loadSettings()
2368
+ .then(loadBoard)
1216
2369
  .then(() => {
1217
2370
  setLiveState("Live", true);
2371
+ rememberCurrentBoard();
2372
+ loadGithubStars();
2373
+ loadBoardSwitcher();
2374
+ window.setInterval(loadBoardSwitcher, 5000);
1218
2375
  connectEvents();
1219
2376
  })
1220
2377
  .catch((error) => {