goalbuddy 0.2.20 → 0.2.22

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 (47) hide show
  1. package/README.md +16 -9
  2. package/goalbuddy/SKILL.md +77 -10
  3. package/goalbuddy/extend/github-projects/README.md +105 -0
  4. package/goalbuddy/extend/github-projects/examples/goal-board-sync/state.yaml +63 -0
  5. package/goalbuddy/extend/github-projects/extension.yaml +43 -0
  6. package/goalbuddy/extend/github-projects/scripts/lib/github-projects.mjs +728 -0
  7. package/goalbuddy/extend/github-projects/scripts/lib/goal-state.mjs +362 -0
  8. package/goalbuddy/extend/github-projects/scripts/sync-github-project.mjs +193 -0
  9. package/goalbuddy/extend/github-projects/test/github-projects.test.mjs +267 -0
  10. package/goalbuddy/extend/local-goal-board/README.md +75 -0
  11. package/goalbuddy/extend/local-goal-board/assets/goalbuddy-mark.png +0 -0
  12. package/goalbuddy/extend/local-goal-board/examples/sample-goal/notes/T001-scout.md +3 -0
  13. package/goalbuddy/extend/local-goal-board/examples/sample-goal/state.yaml +124 -0
  14. package/goalbuddy/extend/local-goal-board/extension.yaml +37 -0
  15. package/goalbuddy/extend/local-goal-board/scripts/lib/goal-board.mjs +1225 -0
  16. package/goalbuddy/extend/local-goal-board/scripts/local-goal-board.mjs +258 -0
  17. package/goalbuddy/extend/local-goal-board/test/local-goal-board.test.mjs +146 -0
  18. package/goalbuddy/scripts/check-goal-state.mjs +24 -9
  19. package/goalbuddy/scripts/check-update.mjs +102 -0
  20. package/goalbuddy/templates/goal.md +12 -8
  21. package/goalbuddy/templates/state.yaml +18 -3
  22. package/internal/assets/goalbuddy-og.png +0 -0
  23. package/internal/assets/goalbuddy-readme-hero.png +0 -0
  24. package/internal/cli/goal-maker.mjs +191 -13
  25. package/package.json +3 -2
  26. package/plugins/goalbuddy/.codex-plugin/plugin.json +3 -3
  27. package/plugins/goalbuddy/README.md +2 -5
  28. package/plugins/goalbuddy/skills/goalbuddy/SKILL.md +77 -10
  29. package/plugins/goalbuddy/skills/goalbuddy/extend/github-projects/README.md +105 -0
  30. package/plugins/goalbuddy/skills/goalbuddy/extend/github-projects/examples/goal-board-sync/state.yaml +63 -0
  31. package/plugins/goalbuddy/skills/goalbuddy/extend/github-projects/extension.yaml +43 -0
  32. package/plugins/goalbuddy/skills/goalbuddy/extend/github-projects/scripts/lib/github-projects.mjs +728 -0
  33. package/plugins/goalbuddy/skills/goalbuddy/extend/github-projects/scripts/lib/goal-state.mjs +362 -0
  34. package/plugins/goalbuddy/skills/goalbuddy/extend/github-projects/scripts/sync-github-project.mjs +193 -0
  35. package/plugins/goalbuddy/skills/goalbuddy/extend/github-projects/test/github-projects.test.mjs +267 -0
  36. package/plugins/goalbuddy/skills/goalbuddy/extend/local-goal-board/README.md +75 -0
  37. package/plugins/goalbuddy/skills/goalbuddy/extend/local-goal-board/assets/goalbuddy-mark.png +0 -0
  38. package/plugins/goalbuddy/skills/goalbuddy/extend/local-goal-board/examples/sample-goal/notes/T001-scout.md +3 -0
  39. package/plugins/goalbuddy/skills/goalbuddy/extend/local-goal-board/examples/sample-goal/state.yaml +124 -0
  40. package/plugins/goalbuddy/skills/goalbuddy/extend/local-goal-board/extension.yaml +37 -0
  41. package/plugins/goalbuddy/skills/goalbuddy/extend/local-goal-board/scripts/lib/goal-board.mjs +1225 -0
  42. package/plugins/goalbuddy/skills/goalbuddy/extend/local-goal-board/scripts/local-goal-board.mjs +258 -0
  43. package/plugins/goalbuddy/skills/goalbuddy/extend/local-goal-board/test/local-goal-board.test.mjs +146 -0
  44. package/plugins/goalbuddy/skills/goalbuddy/scripts/check-goal-state.mjs +24 -9
  45. package/plugins/goalbuddy/skills/goalbuddy/scripts/check-update.mjs +102 -0
  46. package/plugins/goalbuddy/skills/goalbuddy/templates/goal.md +12 -8
  47. package/plugins/goalbuddy/skills/goalbuddy/templates/state.yaml +18 -3
@@ -0,0 +1,1225 @@
1
+ import { copyFileSync, existsSync, mkdirSync, readFileSync, readdirSync, statSync, writeFileSync } from "node:fs";
2
+ import { readFile } from "node:fs/promises";
3
+ import { basename, dirname, join, resolve } from "node:path";
4
+ import { fileURLToPath } from "node:url";
5
+
6
+ const VALID_STATUSES = new Set(["queued", "active", "blocked", "done"]);
7
+ const COLUMN_ORDER = ["todo", "in-progress", "blocked", "completed"];
8
+ const __dirname = dirname(fileURLToPath(import.meta.url));
9
+ const extensionRoot = resolve(__dirname, "../..");
10
+ const logoAssetPath = join(extensionRoot, "assets", "goalbuddy-mark.png");
11
+
12
+ export class GoalBoardError extends Error {
13
+ constructor(message) {
14
+ super(message);
15
+ this.name = "GoalBoardError";
16
+ }
17
+ }
18
+
19
+ export async function loadGoalBoard(goalDir) {
20
+ const root = resolve(goalDir);
21
+ const statePath = join(root, "state.yaml");
22
+ if (!existsSync(statePath)) {
23
+ throw new GoalBoardError(`Missing state.yaml: ${statePath}`);
24
+ }
25
+ const text = await readFile(statePath, "utf8");
26
+ return normalizeGoalBoard(parseGoalStateText(text), root);
27
+ }
28
+
29
+ export function createBoardPayload(goalDir) {
30
+ const root = resolve(goalDir);
31
+ const statePath = join(root, "state.yaml");
32
+ if (!existsSync(statePath)) {
33
+ throw new GoalBoardError(`Missing state.yaml: ${statePath}`);
34
+ }
35
+
36
+ const document = parseGoalStateText(readFileSync(statePath, "utf8"));
37
+ const board = normalizeGoalBoard(document, root);
38
+ const noteIndex = loadNotes(root);
39
+ const tasks = board.tasks.map((task) => attachTaskNote(task, noteIndex));
40
+ const columns = buildColumns(tasks);
41
+ const stateStat = statSync(statePath);
42
+
43
+ return {
44
+ generatedAt: new Date().toISOString(),
45
+ source: {
46
+ goalDir: root,
47
+ statePath,
48
+ stateMtimeMs: stateStat.mtimeMs,
49
+ notesDir: join(root, "notes"),
50
+ },
51
+ goal: {
52
+ title: board.title,
53
+ slug: board.slug,
54
+ kind: board.kind,
55
+ status: board.status,
56
+ tranche: board.tranche,
57
+ activeTask: board.activeTask,
58
+ },
59
+ counts: {
60
+ total: tasks.length,
61
+ todo: columns.find((column) => column.id === "todo").tasks.length,
62
+ inProgress: columns.find((column) => column.id === "in-progress").tasks.length,
63
+ blocked: columns.find((column) => column.id === "blocked").tasks.length,
64
+ completed: columns.find((column) => column.id === "completed").tasks.length,
65
+ },
66
+ columns,
67
+ tasks,
68
+ notes: Object.values(noteIndex).map(({ path, title, mtimeMs }) => ({ path, title, mtimeMs })),
69
+ };
70
+ }
71
+
72
+ export function normalizeGoalBoard(document, goalDir = "<memory>") {
73
+ if (!document || typeof document !== "object" || Array.isArray(document)) {
74
+ throw new GoalBoardError("Goal state must be a YAML mapping.");
75
+ }
76
+ if (Number(document.version) !== 2) {
77
+ throw new GoalBoardError("Only GoalBuddy v2 state.yaml files are supported.");
78
+ }
79
+ if (!document.goal || typeof document.goal !== "object") {
80
+ throw new GoalBoardError("Missing goal metadata.");
81
+ }
82
+ if (!Array.isArray(document.tasks) || document.tasks.length === 0) {
83
+ throw new GoalBoardError("Missing non-empty tasks list.");
84
+ }
85
+
86
+ const tasks = document.tasks.map((task, index) => normalizeTask(task, index));
87
+ const activeTasks = tasks.filter((task) => task.status === "active");
88
+ if (activeTasks.length > 1) {
89
+ throw new GoalBoardError("Goal state has more than one active task.");
90
+ }
91
+
92
+ return {
93
+ goalDir,
94
+ title: cleanText(document.goal.title || "Untitled goal"),
95
+ slug: cleanText(document.goal.slug || "untitled-goal"),
96
+ kind: cleanText(document.goal.kind || "open_ended"),
97
+ tranche: cleanText(document.goal.tranche || ""),
98
+ status: cleanText(document.goal.status || "active"),
99
+ activeTask: cleanText(document.active_task || activeTasks[0]?.id || ""),
100
+ tasks,
101
+ };
102
+ }
103
+
104
+ export function normalizeTask(task, index) {
105
+ if (!task || typeof task !== "object" || Array.isArray(task)) {
106
+ throw new GoalBoardError(`Task ${index + 1} must be a mapping.`);
107
+ }
108
+
109
+ const id = cleanText(task.id);
110
+ const status = cleanText(task.status);
111
+ if (!id) throw new GoalBoardError(`Task ${index + 1} is missing id.`);
112
+ if (!VALID_STATUSES.has(status)) {
113
+ throw new GoalBoardError(`Task ${id} has unsupported status "${status}".`);
114
+ }
115
+
116
+ return {
117
+ id,
118
+ title: titleForTask(task),
119
+ objective: cleanText(task.objective || ""),
120
+ status,
121
+ column: columnForStatus(status),
122
+ type: cleanText(task.type || "pm"),
123
+ assignee: cleanText(task.assignee || ""),
124
+ active: status === "active",
125
+ inputs: normalizeStringList(task.inputs),
126
+ constraints: normalizeStringList(task.constraints),
127
+ expectedOutput: normalizeStringList(task.expected_output),
128
+ allowedFiles: normalizeStringList(task.allowed_files),
129
+ verify: normalizeStringList(task.verify),
130
+ stopIf: normalizeStringList(task.stop_if),
131
+ receipt: normalizeReceipt(task.receipt),
132
+ };
133
+ }
134
+
135
+ export function buildColumns(tasks) {
136
+ const byColumn = new Map(COLUMN_ORDER.map((id) => [id, []]));
137
+ for (const task of tasks) {
138
+ byColumn.get(task.column).push(task);
139
+ }
140
+
141
+ for (const columnTasks of byColumn.values()) {
142
+ columnTasks.sort((left, right) => taskSortKey(left).localeCompare(taskSortKey(right)));
143
+ }
144
+
145
+ return [
146
+ { id: "todo", title: "Todo", description: "Queued work ready to pull", tasks: byColumn.get("todo") },
147
+ { id: "in-progress", title: "In Progress", description: "The active task", tasks: byColumn.get("in-progress") },
148
+ { id: "blocked", title: "Blocked", description: "Needs unblock or a smaller slice", tasks: byColumn.get("blocked") },
149
+ { id: "completed", title: "Completed", description: "Receipted work", tasks: byColumn.get("completed") },
150
+ ];
151
+ }
152
+
153
+ export function writeBoardApp(goalDir) {
154
+ const appDir = join(resolve(goalDir), ".goalbuddy-board");
155
+ mkdirSync(appDir, { recursive: true });
156
+ writeFileSync(join(appDir, "index.html"), `${boardHtml()}\n`);
157
+ writeFileSync(join(appDir, "styles.css"), `${boardCss()}\n`);
158
+ writeFileSync(join(appDir, "app.js"), `${boardJs()}\n`);
159
+ copyFileSync(logoAssetPath, join(appDir, "goalbuddy-mark.png"));
160
+ return appDir;
161
+ }
162
+
163
+ function attachTaskNote(task, noteIndex) {
164
+ const notePath = task.receipt.note || "";
165
+ if (!notePath) return task;
166
+ const normalized = notePath.replaceAll("\\", "/").replace(/^\.?\//, "");
167
+ return {
168
+ ...task,
169
+ note: noteIndex[normalized] || null,
170
+ };
171
+ }
172
+
173
+ function loadNotes(goalDir) {
174
+ const notesDir = join(goalDir, "notes");
175
+ if (!existsSync(notesDir)) return {};
176
+
177
+ const notes = {};
178
+ for (const entry of readdirSync(notesDir, { withFileTypes: true })) {
179
+ if (!entry.isFile() || !entry.name.endsWith(".md")) continue;
180
+ const path = `notes/${entry.name}`;
181
+ const absolute = join(notesDir, entry.name);
182
+ const content = readFileSync(absolute, "utf8");
183
+ notes[path] = {
184
+ path,
185
+ title: noteTitle(content, entry.name),
186
+ content,
187
+ mtimeMs: statSync(absolute).mtimeMs,
188
+ };
189
+ }
190
+ return notes;
191
+ }
192
+
193
+ function noteTitle(content, filename) {
194
+ const heading = content.split(/\r?\n/).find((line) => line.startsWith("# "));
195
+ return heading ? heading.replace(/^#\s+/, "").trim() : basename(filename, ".md");
196
+ }
197
+
198
+ function normalizeReceipt(receipt) {
199
+ if (!receipt) return { present: false, summary: "", result: "", note: "" };
200
+ if (typeof receipt === "string") {
201
+ return { present: true, summary: cleanText(receipt), result: "", note: "" };
202
+ }
203
+ if (Array.isArray(receipt) || typeof receipt !== "object") {
204
+ return { present: true, summary: cleanText(receipt), result: "", note: "" };
205
+ }
206
+ return {
207
+ present: true,
208
+ result: cleanText(receipt.result || ""),
209
+ summary: cleanText(receipt.summary || receipt.decision || receipt.note || receipt.result || ""),
210
+ decision: cleanText(receipt.decision || ""),
211
+ note: cleanText(receipt.note || ""),
212
+ changedFiles: normalizeStringList(receipt.changed_files),
213
+ commands: normalizeCommands(receipt.commands),
214
+ evidence: normalizeStringList(receipt.evidence),
215
+ };
216
+ }
217
+
218
+ function normalizeCommands(commands) {
219
+ if (!commands) return [];
220
+ if (!Array.isArray(commands)) return [cleanText(commands)].filter(Boolean).map((cmd) => ({ cmd, status: "" }));
221
+ return commands.map((command) => {
222
+ if (typeof command === "string") return { cmd: cleanText(command), status: "" };
223
+ return {
224
+ cmd: cleanText(command?.cmd || ""),
225
+ status: cleanText(command?.status || ""),
226
+ };
227
+ }).filter((command) => command.cmd || command.status);
228
+ }
229
+
230
+ function titleForTask(task) {
231
+ const objective = cleanText(task.objective || "Untitled task");
232
+ return objective.replace(/\.$/, "");
233
+ }
234
+
235
+ function columnForStatus(status) {
236
+ if (status === "blocked") return "blocked";
237
+ if (status === "done") return "completed";
238
+ if (status === "queued") return "todo";
239
+ return "in-progress";
240
+ }
241
+
242
+ function taskSortKey(task) {
243
+ const rank = task.status === "active" ? "0" : task.status === "queued" ? "1" : task.status === "blocked" ? "2" : "3";
244
+ return `${rank}:${task.id}`;
245
+ }
246
+
247
+ function normalizeStringList(value) {
248
+ if (!value) return [];
249
+ if (Array.isArray(value)) return value.map(cleanText).filter(Boolean);
250
+ return [cleanText(value)].filter(Boolean);
251
+ }
252
+
253
+ function cleanText(value) {
254
+ return String(value ?? "").trim();
255
+ }
256
+
257
+ 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}.`);
263
+ }
264
+ return value;
265
+ }
266
+
267
+ function tokenizeYaml(text) {
268
+ return text
269
+ .replace(/\r\n/g, "\n")
270
+ .split("\n")
271
+ .map((raw, index) => {
272
+ const withoutComments = stripComment(raw).replace(/\s+$/, "");
273
+ if (!withoutComments.trim()) return null;
274
+ const indent = withoutComments.match(/^ */)[0].length;
275
+ if (indent % 2 !== 0) {
276
+ throw new GoalBoardError(`Unsupported odd indentation at line ${index + 1}.`);
277
+ }
278
+ return {
279
+ number: index + 1,
280
+ indent,
281
+ text: withoutComments.trimStart(),
282
+ };
283
+ })
284
+ .filter(Boolean);
285
+ }
286
+
287
+ function stripComment(line) {
288
+ let quote = null;
289
+ for (let index = 0; index < line.length; index += 1) {
290
+ const char = line[index];
291
+ const previous = line[index - 1];
292
+ if ((char === "\"" || char === "'") && previous !== "\\") {
293
+ quote = quote === char ? null : quote || char;
294
+ continue;
295
+ }
296
+ if (char === "#" && !quote && (index === 0 || /\s/.test(previous))) {
297
+ return line.slice(0, index);
298
+ }
299
+ }
300
+ return line;
301
+ }
302
+
303
+ function parseBlock(lines, index, indent) {
304
+ if (index >= lines.length) return [{}, index];
305
+ if (lines[index].indent < indent) return [{}, index];
306
+ if (lines[index].text.startsWith("- ")) return parseArray(lines, index, indent);
307
+ return parseObject(lines, index, indent);
308
+ }
309
+
310
+ function parseObject(lines, index, indent) {
311
+ const object = {};
312
+ while (index < lines.length) {
313
+ const line = lines[index];
314
+ if (line.indent < indent) break;
315
+ if (line.indent !== indent || line.text.startsWith("- ")) break;
316
+
317
+ const { key, valueText } = splitKeyValue(line);
318
+ index += 1;
319
+
320
+ if (valueText === "") {
321
+ if (index < lines.length && lines[index].indent > indent) {
322
+ const [child, nextIndex] = parseBlock(lines, index, lines[index].indent);
323
+ object[key] = child;
324
+ index = nextIndex;
325
+ } else {
326
+ object[key] = {};
327
+ }
328
+ } else {
329
+ object[key] = parseScalar(valueText);
330
+ }
331
+ }
332
+ return [object, index];
333
+ }
334
+
335
+ function parseArray(lines, index, indent) {
336
+ const array = [];
337
+ while (index < lines.length) {
338
+ const line = lines[index];
339
+ if (line.indent !== indent || !line.text.startsWith("- ")) break;
340
+
341
+ const content = line.text.slice(2).trim();
342
+ index += 1;
343
+
344
+ if (content === "") {
345
+ if (index < lines.length && lines[index].indent > indent) {
346
+ const [child, nextIndex] = parseBlock(lines, index, lines[index].indent);
347
+ array.push(child);
348
+ index = nextIndex;
349
+ } else {
350
+ array.push(null);
351
+ }
352
+ continue;
353
+ }
354
+
355
+ if (isInlineMapping(content)) {
356
+ const object = {};
357
+ const { key, valueText } = splitKeyValue({ text: content, number: line.number });
358
+ object[key] = valueText === "" ? {} : parseScalar(valueText);
359
+ if (index < lines.length && lines[index].indent > indent) {
360
+ const [child, nextIndex] = parseBlock(lines, index, lines[index].indent);
361
+ if (child && typeof child === "object" && !Array.isArray(child)) {
362
+ Object.assign(object, child);
363
+ } else {
364
+ throw new GoalBoardError(`Expected mapping below line ${line.number}.`);
365
+ }
366
+ index = nextIndex;
367
+ }
368
+ array.push(object);
369
+ } else {
370
+ array.push(parseScalar(content));
371
+ }
372
+ }
373
+ return [array, index];
374
+ }
375
+
376
+ function splitKeyValue(line) {
377
+ const separator = line.text.indexOf(":");
378
+ if (separator <= 0) {
379
+ throw new GoalBoardError(`Expected key/value pair at line ${line.number}.`);
380
+ }
381
+ return {
382
+ key: line.text.slice(0, separator).trim(),
383
+ valueText: line.text.slice(separator + 1).trim(),
384
+ };
385
+ }
386
+
387
+ function isInlineMapping(text) {
388
+ return /^[A-Za-z0-9_.-]+:\s*/.test(text);
389
+ }
390
+
391
+ function parseScalar(text) {
392
+ if (text === "[]") return [];
393
+ if (text === "{}") return {};
394
+ if (text === "null" || text === "~") return null;
395
+ if (text === "true") return true;
396
+ if (text === "false") return false;
397
+ if (/^-?\d+(\.\d+)?$/.test(text)) return Number(text);
398
+ if (text.startsWith("[") && text.endsWith("]")) {
399
+ const inner = text.slice(1, -1).trim();
400
+ if (!inner) return [];
401
+ return splitInlineArray(inner).map(parseScalar);
402
+ }
403
+ if (
404
+ (text.startsWith("\"") && text.endsWith("\"")) ||
405
+ (text.startsWith("'") && text.endsWith("'"))
406
+ ) {
407
+ return unquote(text);
408
+ }
409
+ if (text === "|" || text === ">") {
410
+ throw new GoalBoardError("Block scalar YAML is not supported by this lightweight parser.");
411
+ }
412
+ return text;
413
+ }
414
+
415
+ function unquote(text) {
416
+ if (text.startsWith("'")) return text.slice(1, -1).replace(/''/g, "'");
417
+ return text
418
+ .slice(1, -1)
419
+ .replace(/\\"/g, "\"")
420
+ .replace(/\\n/g, "\n")
421
+ .replace(/\\\\/g, "\\");
422
+ }
423
+
424
+ function splitInlineArray(text) {
425
+ const values = [];
426
+ let quote = null;
427
+ let start = 0;
428
+ for (let index = 0; index < text.length; index += 1) {
429
+ const char = text[index];
430
+ const previous = text[index - 1];
431
+ if ((char === "\"" || char === "'") && previous !== "\\") {
432
+ quote = quote === char ? null : quote || char;
433
+ continue;
434
+ }
435
+ if (char === "," && !quote) {
436
+ values.push(text.slice(start, index).trim());
437
+ start = index + 1;
438
+ }
439
+ }
440
+ values.push(text.slice(start).trim());
441
+ return values;
442
+ }
443
+
444
+ function boardHtml() {
445
+ return `<!doctype html>
446
+ <html lang="en">
447
+ <head>
448
+ <meta charset="utf-8">
449
+ <meta name="viewport" content="width=device-width, initial-scale=1">
450
+ <title>GoalBuddy Board</title>
451
+ <link rel="stylesheet" href="./styles.css">
452
+ </head>
453
+ <body>
454
+ <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>
458
+ </div>
459
+ <div class="live-state" id="live-state">Connecting</div>
460
+ </header>
461
+ <main class="shell">
462
+ <section class="goal-header" aria-labelledby="goal-title">
463
+ <div>
464
+ <p class="eyebrow">Local board</p>
465
+ <h1 id="goal-title">GoalBuddy Board</h1>
466
+ <p id="goal-tranche" class="goal-tranche"></p>
467
+ </div>
468
+ <dl class="goal-meta">
469
+ <div><dt>Status</dt><dd id="goal-status">Unknown</dd></div>
470
+ <div><dt>Active</dt><dd id="goal-active">None</dd></div>
471
+ <div><dt>Updated</dt><dd id="goal-updated">Waiting</dd></div>
472
+ </dl>
473
+ </section>
474
+ <section class="board" id="board" aria-label="Goal task board"></section>
475
+ </main>
476
+ <div class="modal" id="task-modal" hidden>
477
+ <button class="modal-scrim" type="button" data-close-modal aria-label="Close task detail"></button>
478
+ <article class="modal-panel" role="dialog" aria-modal="true" aria-labelledby="modal-title">
479
+ <header class="modal-header">
480
+ <div>
481
+ <p class="eyebrow" id="modal-kicker">Task</p>
482
+ <h2 id="modal-title">Task detail</h2>
483
+ </div>
484
+ <button class="icon-button" type="button" data-close-modal aria-label="Close task detail">x</button>
485
+ </header>
486
+ <div class="modal-body" id="modal-body"></div>
487
+ </article>
488
+ </div>
489
+ <script src="./app.js" type="module"></script>
490
+ </body>
491
+ </html>`;
492
+ }
493
+
494
+ function boardCss() {
495
+ return `:root {
496
+ color-scheme: light;
497
+ --canvas: #f7f6f3;
498
+ --surface: #ffffff;
499
+ --surface-muted: #fbfbfa;
500
+ --ink: #111111;
501
+ --muted: #787774;
502
+ --line: #eaeaea;
503
+ --blue-bg: #e1f3fe;
504
+ --blue-text: #1f6c9f;
505
+ --green-bg: #edf3ec;
506
+ --green-text: #346538;
507
+ --red-bg: #fdebec;
508
+ --red-text: #9f2f2d;
509
+ --yellow-bg: #fbf3db;
510
+ --yellow-text: #956400;
511
+ font-family: "SF Pro Display", "Geist Sans", "Helvetica Neue", Arial, sans-serif;
512
+ }
513
+
514
+ * { box-sizing: border-box; }
515
+
516
+ body {
517
+ margin: 0;
518
+ min-height: 100vh;
519
+ background: var(--canvas);
520
+ color: var(--ink);
521
+ }
522
+
523
+ button,
524
+ input,
525
+ textarea {
526
+ font: inherit;
527
+ }
528
+
529
+ .topbar {
530
+ position: sticky;
531
+ top: 0;
532
+ z-index: 10;
533
+ display: flex;
534
+ align-items: center;
535
+ justify-content: space-between;
536
+ 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);
541
+ }
542
+
543
+ .brand {
544
+ display: inline-flex;
545
+ align-items: center;
546
+ gap: 10px;
547
+ color: #071236;
548
+ font-weight: 800;
549
+ }
550
+
551
+ .brand-mark {
552
+ display: block;
553
+ width: 34px;
554
+ height: 34px;
555
+ }
556
+
557
+ .brand-name {
558
+ font-size: 18px;
559
+ letter-spacing: 0;
560
+ }
561
+
562
+ .live-state,
563
+ .badge {
564
+ display: inline-flex;
565
+ align-items: center;
566
+ width: fit-content;
567
+ border-radius: 999px;
568
+ padding: 4px 8px;
569
+ font-size: 11px;
570
+ font-weight: 700;
571
+ letter-spacing: 0.05em;
572
+ text-transform: uppercase;
573
+ background: var(--blue-bg);
574
+ color: var(--blue-text);
575
+ }
576
+
577
+ .live-state.offline {
578
+ background: var(--yellow-bg);
579
+ color: var(--yellow-text);
580
+ }
581
+
582
+ .shell {
583
+ width: min(1440px, 100%);
584
+ margin: 0 auto;
585
+ padding: 28px 24px 40px;
586
+ }
587
+
588
+ .goal-header {
589
+ display: grid;
590
+ grid-template-columns: minmax(0, 1fr) auto;
591
+ gap: 24px;
592
+ align-items: end;
593
+ padding: 8px 0 24px;
594
+ border-bottom: 1px solid var(--line);
595
+ }
596
+
597
+ .eyebrow {
598
+ margin: 0 0 8px;
599
+ color: var(--muted);
600
+ font-size: 11px;
601
+ font-weight: 700;
602
+ letter-spacing: 0.08em;
603
+ text-transform: uppercase;
604
+ }
605
+
606
+ h1,
607
+ h2,
608
+ h3,
609
+ p {
610
+ margin-top: 0;
611
+ }
612
+
613
+ h1 {
614
+ margin-bottom: 10px;
615
+ max-width: 900px;
616
+ font-size: clamp(34px, 5vw, 68px);
617
+ line-height: 0.95;
618
+ letter-spacing: 0;
619
+ }
620
+
621
+ .goal-tranche {
622
+ max-width: 860px;
623
+ margin-bottom: 0;
624
+ color: #2f3437;
625
+ line-height: 1.55;
626
+ }
627
+
628
+ .goal-meta {
629
+ display: grid;
630
+ grid-template-columns: repeat(3, minmax(94px, auto));
631
+ gap: 1px;
632
+ overflow: hidden;
633
+ margin: 0;
634
+ border: 1px solid var(--line);
635
+ border-radius: 8px;
636
+ background: var(--line);
637
+ }
638
+
639
+ .goal-meta div {
640
+ min-width: 0;
641
+ padding: 12px 14px;
642
+ background: var(--surface);
643
+ }
644
+
645
+ .goal-meta dt {
646
+ margin-bottom: 6px;
647
+ color: var(--muted);
648
+ font-size: 11px;
649
+ text-transform: uppercase;
650
+ letter-spacing: 0.05em;
651
+ }
652
+
653
+ .goal-meta dd {
654
+ margin: 0;
655
+ max-width: 180px;
656
+ overflow: hidden;
657
+ text-overflow: ellipsis;
658
+ white-space: nowrap;
659
+ font-size: 14px;
660
+ }
661
+
662
+ .board {
663
+ display: grid;
664
+ grid-template-columns: repeat(4, minmax(0, 1fr));
665
+ gap: 16px;
666
+ padding-top: 18px;
667
+ }
668
+
669
+ .column {
670
+ min-width: 0;
671
+ border: 1px solid var(--line);
672
+ border-radius: 8px;
673
+ background: var(--surface-muted);
674
+ }
675
+
676
+ .column-header {
677
+ display: flex;
678
+ align-items: start;
679
+ justify-content: space-between;
680
+ gap: 12px;
681
+ padding: 16px;
682
+ border-bottom: 1px solid var(--line);
683
+ }
684
+
685
+ .column-header h2 {
686
+ margin: 0 0 4px;
687
+ font-size: 16px;
688
+ line-height: 1.2;
689
+ }
690
+
691
+ .column-header p {
692
+ margin: 0;
693
+ color: var(--muted);
694
+ font-size: 13px;
695
+ line-height: 1.4;
696
+ }
697
+
698
+ .column-count {
699
+ color: var(--muted);
700
+ font-family: "Geist Mono", "SF Mono", monospace;
701
+ font-size: 13px;
702
+ }
703
+
704
+ .card-list {
705
+ display: grid;
706
+ gap: 10px;
707
+ padding: 12px;
708
+ }
709
+
710
+ .task-card {
711
+ width: 100%;
712
+ min-height: 138px;
713
+ display: flex;
714
+ flex-direction: column;
715
+ gap: 12px;
716
+ padding: 14px;
717
+ border: 1px solid var(--line);
718
+ border-radius: 8px;
719
+ background: var(--surface);
720
+ color: inherit;
721
+ text-align: left;
722
+ cursor: pointer;
723
+ transition: transform 160ms ease, border-color 160ms ease;
724
+ will-change: transform, opacity;
725
+ }
726
+
727
+ .task-card:hover {
728
+ border-color: #d1d0cc;
729
+ transform: translateY(-1px);
730
+ }
731
+
732
+ .task-card:focus-visible,
733
+ .icon-button:focus-visible {
734
+ outline: 2px solid #2f3437;
735
+ outline-offset: 2px;
736
+ }
737
+
738
+ .task-card.is-active {
739
+ border-color: #a8cfe7;
740
+ background: #fbfdfe;
741
+ }
742
+
743
+ .task-card.is-moving {
744
+ border-color: #c2b8ff;
745
+ }
746
+
747
+ .card-topline {
748
+ display: flex;
749
+ align-items: center;
750
+ justify-content: space-between;
751
+ gap: 8px;
752
+ }
753
+
754
+ .task-id {
755
+ color: var(--muted);
756
+ font-family: "Geist Mono", "SF Mono", monospace;
757
+ font-size: 12px;
758
+ }
759
+
760
+ .task-title {
761
+ margin: 0;
762
+ color: #2f3437;
763
+ font-size: 15px;
764
+ line-height: 1.35;
765
+ }
766
+
767
+ .card-footer {
768
+ display: flex;
769
+ flex-wrap: wrap;
770
+ gap: 6px;
771
+ margin-top: auto;
772
+ }
773
+
774
+ .badge.status-active,
775
+ .badge.status-queued { background: var(--blue-bg); color: var(--blue-text); }
776
+ .badge.status-done { background: var(--green-bg); color: var(--green-text); }
777
+ .badge.status-blocked { background: var(--red-bg); color: var(--red-text); }
778
+ .badge.role { background: var(--yellow-bg); color: var(--yellow-text); }
779
+
780
+ .empty {
781
+ padding: 18px;
782
+ color: var(--muted);
783
+ font-size: 14px;
784
+ }
785
+
786
+ @media (prefers-reduced-motion: reduce) {
787
+ .task-card {
788
+ transition: none;
789
+ }
790
+ }
791
+
792
+ .modal[hidden] {
793
+ display: none;
794
+ }
795
+
796
+ .modal {
797
+ position: fixed;
798
+ inset: 0;
799
+ z-index: 30;
800
+ display: grid;
801
+ place-items: center;
802
+ padding: 24px;
803
+ }
804
+
805
+ .modal-scrim {
806
+ position: absolute;
807
+ inset: 0;
808
+ border: 0;
809
+ background: rgba(17, 17, 17, 0.32);
810
+ }
811
+
812
+ .modal-panel {
813
+ position: relative;
814
+ width: min(760px, 100%);
815
+ max-height: min(760px, calc(100vh - 48px));
816
+ overflow: auto;
817
+ border: 1px solid var(--line);
818
+ border-radius: 8px;
819
+ background: var(--surface);
820
+ }
821
+
822
+ .modal-header {
823
+ position: sticky;
824
+ top: 0;
825
+ display: flex;
826
+ align-items: start;
827
+ justify-content: space-between;
828
+ gap: 16px;
829
+ padding: 20px;
830
+ border-bottom: 1px solid var(--line);
831
+ background: var(--surface);
832
+ }
833
+
834
+ .modal-header h2 {
835
+ margin: 0;
836
+ font-size: 24px;
837
+ line-height: 1.15;
838
+ letter-spacing: 0;
839
+ }
840
+
841
+ .icon-button {
842
+ width: 32px;
843
+ height: 32px;
844
+ border: 1px solid var(--line);
845
+ border-radius: 6px;
846
+ background: var(--surface);
847
+ color: #2f3437;
848
+ cursor: pointer;
849
+ }
850
+
851
+ .modal-body {
852
+ display: grid;
853
+ gap: 18px;
854
+ padding: 20px;
855
+ }
856
+
857
+ .detail-grid {
858
+ display: grid;
859
+ grid-template-columns: repeat(2, minmax(0, 1fr));
860
+ gap: 1px;
861
+ overflow: hidden;
862
+ border: 1px solid var(--line);
863
+ border-radius: 8px;
864
+ background: var(--line);
865
+ }
866
+
867
+ .detail-item {
868
+ min-width: 0;
869
+ padding: 12px;
870
+ background: var(--surface-muted);
871
+ }
872
+
873
+ .detail-item dt {
874
+ margin-bottom: 6px;
875
+ color: var(--muted);
876
+ font-size: 11px;
877
+ text-transform: uppercase;
878
+ letter-spacing: 0.05em;
879
+ }
880
+
881
+ .detail-item dd {
882
+ margin: 0;
883
+ line-height: 1.45;
884
+ }
885
+
886
+ .detail-section {
887
+ border-top: 1px solid var(--line);
888
+ padding-top: 14px;
889
+ }
890
+
891
+ .detail-section h3 {
892
+ margin: 0 0 10px;
893
+ font-size: 14px;
894
+ }
895
+
896
+ .detail-section ul {
897
+ margin: 0;
898
+ padding-left: 18px;
899
+ color: #2f3437;
900
+ line-height: 1.55;
901
+ }
902
+
903
+ pre.note {
904
+ overflow: auto;
905
+ margin: 0;
906
+ padding: 14px;
907
+ border: 1px solid var(--line);
908
+ border-radius: 8px;
909
+ background: var(--canvas);
910
+ color: #2f3437;
911
+ font-family: "Geist Mono", "SF Mono", monospace;
912
+ font-size: 12px;
913
+ line-height: 1.55;
914
+ white-space: pre-wrap;
915
+ }
916
+
917
+ @media (max-width: 980px) {
918
+ .goal-header {
919
+ grid-template-columns: 1fr;
920
+ }
921
+
922
+ .goal-meta {
923
+ grid-template-columns: repeat(3, minmax(0, 1fr));
924
+ }
925
+
926
+ .board {
927
+ grid-template-columns: 1fr;
928
+ }
929
+ }
930
+
931
+ @media (max-width: 640px) {
932
+ .topbar,
933
+ .shell {
934
+ padding-left: 14px;
935
+ padding-right: 14px;
936
+ }
937
+
938
+ .goal-meta,
939
+ .detail-grid {
940
+ grid-template-columns: 1fr;
941
+ }
942
+
943
+ h1 {
944
+ font-size: 38px;
945
+ }
946
+ }`;
947
+ }
948
+
949
+ function boardJs() {
950
+ return `let currentBoard = null;
951
+ let eventSource = null;
952
+
953
+ const boardEl = document.getElementById("board");
954
+ const liveStateEl = document.getElementById("live-state");
955
+ const modalEl = document.getElementById("task-modal");
956
+ const modalTitleEl = document.getElementById("modal-title");
957
+ const modalKickerEl = document.getElementById("modal-kicker");
958
+ const modalBodyEl = document.getElementById("modal-body");
959
+
960
+ document.addEventListener("click", (event) => {
961
+ const card = event.target.closest("[data-task-id]");
962
+ if (card) openTask(card.dataset.taskId);
963
+ if (event.target.matches("[data-close-modal]")) closeModal();
964
+ });
965
+
966
+ document.addEventListener("keydown", (event) => {
967
+ if (event.key === "Escape") closeModal();
968
+ });
969
+
970
+ async function loadBoard() {
971
+ const response = await fetch("./api/board", { cache: "no-store" });
972
+ if (!response.ok) throw new Error("Board request failed");
973
+ renderBoard(await response.json());
974
+ }
975
+
976
+ function connectEvents() {
977
+ eventSource = new EventSource("./events");
978
+ eventSource.addEventListener("board", (event) => {
979
+ setLiveState("Live", true);
980
+ renderBoard(JSON.parse(event.data));
981
+ });
982
+ eventSource.addEventListener("error", () => {
983
+ setLiveState("Reconnecting", false);
984
+ });
985
+ }
986
+
987
+ function renderBoard(board) {
988
+ const previousPositions = measureCards();
989
+ const previousColumns = new Map();
990
+ for (const column of currentBoard?.columns || []) {
991
+ for (const task of column.tasks) previousColumns.set(task.id, column.id);
992
+ }
993
+ const movingTaskIds = tasksChangingColumns(board, previousColumns);
994
+ if (movingTaskIds.size) highlightMovingCards(movingTaskIds);
995
+ currentBoard = board;
996
+ document.getElementById("goal-title").textContent = board.goal.title;
997
+ document.title = board.goal.title ? board.goal.title + " - GoalBuddy Board" : "GoalBuddy Board";
998
+ document.getElementById("goal-tranche").textContent = board.goal.tranche || "";
999
+ document.getElementById("goal-status").textContent = board.goal.status;
1000
+ document.getElementById("goal-active").textContent = board.goal.activeTask || "None";
1001
+ document.getElementById("goal-updated").textContent = new Date(board.generatedAt).toLocaleTimeString();
1002
+
1003
+ const delay = movingTaskIds.size ? 260 : 0;
1004
+ window.setTimeout(() => {
1005
+ boardEl.replaceChildren(...board.columns.map(renderColumn));
1006
+ animateCardMoves(previousPositions, movingTaskIds);
1007
+ }, delay);
1008
+ }
1009
+
1010
+ function renderColumn(column) {
1011
+ const section = el("section", "column");
1012
+ section.dataset.columnId = column.id;
1013
+ const header = el("header", "column-header");
1014
+ const titleWrap = el("div");
1015
+ titleWrap.append(el("h2", "", column.title), el("p", "", column.description));
1016
+ header.append(titleWrap, el("span", "column-count", String(column.tasks.length)));
1017
+
1018
+ const list = el("div", "card-list");
1019
+ if (column.tasks.length === 0) {
1020
+ list.append(el("p", "empty", "No cards"));
1021
+ } else {
1022
+ for (const task of column.tasks) list.append(renderCard(task));
1023
+ }
1024
+
1025
+ section.append(header, list);
1026
+ return section;
1027
+ }
1028
+
1029
+ function renderCard(task) {
1030
+ const button = el("button", \`task-card \${task.active ? "is-active" : ""}\`);
1031
+ button.type = "button";
1032
+ button.dataset.taskId = task.id;
1033
+ button.dataset.status = task.status;
1034
+
1035
+ const topline = el("div", "card-topline");
1036
+ topline.append(el("span", "task-id", task.id), statusBadge(task.status));
1037
+
1038
+ const footer = el("div", "card-footer");
1039
+ footer.append(el("span", "badge role", task.assignee || task.type || "PM"));
1040
+ if (task.receipt?.present) footer.append(el("span", "badge status-done", "Receipt"));
1041
+
1042
+ button.append(topline, el("h3", "task-title", task.title), footer);
1043
+ return button;
1044
+ }
1045
+
1046
+ function measureCards() {
1047
+ const positions = new Map();
1048
+ for (const card of boardEl.querySelectorAll("[data-task-id]")) {
1049
+ const rect = card.getBoundingClientRect();
1050
+ positions.set(card.dataset.taskId, {
1051
+ left: rect.left,
1052
+ top: rect.top,
1053
+ width: rect.width,
1054
+ height: rect.height,
1055
+ columnId: card.closest("[data-column-id]")?.dataset.columnId || "",
1056
+ });
1057
+ }
1058
+ return positions;
1059
+ }
1060
+
1061
+ function tasksChangingColumns(board, previousColumns) {
1062
+ const moving = new Set();
1063
+ for (const column of board.columns) {
1064
+ for (const task of column.tasks) {
1065
+ const previousColumn = previousColumns.get(task.id);
1066
+ if (previousColumn && previousColumn !== column.id) moving.add(task.id);
1067
+ }
1068
+ }
1069
+ return moving;
1070
+ }
1071
+
1072
+ function highlightMovingCards(taskIds) {
1073
+ if (window.matchMedia("(prefers-reduced-motion: reduce)").matches) return;
1074
+ for (const card of boardEl.querySelectorAll("[data-task-id]")) {
1075
+ if (!taskIds.has(card.dataset.taskId)) continue;
1076
+ card.classList.add("is-moving");
1077
+ card.animate([
1078
+ { transform: "scale(1)", borderColor: "#eaeaea" },
1079
+ { transform: "scale(1.025)", borderColor: "#9d8cff" },
1080
+ { transform: "scale(1)", borderColor: "#c2b8ff" },
1081
+ ], {
1082
+ duration: 240,
1083
+ easing: "cubic-bezier(0.16, 1, 0.3, 1)",
1084
+ });
1085
+ }
1086
+ }
1087
+
1088
+ function animateCardMoves(previousPositions, movingTaskIds = new Set()) {
1089
+ if (window.matchMedia("(prefers-reduced-motion: reduce)").matches) return;
1090
+
1091
+ for (const card of boardEl.querySelectorAll("[data-task-id]")) {
1092
+ const previous = previousPositions.get(card.dataset.taskId);
1093
+ const current = card.getBoundingClientRect();
1094
+ const columnId = card.closest("[data-column-id]")?.dataset.columnId || "";
1095
+
1096
+ if (!previous) {
1097
+ card.animate([
1098
+ { opacity: 0, transform: "translateY(10px) scale(0.98)" },
1099
+ { opacity: 1, transform: "translateY(0) scale(1)" },
1100
+ ], {
1101
+ duration: 260,
1102
+ easing: "cubic-bezier(0.16, 1, 0.3, 1)",
1103
+ });
1104
+ continue;
1105
+ }
1106
+
1107
+ const dx = previous.left - current.left;
1108
+ const dy = previous.top - current.top;
1109
+ if (Math.abs(dx) < 1 && Math.abs(dy) < 1) continue;
1110
+
1111
+ const changedColumn = previous.columnId !== columnId;
1112
+ const wasSelected = movingTaskIds.has(card.dataset.taskId);
1113
+ card.animate([
1114
+ {
1115
+ transform: \`translate(\${dx}px, \${dy}px) scale(\${changedColumn ? "1.015" : "1"})\`,
1116
+ opacity: changedColumn ? 0.9 : 1,
1117
+ borderColor: wasSelected ? "#9d8cff" : "#eaeaea",
1118
+ },
1119
+ {
1120
+ transform: "translate(0, 0) scale(1)",
1121
+ opacity: 1,
1122
+ borderColor: "#eaeaea",
1123
+ },
1124
+ ], {
1125
+ duration: changedColumn ? 980 : 520,
1126
+ easing: "cubic-bezier(0.19, 1, 0.22, 1)",
1127
+ });
1128
+ }
1129
+ }
1130
+
1131
+ function openTask(taskId) {
1132
+ const task = currentBoard?.tasks.find((candidate) => candidate.id === taskId);
1133
+ if (!task) return;
1134
+
1135
+ modalKickerEl.textContent = \`\${task.id} · \${task.status}\`;
1136
+ modalTitleEl.textContent = task.title;
1137
+ modalBodyEl.replaceChildren(renderTaskDetail(task));
1138
+ modalEl.hidden = false;
1139
+ }
1140
+
1141
+ function closeModal() {
1142
+ modalEl.hidden = true;
1143
+ }
1144
+
1145
+ function renderTaskDetail(task) {
1146
+ const root = el("div");
1147
+ const grid = el("dl", "detail-grid");
1148
+ for (const [label, value] of [
1149
+ ["Status", task.status],
1150
+ ["Assignee", task.assignee || "Unassigned"],
1151
+ ["Type", task.type],
1152
+ ["Receipt", task.receipt?.summary || "None"],
1153
+ ]) {
1154
+ const item = el("div", "detail-item");
1155
+ item.append(el("dt", "", label), el("dd", "", value));
1156
+ grid.append(item);
1157
+ }
1158
+ root.append(grid);
1159
+ root.append(detailText("Objective", task.objective));
1160
+ root.append(detailList("Inputs", task.inputs));
1161
+ root.append(detailList("Constraints", task.constraints));
1162
+ root.append(detailList("Expected Output", task.expectedOutput));
1163
+ root.append(detailList("Allowed Files", task.allowedFiles));
1164
+ root.append(detailList("Verify", task.verify));
1165
+ root.append(detailList("Stop If", task.stopIf));
1166
+ if (task.receipt?.decision) root.append(detailText("Decision", task.receipt.decision));
1167
+ if (task.receipt?.changedFiles?.length) root.append(detailList("Changed Files", task.receipt.changedFiles));
1168
+ if (task.receipt?.commands?.length) {
1169
+ root.append(detailList("Commands", task.receipt.commands.map((command) => command.status ? \`\${command.status}: \${command.cmd}\` : command.cmd)));
1170
+ }
1171
+ if (task.note?.content) {
1172
+ const section = el("section", "detail-section");
1173
+ section.append(el("h3", "", task.note.title || task.note.path), el("pre", "note", task.note.content));
1174
+ root.append(section);
1175
+ }
1176
+ return root;
1177
+ }
1178
+
1179
+ function detailText(title, value) {
1180
+ const section = el("section", "detail-section");
1181
+ section.append(el("h3", "", title), el("p", "", value || "None"));
1182
+ return section;
1183
+ }
1184
+
1185
+ function detailList(title, values) {
1186
+ const section = el("section", "detail-section");
1187
+ section.append(el("h3", "", title));
1188
+ if (!values?.length) {
1189
+ section.append(el("p", "", "None"));
1190
+ return section;
1191
+ }
1192
+ const list = el("ul");
1193
+ for (const value of values) list.append(el("li", "", value));
1194
+ section.append(list);
1195
+ return section;
1196
+ }
1197
+
1198
+ function statusBadge(status) {
1199
+ const label = status === "done" ? "Completed" : status === "active" ? "Active" : status === "blocked" ? "Blocked" : "Queued";
1200
+ return el("span", \`badge status-\${status}\`, label);
1201
+ }
1202
+
1203
+ function setLiveState(text, live) {
1204
+ liveStateEl.textContent = text;
1205
+ liveStateEl.classList.toggle("offline", !live);
1206
+ }
1207
+
1208
+ function el(tag, className = "", text = "") {
1209
+ const node = document.createElement(tag);
1210
+ if (className) node.className = className;
1211
+ if (text !== "") node.textContent = text;
1212
+ return node;
1213
+ }
1214
+
1215
+ loadBoard()
1216
+ .then(() => {
1217
+ setLiveState("Live", true);
1218
+ connectEvents();
1219
+ })
1220
+ .catch((error) => {
1221
+ setLiveState("Offline", false);
1222
+ boardEl.textContent = error.message;
1223
+ });
1224
+ `;
1225
+ }