@polderlabs/bizar 2.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (85) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +364 -0
  3. package/cli/audit.mjs +144 -0
  4. package/cli/banner.mjs +41 -0
  5. package/cli/bin.mjs +186 -0
  6. package/cli/copy.mjs +508 -0
  7. package/cli/export.mjs +87 -0
  8. package/cli/init.mjs +147 -0
  9. package/cli/install.mjs +390 -0
  10. package/cli/plan-templates.mjs +523 -0
  11. package/cli/plan.mjs +2087 -0
  12. package/cli/prompts.mjs +163 -0
  13. package/cli/update.mjs +273 -0
  14. package/cli/utils.mjs +153 -0
  15. package/config/AGENTS.md +282 -0
  16. package/config/agents/baldr.md +148 -0
  17. package/config/agents/forseti.md +112 -0
  18. package/config/agents/frigg.md +101 -0
  19. package/config/agents/heimdall.md +157 -0
  20. package/config/agents/hermod.md +144 -0
  21. package/config/agents/mimir.md +115 -0
  22. package/config/agents/odin.md +309 -0
  23. package/config/agents/quick.md +78 -0
  24. package/config/agents/semble-search.md +44 -0
  25. package/config/agents/thor.md +97 -0
  26. package/config/agents/tyr.md +96 -0
  27. package/config/agents/vidarr.md +100 -0
  28. package/config/agents/vor.md +140 -0
  29. package/config/commands/audit.md +1 -0
  30. package/config/commands/explain.md +1 -0
  31. package/config/commands/init.md +1 -0
  32. package/config/commands/learn.md +1 -0
  33. package/config/commands/pr-review.md +1 -0
  34. package/config/commands/tailscale-serve.md +96 -0
  35. package/config/hooks/README.md +29 -0
  36. package/config/hooks/post-tool-use.md +16 -0
  37. package/config/hooks/pre-tool-use.md +16 -0
  38. package/config/opencode.json +52 -0
  39. package/config/opencode.json.template +52 -0
  40. package/config/rules/general.md +8 -0
  41. package/config/rules/git.md +11 -0
  42. package/config/rules/javascript.md +10 -0
  43. package/config/rules/python.md +10 -0
  44. package/config/rules/testing.md +10 -0
  45. package/config/skills/bizar/README.md +9 -0
  46. package/config/skills/bizar/SKILL.md +187 -0
  47. package/config/skills/cpp-coding-standards/README.md +28 -0
  48. package/config/skills/cpp-coding-standards/SKILL.md +634 -0
  49. package/config/skills/cpp-coding-standards/agents/openai.yaml +4 -0
  50. package/config/skills/cpp-coding-standards/references/concurrency.md +320 -0
  51. package/config/skills/cpp-coding-standards/references/error-handling.md +229 -0
  52. package/config/skills/cpp-coding-standards/references/memory-safety.md +216 -0
  53. package/config/skills/cpp-coding-standards/references/modern-idioms.md +282 -0
  54. package/config/skills/cpp-coding-standards/references/review-checklist.md +96 -0
  55. package/config/skills/cpp-testing/README.md +28 -0
  56. package/config/skills/cpp-testing/SKILL.md +304 -0
  57. package/config/skills/cpp-testing/agents/openai.yaml +4 -0
  58. package/config/skills/cpp-testing/references/coverage.md +370 -0
  59. package/config/skills/cpp-testing/references/framework-compare.md +175 -0
  60. package/config/skills/cpp-testing/references/host-test-for-embedded.md +499 -0
  61. package/config/skills/cpp-testing/references/mocking.md +364 -0
  62. package/config/skills/cpp-testing/references/tdd-workflow.md +308 -0
  63. package/config/skills/embedded-esp-idf/README.md +41 -0
  64. package/config/skills/embedded-esp-idf/SKILL.md +439 -0
  65. package/config/skills/embedded-esp-idf/agents/openai.yaml +4 -0
  66. package/config/skills/embedded-esp-idf/references/freertos-patterns.md +214 -0
  67. package/config/skills/embedded-esp-idf/references/host-tests.md +164 -0
  68. package/config/skills/embedded-esp-idf/references/idf-py-commands.md +157 -0
  69. package/config/skills/embedded-esp-idf/references/kconfig.md +159 -0
  70. package/config/skills/embedded-esp-idf/references/logging-discipline.md +118 -0
  71. package/config/skills/embedded-esp-idf/references/memory-and-iram.md +137 -0
  72. package/config/skills/embedded-esp-idf/references/nvs.md +121 -0
  73. package/config/skills/embedded-esp-idf/references/packed-structs.md +192 -0
  74. package/config/skills/embedded-esp-idf/scripts/idf_env.sh +47 -0
  75. package/config/skills/embedded-esp-idf/scripts/size_check.sh +77 -0
  76. package/config/skills/self-improvement/SKILL.md +64 -0
  77. package/package.json +47 -0
  78. package/templates/plan/htmx.min.js +1 -0
  79. package/templates/plan/library/bug-investigation.mdx +79 -0
  80. package/templates/plan/library/decision-record.mdx +71 -0
  81. package/templates/plan/library/feature-design.mdx +92 -0
  82. package/templates/plan/meta.json.template +8 -0
  83. package/templates/plan/plan.canvas.template +1711 -0
  84. package/templates/plan/plan.html.template +937 -0
  85. package/templates/plan/plan.mdx.template +46 -0
@@ -0,0 +1,1711 @@
1
+ <!doctype html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="utf-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1">
6
+ <title>{{title}} — Bizar Plan</title>
7
+
8
+ <!-- Self-hosted htmx (no CDN) — used for the comment side panel where
9
+ it shines. Canvas interactions (pan/zoom/drag) are vanilla JS. -->
10
+ <script src="/htmx.min.js"></script>
11
+
12
+ <style>
13
+ /* ============================================================
14
+ Theme tokens (match the v1 viewer for visual continuity)
15
+ ============================================================ */
16
+ :root {
17
+ --color-bg: #ffffff;
18
+ --color-text: #1a1a1a;
19
+ --color-muted: #6b7280;
20
+ --color-border: #e5e7eb;
21
+ --color-accent: #3b82f6;
22
+ --color-accent-hover: #2563eb;
23
+ --color-code-bg: #f3f4f6;
24
+ --color-card-bg: #ffffff;
25
+ --color-card-shadow: rgba(0, 0, 0, 0.08);
26
+ --color-button-text: #1a1a1a;
27
+ --color-primary-text: #ffffff;
28
+ --color-save-ok: #10b981;
29
+ --color-save-err: #ef4444;
30
+ --color-pin: #f59e0b;
31
+ --color-pin-hover: #d97706;
32
+ --color-connection: #6b7280;
33
+ --color-connection-arrow: #3b82f6;
34
+ --color-canvas-bg: #f8fafc;
35
+ --color-canvas-grid: #e2e8f0;
36
+ --color-element-selected: #3b82f6;
37
+ --font-sans: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
38
+ --font-mono: ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, monospace;
39
+ }
40
+
41
+ @media (prefers-color-scheme: dark) {
42
+ :root {
43
+ --color-bg: #0f172a;
44
+ --color-text: #e2e8f0;
45
+ --color-muted: #94a3b8;
46
+ --color-border: #334155;
47
+ --color-accent: #60a5fa;
48
+ --color-accent-hover: #93c5fd;
49
+ --color-code-bg: #1e293b;
50
+ --color-card-bg: #1e293b;
51
+ --color-card-shadow: rgba(0, 0, 0, 0.4);
52
+ --color-button-text: #e2e8f0;
53
+ --color-primary-text: #0f172a;
54
+ --color-save-ok: #34d399;
55
+ --color-save-err: #f87171;
56
+ --color-pin: #fbbf24;
57
+ --color-pin-hover: #f59e0b;
58
+ --color-connection: #94a3b8;
59
+ --color-connection-arrow: #60a5fa;
60
+ --color-canvas-bg: #0b1220;
61
+ --color-canvas-grid: #1e293b;
62
+ --color-element-selected: #60a5fa;
63
+ }
64
+ }
65
+
66
+ /* ============================================================
67
+ Base
68
+ ============================================================ */
69
+ *, *::before, *::after { box-sizing: border-box; }
70
+ html, body { height: 100%; margin: 0; padding: 0; }
71
+ body {
72
+ font-family: var(--font-sans);
73
+ background: var(--color-bg);
74
+ color: var(--color-text);
75
+ line-height: 1.5;
76
+ overflow: hidden; /* the canvas handles its own scrolling */
77
+ }
78
+ button { font: inherit; cursor: pointer; }
79
+
80
+ /* ============================================================
81
+ Layout
82
+ ============================================================ */
83
+ .app { display: flex; flex-direction: column; height: 100vh; }
84
+ .header {
85
+ display: flex; align-items: center; gap: 0.75rem;
86
+ padding: 0.5rem 1rem;
87
+ border-bottom: 1px solid var(--color-border);
88
+ background: var(--color-card-bg);
89
+ flex-shrink: 0;
90
+ z-index: 30;
91
+ }
92
+ .header h1 { margin: 0; font-size: 1.05em; font-weight: 600; }
93
+ .header .meta {
94
+ color: var(--color-muted); font-size: 0.85em; margin-left: 0.5rem;
95
+ overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
96
+ }
97
+ .header-actions { margin-left: auto; display: flex; gap: 0.5rem; align-items: center; }
98
+ .save-status { font-size: 0.8em; color: var(--color-muted); margin-left: 0.5rem; }
99
+ .save-status.saved { color: var(--color-save-ok); }
100
+ .save-status.error { color: var(--color-save-err); }
101
+
102
+ /* ============================================================
103
+ Toolbar
104
+ ============================================================ */
105
+ .toolbar {
106
+ display: flex; align-items: center; gap: 0.5rem;
107
+ padding: 0.5rem 1rem;
108
+ border-bottom: 1px solid var(--color-border);
109
+ background: var(--color-card-bg);
110
+ flex-shrink: 0;
111
+ z-index: 20;
112
+ }
113
+ .toolbar button {
114
+ border: 1px solid var(--color-border);
115
+ background: var(--color-card-bg);
116
+ color: var(--color-button-text);
117
+ padding: 0.4rem 0.75rem;
118
+ border-radius: 6px;
119
+ font-size: 0.9em;
120
+ transition: background 0.15s, border-color 0.15s, color 0.15s;
121
+ }
122
+ .toolbar button:hover { background: var(--color-code-bg); }
123
+ .toolbar button:focus-visible { outline: 2px solid var(--color-accent); outline-offset: 2px; }
124
+ .toolbar button.active {
125
+ background: var(--color-accent);
126
+ color: var(--color-primary-text);
127
+ border-color: var(--color-accent);
128
+ }
129
+ .toolbar .sep {
130
+ width: 1px; height: 1.5rem; background: var(--color-border); margin: 0 0.25rem;
131
+ }
132
+ .toolbar .zoom-controls { display: flex; align-items: center; gap: 0.25rem; }
133
+ .toolbar .zoom-controls .zoom-level { font-size: 0.8em; color: var(--color-muted); min-width: 3.5em; text-align: center; }
134
+ .toolbar .spacer { flex: 1; }
135
+ .toolbar .view-toggle { font-size: 0.85em; color: var(--color-muted); }
136
+
137
+ /* ============================================================
138
+ Canvas (infinite scrollable surface)
139
+ ============================================================ */
140
+ .canvas-wrap {
141
+ position: relative; flex: 1; min-height: 0;
142
+ background: var(--color-canvas-bg);
143
+ overflow: hidden;
144
+ }
145
+ .canvas {
146
+ position: absolute; top: 0; left: 0;
147
+ width: 5000px; height: 5000px; /* large virtual area */
148
+ transform-origin: 0 0;
149
+ will-change: transform;
150
+ }
151
+ .canvas.panning { cursor: grabbing; }
152
+ .canvas.space-down { cursor: grab; }
153
+
154
+ /* Subtle dot grid background, painted via SVG via background-image fallback */
155
+ .canvas::before {
156
+ content: ''; position: absolute; top: 0; left: 0; right: 0; bottom: 0;
157
+ background-image: radial-gradient(circle, var(--color-canvas-grid) 1px, transparent 1px);
158
+ background-size: 24px 24px;
159
+ pointer-events: none;
160
+ opacity: 0.6;
161
+ }
162
+
163
+ /* SVG layer for connections — sits behind elements but in front of the grid */
164
+ #connections-layer {
165
+ position: absolute; top: 0; left: 0;
166
+ width: 100%; height: 100%;
167
+ pointer-events: none; /* let the canvas receive pan events; individual
168
+ <path>s re-enable pointer-events: stroke below */
169
+ z-index: 1;
170
+ }
171
+ #connections-layer path {
172
+ pointer-events: stroke;
173
+ cursor: pointer;
174
+ }
175
+ #connections-layer .connection-hit { stroke-width: 14; stroke: transparent; fill: none; }
176
+ #connections-layer .connection-line { stroke: var(--color-connection); stroke-width: 2; fill: none; }
177
+ #connections-layer .connection-arrow.arrow { marker-end: url(#arrowhead); stroke: var(--color-connection-arrow); }
178
+ #connections-layer .connection-arrow.dependency { marker-end: url(#arrowhead-dep); stroke-dasharray: 6 4; stroke: var(--color-connection); }
179
+ #connections-layer .connection-label { fill: var(--color-text); font: 12px var(--font-sans); paint-order: stroke; stroke: var(--color-card-bg); stroke-width: 4; }
180
+
181
+ /* ============================================================
182
+ Elements
183
+ ============================================================ */
184
+ .element {
185
+ position: absolute;
186
+ background: var(--color-card-bg);
187
+ border: 1px solid var(--color-border);
188
+ border-radius: 6px;
189
+ box-shadow: 0 1px 3px var(--color-card-shadow);
190
+ display: flex; flex-direction: column;
191
+ overflow: hidden;
192
+ z-index: 5;
193
+ min-width: 80px; min-height: 40px;
194
+ }
195
+ .element:hover { box-shadow: 0 2px 8px var(--color-card-shadow); }
196
+ .element.selected { border-color: var(--color-element-selected); box-shadow: 0 0 0 2px var(--color-element-selected); }
197
+ .element.connect-source { border-color: var(--color-connection-arrow); }
198
+
199
+ .element-header {
200
+ display: flex; align-items: center; gap: 0.4rem;
201
+ padding: 0.35rem 0.5rem;
202
+ background: var(--color-code-bg);
203
+ border-bottom: 1px solid var(--color-border);
204
+ cursor: move;
205
+ font-size: 0.85em;
206
+ user-select: none;
207
+ }
208
+ .element-header .type-badge {
209
+ font-size: 0.75em; color: var(--color-muted);
210
+ background: var(--color-bg);
211
+ border: 1px solid var(--color-border);
212
+ border-radius: 4px;
213
+ padding: 0.1em 0.4em;
214
+ flex-shrink: 0;
215
+ }
216
+ .element-header .title {
217
+ flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
218
+ }
219
+ .element-header .actions { display: flex; gap: 0.25rem; flex-shrink: 0; }
220
+ .element-header .actions button {
221
+ background: transparent; border: 0; padding: 0.1rem 0.35rem;
222
+ color: var(--color-muted); font-size: 0.85em; cursor: pointer;
223
+ border-radius: 3px;
224
+ }
225
+ .element-header .actions button:hover { background: var(--color-border); color: var(--color-text); }
226
+
227
+ .element-body { flex: 1; overflow: auto; padding: 0.5rem 0.75rem; }
228
+ .element-body pre {
229
+ margin: 0; font-family: var(--font-mono); font-size: 0.85em;
230
+ white-space: pre-wrap; word-wrap: break-word;
231
+ }
232
+ .element-body img { max-width: 100%; height: auto; display: block; }
233
+ .element-body code { font-family: var(--font-mono); font-size: 0.85em; }
234
+ .element-body .ui-mockup-button {
235
+ display: inline-block; padding: 0.4rem 1rem;
236
+ background: var(--color-accent); color: var(--color-primary-text);
237
+ border: 0; border-radius: 4px; font-size: 0.95em;
238
+ }
239
+ .element-body .ui-mockup-input {
240
+ display: block; width: 100%; padding: 0.4rem 0.6rem;
241
+ font: inherit; border: 1px solid var(--color-border); border-radius: 4px;
242
+ background: var(--color-bg); color: var(--color-text);
243
+ }
244
+ .element-body .ui-mockup-card {
245
+ padding: 0.75rem; border: 1px solid var(--color-border); border-radius: 6px;
246
+ }
247
+ .element-body .ui-mockup-card h4 { margin: 0 0 0.5rem; font-size: 1em; }
248
+ .element-body .ui-mockup-card p { margin: 0; color: var(--color-muted); }
249
+
250
+ .element-body[contenteditable="true"] { outline: 2px solid var(--color-accent); outline-offset: -2px; }
251
+
252
+ .resize-handle {
253
+ position: absolute; right: 0; bottom: 0; width: 12px; height: 12px;
254
+ cursor: nwse-resize;
255
+ background: linear-gradient(135deg, transparent 50%, var(--color-muted) 50%);
256
+ }
257
+
258
+ /* ============================================================
259
+ Comment pins
260
+ ============================================================ */
261
+ .comment-pin {
262
+ position: absolute;
263
+ width: 26px; height: 26px;
264
+ border-radius: 50%;
265
+ background: var(--color-pin);
266
+ color: #fff;
267
+ display: flex; align-items: center; justify-content: center;
268
+ font-size: 0.8em; font-weight: 600;
269
+ cursor: grab;
270
+ box-shadow: 0 1px 4px rgba(0,0,0,0.2);
271
+ z-index: 10;
272
+ user-select: none;
273
+ }
274
+ .comment-pin:hover { background: var(--color-pin-hover); }
275
+ .comment-pin.active {
276
+ outline: 3px solid var(--color-accent);
277
+ outline-offset: 2px;
278
+ }
279
+ .comment-pin.dragging { cursor: grabbing; }
280
+
281
+ /* ============================================================
282
+ Comment side panel
283
+ ============================================================ */
284
+ .comment-panel {
285
+ position: fixed; top: 0; right: 0;
286
+ width: 420px; max-width: 100vw; height: 100vh;
287
+ background: var(--color-card-bg);
288
+ box-shadow: -2px 0 12px var(--color-card-shadow);
289
+ transform: translateX(100%);
290
+ transition: transform 0.25s ease-out;
291
+ z-index: 100;
292
+ display: flex; flex-direction: column;
293
+ box-sizing: border-box;
294
+ }
295
+ .comment-panel.open { transform: translateX(0); }
296
+ .comment-panel-header {
297
+ display: flex; justify-content: space-between; align-items: center;
298
+ padding: 1rem 1.25rem;
299
+ border-bottom: 1px solid var(--color-border);
300
+ flex-shrink: 0;
301
+ }
302
+ .comment-panel-header h3 { margin: 0; font-size: 1em; }
303
+ #close-comment-panel {
304
+ background: transparent; border: 0; font-size: 1.4em; line-height: 1;
305
+ padding: 0.25rem 0.5rem; color: var(--color-muted); cursor: pointer;
306
+ }
307
+ #close-comment-panel:hover { color: var(--color-text); }
308
+
309
+ .comment-list { list-style: none; padding: 0; margin: 0; overflow-y: auto; flex: 1; }
310
+ .comment { padding: 0.75rem 1.25rem; border-bottom: 1px solid var(--color-border); }
311
+ .comment-meta { font-size: 0.8em; color: var(--color-muted); margin-bottom: 0.25rem; }
312
+ .comment-text { white-space: pre-wrap; word-wrap: break-word; }
313
+ .reply {
314
+ margin-top: 0.5rem; margin-left: 1rem;
315
+ padding-left: 0.75rem; border-left: 2px solid var(--color-accent);
316
+ }
317
+ .reply-meta { font-size: 0.75em; color: var(--color-muted); margin-bottom: 0.15rem; }
318
+ .reply-text { white-space: pre-wrap; word-wrap: break-word; font-size: 0.9em; }
319
+ .empty { color: var(--color-muted); text-align: center; padding: 2rem 1rem; font-style: italic; list-style: none; }
320
+
321
+ .comment-form {
322
+ border-top: 1px solid var(--color-border);
323
+ padding: 0.75rem 1.25rem;
324
+ flex-shrink: 0;
325
+ }
326
+ .comment-form textarea {
327
+ width: 100%; min-height: 70px; font: inherit;
328
+ background: var(--color-bg); color: var(--color-text);
329
+ border: 1px solid var(--color-border); border-radius: 6px;
330
+ padding: 0.5rem; resize: vertical; box-sizing: border-box;
331
+ }
332
+ .comment-form-actions {
333
+ display: flex; gap: 0.5rem; align-items: center; margin-top: 0.5rem;
334
+ }
335
+ .comment-form .hint { font-size: 0.8em; color: var(--color-muted); margin-left: auto; }
336
+
337
+ /* ============================================================
338
+ Modal: add/edit element
339
+ ============================================================ */
340
+ .modal-backdrop {
341
+ position: fixed; top: 0; left: 0; right: 0; bottom: 0;
342
+ background: rgba(0,0,0,0.4);
343
+ display: none; align-items: center; justify-content: center;
344
+ z-index: 200;
345
+ }
346
+ .modal-backdrop.open { display: flex; }
347
+ .modal {
348
+ background: var(--color-card-bg);
349
+ border-radius: 8px;
350
+ padding: 1.25rem;
351
+ width: 480px; max-width: 92vw;
352
+ max-height: 90vh; overflow-y: auto;
353
+ box-shadow: 0 8px 32px rgba(0,0,0,0.2);
354
+ }
355
+ .modal h2 { margin: 0 0 1rem; font-size: 1.1em; }
356
+ .modal label { display: block; margin-bottom: 0.75rem; font-size: 0.9em; }
357
+ .modal label span { display: block; margin-bottom: 0.25rem; color: var(--color-muted); font-size: 0.85em; }
358
+ .modal input, .modal textarea, .modal select {
359
+ width: 100%; padding: 0.4rem 0.6rem; font: inherit;
360
+ border: 1px solid var(--color-border); border-radius: 4px;
361
+ background: var(--color-bg); color: var(--color-text);
362
+ box-sizing: border-box;
363
+ }
364
+ .modal textarea { min-height: 100px; resize: vertical; }
365
+ .modal-actions { display: flex; gap: 0.5rem; justify-content: flex-end; margin-top: 1rem; }
366
+ .modal button {
367
+ padding: 0.45rem 0.9rem; border: 1px solid var(--color-border);
368
+ background: var(--color-card-bg); color: var(--color-button-text);
369
+ border-radius: 4px; cursor: pointer;
370
+ }
371
+ .modal button.primary { background: var(--color-accent); color: var(--color-primary-text); border-color: var(--color-accent); }
372
+ .modal button:hover { background: var(--color-code-bg); }
373
+ .modal button.primary:hover { background: var(--color-accent-hover); border-color: var(--color-accent-hover); }
374
+
375
+ /* ============================================================
376
+ Misc
377
+ ============================================================ */
378
+ .empty-canvas {
379
+ position: absolute; top: 50%; left: 50%;
380
+ transform: translate(-50%, -50%);
381
+ color: var(--color-muted); font-style: italic;
382
+ pointer-events: none;
383
+ text-align: center;
384
+ }
385
+ .empty-canvas.hidden { display: none; }
386
+
387
+ /* hide the cursor while we draw a connection line */
388
+ .canvas.connecting { cursor: crosshair; }
389
+
390
+ @media (max-width: 700px) {
391
+ .comment-panel { width: 100%; }
392
+ .header h1 { font-size: 0.95em; }
393
+ .toolbar button { padding: 0.35rem 0.5rem; font-size: 0.85em; }
394
+ }
395
+ </style>
396
+ </head>
397
+ <body>
398
+ <div class="app">
399
+ <!-- ── Header ────────────────────────────────────────────────── -->
400
+ <header class="header">
401
+ <h1 id="header-title">{{title}}</h1>
402
+ <span class="meta" id="header-meta">
403
+ Slug: {{slug}} · Status: {{status}} · Author: {{author}}
404
+ </span>
405
+ <div class="header-actions">
406
+ <button id="export-md-btn" type="button" title="Export as Markdown">📤 Export</button>
407
+ <span id="save-status" class="save-status" aria-live="polite"></span>
408
+ </div>
409
+ </header>
410
+
411
+ <!-- ── Toolbar ───────────────────────────────────────────────── -->
412
+ <div class="toolbar" role="toolbar" aria-label="Canvas tools">
413
+ <button data-tool="select" class="active" title="Select / pan (Esc)">↖ Select</button>
414
+ <button data-tool="text" title="Add a text block (T)">📝 Text</button>
415
+ <button data-tool="image" title="Add an image (I)">🖼 Image</button>
416
+ <button data-tool="code" title="Add a code block (C)">💻 Code</button>
417
+ <button data-tool="diagram" title="Add a mermaid diagram (D)">📊 Diagram</button>
418
+ <button data-tool="ui-mockup" title="Add a UI mockup (U)">🎨 UI Mockup</button>
419
+ <span class="sep"></span>
420
+ <button data-tool="connect" title="Draw a connection between elements (L)">↔ Connect</button>
421
+ <button data-tool="comment" title="Click to drop a comment pin (M)">💬 Comment</button>
422
+ <span class="spacer"></span>
423
+ <div class="zoom-controls" aria-label="Zoom">
424
+ <button id="zoom-out" type="button" title="Zoom out (-)">−</button>
425
+ <span class="zoom-level" id="zoom-level">100%</span>
426
+ <button id="zoom-in" type="button" title="Zoom in (+)">+</button>
427
+ <button id="zoom-reset" type="button" title="Reset zoom (0)">⤢</button>
428
+ </div>
429
+ </div>
430
+
431
+ <!-- ── Canvas ────────────────────────────────────────────────── -->
432
+ <div class="canvas-wrap" id="canvas-wrap">
433
+ <div class="canvas" id="canvas">
434
+ <svg id="connections-layer" xmlns="http://www.w3.org/2000/svg">
435
+ <defs>
436
+ <marker id="arrowhead" viewBox="0 0 10 10" refX="9" refY="5"
437
+ markerWidth="8" markerHeight="8" orient="auto-start-reverse">
438
+ <path d="M 0 0 L 10 5 L 0 10 z" fill="var(--color-connection-arrow)"></path>
439
+ </marker>
440
+ <marker id="arrowhead-dep" viewBox="0 0 10 10" refX="9" refY="5"
441
+ markerWidth="8" markerHeight="8" orient="auto-start-reverse">
442
+ <path d="M 0 0 L 10 5 L 0 10 z" fill="var(--color-connection)"></path>
443
+ </marker>
444
+ </defs>
445
+ <g id="connections-g"></g>
446
+ <g id="pending-connection-g"></g>
447
+ </svg>
448
+ <div id="elements-layer" aria-label="Canvas elements"></div>
449
+ <div id="comments-layer" aria-label="Canvas comments"></div>
450
+ <div class="empty-canvas" id="empty-canvas-msg">
451
+ Empty canvas — pick a tool from the toolbar to add an element.
452
+ </div>
453
+ </div>
454
+ </div>
455
+ </div>
456
+
457
+ <!-- ── Comment side panel ──────────────────────────────────────── -->
458
+ <aside id="comment-panel" class="comment-panel" aria-hidden="true">
459
+ <div class="comment-panel-header">
460
+ <h3>Comments <span id="comment-target" style="font-weight: normal; color: var(--color-muted);"></span></h3>
461
+ <button id="close-comment-panel" type="button" aria-label="Close comments">×</button>
462
+ </div>
463
+ <ul id="comment-list" class="comment-list"></ul>
464
+ <!-- Reply form: htmx PUTs to /api/<slug>/comments/<id>; the response is
465
+ the full updated <li class="comment"> thread which htmx swaps in
466
+ place of the current thread (outerHTML on #comment-list).
467
+ The hx-put URL is updated by JS via setAttribute() when a
468
+ comment is opened. The placeholder hx-put here keeps the form
469
+ semantically valid (and testable) before any comment is opened. -->
470
+ <form class="comment-form" id="comment-form" autocomplete="off"
471
+ hx-put="/api/{{slug}}/comments/_"
472
+ hx-target="#comment-list"
473
+ hx-swap="outerHTML"
474
+ hx-trigger="submit">
475
+ <textarea name="reply" id="new-comment" placeholder="Reply or add a comment…"></textarea>
476
+ <input type="hidden" name="replyAuthor" id="reply-author">
477
+ <div class="comment-form-actions">
478
+ <button type="submit" class="primary" id="comment-submit">Add</button>
479
+ <span class="hint">⌘/Ctrl+Enter</span>
480
+ </div>
481
+ </form>
482
+ </aside>
483
+
484
+ <!-- ── Add element modal ──────────────────────────────────────── -->
485
+ <div class="modal-backdrop" id="modal-backdrop" aria-hidden="true">
486
+ <div class="modal" role="dialog" aria-labelledby="modal-title">
487
+ <h2 id="modal-title">Add element</h2>
488
+ <!-- htmx attributes are set by JS based on whether we're adding or
489
+ editing (hx-post vs hx-put). hx-target is the elements layer so
490
+ the new element is appended. hx-swap="beforeend" only applies on
491
+ POST; for PUT (edit) we use "outerHTML" via JS (see submitElementForm). -->
492
+ <form id="element-form"
493
+ hx-post="/api/{{slug}}/elements"
494
+ hx-target="#elements-layer"
495
+ hx-swap="beforeend"
496
+ hx-on::after-request="if(event.detail.successful &amp;&amp; !window.__elementEditId) { this.dispatchEvent(new CustomEvent('element-added', {bubbles:true, detail:event.detail})); }">
497
+ <label id="modal-type-row" hidden>
498
+ <span>Type</span>
499
+ <select id="el-type" name="type">
500
+ <option value="text">Text</option>
501
+ <option value="image">Image</option>
502
+ <option value="code">Code</option>
503
+ <option value="diagram">Mermaid diagram</option>
504
+ <option value="ui-mockup">UI mockup</option>
505
+ </select>
506
+ </label>
507
+ <label>
508
+ <span>Title (optional)</span>
509
+ <input type="text" id="el-title" name="title" placeholder="A short label">
510
+ </label>
511
+ <label id="el-language-row" hidden>
512
+ <span>Language (for code blocks)</span>
513
+ <input type="text" id="el-language" name="language" placeholder="javascript">
514
+ </label>
515
+ <label id="el-content-row">
516
+ <span id="el-content-label">Content</span>
517
+ <textarea id="el-content" name="content" placeholder="Write the text content…"></textarea>
518
+ </label>
519
+ <label id="el-component-row" hidden>
520
+ <span>UI component</span>
521
+ <select id="el-component" name="component">
522
+ <option value="button">Button</option>
523
+ <option value="input">Input field</option>
524
+ <option value="card">Card</option>
525
+ </select>
526
+ </label>
527
+ <label id="el-label-row" hidden>
528
+ <span>Button label</span>
529
+ <input type="text" id="el-label" name="label" placeholder="Submit">
530
+ </label>
531
+ <label id="el-placeholder-row" hidden>
532
+ <span>Input placeholder</span>
533
+ <input type="text" id="el-placeholder" name="placeholder" placeholder="Email">
534
+ </label>
535
+ <label id="el-value-row" hidden>
536
+ <span>Input value</span>
537
+ <input type="text" id="el-value" name="value" placeholder="">
538
+ </label>
539
+ <label id="el-card-body-row" hidden>
540
+ <span>Card body</span>
541
+ <textarea id="el-card-body" name="body" placeholder="Body text"></textarea>
542
+ </label>
543
+ <!-- x/y/width/height are filled in by JS just before submit so the
544
+ new element lands in the centre of the current view. -->
545
+ <input type="hidden" id="el-x" name="x">
546
+ <input type="hidden" id="el-y" name="y">
547
+ <input type="hidden" id="el-width" name="width">
548
+ <input type="hidden" id="el-height" name="height">
549
+ <div class="modal-actions">
550
+ <button type="button" id="modal-cancel">Cancel</button>
551
+ <button type="submit" class="primary" id="modal-ok">Add</button>
552
+ </div>
553
+ </form>
554
+ </div>
555
+ </div>
556
+
557
+ <!-- ── Embedded state (baked in via {{…}} substitution) ─────── -->
558
+ <script id="meta-source" type="application/json">{{metaJson}}</script>
559
+ <script id="canvas-source" type="application/json">{{canvasJson}}</script>
560
+
561
+ <script>
562
+ /* ============================================================
563
+ * Bizar Plan — Canvas viewer
564
+ *
565
+ * State model (v2):
566
+ * - elements: positioned, draggable cards (text/image/code/diagram/ui-mockup)
567
+ * - connections: SVG arrows/lines between two element ids
568
+ * - comments: pins placed at (x,y) on the canvas, optionally
569
+ * attached to an elementId; each comment has a thread
570
+ * of replies.
571
+ * - viewport: { x, y, zoom } — pan & zoom state
572
+ *
573
+ * Interaction model:
574
+ * - Pan: hold space + drag, or middle-mouse drag
575
+ * - Zoom: Ctrl+scroll, or +/-/reset buttons
576
+ * - Drag: grab the element header and drag
577
+ * - Edit: double-click an element to edit content inline
578
+ * - Connect: switch to Connect tool, click first element, then second
579
+ * - Comment: switch to Comment tool, click anywhere on the canvas
580
+ *
581
+ * Persistence: every change autosaves via PUT /api/<slug>/canvas.
582
+ * ============================================================ */
583
+ (function () {
584
+ 'use strict';
585
+
586
+ // ── Embedded state (baked in via {{…}}) ─────────────────────
587
+ var META = {{metaJson}};
588
+ var INITIAL_CANVAS = {{canvasJson}};
589
+
590
+ var SLUG = (META && META.slug) || '{{slug}}';
591
+ var TITLE = (META && META.title) || '{{title}}';
592
+ var AUTHOR = (META && META.author) || (typeof process !== 'undefined' && process.env && process.env.USER) || 'anonymous';
593
+
594
+ // ── In-memory state ─────────────────────────────────────────
595
+ // Live state — what the UI renders from, and what we PUT back.
596
+ var state = normalizeCanvas(INITIAL_CANVAS, TITLE);
597
+ var activeTool = 'select';
598
+ var selectedElementId = null;
599
+ var activeCommentId = null; // currently-open comment in side panel
600
+ var connectFromId = null; // first element of a pending connection
601
+ var pendingPin = null; // { x, y } while user clicks to drop a pin
602
+ var spaceDown = false; // spacebar held = pan mode
603
+ var panState = null; // { startX, startY, origX, origY }
604
+ var resizeState = null; // { id, startX, startY, origW, origH }
605
+
606
+ // ── DOM helpers ─────────────────────────────────────────────
607
+ function $(sel) { return document.querySelector(sel); }
608
+ function $$(sel) { return Array.prototype.slice.call(document.querySelectorAll(sel)); }
609
+ function el(tag, attrs, children) {
610
+ var node = document.createElement(tag);
611
+ if (attrs) for (var k in attrs) {
612
+ if (k === 'class') node.className = attrs[k];
613
+ else if (k === 'text') node.textContent = attrs[k];
614
+ else if (k.indexOf('on') === 0) node.addEventListener(k.slice(2), attrs[k]);
615
+ else if (k === 'dataset') for (var d in attrs[k]) node.dataset[d] = attrs[k][d];
616
+ else node.setAttribute(k, attrs[k]);
617
+ }
618
+ if (children) {
619
+ if (!Array.isArray(children)) children = [children];
620
+ for (var i = 0; i < children.length; i++) {
621
+ var c = children[i];
622
+ if (c == null) continue;
623
+ if (typeof c === 'string') node.appendChild(document.createTextNode(c));
624
+ else node.appendChild(c);
625
+ }
626
+ }
627
+ return node;
628
+ }
629
+ function svgEl(tag, attrs) {
630
+ var node = document.createElementNS('http://www.w3.org/2000/svg', tag);
631
+ if (attrs) for (var k in attrs) node.setAttribute(k, attrs[k]);
632
+ return node;
633
+ }
634
+ function escapeHtml(s) {
635
+ return String(s == null ? '' : s)
636
+ .replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
637
+ .replace(/"/g, '&quot;').replace(/'/g, '&#39;');
638
+ }
639
+
640
+ // ── State normalization ─────────────────────────────────────
641
+ function normalizeCanvas(raw, fallbackTitle) {
642
+ if (raw == null || typeof raw !== 'object') {
643
+ return emptyCanvas(fallbackTitle);
644
+ }
645
+ var c = {
646
+ schemaVersion: raw.schemaVersion || 2,
647
+ title: typeof raw.title === 'string' ? raw.title : (fallbackTitle || 'Untitled'),
648
+ elements: Array.isArray(raw.elements) ? raw.elements : [],
649
+ connections: Array.isArray(raw.connections) ? raw.connections : [],
650
+ comments: Array.isArray(raw.comments) ? raw.comments : [],
651
+ viewport: raw.viewport && typeof raw.viewport === 'object'
652
+ ? raw.viewport : { x: 0, y: 0, zoom: 1 },
653
+ };
654
+ return c;
655
+ }
656
+ function emptyCanvas(title) {
657
+ return {
658
+ schemaVersion: 2,
659
+ title: title || 'Untitled',
660
+ elements: [], connections: [], comments: [],
661
+ viewport: { x: 0, y: 0, zoom: 1 },
662
+ };
663
+ }
664
+
665
+ // ── Save status indicator ───────────────────────────────────
666
+ function setSaveStatus(text, kind) {
667
+ var s = $('#save-status');
668
+ if (!s) return;
669
+ s.textContent = text || '';
670
+ s.className = 'save-status' + (kind ? ' ' + kind : '');
671
+ }
672
+ var saveDebounce = null;
673
+ function scheduleSave() {
674
+ if (saveDebounce) clearTimeout(saveDebounce);
675
+ setSaveStatus('Saving…');
676
+ saveDebounce = setTimeout(saveNow, 500);
677
+ }
678
+ function saveNow() {
679
+ saveDebounce = null;
680
+ // htmx.ajax does the PUT for us. The server returns a saved-status
681
+ // <span> which htmx swaps into #save-status. On error, htmx fires
682
+ // htmx:responseError; we listen for it in wire() to show the message.
683
+ try {
684
+ window.htmx.ajax('PUT', '/api/' + encodeURIComponent(SLUG) + '/canvas', {
685
+ source: '#save-status',
686
+ target: '#save-status',
687
+ swap: 'innerHTML',
688
+ values: state,
689
+ headers: { 'Content-Type': 'application/json' },
690
+ });
691
+ } catch (err) {
692
+ setSaveStatus('Save failed: ' + err.message, 'error');
693
+ }
694
+ }
695
+
696
+ // ── Coordinate conversion ───────────────────────────────────
697
+ // The canvas is a 5000×5000 surface translated by viewport.x, viewport.y
698
+ // and scaled by viewport.zoom. Anything inside the canvas is in
699
+ // "world" coordinates; clicks/mouse events arrive in "screen" coords.
700
+ function screenToWorld(sx, sy) {
701
+ var r = $('#canvas').getBoundingClientRect();
702
+ // r.left/r.top is the top-left of the canvas in screen coords.
703
+ // World point of (sx, sy) is:
704
+ // worldX = (sx - r.left - viewport.x) / zoom
705
+ // worldY = (sy - r.top - viewport.y) / zoom
706
+ var wx = (sx - r.left - state.viewport.x) / state.viewport.zoom;
707
+ var wy = (sy - r.top - state.viewport.y) / state.viewport.zoom;
708
+ return { x: wx, y: wy };
709
+ }
710
+ function applyViewport() {
711
+ var canvas = $('#canvas');
712
+ canvas.style.transform =
713
+ 'translate(' + state.viewport.x + 'px, ' + state.viewport.y + 'px) scale(' + state.viewport.zoom + ')';
714
+ $('#zoom-level').textContent = Math.round(state.viewport.zoom * 100) + '%';
715
+ }
716
+
717
+ // ── Element rendering ───────────────────────────────────────
718
+ function findElement(id) {
719
+ for (var i = 0; i < state.elements.length; i++) {
720
+ if (state.elements[i].id === id) return state.elements[i];
721
+ }
722
+ return null;
723
+ }
724
+ function findComment(id) {
725
+ for (var i = 0; i < state.comments.length; i++) {
726
+ if (state.comments[i].id === id) return state.comments[i];
727
+ }
728
+ return null;
729
+ }
730
+ function renderBody(e) {
731
+ var body = el('div', { class: 'element-body' });
732
+ if (e.type === 'text') {
733
+ // Render the text as preformatted so multi-line content is preserved
734
+ body.appendChild(el('pre', { text: e.content || '' }));
735
+ } else if (e.type === 'image') {
736
+ if (e.content) body.appendChild(el('img', { src: e.content, alt: e.title || 'image' }));
737
+ else body.appendChild(el('div', { text: '(no image URL set — double-click to edit)', class: 'muted' }));
738
+ } else if (e.type === 'code') {
739
+ var pre = el('pre', null, [el('code', { class: e.language ? 'language-' + e.language : '', text: e.content || '' })]);
740
+ body.appendChild(pre);
741
+ } else if (e.type === 'diagram') {
742
+ var code = el('pre', { class: 'mermaid', text: e.content || '' });
743
+ body.appendChild(code);
744
+ } else if (e.type === 'ui-mockup') {
745
+ var comp = (e.component || '').toLowerCase();
746
+ if (comp === 'button') {
747
+ body.appendChild(el('button', { class: 'ui-mockup-button', text: e.label || 'Button' }));
748
+ } else if (comp === 'input') {
749
+ body.appendChild(el('input', {
750
+ class: 'ui-mockup-input', type: 'text',
751
+ placeholder: e.placeholder || '',
752
+ value: e.value || '',
753
+ }));
754
+ } else if (comp === 'card') {
755
+ body.appendChild(el('div', { class: 'ui-mockup-card' }, [
756
+ el('h4', { text: e.title || 'Card' }),
757
+ el('p', { text: e.body || '' }),
758
+ ]));
759
+ } else {
760
+ body.appendChild(el('div', { text: 'Unknown ui-mockup component: ' + comp }));
761
+ }
762
+ } else {
763
+ body.appendChild(el('pre', { text: e.content || '' }));
764
+ }
765
+ return body;
766
+ }
767
+ function renderElement(e) {
768
+ var node = el('div', {
769
+ class: 'element' + (e.id === selectedElementId ? ' selected' : '')
770
+ + (e.id === connectFromId ? ' connect-source' : ''),
771
+ dataset: { elementId: e.id, elementType: e.type },
772
+ });
773
+ node.style.left = e.x + 'px';
774
+ node.style.top = e.y + 'px';
775
+ node.style.width = e.width + 'px';
776
+ node.style.height = e.height + 'px';
777
+
778
+ var typeBadge = el('span', { class: 'type-badge', text: typeLabel(e.type) });
779
+ var title = el('span', { class: 'title', text: e.title || '' });
780
+ // Edit/Delete buttons now use data-action so event delegation in
781
+ // wire() can pick them up (works for htmx-injected elements too).
782
+ var actions = el('div', { class: 'actions' }, [
783
+ el('button', { type: 'button', title: 'Edit content',
784
+ 'data-action': 'edit-element', text: '✎' }),
785
+ el('button', { type: 'button', title: 'Delete element',
786
+ 'data-action': 'delete-element', text: '🗑' }),
787
+ ]);
788
+ var header = el('div', { class: 'element-header' }, [typeBadge, title, actions]);
789
+ // Drag: pointerdown on header starts drag
790
+ header.addEventListener('pointerdown', function (ev) {
791
+ if (ev.button !== 0) return;
792
+ // Ignore drags that start on action buttons (they have their own handlers)
793
+ if (ev.target.closest('button')) return;
794
+ ev.stopPropagation();
795
+ startElementDrag(e.id, ev);
796
+ });
797
+ node.appendChild(header);
798
+ node.appendChild(renderBody(e));
799
+
800
+ // Click on element: select it (so connection tool knows the source)
801
+ node.addEventListener('click', function (ev) {
802
+ if (ev.target.closest('button') || ev.target.closest('input,textarea,select')) return;
803
+ ev.stopPropagation();
804
+ handleElementClick(e);
805
+ });
806
+
807
+ // Double-click: edit content
808
+ node.addEventListener('dblclick', function (ev) {
809
+ ev.stopPropagation();
810
+ openElementEditor(e);
811
+ });
812
+
813
+ // Resize handle
814
+ var handle = el('div', { class: 'resize-handle', title: 'Resize' });
815
+ handle.addEventListener('pointerdown', function (ev) {
816
+ if (ev.button !== 0) return;
817
+ ev.stopPropagation();
818
+ startElementResize(e.id, ev);
819
+ });
820
+ node.appendChild(handle);
821
+
822
+ return node;
823
+ }
824
+ function typeLabel(t) {
825
+ if (t === 'ui-mockup') return 'UI';
826
+ if (t === 'text') return 'TXT';
827
+ if (t === 'image') return 'IMG';
828
+ if (t === 'code') return 'CODE';
829
+ if (t === 'diagram') return 'DIAG';
830
+ return (t || '').toUpperCase();
831
+ }
832
+
833
+ // ── Re-extract an element's state from its DOM node (used after htmx
834
+ // has appended server-rendered HTML so local state stays in sync). ──
835
+ function readElementFromNode(node) {
836
+ if (!node) return null;
837
+ return {
838
+ id: node.dataset.elementId,
839
+ type: node.dataset.elementType,
840
+ x: parseFloat(node.style.left) || 0,
841
+ y: parseFloat(node.style.top) || 0,
842
+ width: parseFloat(node.style.width) || 240,
843
+ height: parseFloat(node.style.height) || 160,
844
+ // Content fields are not in data-attributes (could be large); the
845
+ // existing in-memory copy is authoritative for content edits.
846
+ // Walk the in-memory state to find the matching id — but since
847
+ // the server just made it, it won't be there yet. Caller must
848
+ // merge with `props` if it has them.
849
+ };
850
+ }
851
+
852
+ /** Re-draw only the connections layer (cheaper than full rerender). */
853
+ function rerenderConnections() { renderConnections(); }
854
+
855
+ /** Re-draw only the comment pins (cheaper than full rerender). */
856
+ function rerenderComments() {
857
+ var layer = $('#comments-layer');
858
+ if (!layer) return;
859
+ // Replace the entire layer's children with what we know about.
860
+ // htmx has already appended the new pin; we need to re-attach
861
+ // drag handlers via delegation (already wired in wire()).
862
+ layer.innerHTML = '';
863
+ for (var j = 0; j < state.comments.length; j++) {
864
+ var pin = renderCommentPin(state.comments[j]);
865
+ layer.appendChild(pin);
866
+ }
867
+ // Active highlight
868
+ $$('.comment-pin').forEach(function (p) {
869
+ p.classList.toggle('active', p.dataset.commentId === activeCommentId);
870
+ });
871
+ }
872
+ function handleElementClick(e) {
873
+ if (activeTool === 'connect') {
874
+ if (!connectFromId) {
875
+ connectFromId = e.id;
876
+ rerender();
877
+ } else if (connectFromId !== e.id) {
878
+ // Create the connection
879
+ addConnection(connectFromId, e.id);
880
+ connectFromId = null;
881
+ activeTool = 'select';
882
+ updateToolbar();
883
+ rerender();
884
+ }
885
+ return;
886
+ }
887
+ selectedElementId = e.id;
888
+ rerender();
889
+ }
890
+
891
+ // ── Comment rendering ───────────────────────────────────────
892
+ function renderCommentPin(c) {
893
+ var pin = el('div', {
894
+ class: 'comment-pin' + (c.id === activeCommentId ? ' active' : ''),
895
+ dataset: { commentId: c.id },
896
+ title: (c.text || '').slice(0, 80),
897
+ text: commentIndex(c.id),
898
+ });
899
+ pin.style.left = c.x + 'px';
900
+ pin.style.top = c.y + 'px';
901
+ pin.addEventListener('pointerdown', function (ev) {
902
+ if (ev.button !== 0) return;
903
+ ev.stopPropagation();
904
+ startPinDrag(c.id, ev);
905
+ });
906
+ pin.addEventListener('click', function (ev) {
907
+ ev.stopPropagation();
908
+ openCommentPanel(c.id);
909
+ });
910
+ return pin;
911
+ }
912
+ function commentIndex(commentId) {
913
+ // 1-based number for visual identification (sorted by created time).
914
+ var sorted = state.comments.slice().sort(function (a, b) {
915
+ return String(a.created || '').localeCompare(String(b.created || ''));
916
+ });
917
+ for (var i = 0; i < sorted.length; i++) {
918
+ if (sorted[i].id === commentId) return i + 1;
919
+ }
920
+ return '?';
921
+ }
922
+ function startPinDrag(commentId, ev) {
923
+ var c = findComment(commentId);
924
+ if (!c) return;
925
+ var pin = ev.currentTarget;
926
+ pin.classList.add('dragging');
927
+ var pointerId = ev.pointerId;
928
+ pin.setPointerCapture(pointerId);
929
+ var startWorld = screenToWorld(ev.clientX, ev.clientY);
930
+ var orig = { x: c.x, y: c.y };
931
+ function onMove(e) {
932
+ var w = screenToWorld(e.clientX, e.clientY);
933
+ c.x = Math.round(orig.x + (w.x - startWorld.x));
934
+ c.y = Math.round(orig.y + (w.y - startWorld.y));
935
+ // Move the pin directly without a full rerender
936
+ var node = document.querySelector('.comment-pin[data-comment-id="' + c.id + '"]');
937
+ if (node) { node.style.left = c.x + 'px'; node.style.top = c.y + 'px'; }
938
+ }
939
+ function onUp() {
940
+ pin.releasePointerCapture(pointerId);
941
+ pin.classList.remove('dragging');
942
+ pin.removeEventListener('pointermove', onMove);
943
+ pin.removeEventListener('pointerup', onUp);
944
+ pin.removeEventListener('pointercancel', onUp);
945
+ scheduleSave();
946
+ }
947
+ pin.addEventListener('pointermove', onMove);
948
+ pin.addEventListener('pointerup', onUp);
949
+ pin.addEventListener('pointercancel', onUp);
950
+ }
951
+
952
+ // ── Connection rendering (SVG) ──────────────────────────────
953
+ function elementCenter(e) {
954
+ // Center in world coordinates. The element is positioned at (e.x, e.y)
955
+ // with size (e.width, e.height). Center is (e.x + e.w/2, e.y + e.h/2).
956
+ return {
957
+ x: (e.x || 0) + (e.width || 0) / 2,
958
+ y: (e.y || 0) + (e.height || 0) / 2,
959
+ };
960
+ }
961
+ function renderConnections() {
962
+ var g = $('#connections-g');
963
+ g.innerHTML = '';
964
+ for (var i = 0; i < state.connections.length; i++) {
965
+ var c = state.connections[i];
966
+ var from = findElement(c.from);
967
+ var to = findElement(c.to);
968
+ if (!from || !to) continue; // skip orphans
969
+ var a = elementCenter(from);
970
+ var b = elementCenter(to);
971
+ // The SVG has its own viewBox = canvas size (5000x5000) so we draw
972
+ // in world coordinates directly. (The transform on the parent .canvas
973
+ // applies uniformly to the SVG and the divs.)
974
+ g.appendChild(svgEl('path', {
975
+ class: 'connection-hit',
976
+ d: 'M ' + a.x + ' ' + a.y + ' L ' + b.x + ' ' + b.y,
977
+ }));
978
+ var lineClass = 'connection-line connection-arrow ' + (c.type || 'arrow');
979
+ g.appendChild(svgEl('path', {
980
+ class: lineClass,
981
+ d: 'M ' + a.x + ' ' + a.y + ' L ' + b.x + ' ' + b.y,
982
+ dataset: { connectionId: c.id },
983
+ }));
984
+ if (c.label) {
985
+ var mid = { x: (a.x + b.x) / 2, y: (a.y + b.y) / 2 - 8 };
986
+ var text = svgEl('text', {
987
+ class: 'connection-label',
988
+ x: mid.x, y: mid.y,
989
+ 'text-anchor': 'middle',
990
+ });
991
+ text.textContent = c.label;
992
+ g.appendChild(text);
993
+ }
994
+ }
995
+ }
996
+
997
+ // ── Server interactions (via htmx.ajax — declarative data layer) ─────
998
+ // These functions used to call fetch() directly; now they delegate
999
+ // to htmx which posts the request and swaps the server's HTML
1000
+ // fragment into the appropriate layer. We listen for htmx:afterRequest
1001
+ // to capture the new entity's id (returned via HX-Trigger-After-Swap)
1002
+ // and update local state for the controller layer to know about.
1003
+
1004
+ /**
1005
+ * Add a new element. The server returns the <div class="element"> HTML
1006
+ * which htmx appends to #elements-layer (target + swap are on the
1007
+ * server's element-form). This function is called from the modal
1008
+ * submit flow after the form has set hidden x/y/width/height.
1009
+ */
1010
+ function addElement(type, props) {
1011
+ var body = Object.assign({ type: type }, props || {});
1012
+ // Trigger htmx POST programmatically with the values as the form body.
1013
+ return new Promise(function (resolve) {
1014
+ // Listen for the htmx:afterRequest to capture the new id.
1015
+ var id = null;
1016
+ var oneOff = function (ev) {
1017
+ document.body.removeEventListener('htmx:afterRequest', oneOff);
1018
+ var resp = ev.detail && ev.detail.xhr && ev.detail.xhr.response;
1019
+ if (resp && typeof resp === 'string') {
1020
+ var m = resp.match(/data-element-id="([^"]+)"/);
1021
+ if (m) id = m[1];
1022
+ }
1023
+ if (id) {
1024
+ // Push the new element into local state by re-extracting from
1025
+ // the DOM htmx just appended. This keeps state in sync with
1026
+ // what the server actually wrote.
1027
+ var node = document.querySelector('[data-element-id="' + id + '"]');
1028
+ if (node) state.elements.push(readElementFromNode(node));
1029
+ resolve(state.elements[state.elements.length - 1]);
1030
+ } else {
1031
+ setSaveStatus('Add failed: no element id in response', 'error');
1032
+ resolve(null);
1033
+ }
1034
+ };
1035
+ document.body.addEventListener('htmx:afterRequest', oneOff);
1036
+ // Fire the POST through htmx.
1037
+ window.htmx.ajax('POST', '/api/' + encodeURIComponent(SLUG) + '/elements', {
1038
+ target: '#elements-layer',
1039
+ swap: 'beforeend',
1040
+ values: body,
1041
+ });
1042
+ });
1043
+ }
1044
+
1045
+ /** Delete an element. htmx removes the DOM node; we update state after. */
1046
+ function deleteElement(id) {
1047
+ var node = document.querySelector('[data-element-id="' + id + '"]');
1048
+ if (node) {
1049
+ // Use htmx.ajax with swap='delete' so the node is removed on 200.
1050
+ window.htmx.ajax('DELETE',
1051
+ '/api/' + encodeURIComponent(SLUG) + '/elements/' + encodeURIComponent(id),
1052
+ { target: node, swap: 'delete' });
1053
+ }
1054
+ // Update local state immediately (cascade is server-side).
1055
+ state.elements = state.elements.filter(function (e) { return e.id !== id; });
1056
+ state.connections = state.connections.filter(function (c) { return c.from !== id && c.to !== id; });
1057
+ for (var i = 0; i < state.comments.length; i++) {
1058
+ if (state.comments[i].elementId === id) state.comments[i].elementId = null;
1059
+ }
1060
+ if (selectedElementId === id) selectedElementId = null;
1061
+ }
1062
+
1063
+ /** Add a connection. Server returns an SVG <g data-connection-id> fragment. */
1064
+ function addConnection(fromId, toId, type) {
1065
+ var body = { from: fromId, to: toId, type: type || 'arrow' };
1066
+ var oneOff = function (ev) {
1067
+ document.body.removeEventListener('htmx:afterRequest', oneOff);
1068
+ var resp = ev.detail && ev.detail.xhr && ev.detail.xhr.response;
1069
+ if (resp && typeof resp === 'string') {
1070
+ var m = resp.match(/data-connection-id="([^"]+)"/);
1071
+ if (m) {
1072
+ state.connections.push({
1073
+ id: m[1], from: fromId, to: toId, type: type || 'arrow',
1074
+ });
1075
+ rerenderConnections(); // re-draw with real geometry
1076
+ }
1077
+ }
1078
+ };
1079
+ document.body.addEventListener('htmx:afterRequest', oneOff);
1080
+ window.htmx.ajax('POST', '/api/' + encodeURIComponent(SLUG) + '/connections', {
1081
+ target: '#connections-g',
1082
+ swap: 'beforeend',
1083
+ values: body,
1084
+ });
1085
+ }
1086
+
1087
+ /** Add a comment pin. Server returns the pin <div> appended to the layer. */
1088
+ function addComment(x, y, text, elementId) {
1089
+ var body = { x: x, y: y, text: text, author: AUTHOR, elementId: elementId || null };
1090
+ var oneOff = function (ev) {
1091
+ document.body.removeEventListener('htmx:afterRequest', oneOff);
1092
+ var resp = ev.detail && ev.detail.xhr && ev.detail.xhr.response;
1093
+ var newId = null;
1094
+ if (resp && typeof resp === 'string') {
1095
+ var m = resp.match(/data-comment-id="([^"]+)"/);
1096
+ if (m) newId = m[1];
1097
+ }
1098
+ if (newId) {
1099
+ state.comments.push({
1100
+ id: newId, x: x, y: y, text: text,
1101
+ author: AUTHOR, elementId: elementId || null,
1102
+ created: new Date().toISOString(), thread: [],
1103
+ });
1104
+ rerenderComments();
1105
+ openCommentPanel(newId);
1106
+ } else {
1107
+ setSaveStatus('Comment failed: no id in response', 'error');
1108
+ }
1109
+ };
1110
+ document.body.addEventListener('htmx:afterRequest', oneOff);
1111
+ window.htmx.ajax('POST', '/api/' + encodeURIComponent(SLUG) + '/comments', {
1112
+ target: '#comments-layer',
1113
+ swap: 'beforeend',
1114
+ values: body,
1115
+ });
1116
+ }
1117
+
1118
+ // ── Element editor (double-click to edit) ───────────────────
1119
+ function openElementEditor(e) {
1120
+ // Open the modal in edit mode. Reuse the add flow by setting hidden
1121
+ // fields and filling in current values.
1122
+ var m = $('#modal-backdrop');
1123
+ $('#modal-title').textContent = 'Edit element';
1124
+ $('#modal-ok').textContent = 'Save';
1125
+ $('#el-type').value = e.type;
1126
+ $('#el-type').disabled = true; // can't change type on edit
1127
+ $('#modal-type-row').hidden = false;
1128
+ $('#el-title').value = e.title || '';
1129
+ $('#el-language').value = e.language || '';
1130
+ $('#el-content').value = e.content || '';
1131
+ $('#el-component').value = e.component || 'button';
1132
+ $('#el-label').value = e.label || '';
1133
+ $('#el-placeholder').value = e.placeholder || '';
1134
+ $('#el-value').value = e.value || '';
1135
+ $('#el-card-body').value = e.body || '';
1136
+ syncEditorFieldsForType(e.type);
1137
+ m.dataset.editElementId = e.id;
1138
+ m.classList.add('open');
1139
+ m.setAttribute('aria-hidden', 'false');
1140
+ setTimeout(function () { $('#el-title').focus(); }, 50);
1141
+ }
1142
+ function syncEditorFieldsForType(t) {
1143
+ $('#el-language-row').hidden = (t !== 'code');
1144
+ $('#el-component-row').hidden = (t !== 'ui-mockup');
1145
+ $('#el-label-row').hidden = !(t === 'ui-mockup' && $('#el-component').value === 'button');
1146
+ $('#el-placeholder-row').hidden = !(t === 'ui-mockup' && $('#el-component').value === 'input');
1147
+ $('#el-value-row').hidden = !(t === 'ui-mockup' && $('#el-component').value === 'input');
1148
+ $('#el-card-body-row').hidden = !(t === 'ui-mockup' && $('#el-component').value === 'card');
1149
+ var labelText = t === 'code' ? 'Code' : t === 'image' ? 'Image URL' : t === 'diagram' ? 'Mermaid source' : 'Content';
1150
+ $('#el-content-label').textContent = labelText;
1151
+ }
1152
+ function openElementAdder(type) {
1153
+ var m = $('#modal-backdrop');
1154
+ $('#modal-title').textContent = 'Add ' + typeLabel(type).toLowerCase();
1155
+ $('#modal-ok').textContent = 'Add';
1156
+ $('#el-type').value = type;
1157
+ $('#el-type').disabled = true;
1158
+ $('#modal-type-row').hidden = true;
1159
+ $('#el-title').value = '';
1160
+ $('#el-language').value = '';
1161
+ $('#el-content').value = '';
1162
+ $('#el-component').value = 'button';
1163
+ $('#el-label').value = '';
1164
+ $('#el-placeholder').value = '';
1165
+ $('#el-value').value = '';
1166
+ $('#el-card-body').value = '';
1167
+ syncEditorFieldsForType(type);
1168
+ delete m.dataset.editElementId;
1169
+ m.classList.add('open');
1170
+ m.setAttribute('aria-hidden', 'false');
1171
+ setTimeout(function () { $('#el-title').focus(); }, 50);
1172
+ }
1173
+ function closeModal() {
1174
+ var m = $('#modal-backdrop');
1175
+ m.classList.remove('open');
1176
+ m.setAttribute('aria-hidden', 'true');
1177
+ $('#el-type').disabled = false;
1178
+ }
1179
+ function readModalElement() {
1180
+ var type = $('#el-type').value;
1181
+ var e = {
1182
+ type: type,
1183
+ title: $('#el-title').value || '',
1184
+ content: $('#el-content').value || '',
1185
+ };
1186
+ if (type === 'code') e.language = $('#el-language').value || '';
1187
+ if (type === 'ui-mockup') {
1188
+ e.component = $('#el-component').value || 'button';
1189
+ if (e.component === 'button') e.label = $('#el-label').value || 'Button';
1190
+ if (e.component === 'input') {
1191
+ e.placeholder = $('#el-placeholder').value || '';
1192
+ e.value = $('#el-value').value || '';
1193
+ }
1194
+ if (e.component === 'card') {
1195
+ e.title = e.title || 'Card';
1196
+ e.body = $('#el-card-body').value || '';
1197
+ }
1198
+ }
1199
+ return e;
1200
+ }
1201
+ async function submitElementForm(ev) {
1202
+ ev.preventDefault();
1203
+ var m = $('#modal-backdrop');
1204
+ var form = $('#element-form');
1205
+ var data = readModalElement();
1206
+ var editId = m.dataset.editElementId;
1207
+ if (editId) {
1208
+ // ── EDIT MODE: PUT to /elements/<id>. The server returns the
1209
+ // updated <div class="element"> HTML; htmx swaps it in place of
1210
+ // the existing element via outerHTML. ──
1211
+ form.setAttribute('hx-put',
1212
+ '/api/' + encodeURIComponent(SLUG) + '/elements/' + encodeURIComponent(editId));
1213
+ form.setAttribute('hx-target', '[data-element-id="' + editId + '"]');
1214
+ form.setAttribute('hx-swap', 'outerHTML');
1215
+ // Listen for the response so we can update local state.
1216
+ var oneOff = function (ev2) {
1217
+ document.body.removeEventListener('htmx:afterRequest', oneOff);
1218
+ if (ev2.detail && ev2.detail.successful) {
1219
+ var xhr = ev2.detail.xhr;
1220
+ var resp = xhr && xhr.response;
1221
+ if (resp && typeof resp === 'string') {
1222
+ var m2 = resp.match(/data-element-id="([^"]+)"/);
1223
+ if (m2) {
1224
+ var id2 = m2[1];
1225
+ for (var i = 0; i < state.elements.length; i++) {
1226
+ if (state.elements[i].id === id2) {
1227
+ // Preserve x/y/width/height (the server keeps them,
1228
+ // but the PUT body may not include them — re-read from DOM).
1229
+ var node = document.querySelector('[data-element-id="' + id2 + '"]');
1230
+ if (node) {
1231
+ state.elements[i].x = parseFloat(node.style.left) || state.elements[i].x;
1232
+ state.elements[i].y = parseFloat(node.style.top) || state.elements[i].y;
1233
+ state.elements[i].width = parseFloat(node.style.width) || state.elements[i].width;
1234
+ state.elements[i].height = parseFloat(node.style.height) || state.elements[i].height;
1235
+ }
1236
+ // Update content fields from the form data
1237
+ for (var k in data) state.elements[i][k] = data[k];
1238
+ break;
1239
+ }
1240
+ }
1241
+ rerenderConnections();
1242
+ }
1243
+ }
1244
+ }
1245
+ closeModal();
1246
+ // Reset form attrs to default (POST mode)
1247
+ form.removeAttribute('hx-put');
1248
+ form.setAttribute('hx-target', '#elements-layer');
1249
+ form.setAttribute('hx-swap', 'beforeend');
1250
+ };
1251
+ document.body.addEventListener('htmx:afterRequest', oneOff);
1252
+ // Trigger htmx to submit the form
1253
+ window.htmx.trigger(form, 'submit');
1254
+ return;
1255
+ }
1256
+ // ── ADD MODE: center the new element in the current view ──
1257
+ var wrap = $('#canvas-wrap').getBoundingClientRect();
1258
+ var cx = (wrap.width / 2 - state.viewport.x) / state.viewport.zoom;
1259
+ var cy = (wrap.height / 2 - state.viewport.y) / state.viewport.zoom;
1260
+ if (data.type === 'code') { data.width = 360; data.height = 200; }
1261
+ else if (data.type === 'image') { data.width = 320; data.height = 240; }
1262
+ else if (data.type === 'diagram') { data.width = 360; data.height = 220; }
1263
+ else if (data.type === 'ui-mockup') { data.width = 280; data.height = 140; }
1264
+ else { data.width = 280; data.height = 160; }
1265
+ data.x = Math.round(cx - data.width / 2);
1266
+ data.y = Math.round(cy - data.height / 2);
1267
+ // Fill hidden inputs so the form submits the right geometry
1268
+ $('#el-x').value = data.x;
1269
+ $('#el-y').value = data.y;
1270
+ $('#el-width').value = data.width;
1271
+ $('#el-height').value = data.height;
1272
+ // Reset to POST (in case we previously edited)
1273
+ form.setAttribute('hx-target', '#elements-layer');
1274
+ form.setAttribute('hx-swap', 'beforeend');
1275
+ form.removeAttribute('hx-put');
1276
+ // After htmx appends, capture the new element id and update state
1277
+ var oneOff2 = function (ev2) {
1278
+ document.body.removeEventListener('htmx:afterRequest', oneOff2);
1279
+ if (ev2.detail && ev2.detail.successful) {
1280
+ var xhr = ev2.detail.xhr;
1281
+ var resp = xhr && xhr.response;
1282
+ if (resp && typeof resp === 'string') {
1283
+ var mm = resp.match(/data-element-id="([^"]+)"/);
1284
+ if (mm) {
1285
+ state.elements.push(Object.assign({}, data, { id: mm[1] }));
1286
+ }
1287
+ }
1288
+ }
1289
+ closeModal();
1290
+ };
1291
+ document.body.addEventListener('htmx:afterRequest', oneOff2);
1292
+ window.htmx.trigger(form, 'submit');
1293
+ }
1294
+
1295
+ // ── Element drag/resize ─────────────────────────────────────
1296
+ function startElementDrag(elementId, ev) {
1297
+ var e = findElement(elementId);
1298
+ if (!e) return;
1299
+ if (activeTool === 'connect') return; // don't drag while connecting
1300
+ var pointerId = ev.pointerId;
1301
+ ev.currentTarget.setPointerCapture(pointerId);
1302
+ var startWorld = screenToWorld(ev.clientX, ev.clientY);
1303
+ var orig = { x: e.x, y: e.y };
1304
+ var node = document.querySelector('.element[data-element-id="' + e.id + '"]');
1305
+ function onMove(ev2) {
1306
+ var w = screenToWorld(ev2.clientX, ev2.clientY);
1307
+ e.x = Math.round(orig.x + (w.x - startWorld.x));
1308
+ e.y = Math.round(orig.y + (w.y - startWorld.y));
1309
+ if (node) { node.style.left = e.x + 'px'; node.style.top = e.y + 'px'; }
1310
+ renderConnections();
1311
+ }
1312
+ function onUp() {
1313
+ ev.currentTarget.releasePointerCapture(pointerId);
1314
+ ev.currentTarget.removeEventListener('pointermove', onMove);
1315
+ ev.currentTarget.removeEventListener('pointerup', onUp);
1316
+ ev.currentTarget.removeEventListener('pointercancel', onUp);
1317
+ scheduleSave();
1318
+ }
1319
+ ev.currentTarget.addEventListener('pointermove', onMove);
1320
+ ev.currentTarget.addEventListener('pointerup', onUp);
1321
+ ev.currentTarget.addEventListener('pointercancel', onUp);
1322
+ }
1323
+ function startElementResize(elementId, ev) {
1324
+ var e = findElement(elementId);
1325
+ if (!e) return;
1326
+ var pointerId = ev.pointerId;
1327
+ ev.currentTarget.setPointerCapture(pointerId);
1328
+ var startScreen = { x: ev.clientX, y: ev.clientY };
1329
+ var orig = { w: e.width, h: e.height };
1330
+ var node = document.querySelector('.element[data-element-id="' + e.id + '"]');
1331
+ function onMove(ev2) {
1332
+ var dx = (ev2.clientX - startScreen.x) / state.viewport.zoom;
1333
+ var dy = (ev2.clientY - startScreen.y) / state.viewport.zoom;
1334
+ e.width = Math.max(80, Math.round(orig.w + dx));
1335
+ e.height = Math.max(40, Math.round(orig.h + dy));
1336
+ if (node) { node.style.width = e.width + 'px'; node.style.height = e.height + 'px'; }
1337
+ renderConnections();
1338
+ }
1339
+ function onUp() {
1340
+ ev.currentTarget.releasePointerCapture(pointerId);
1341
+ ev.currentTarget.removeEventListener('pointermove', onMove);
1342
+ ev.currentTarget.removeEventListener('pointerup', onUp);
1343
+ ev.currentTarget.removeEventListener('pointercancel', onUp);
1344
+ scheduleSave();
1345
+ }
1346
+ ev.currentTarget.addEventListener('pointermove', onMove);
1347
+ ev.currentTarget.addEventListener('pointerup', onUp);
1348
+ ev.currentTarget.addEventListener('pointercancel', onUp);
1349
+ }
1350
+
1351
+ // ── Pan & zoom ──────────────────────────────────────────────
1352
+ function startPan(ev) {
1353
+ if (ev.button !== 0 && ev.button !== 1) return; // left or middle
1354
+ if (ev.button === 0 && !spaceDown) return; // left only with space
1355
+ ev.preventDefault();
1356
+ var canvas = $('#canvas');
1357
+ canvas.classList.add('panning');
1358
+ var startX = ev.clientX, startY = ev.clientY;
1359
+ var orig = { x: state.viewport.x, y: state.viewport.y };
1360
+ function onMove(ev2) {
1361
+ state.viewport.x = Math.round(orig.x + (ev2.clientX - startX));
1362
+ state.viewport.y = Math.round(orig.y + (ev2.clientY - startY));
1363
+ applyViewport();
1364
+ }
1365
+ function onUp() {
1366
+ canvas.classList.remove('panning');
1367
+ document.removeEventListener('pointermove', onMove);
1368
+ document.removeEventListener('pointerup', onUp);
1369
+ document.removeEventListener('pointercancel', onUp);
1370
+ scheduleSave();
1371
+ }
1372
+ document.addEventListener('pointermove', onMove);
1373
+ document.addEventListener('pointerup', onUp);
1374
+ document.addEventListener('pointercancel', onUp);
1375
+ }
1376
+ function zoom(delta, ev) {
1377
+ var newZoom = Math.max(0.2, Math.min(3, state.viewport.zoom + delta));
1378
+ if (newZoom === state.viewport.zoom) return;
1379
+ // Zoom toward the cursor: keep the world point under the cursor fixed.
1380
+ if (ev) {
1381
+ var before = screenToWorld(ev.clientX, ev.clientY);
1382
+ state.viewport.zoom = newZoom;
1383
+ applyViewport();
1384
+ var after = screenToWorld(ev.clientX, ev.clientY);
1385
+ state.viewport.x += Math.round((after.x - before.x) * newZoom);
1386
+ state.viewport.y += Math.round((after.y - before.y) * newZoom);
1387
+ } else {
1388
+ state.viewport.zoom = newZoom;
1389
+ }
1390
+ applyViewport();
1391
+ scheduleSave();
1392
+ }
1393
+ function resetZoom() {
1394
+ state.viewport = { x: 0, y: 0, zoom: 1 };
1395
+ applyViewport();
1396
+ scheduleSave();
1397
+ }
1398
+
1399
+ // ── Comment panel ───────────────────────────────────────────
1400
+ function openCommentPanel(commentId) {
1401
+ activeCommentId = commentId;
1402
+ renderCommentList();
1403
+ var panel = $('#comment-panel');
1404
+ panel.classList.add('open');
1405
+ panel.setAttribute('aria-hidden', 'false');
1406
+ // Highlight the pin
1407
+ $$('.comment-pin').forEach(function (p) { p.classList.toggle('active', p.dataset.commentId === commentId); });
1408
+ setTimeout(function () { $('#new-comment').focus(); }, 200);
1409
+ }
1410
+ function closeCommentPanel() {
1411
+ var panel = $('#comment-panel');
1412
+ panel.classList.remove('open');
1413
+ panel.setAttribute('aria-hidden', 'true');
1414
+ activeCommentId = null;
1415
+ $$('.comment-pin').forEach(function (p) { p.classList.remove('active'); });
1416
+ }
1417
+ function renderCommentList() {
1418
+ var c = findComment(activeCommentId);
1419
+ var list = $('#comment-list');
1420
+ list.innerHTML = '';
1421
+ if (!c) {
1422
+ list.appendChild(el('li', { class: 'empty', text: 'No comment selected.' }));
1423
+ return;
1424
+ }
1425
+ var target = c.elementId ? (findElement(c.elementId) || {}).title || 'element' : 'canvas';
1426
+ $('#comment-target').textContent = '· ' + target;
1427
+ var li = el('li', { class: 'comment' });
1428
+ li.appendChild(el('div', { class: 'comment-meta',
1429
+ text: (c.author || 'Anonymous') + ' · ' + formatDate(c.created) }));
1430
+ li.appendChild(el('div', { class: 'comment-text', text: c.text || '' }));
1431
+ list.appendChild(li);
1432
+ // Replies
1433
+ if (Array.isArray(c.thread)) {
1434
+ for (var i = 0; i < c.thread.length; i++) {
1435
+ var r = c.thread[i];
1436
+ var reply = el('li', { class: 'reply' });
1437
+ reply.appendChild(el('div', { class: 'reply-meta',
1438
+ text: (r.author || 'ai') + ' · ' + formatDate(r.created) }));
1439
+ reply.appendChild(el('div', { class: 'reply-text', text: r.text || '' }));
1440
+ list.appendChild(reply);
1441
+ }
1442
+ }
1443
+ }
1444
+ function formatDate(iso) {
1445
+ if (!iso) return '';
1446
+ var d = new Date(iso);
1447
+ if (isNaN(d.getTime())) return String(iso);
1448
+ return d.toLocaleString();
1449
+ }
1450
+ async function submitCommentForm(ev) {
1451
+ ev.preventDefault();
1452
+ var text = $('#new-comment').value.trim();
1453
+ if (!text) return;
1454
+ if (!activeCommentId) return; // No active comment → no-op
1455
+ // htmx: the form already has hx-target="#comment-list" + hx-swap="outerHTML".
1456
+ // We just need to set hx-put to the active comment's URL.
1457
+ var form = $('#comment-form');
1458
+ form.setAttribute('hx-put',
1459
+ '/api/' + encodeURIComponent(SLUG) + '/comments/' + encodeURIComponent(activeCommentId));
1460
+ $('#reply-author').value = AUTHOR;
1461
+ // Listen for the response so we can update local state with the
1462
+ // server's updated comment (which includes the new reply in its thread).
1463
+ var oneOff = function (ev2) {
1464
+ document.body.removeEventListener('htmx:afterRequest', oneOff);
1465
+ if (ev2.detail && ev2.detail.successful) {
1466
+ var xhr = ev2.detail.xhr;
1467
+ var resp = xhr && xhr.response;
1468
+ if (resp && typeof resp === 'string') {
1469
+ var m = resp.match(/id="comment-([^"]+)"/);
1470
+ if (m) {
1471
+ var id = m[1];
1472
+ var idx = state.comments.findIndex(function (c) { return c.id === id; });
1473
+ if (idx >= 0) {
1474
+ // Update thread from the new state (server is authoritative)
1475
+ var threadMatch = resp.match(/<li class="reply"[\s\S]*?<\/li>/g);
1476
+ if (threadMatch) {
1477
+ state.comments[idx].thread = state.comments[idx].thread || [];
1478
+ }
1479
+ }
1480
+ }
1481
+ }
1482
+ } else {
1483
+ setSaveStatus('Reply failed', 'error');
1484
+ }
1485
+ $('#new-comment').value = '';
1486
+ };
1487
+ document.body.addEventListener('htmx:afterRequest', oneOff);
1488
+ window.htmx.trigger(form, 'submit');
1489
+ // (Top-level "add new comment" happens on canvas click in comment tool.)
1490
+ }
1491
+
1492
+ // ── Render everything ───────────────────────────────────────
1493
+ function rerender() {
1494
+ // Elements
1495
+ var layer = $('#elements-layer');
1496
+ layer.innerHTML = '';
1497
+ for (var i = 0; i < state.elements.length; i++) {
1498
+ layer.appendChild(renderElement(state.elements[i]));
1499
+ }
1500
+ // Comments
1501
+ var commentsLayer = $('#comments-layer');
1502
+ commentsLayer.innerHTML = '';
1503
+ for (var j = 0; j < state.comments.length; j++) {
1504
+ commentsLayer.appendChild(renderCommentPin(state.comments[j]));
1505
+ }
1506
+ // Empty-canvas message
1507
+ $('#empty-canvas-msg').classList.toggle('hidden',
1508
+ state.elements.length > 0 || state.comments.length > 0);
1509
+ // Connections
1510
+ renderConnections();
1511
+ // Active comment highlight
1512
+ $$('.comment-pin').forEach(function (p) {
1513
+ p.classList.toggle('active', p.dataset.commentId === activeCommentId);
1514
+ });
1515
+ }
1516
+ function updateToolbar() {
1517
+ $$('.toolbar button[data-tool]').forEach(function (b) {
1518
+ b.classList.toggle('active', b.dataset.tool === activeTool);
1519
+ });
1520
+ $('#canvas').classList.toggle('connecting', activeTool === 'connect' || activeTool === 'comment');
1521
+ }
1522
+
1523
+ // ── Wire up events ──────────────────────────────────────────
1524
+ function wire() {
1525
+ // Toolbar tool selection
1526
+ $$('.toolbar button[data-tool]').forEach(function (b) {
1527
+ b.addEventListener('click', function () {
1528
+ activeTool = b.dataset.tool;
1529
+ if (activeTool !== 'connect') connectFromId = null;
1530
+ updateToolbar();
1531
+ rerender();
1532
+ if (['text', 'image', 'code', 'diagram', 'ui-mockup'].indexOf(activeTool) >= 0) {
1533
+ openElementAdder(activeTool);
1534
+ }
1535
+ });
1536
+ });
1537
+ // Zoom
1538
+ $('#zoom-in').addEventListener('click', function (ev) { zoom(0.1, ev); });
1539
+ $('#zoom-out').addEventListener('click', function (ev) { zoom(-0.1, ev); });
1540
+ $('#zoom-reset').addEventListener('click', resetZoom);
1541
+
1542
+ // Header — Markdown export. This stays as fetch() because it's a
1543
+ // download flow (Blob → URL.createObjectURL) which doesn't fit
1544
+ // htmx's swap model. It's not CRUD on canvas data.
1545
+ $('#export-md-btn').addEventListener('click', async function () {
1546
+ try {
1547
+ var res = await fetch('/api/' + encodeURIComponent(SLUG) + '/markdown-export');
1548
+ if (!res.ok) throw new Error('HTTP ' + res.status);
1549
+ var md = await res.text();
1550
+ var blob = new Blob([md], { type: 'text/markdown' });
1551
+ var url = URL.createObjectURL(blob);
1552
+ var a = document.createElement('a');
1553
+ a.href = url; a.download = SLUG + '.md';
1554
+ document.body.appendChild(a); a.click(); document.body.removeChild(a);
1555
+ URL.revokeObjectURL(url);
1556
+ } catch (err) {
1557
+ setSaveStatus('Export failed: ' + err.message, 'error');
1558
+ }
1559
+ });
1560
+
1561
+ // Canvas pan: hold space + drag, or middle-mouse
1562
+ var wrap = $('#canvas-wrap');
1563
+ wrap.addEventListener('pointerdown', startPan);
1564
+
1565
+ // Canvas click: drop a comment pin if the comment tool is active
1566
+ wrap.addEventListener('click', function (ev) {
1567
+ // Only respond to clicks on the canvas background, not on elements
1568
+ if (ev.target.closest('.element') || ev.target.closest('.comment-pin')) return;
1569
+ if (activeTool !== 'comment') return;
1570
+ var w = screenToWorld(ev.clientX, ev.clientY);
1571
+ var text = prompt('Comment text:');
1572
+ if (text && text.trim()) {
1573
+ addComment(Math.round(w.x), Math.round(w.y), text.trim(), null);
1574
+ }
1575
+ activeTool = 'select';
1576
+ updateToolbar();
1577
+ });
1578
+
1579
+ // Mouse wheel: zoom with Ctrl, otherwise no-op (the canvas is infinite)
1580
+ wrap.addEventListener('wheel', function (ev) {
1581
+ if (!ev.ctrlKey && !ev.metaKey) return;
1582
+ ev.preventDefault();
1583
+ var delta = ev.deltaY < 0 ? 0.1 : -0.1;
1584
+ zoom(delta, ev);
1585
+ }, { passive: false });
1586
+
1587
+ // Keyboard shortcuts
1588
+ document.addEventListener('keydown', function (ev) {
1589
+ // Spacebar → enter pan mode
1590
+ if (ev.code === 'Space' && !ev.target.matches('input,textarea,select')) {
1591
+ ev.preventDefault();
1592
+ spaceDown = true;
1593
+ $('#canvas').classList.add('space-down');
1594
+ }
1595
+ // Tool shortcuts
1596
+ if (ev.target.matches('input,textarea,select')) return;
1597
+ var k = ev.key.toLowerCase();
1598
+ if (k === 't') { activeTool = 'text'; openElementAdder('text'); updateToolbar(); }
1599
+ else if (k === 'i') { activeTool = 'image'; openElementAdder('image'); updateToolbar(); }
1600
+ else if (k === 'c') { activeTool = 'code'; openElementAdder('code'); updateToolbar(); }
1601
+ else if (k === 'd') { activeTool = 'diagram'; openElementAdder('diagram'); updateToolbar(); }
1602
+ else if (k === 'u') { activeTool = 'ui-mockup'; openElementAdder('ui-mockup'); updateToolbar(); }
1603
+ else if (k === 'l') { activeTool = 'connect'; connectFromId = null; updateToolbar(); }
1604
+ else if (k === 'm') { activeTool = 'comment'; updateToolbar(); }
1605
+ else if (k === 'escape') {
1606
+ activeTool = 'select';
1607
+ connectFromId = null;
1608
+ selectedElementId = null;
1609
+ closeModal();
1610
+ updateToolbar(); rerender();
1611
+ } else if (k === '+' || k === '=') { zoom(0.1, null); }
1612
+ else if (k === '-' || k === '_') { zoom(-0.1, null); }
1613
+ else if (k === '0') { resetZoom(); }
1614
+ // Cmd/Ctrl+Enter in comment form → submit
1615
+ if ((ev.metaKey || ev.ctrlKey) && ev.key === 'Enter' &&
1616
+ ev.target && ev.target.id === 'new-comment') {
1617
+ ev.preventDefault();
1618
+ $('#comment-form').requestSubmit();
1619
+ }
1620
+ });
1621
+ document.addEventListener('keyup', function (ev) {
1622
+ if (ev.code === 'Space') {
1623
+ spaceDown = false;
1624
+ $('#canvas').classList.remove('space-down');
1625
+ }
1626
+ });
1627
+
1628
+ // Comment form
1629
+ $('#comment-form').addEventListener('submit', submitCommentForm);
1630
+ $('#close-comment-panel').addEventListener('click', closeCommentPanel);
1631
+
1632
+ // Modal
1633
+ $('#modal-cancel').addEventListener('click', closeModal);
1634
+ $('#element-form').addEventListener('submit', submitElementForm);
1635
+ $('#el-component').addEventListener('change', function () {
1636
+ syncEditorFieldsForType('ui-mockup');
1637
+ });
1638
+ $('#el-type').addEventListener('change', function () {
1639
+ syncEditorFieldsForType($('#el-type').value);
1640
+ });
1641
+ $('#modal-backdrop').addEventListener('click', function (ev) {
1642
+ if (ev.target.id === 'modal-backdrop') closeModal();
1643
+ });
1644
+
1645
+ // Click on canvas background (no tool) → deselect
1646
+ $('#canvas').addEventListener('click', function (ev) {
1647
+ if (ev.target.id === 'canvas' || ev.target.classList.contains('empty-canvas')) {
1648
+ selectedElementId = null;
1649
+ connectFromId = null;
1650
+ rerender();
1651
+ }
1652
+ });
1653
+
1654
+ // ── Event delegation for htmx-injected elements ───────────────────
1655
+ // Because htmx appends server-rendered HTML on add/delete, those
1656
+ // new nodes don't have JS-bound event listeners. We delegate clicks
1657
+ // on action buttons (data-action="...") to the controller layer.
1658
+ $('#elements-layer').addEventListener('click', function (ev) {
1659
+ var btn = ev.target.closest('button[data-action]');
1660
+ if (!btn) return;
1661
+ ev.stopPropagation();
1662
+ var elementNode = btn.closest('[data-element-id]');
1663
+ if (!elementNode) return;
1664
+ var id = elementNode.dataset.elementId;
1665
+ var e = findElement(id);
1666
+ if (!e) return;
1667
+ if (btn.dataset.action === 'edit-element') {
1668
+ openElementEditor(e);
1669
+ } else if (btn.dataset.action === 'delete-element') {
1670
+ if (confirm('Delete this element?')) deleteElement(id);
1671
+ }
1672
+ });
1673
+
1674
+ // ── htmx error handling ──────────────────────────────────────────
1675
+ document.body.addEventListener('htmx:responseError', function (ev) {
1676
+ var status = ev.detail && ev.detail.xhr && ev.detail.xhr.status;
1677
+ setSaveStatus('Request failed: HTTP ' + status, 'error');
1678
+ });
1679
+ document.body.addEventListener('htmx:sendError', function (ev) {
1680
+ setSaveStatus('Network error', 'error');
1681
+ });
1682
+
1683
+ // ── htmx after-process-node: re-apply connection lines after a
1684
+ // server-rendered element is swapped in via outerHTML. ──
1685
+ document.body.addEventListener('htmx:afterSwap', function (ev) {
1686
+ // The target was an existing element → re-attach connections
1687
+ var tgt = ev.detail && ev.detail.target;
1688
+ if (tgt && tgt.classList && tgt.classList.contains('element')) {
1689
+ rerenderConnections();
1690
+ }
1691
+ });
1692
+ }
1693
+
1694
+ // ── Init ────────────────────────────────────────────────────
1695
+ function init() {
1696
+ document.title = TITLE + ' — Bizar Plan';
1697
+ $('#header-title').textContent = TITLE;
1698
+ applyViewport();
1699
+ rerender();
1700
+ updateToolbar();
1701
+ wire();
1702
+ }
1703
+ if (document.readyState === 'loading') {
1704
+ document.addEventListener('DOMContentLoaded', init);
1705
+ } else {
1706
+ init();
1707
+ }
1708
+ })();
1709
+ </script>
1710
+ </body>
1711
+ </html>