@polderlabs/bizar 4.5.2 → 4.7.1

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 (123) hide show
  1. package/bizar-dash/dist/assets/main-DHZmbnxQ.js +361 -0
  2. package/bizar-dash/dist/assets/main-DHZmbnxQ.js.map +1 -0
  3. package/bizar-dash/dist/assets/main-DX_Jh8Wc.css +1 -0
  4. package/bizar-dash/dist/assets/{mobile-lbH6szyX.js → mobile-BK8-ythT.js} +18 -18
  5. package/bizar-dash/dist/assets/mobile-BK8-ythT.js.map +1 -0
  6. package/bizar-dash/dist/assets/{mobile-BRhoDOUz.js → mobile-Chvf9u_B.js} +1 -1
  7. package/bizar-dash/dist/assets/{mobile-BRhoDOUz.js.map → mobile-Chvf9u_B.js.map} +1 -1
  8. package/bizar-dash/dist/index.html +3 -3
  9. package/bizar-dash/dist/mobile.html +2 -2
  10. package/bizar-dash/node_modules/.vite/vitest/da39a3ee5e6b4b0d3255bfef95601890afd80709/results.json +1 -0
  11. package/bizar-dash/skills/publishing/SKILL.md +146 -0
  12. package/bizar-dash/src/server/api.mjs +8 -0
  13. package/bizar-dash/src/server/backup-store.mjs +525 -0
  14. package/bizar-dash/src/server/digest-store.mjs +558 -0
  15. package/bizar-dash/src/server/lib/rate-limit.mjs +122 -0
  16. package/bizar-dash/src/server/logger.mjs +71 -0
  17. package/bizar-dash/src/server/memory-lightrag.mjs +45 -6
  18. package/bizar-dash/src/server/metrics.mjs +193 -0
  19. package/bizar-dash/src/server/routes/backup.mjs +112 -0
  20. package/bizar-dash/src/server/routes/chat.mjs +20 -3
  21. package/bizar-dash/src/server/routes/digests.mjs +82 -0
  22. package/bizar-dash/src/server/routes/lightrag.mjs +3 -2
  23. package/bizar-dash/src/server/routes/memory.mjs +5 -4
  24. package/bizar-dash/src/server/routes/misc.mjs +2 -1
  25. package/bizar-dash/src/server/routes/overview.mjs +2 -1
  26. package/bizar-dash/src/server/routes-v2/events.mjs +14 -0
  27. package/bizar-dash/src/server/schedules-runner.mjs +126 -0
  28. package/bizar-dash/src/server/server.mjs +79 -0
  29. package/bizar-dash/src/web/App.tsx +8 -1
  30. package/bizar-dash/src/web/components/BackupRestore.tsx +330 -0
  31. package/bizar-dash/src/web/components/SearchModal.tsx +6 -3
  32. package/bizar-dash/src/web/components/VirtualList.tsx +53 -0
  33. package/bizar-dash/src/web/components/chat/ChatThread.tsx +17 -11
  34. package/bizar-dash/src/web/components/chat/Composer.tsx +2 -0
  35. package/bizar-dash/src/web/hooks/useI18n.ts +13 -0
  36. package/bizar-dash/src/web/lib/i18n.ts +25 -0
  37. package/bizar-dash/src/web/locales/en.json +52 -0
  38. package/bizar-dash/src/web/main.tsx +5 -0
  39. package/bizar-dash/src/web/styles/main.css +70 -8
  40. package/bizar-dash/src/web/views/Activity.tsx +35 -18
  41. package/bizar-dash/src/web/views/Agents.tsx +57 -42
  42. package/bizar-dash/src/web/views/Artifacts.tsx +38 -25
  43. package/bizar-dash/src/web/views/Chat.tsx +8 -0
  44. package/bizar-dash/src/web/views/History.tsx +94 -76
  45. package/bizar-dash/src/web/views/MiniMaxUsage.tsx +21 -4
  46. package/bizar-dash/src/web/views/Mods.tsx +30 -17
  47. package/bizar-dash/src/web/views/Overview.tsx +19 -9
  48. package/bizar-dash/src/web/views/Providers.tsx +16 -16
  49. package/bizar-dash/src/web/views/Schedules.tsx +33 -15
  50. package/bizar-dash/src/web/views/Settings.tsx +97 -1745
  51. package/bizar-dash/src/web/views/Skills.tsx +4 -1
  52. package/bizar-dash/src/web/views/Tasks.tsx +11 -2
  53. package/bizar-dash/src/web/views/memory/ConfigPanel.tsx +8 -2
  54. package/bizar-dash/src/web/views/memory/LightragPanel.tsx +3 -0
  55. package/bizar-dash/src/web/views/memory/ObsidianPanel.tsx +12 -4
  56. package/bizar-dash/src/web/views/memory/SemanticSearchPanel.tsx +3 -0
  57. package/bizar-dash/src/web/views/settings/ActivitySection.tsx +205 -0
  58. package/bizar-dash/src/web/views/settings/AgentSection.tsx +294 -0
  59. package/bizar-dash/src/web/views/settings/AuthSection.tsx +159 -0
  60. package/bizar-dash/src/web/views/settings/BackupSection.tsx +16 -0
  61. package/bizar-dash/src/web/views/settings/EnvVarsSection.tsx +16 -0
  62. package/bizar-dash/src/web/views/settings/GeneralSection.tsx +105 -0
  63. package/bizar-dash/src/web/views/settings/HeadroomSection.tsx +39 -0
  64. package/bizar-dash/src/web/views/settings/MemorySection.tsx +16 -0
  65. package/bizar-dash/src/web/views/settings/NetworkSection.tsx +87 -0
  66. package/bizar-dash/src/web/views/settings/NotificationsSection.tsx +34 -0
  67. package/bizar-dash/src/web/views/settings/ProvidersSection.tsx +16 -0
  68. package/bizar-dash/src/web/views/settings/SkillsSection.tsx +16 -0
  69. package/bizar-dash/src/web/views/settings/SystemLlmSection.tsx +81 -0
  70. package/bizar-dash/src/web/views/settings/ThemeSection.tsx +168 -0
  71. package/bizar-dash/src/web/views/settings/UpdatesSection.tsx +256 -0
  72. package/bizar-dash/tests/a11y.test.tsx +206 -0
  73. package/bizar-dash/tests/backup-restore.test.mjs +217 -0
  74. package/bizar-dash/tests/backup-restore.test.tsx +123 -0
  75. package/bizar-dash/tests/backup-store.test.mjs +300 -0
  76. package/bizar-dash/tests/cli-bugfixes.test.mjs +4 -4
  77. package/bizar-dash/tests/cli-error-visibility.test.mjs +153 -0
  78. package/bizar-dash/tests/cli-refactor.test.mjs +184 -0
  79. package/bizar-dash/tests/components/Button.test.tsx +41 -0
  80. package/bizar-dash/tests/components/Card.test.tsx +42 -0
  81. package/bizar-dash/tests/components/Modal.test.tsx +104 -0
  82. package/bizar-dash/tests/components/Spinner.test.tsx +32 -0
  83. package/bizar-dash/tests/components/StatusBadge.test.tsx +35 -0
  84. package/bizar-dash/tests/components/Toast.test.tsx +108 -0
  85. package/bizar-dash/tests/digest-generation.test.mjs +191 -0
  86. package/bizar-dash/tests/digest-store.test.mjs +264 -0
  87. package/bizar-dash/tests/hooks/useModal.test.tsx +84 -0
  88. package/bizar-dash/tests/hooks/useToast.test.tsx +50 -0
  89. package/bizar-dash/tests/lib/i18n.test.ts +46 -0
  90. package/bizar-dash/tests/lib/utils.test.ts +194 -0
  91. package/bizar-dash/tests/logger.test.mjs +207 -0
  92. package/bizar-dash/tests/metrics.test.mjs +183 -0
  93. package/bizar-dash/tests/rate-limit.test.mjs +298 -0
  94. package/bizar-dash/tests/server-bugfixes.test.mjs +2 -2
  95. package/bizar-dash/tests/setup.ts +7 -0
  96. package/bizar-dash/vitest.config.ts +13 -0
  97. package/cli/artifact-cli.mjs +605 -0
  98. package/cli/artifact-render.mjs +621 -0
  99. package/cli/artifact-server.mjs +847 -0
  100. package/cli/artifact.mjs +38 -2096
  101. package/cli/bg.mjs +5 -13
  102. package/cli/bin.mjs +221 -1350
  103. package/cli/commands/artifact.mjs +20 -0
  104. package/cli/commands/dash.mjs +160 -0
  105. package/cli/commands/headroom.mjs +204 -0
  106. package/cli/commands/install.mjs +169 -0
  107. package/cli/commands/memory.mjs +25 -0
  108. package/cli/commands/minimax.mjs +285 -0
  109. package/cli/commands/mod.mjs +185 -0
  110. package/cli/commands/service.mjs +65 -0
  111. package/cli/commands/usage.mjs +109 -0
  112. package/cli/commands/util.mjs +459 -0
  113. package/cli/digest.mjs +149 -0
  114. package/cli/doctor.mjs +1 -13
  115. package/cli/provision.mjs +2 -13
  116. package/cli/service-controller.mjs +1 -11
  117. package/cli/service.mjs +1 -11
  118. package/cli/utils.mjs +41 -1
  119. package/package.json +6 -1
  120. package/bizar-dash/dist/assets/main-B4OfGAwz.js +0 -361
  121. package/bizar-dash/dist/assets/main-B4OfGAwz.js.map +0 -1
  122. package/bizar-dash/dist/assets/main-DAlLdW8I.css +0 -1
  123. package/bizar-dash/dist/assets/mobile-lbH6szyX.js.map +0 -1
@@ -0,0 +1,621 @@
1
+ /**
2
+ * cli/artifact-render.mjs
3
+ *
4
+ * HTML rendering helpers, canvas state management, and MDX export.
5
+ * Extracted from artifact.mjs to separate rendering from CLI and server logic.
6
+ */
7
+ import {
8
+ readFileSync,
9
+ writeFileSync,
10
+ existsSync,
11
+ mkdirSync,
12
+ renameSync,
13
+ rmSync,
14
+ } from 'node:fs';
15
+ import { join, resolve, basename } from 'node:path';
16
+ import { dirname } from 'node:path';
17
+ import { fileURLToPath } from 'node:url';
18
+ import { spawn, spawnSync } from 'node:child_process';
19
+
20
+ const __dirname = dirname(fileURLToPath(import.meta.url));
21
+ const PROJECT_ROOT = resolve(__dirname, '..');
22
+ const TEMPLATES_DIR = join(PROJECT_ROOT, 'templates', 'artifact');
23
+ const PLANS_DIR = join(PROJECT_ROOT, 'artifacts');
24
+ const MAX_REQUEST_BODY_BYTES = 5 * 1024 * 1024;
25
+
26
+ // ─── Canvas schema version ─────────────────────────────────────────────────────
27
+
28
+ export const CANVAS_SCHEMA_VERSION = 2;
29
+
30
+ // ─── Canvas state ─────────────────────────────────────────────────────────────
31
+
32
+ /**
33
+ * The current canvas schema. v2 introduces elements, connections, viewport,
34
+ * and threaded comments. The single source of truth for new artifacts.
35
+ */
36
+ export function emptyCanvas(title) {
37
+ return {
38
+ schemaVersion: CANVAS_SCHEMA_VERSION,
39
+ title: title || 'Untitled artifact',
40
+ elements: [],
41
+ connections: [],
42
+ comments: [],
43
+ viewport: { x: 0, y: 0, zoom: 1 },
44
+ };
45
+ }
46
+
47
+ export function readCanvasFile(planDir) {
48
+ const path = join(planDir, 'artifact.json');
49
+ if (!existsSync(path)) return null;
50
+ try {
51
+ return JSON.parse(readFileSync(path, 'utf-8'));
52
+ } catch {
53
+ return null;
54
+ }
55
+ }
56
+
57
+ export function writeCanvasFile(planDir, canvas) {
58
+ atomicWriteJson(join(planDir, 'artifact.json'), canvas);
59
+ }
60
+
61
+ /**
62
+ * Migration shim: if a v1 artifact (artifact.mdx) exists but no artifact.json, build
63
+ * a v2 canvas with the mdx content as a single "text" element on the
64
+ * canvas. Idempotent — won't overwrite an existing artifact.json.
65
+ *
66
+ * Returns the resulting canvas.
67
+ */
68
+ export function loadOrMigrateCanvas(planDir, fallbackTitle) {
69
+ const existing = readCanvasFile(planDir);
70
+ if (existing) {
71
+ // Backfill defaults for older v2 files that may not have every field.
72
+ if (!Array.isArray(existing.elements)) existing.elements = [];
73
+ if (!Array.isArray(existing.connections)) existing.connections = [];
74
+ if (!Array.isArray(existing.comments)) existing.comments = [];
75
+ if (!existing.viewport || typeof existing.viewport !== 'object') {
76
+ existing.viewport = { x: 0, y: 0, zoom: 1 };
77
+ }
78
+ if (typeof existing.title !== 'string') existing.title = fallbackTitle || 'Untitled artifact';
79
+ if (existing.schemaVersion !== CANVAS_SCHEMA_VERSION) existing.schemaVersion = CANVAS_SCHEMA_VERSION;
80
+ return existing;
81
+ }
82
+
83
+ const mdxPath = join(planDir, 'artifact.mdx');
84
+ if (existsSync(mdxPath)) {
85
+ const mdx = readFileSync(mdxPath, 'utf-8');
86
+ // If the mdx is empty or just whitespace, return a blank canvas.
87
+ if (!mdx || !mdx.trim()) {
88
+ const c = emptyCanvas(fallbackTitle);
89
+ writeCanvasFile(planDir, c);
90
+ return c;
91
+ }
92
+ // Wrap the entire mdx into a single "text" element centered on the canvas.
93
+ const elId = 'el_' + Date.now().toString(36) + Math.random().toString(36).slice(2, 8);
94
+ const canvas = emptyCanvas(fallbackTitle);
95
+ canvas.elements.push({
96
+ id: elId,
97
+ type: 'text',
98
+ x: 80,
99
+ y: 80,
100
+ width: 560,
101
+ height: 420,
102
+ title: 'Migrated content',
103
+ content: mdx,
104
+ });
105
+ writeCanvasFile(planDir, canvas);
106
+ return canvas;
107
+ }
108
+
109
+ // No artifact.json and no artifact.mdx — start fresh.
110
+ const c = emptyCanvas(fallbackTitle);
111
+ writeCanvasFile(planDir, c);
112
+ return c;
113
+ }
114
+
115
+ /**
116
+ * Convert a v2 canvas state to a derived markdown document.
117
+ * Used by:
118
+ * - the /api/<slug>/markdown-export endpoint
119
+ * - the `bizar artifact export` subcommand (for backwards compat)
120
+ *
121
+ * Strategy: emit each element as a section, in a stable order (top-to-bottom
122
+ * by y, then left-to-right by x). Connections are not represented in the
123
+ * markdown — they're a visual concept.
124
+ */
125
+ export function canvasToMarkdown(canvas) {
126
+ const lines = [];
127
+ const title = (canvas && canvas.title) || 'Untitled artifact';
128
+ lines.push('# ' + title);
129
+ lines.push('');
130
+ lines.push('*Exported from canvas on ' + new Date().toISOString() + '*');
131
+ lines.push('');
132
+
133
+ const elements = (canvas && Array.isArray(canvas.elements)) ? canvas.elements.slice() : [];
134
+ // Sort by y, then x. Elements without y/x are placed at the end.
135
+ elements.sort(function (a, b) {
136
+ const ay = typeof a.y === 'number' ? a.y : 99999;
137
+ const by = typeof b.y === 'number' ? b.y : 99999;
138
+ if (ay !== by) return ay - by;
139
+ const ax = typeof a.x === 'number' ? a.x : 0;
140
+ const bx = typeof b.x === 'number' ? b.x : 0;
141
+ return ax - bx;
142
+ });
143
+
144
+ for (let i = 0; i < elements.length; i++) {
145
+ const el = elements[i];
146
+ if (el.title) {
147
+ lines.push('## ' + String(el.title));
148
+ lines.push('');
149
+ }
150
+ if (el.type === 'text') {
151
+ const content = typeof el.content === 'string' ? el.content : '';
152
+ if (content) {
153
+ lines.push(content);
154
+ lines.push('');
155
+ }
156
+ } else if (el.type === 'code') {
157
+ const lang = (el.language || '').trim();
158
+ lines.push('```' + lang);
159
+ lines.push(typeof el.content === 'string' ? el.content : '');
160
+ lines.push('```');
161
+ lines.push('');
162
+ } else if (el.type === 'image') {
163
+ const url = typeof el.content === 'string' ? el.content : '';
164
+ if (url) {
165
+ lines.push('![' + (el.title || 'image') + '](' + url + ')');
166
+ lines.push('');
167
+ }
168
+ } else if (el.type === 'diagram') {
169
+ const content = typeof el.content === 'string' ? el.content : '';
170
+ if (content) {
171
+ lines.push('```mermaid');
172
+ lines.push(content);
173
+ lines.push('```');
174
+ lines.push('');
175
+ }
176
+ } else if (el.type === 'ui-mockup') {
177
+ const component = (el.component || '').toLowerCase();
178
+ if (component === 'button') {
179
+ lines.push('> [' + (el.label || 'Button') + ']');
180
+ lines.push('');
181
+ } else if (component === 'input') {
182
+ lines.push('> Input(' + (el.placeholder || 'placeholder') + ') = "' + (el.value || '') + '"');
183
+ lines.push('');
184
+ } else if (component === 'card') {
185
+ lines.push('> **' + (el.title || 'Card') + '**');
186
+ if (el.body) lines.push('> ' + String(el.body).replace(/\n/g, '\n> '));
187
+ lines.push('');
188
+ } else {
189
+ lines.push('> [ui-mockup: ' + component + ']');
190
+ lines.push('');
191
+ }
192
+ } else {
193
+ const content = typeof el.content === 'string' ? el.content : '';
194
+ if (content) {
195
+ lines.push(content);
196
+ lines.push('');
197
+ }
198
+ }
199
+ }
200
+
201
+ // Comment summary at the end.
202
+ const comments = (canvas && Array.isArray(canvas.comments)) ? canvas.comments : [];
203
+ if (comments.length > 0) {
204
+ lines.push('## Notes');
205
+ lines.push('');
206
+ for (let i = 0; i < comments.length; i++) {
207
+ const c = comments[i];
208
+ const author = c.author || 'Anonymous';
209
+ const text = c.text || '';
210
+ lines.push('- **' + author + '**: ' + text);
211
+ if (Array.isArray(c.thread)) {
212
+ for (let j = 0; j < c.thread.length; j++) {
213
+ const reply = c.thread[j];
214
+ lines.push(' - **' + (reply.author || 'ai') + '**: ' + (reply.text || ''));
215
+ }
216
+ }
217
+ }
218
+ lines.push('');
219
+ }
220
+
221
+ return lines.join('\n');
222
+ }
223
+
224
+ // ─── ID generators ─────────────────────────────────────────────────────────────
225
+
226
+ export function makeElementId() {
227
+ return 'el_' + Date.now().toString(36) + Math.random().toString(36).slice(2, 8);
228
+ }
229
+ export function makeConnectionId() {
230
+ return 'conn_' + Date.now().toString(36) + Math.random().toString(36).slice(2, 8);
231
+ }
232
+ export function makeCommentId() {
233
+ return 'c_' + Date.now().toString(36) + Math.random().toString(36).slice(2, 8);
234
+ }
235
+ export function makeReplyId() {
236
+ return 'r_' + Date.now().toString(36) + Math.random().toString(36).slice(2, 8);
237
+ }
238
+
239
+ /** Best-effort read of artifacts/<slug>/meta.json. Returns null on missing or
240
+ * invalid JSON. Used by the canvas endpoints to surface the artifact title. */
241
+ export function readPlanMeta(planDir) {
242
+ const metaPath = join(planDir, 'meta.json');
243
+ if (!existsSync(metaPath)) return null;
244
+ try {
245
+ return JSON.parse(readFileSync(metaPath, 'utf-8'));
246
+ } catch {
247
+ return null;
248
+ }
249
+ }
250
+
251
+ // ─── HTML fragment helpers (for htmx) ─────────────────────────────────────────
252
+
253
+ /** Minimal HTML escaper — same rules as the client-side renderer. */
254
+ export function escapeHtml(s) {
255
+ return String(s)
256
+ .replace(/&/g, '&amp;')
257
+ .replace(/</g, '&lt;')
258
+ .replace(/>/g, '&gt;')
259
+ .replace(/"/g, '&quot;')
260
+ .replace(/'/g, '&#39;');
261
+ }
262
+
263
+ export function formatDate(iso) {
264
+ if (!iso) return '';
265
+ const d = new Date(iso);
266
+ if (isNaN(d.getTime())) return String(iso);
267
+ return d.toLocaleString();
268
+ }
269
+
270
+ /**
271
+ * Content negotiation: an htmx request prefers HTML fragments. JSON requests
272
+ * (explicit Accept: application/json, or no Accept header at all) get JSON
273
+ * for backwards compatibility with the AI tool, tests, and CLI scripts.
274
+ *
275
+ * Detection order:
276
+ * - If `HX-Request: true` is present → htmx (HTML)
277
+ * - If Accept header includes text/html (and not application/json) → htmx
278
+ * - Otherwise → JSON
279
+ */
280
+ export function isHtmxRequest(req) {
281
+ if ((req.headers['hx-request'] || '').toLowerCase() === 'true') return true;
282
+ const accept = (req.headers['accept'] || '').toLowerCase();
283
+ if (!accept) return false;
284
+ if (accept.includes('application/json') && !accept.includes('text/html')) return false;
285
+ if (accept.includes('text/html')) return true;
286
+ return false;
287
+ }
288
+
289
+ /**
290
+ * htmx form-encodes nested objects via JSON.stringify (see htmx 2.x `qn`
291
+ * function). When the body is form-encoded with nested-stringified values,
292
+ * walk the top-level keys and JSON-parse any value that looks like JSON
293
+ * (starts with `[` or `{`). This lets htmx POST/PUT complex state (the
294
+ * full canvas) without needing the `json-enc` extension.
295
+ */
296
+ export function decodeHtmxFormBody(data) {
297
+ if (!data || typeof data !== 'object') return data;
298
+ for (const k of Object.keys(data)) {
299
+ const v = data[k];
300
+ if (typeof v === 'string' && v.length > 0) {
301
+ const c = v.charAt(0);
302
+ if (c === '[' || c === '{') {
303
+ try { data[k] = JSON.parse(v); } catch { /* keep as string */ }
304
+ }
305
+ }
306
+ }
307
+ return data;
308
+ }
309
+
310
+ /** Short label for the element type, matches the client-side typeLabel(). */
311
+ export function elementTypeBadge(type) {
312
+ const t = (type || '').toLowerCase();
313
+ if (t === 'ui-mockup') return 'UI';
314
+ if (t === 'text') return 'TXT';
315
+ if (t === 'image') return 'IMG';
316
+ if (t === 'code') return 'CODE';
317
+ if (t === 'diagram') return 'DIAG';
318
+ return (type || '').toUpperCase();
319
+ }
320
+
321
+ /** Render the inner body of an element (text/image/code/diagram/ui-mockup). */
322
+ export function renderElementBody(e) {
323
+ if (e.type === 'text') {
324
+ return '<pre>' + escapeHtml(e.content || '') + '</pre>';
325
+ }
326
+ if (e.type === 'image') {
327
+ if (e.content) {
328
+ return '<img src="' + escapeHtml(e.content) + '" alt="' + escapeHtml(e.title || 'image') + '">';
329
+ }
330
+ return '<div class="muted">(no image URL set — double-click to edit)</div>';
331
+ }
332
+ if (e.type === 'code') {
333
+ const lang = e.language ? ' class="language-' + escapeHtml(e.language) + '"' : '';
334
+ return '<pre><code' + lang + '>' + escapeHtml(e.content || '') + '</code></pre>';
335
+ }
336
+ if (e.type === 'diagram') {
337
+ return '<pre class="mermaid">' + escapeHtml(e.content || '') + '</pre>';
338
+ }
339
+ if (e.type === 'ui-mockup') {
340
+ const comp = (e.component || '').toLowerCase();
341
+ if (comp === 'button') {
342
+ return '<button class="ui-mockup-button">' + escapeHtml(e.label || 'Button') + '</button>';
343
+ }
344
+ if (comp === 'input') {
345
+ const ph = escapeHtml(e.placeholder || '');
346
+ const val = escapeHtml(e.value || '');
347
+ return '<input class="ui-mockup-input" type="text" placeholder="' + ph + '" value="' + val + '">';
348
+ }
349
+ if (comp === 'card') {
350
+ const title = escapeHtml(e.title || 'Card');
351
+ const body = escapeHtml(e.body || '');
352
+ return '<div class="ui-mockup-card"><h4>' + title + '</h4><p>' + body + '</p></div>';
353
+ }
354
+ return '<div>Unknown ui-mockup component: ' + escapeHtml(comp) + '</div>';
355
+ }
356
+ return '<pre>' + escapeHtml(e.content || '') + '</pre>';
357
+ }
358
+
359
+ /**
360
+ * Render a v2 canvas element as an HTML fragment.
361
+ */
362
+ export function renderElementHTML(e) {
363
+ if (!e || !e.id) return '';
364
+ const id = escapeHtml(e.id);
365
+ const type = escapeHtml(e.type || '');
366
+ const title = escapeHtml(e.title || '');
367
+ const x = typeof e.x === 'number' ? e.x : 0;
368
+ const y = typeof e.y === 'number' ? e.y : 0;
369
+ const w = typeof e.width === 'number' ? e.width : 240;
370
+ const h = typeof e.height === 'number' ? e.height : 160;
371
+ const badge = escapeHtml(elementTypeBadge(e.type));
372
+ return '<div class="element"'
373
+ + ' data-element-id="' + id + '"'
374
+ + ' data-element-type="' + type + '"'
375
+ + ' style="left:' + x + 'px;top:' + y + 'px;width:' + w + 'px;height:' + h + 'px">'
376
+ + '<div class="element-header">'
377
+ + '<span class="type-badge">' + badge + '</span>'
378
+ + '<span class="title">' + title + '</span>'
379
+ + '<div class="actions">'
380
+ + '<button type="button" title="Edit content" data-action="edit-element">✎</button>'
381
+ + '<button type="button" title="Delete element" data-action="delete-element">🗑</button>'
382
+ + '</div>'
383
+ + '</div>'
384
+ + '<div class="element-body">' + renderElementBody(e) + '</div>'
385
+ + '<div class="resize-handle" title="Resize"></div>'
386
+ + '</div>';
387
+ }
388
+
389
+ /**
390
+ * Render a v2 canvas connection as an SVG fragment.
391
+ */
392
+ export function renderConnectionHTML(conn) {
393
+ if (!conn || !conn.id) return '';
394
+ return '<g class="connection" data-connection-id="' + escapeHtml(conn.id) + '"'
395
+ + ' data-from="' + escapeHtml(conn.from) + '"'
396
+ + ' data-to="' + escapeHtml(conn.to) + '"'
397
+ + ' data-type="' + escapeHtml(conn.type || 'arrow') + '"'
398
+ + (conn.label ? ' data-label="' + escapeHtml(conn.label) + '"' : '')
399
+ + '></g>';
400
+ }
401
+
402
+ /**
403
+ * Render a v2 canvas comment pin as an HTML fragment.
404
+ */
405
+ export function renderCommentPinHTML(c, indexHint) {
406
+ if (!c || !c.id) return '';
407
+ const id = escapeHtml(c.id);
408
+ const x = typeof c.x === 'number' ? c.x : 0;
409
+ const y = typeof c.y === 'number' ? c.y : 0;
410
+ const label = typeof indexHint === 'number' ? (indexHint + 1) : '?';
411
+ const tip = escapeHtml((c.text || '').slice(0, 80));
412
+ return '<div class="comment-pin"'
413
+ + ' data-comment-id="' + id + '"'
414
+ + ' title="' + tip + '"'
415
+ + ' style="left:' + x + 'px;top:' + y + 'px">' + label + '</div>';
416
+ }
417
+
418
+ /** Render a single reply as an HTML fragment (used in the side panel). */
419
+ export function renderReplyHTML(r) {
420
+ if (!r) return '';
421
+ const author = escapeHtml(r.author || 'ai');
422
+ const created = escapeHtml(formatDate(r.created));
423
+ const text = escapeHtml(r.text || '');
424
+ const rid = r.id ? ' id="reply-' + escapeHtml(r.id) + '"' : '';
425
+ return '<li class="reply"' + rid + '>'
426
+ + '<div class="reply-meta">' + author + ' · ' + created + '</div>'
427
+ + '<div class="reply-text">' + text + '</div>'
428
+ + '</li>';
429
+ }
430
+
431
+ /**
432
+ * Render the entire comment (header + thread) as a fragment for the side panel.
433
+ */
434
+ export function renderCommentThreadHTML(c) {
435
+ if (!c) return '';
436
+ const author = escapeHtml(c.author || 'Anonymous');
437
+ const created = escapeHtml(formatDate(c.created));
438
+ const text = escapeHtml(c.text || '');
439
+ const thread = Array.isArray(c.thread) ? c.thread : [];
440
+ const replies = thread.map(renderReplyHTML).join('');
441
+ return '<li class="comment" id="comment-' + escapeHtml(c.id) + '">'
442
+ + '<div class="comment-meta">' + author + ' · ' + created + '</div>'
443
+ + '<div class="comment-text">' + text + '</div>'
444
+ + replies
445
+ + '</li>';
446
+ }
447
+
448
+ /** Render a single comment as an <li> fragment (no wrapper). */
449
+ export function renderCommentLi(c) {
450
+ return '<li class="comment" id="comment-' + escapeHtml(c.id) + '">'
451
+ + '<div class="comment-meta">' + escapeHtml(c.author || 'Anonymous')
452
+ + ' · ' + escapeHtml(formatDate(c.timestamp || c.created)) + '</div>'
453
+ + '<div class="comment-text">' + escapeHtml(c.text || '') + '</div>'
454
+ + '</li>';
455
+ }
456
+
457
+ /** Render a comments list as <li> elements. If empty, returns the empty marker. */
458
+ export function renderCommentListHtml(comments, sectionId) {
459
+ const items = comments
460
+ .filter((c) => !sectionId || c.sectionId === sectionId)
461
+ .sort((a, b) => String(a.timestamp || a.created || '').localeCompare(String(b.timestamp || b.created || '')));
462
+ if (items.length === 0) {
463
+ return '<li class="empty" data-empty>No comments yet — be the first.</li>';
464
+ }
465
+ return items.map(renderCommentLi).join('');
466
+ }
467
+
468
+ /** Render just the count badge for a section, used to update the comment button. */
469
+ export function renderCommentCountHtml(count) {
470
+ return '<span class="count">' + count + '</span>';
471
+ }
472
+
473
+ /** Parse an HTTP request body. Supports:
474
+ * - application/x-www-form-urlencoded (htmx default for <form>)
475
+ * - application/json (legacy / direct API)
476
+ * - text/plain (raw MDX for artifact save)
477
+ * - anything else: returns raw string
478
+ */
479
+ export function readRequestBody(req) {
480
+ return new Promise((resolve, reject) => {
481
+ let body = '';
482
+ let bodySize = 0;
483
+ let tooLarge = false;
484
+ req.on('data', (chunk) => {
485
+ if (tooLarge) return;
486
+ bodySize += chunk.length;
487
+ if (bodySize > MAX_REQUEST_BODY_BYTES) {
488
+ tooLarge = true;
489
+ reject(new Error(`Request body too large (max ${MAX_REQUEST_BODY_BYTES} bytes)`));
490
+ return;
491
+ }
492
+ body += chunk;
493
+ });
494
+ req.on('end', () => {
495
+ if (tooLarge) return;
496
+ const ct = (req.headers['content-type'] || '').toLowerCase();
497
+ try {
498
+ if (ct.includes('application/x-www-form-urlencoded')) {
499
+ const params = new URLSearchParams(body);
500
+ const obj = {};
501
+ for (const [k, v] of params) obj[k] = v;
502
+ resolve({ kind: 'form', data: obj, raw: body });
503
+ } else if (ct.includes('application/json')) {
504
+ resolve({ kind: 'json', data: body ? JSON.parse(body) : {}, raw: body });
505
+ } else {
506
+ resolve({ kind: 'raw', data: body, raw: body });
507
+ }
508
+ } catch (err) {
509
+ reject(err);
510
+ }
511
+ });
512
+ req.on('error', reject);
513
+ });
514
+ }
515
+
516
+ /** Update the meta.json's lastEdited timestamp; ignores errors so a single bad
517
+ * meta doesn't block the rest of the save. */
518
+ export function bumpLastEdited(planDir) {
519
+ const metaPath = join(planDir, 'meta.json');
520
+ if (!existsSync(metaPath)) return;
521
+ try {
522
+ const meta = JSON.parse(readFileSync(metaPath, 'utf-8'));
523
+ meta.lastEdited = new Date().toISOString();
524
+ atomicWriteJson(metaPath, meta);
525
+ } catch { /* swallow */ }
526
+ }
527
+
528
+ // ─── Browser opening ──────────────────────────────────────────────────────────
529
+
530
+ function isWSL() {
531
+ try {
532
+ const version = readFileSync('/proc/version', 'utf8');
533
+ if (version.toLowerCase().includes('microsoft')) return true;
534
+ } catch { /* not WSL */ }
535
+ return !!process.env.WSL_INTEROP;
536
+ }
537
+
538
+ export function openBrowser(url) {
539
+ const platform = process.platform;
540
+ const wsl = isWSL();
541
+ let cmd, args;
542
+ if (platform === 'darwin') {
543
+ cmd = 'open';
544
+ args = [url];
545
+ } else if (platform === 'win32' || wsl) {
546
+ cmd = 'cmd';
547
+ args = ['/c', 'start', '""', url];
548
+ } else {
549
+ cmd = 'xdg-open';
550
+ args = [url];
551
+ }
552
+
553
+ // Check if the command exists
554
+ const probe = spawnSync('which', [cmd], { stdio: 'ignore' });
555
+ if (probe.status !== 0) {
556
+ console.log(` ℹ Open ${url} in your browser (no ${cmd} available)`);
557
+ return false;
558
+ }
559
+
560
+ try {
561
+ const child = spawn(cmd, args, { detached: true, stdio: 'ignore' });
562
+ child.on('error', (err) => {
563
+ console.log(` ℹ Could not open browser: ${err.message}`);
564
+ console.log(` Open manually: ${url}`);
565
+ });
566
+ child.unref();
567
+ return true;
568
+ } catch (err) {
569
+ console.log(` ℹ Could not open browser: ${err.message}`);
570
+ console.log(` Open manually: ${url}`);
571
+ return false;
572
+ }
573
+ }
574
+
575
+ // ─── File helpers ──────────────────────────────────────────────────────────────
576
+
577
+ export function replaceTemplate(content, vars) {
578
+ let result = content;
579
+ for (const [key, value] of Object.entries(vars)) {
580
+ result = result.replace(new RegExp(`\\{\\{${key}\\}\\}`, 'g'), value);
581
+ }
582
+ return result;
583
+ }
584
+
585
+ export async function readTemplate(name) {
586
+ const path = join(TEMPLATES_DIR, name);
587
+ if (!existsSync(path)) {
588
+ throw new Error(`Template not found: ${path}`);
589
+ }
590
+ return readFileSync(path, 'utf-8');
591
+ }
592
+
593
+ export function atomicWriteText(filePath, content) {
594
+ mkdirSync(dirname(filePath), { recursive: true });
595
+ const tmpPath = `${filePath}.tmp.${process.pid}.${Date.now()}`;
596
+ try {
597
+ writeFileSync(tmpPath, content, 'utf-8');
598
+ renameSync(tmpPath, filePath);
599
+ } catch (error) {
600
+ try {
601
+ rmSync(tmpPath, { force: true });
602
+ } catch {
603
+ // ignore cleanup failure
604
+ }
605
+ throw error;
606
+ }
607
+ }
608
+
609
+ export function atomicWriteJson(filePath, value) {
610
+ atomicWriteText(filePath, JSON.stringify(value, null, 2));
611
+ }
612
+
613
+ export function writePlanFile(slug, filename, content) {
614
+ const dir = join(PLANS_DIR, slug);
615
+ mkdirSync(dir, { recursive: true });
616
+ atomicWriteText(join(dir, filename), content);
617
+ }
618
+
619
+ export function readPlanFile(slug, filename) {
620
+ return readFileSync(join(PLANS_DIR, slug, filename), 'utf-8');
621
+ }