@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,937 @@
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, no network) — served by the local plan server
9
+ at /htmx.min.js from templates/plan/htmx.min.js. -->
10
+ <script src="/htmx.min.js"></script>
11
+
12
+ <style>
13
+ /* ============================================================
14
+ Theme tokens
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-table-stripe: #f9fafb;
25
+ --color-card-bg: #ffffff;
26
+ --color-card-shadow: rgba(0, 0, 0, 0.06);
27
+ --color-blockquote-border: #e5e7eb;
28
+ --color-button-text: #1a1a1a;
29
+ --color-primary-text: #ffffff;
30
+ --color-save-ok: #10b981;
31
+ --color-save-err: #ef4444;
32
+ --font-sans: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
33
+ --font-mono: ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, monospace;
34
+ }
35
+
36
+ @media (prefers-color-scheme: dark) {
37
+ :root {
38
+ --color-bg: #0f172a;
39
+ --color-text: #e2e8f0;
40
+ --color-muted: #94a3b8;
41
+ --color-border: #334155;
42
+ --color-accent: #60a5fa;
43
+ --color-accent-hover: #93c5fd;
44
+ --color-code-bg: #1e293b;
45
+ --color-table-stripe: #1e293b;
46
+ --color-card-bg: #1e293b;
47
+ --color-card-shadow: rgba(0, 0, 0, 0.4);
48
+ --color-blockquote-border: #334155;
49
+ --color-button-text: #e2e8f0;
50
+ --color-primary-text: #0f172a;
51
+ --color-save-ok: #34d399;
52
+ --color-save-err: #f87171;
53
+ }
54
+ }
55
+
56
+ /* ============================================================
57
+ Base
58
+ ============================================================ */
59
+ *, *::before, *::after { box-sizing: border-box; }
60
+ html { scroll-behavior: smooth; }
61
+ body {
62
+ font-family: var(--font-sans);
63
+ background: var(--color-bg);
64
+ color: var(--color-text);
65
+ line-height: 1.6;
66
+ max-width: 800px;
67
+ margin: 0 auto;
68
+ padding: 2rem 1.5rem 6rem;
69
+ }
70
+ h1 { font-size: 2em; line-height: 1.2; margin: 0 0 0.5rem; }
71
+ h2 { font-size: 1.4em; line-height: 1.3; margin: 1.5rem 0 0.75rem; }
72
+ h3 { font-size: 1.2em; margin: 1.25rem 0 0.5rem; }
73
+ h4, h5, h6 { margin: 1rem 0 0.5rem; }
74
+ p { margin: 0.75rem 0; }
75
+ a { color: var(--color-accent); text-decoration: none; }
76
+ a:hover { text-decoration: underline; }
77
+ code {
78
+ font-family: var(--font-mono);
79
+ font-size: 0.9em;
80
+ background: var(--color-code-bg);
81
+ padding: 0.15em 0.35em;
82
+ border-radius: 3px;
83
+ }
84
+ pre {
85
+ font-family: var(--font-mono);
86
+ background: var(--color-code-bg);
87
+ padding: 1rem;
88
+ border-radius: 6px;
89
+ overflow-x: auto;
90
+ line-height: 1.45;
91
+ }
92
+ pre code { background: none; padding: 0; }
93
+ ul, ol { padding-left: 1.5rem; margin: 0.5rem 0; }
94
+ li { margin: 0.25rem 0; }
95
+ blockquote {
96
+ border-left: 3px solid var(--color-blockquote-border);
97
+ margin: 1rem 0;
98
+ padding: 0.25rem 0 0.25rem 1rem;
99
+ color: var(--color-muted);
100
+ }
101
+ hr { border: 0; border-top: 1px solid var(--color-border); margin: 2rem 0; }
102
+ table {
103
+ border-collapse: collapse;
104
+ width: 100%;
105
+ margin: 1rem 0;
106
+ }
107
+ th, td {
108
+ border: 1px solid var(--color-border);
109
+ padding: 0.5rem 0.75rem;
110
+ text-align: left;
111
+ }
112
+ th { background: var(--color-code-bg); font-weight: 600; }
113
+ tbody tr:nth-child(even) { background: var(--color-table-stripe); }
114
+
115
+ /* ============================================================
116
+ Header
117
+ ============================================================ */
118
+ header.plan-header {
119
+ margin-bottom: 1.5rem;
120
+ padding-bottom: 1.5rem;
121
+ border-bottom: 1px solid var(--color-border);
122
+ }
123
+ .meta {
124
+ color: var(--color-muted);
125
+ font-size: 0.9em;
126
+ margin: 0.25rem 0 1rem;
127
+ }
128
+ .header-actions {
129
+ display: flex;
130
+ gap: 0.5rem;
131
+ flex-wrap: wrap;
132
+ margin-top: 1rem;
133
+ align-items: center;
134
+ }
135
+ .save-status {
136
+ font-size: 0.85em;
137
+ color: var(--color-muted);
138
+ margin-left: 0.5rem;
139
+ transition: color 0.2s;
140
+ }
141
+ .save-status.saving { color: var(--color-muted); }
142
+ .save-status.saved { color: var(--color-save-ok); }
143
+ .save-status.error { color: var(--color-save-err); }
144
+
145
+ /* ============================================================
146
+ Buttons
147
+ ============================================================ */
148
+ button {
149
+ font: inherit;
150
+ cursor: pointer;
151
+ border: 1px solid var(--color-border);
152
+ background: var(--color-card-bg);
153
+ color: var(--color-button-text);
154
+ padding: 0.5rem 1rem;
155
+ border-radius: 6px;
156
+ transition: background 0.15s, border-color 0.15s, color 0.15s;
157
+ }
158
+ button:hover { background: var(--color-code-bg); }
159
+ button:focus-visible {
160
+ outline: 2px solid var(--color-accent);
161
+ outline-offset: 2px;
162
+ }
163
+ button.primary {
164
+ background: var(--color-accent);
165
+ color: var(--color-primary-text);
166
+ border-color: var(--color-accent);
167
+ }
168
+ button.primary:hover {
169
+ background: var(--color-accent-hover);
170
+ border-color: var(--color-accent-hover);
171
+ }
172
+
173
+ /* ============================================================
174
+ Table of contents
175
+ ============================================================ */
176
+ .toc {
177
+ position: sticky;
178
+ top: 0;
179
+ background: var(--color-bg);
180
+ z-index: 50;
181
+ padding: 0.75rem 0;
182
+ border-bottom: 1px solid var(--color-border);
183
+ margin: 0 -1.5rem 2rem;
184
+ padding-left: 1.5rem;
185
+ padding-right: 1.5rem;
186
+ }
187
+ .toc-title {
188
+ font-size: 0.75em;
189
+ text-transform: uppercase;
190
+ letter-spacing: 0.08em;
191
+ color: var(--color-muted);
192
+ margin: 0 0 0.5rem;
193
+ font-weight: 600;
194
+ }
195
+ .toc ol { margin: 0; padding-left: 1.5rem; }
196
+ .toc a { color: var(--color-text); }
197
+ .toc a:hover { color: var(--color-accent); }
198
+
199
+ /* ============================================================
200
+ Sections
201
+ ============================================================ */
202
+ main section {
203
+ border-left: 4px solid var(--color-accent);
204
+ padding-left: 1.5rem;
205
+ margin-bottom: 2.5rem;
206
+ scroll-margin-top: 5rem;
207
+ }
208
+ .section-header {
209
+ display: flex;
210
+ justify-content: space-between;
211
+ align-items: baseline;
212
+ gap: 1rem;
213
+ margin-bottom: 0.5rem;
214
+ }
215
+ .section-header h2 { margin: 0; }
216
+ .comment-btn {
217
+ font-size: 0.85em;
218
+ padding: 0.3rem 0.7rem;
219
+ white-space: nowrap;
220
+ flex-shrink: 0;
221
+ }
222
+ .comment-btn .count {
223
+ font-weight: 600;
224
+ margin-left: 0.25rem;
225
+ }
226
+ .comment-btn.has-comments {
227
+ border-color: var(--color-accent);
228
+ color: var(--color-accent);
229
+ }
230
+ .comment-btn:disabled { opacity: 0.4; cursor: not-allowed; }
231
+
232
+ .section-editor {
233
+ width: 100%;
234
+ min-height: 200px;
235
+ font-family: var(--font-mono);
236
+ font-size: 0.9em;
237
+ background: var(--color-code-bg);
238
+ color: var(--color-text);
239
+ border: 1px solid var(--color-border);
240
+ border-radius: 6px;
241
+ padding: 0.75rem;
242
+ resize: vertical;
243
+ line-height: 1.5;
244
+ margin: 0.5rem 0;
245
+ }
246
+
247
+ /* ============================================================
248
+ Footer (save/discard in edit mode)
249
+ ============================================================ */
250
+ footer.editor-footer {
251
+ position: sticky;
252
+ bottom: 0;
253
+ background: var(--color-bg);
254
+ border-top: 1px solid var(--color-border);
255
+ padding: 1rem 0;
256
+ margin: 2rem -1.5rem 0;
257
+ padding-left: 1.5rem;
258
+ padding-right: 1.5rem;
259
+ display: none;
260
+ gap: 0.5rem;
261
+ z-index: 40;
262
+ }
263
+ body[data-mode="edit"] footer.editor-footer { display: flex; }
264
+
265
+ /* ============================================================
266
+ Comment side panel
267
+ ============================================================ */
268
+ .comment-panel {
269
+ position: fixed;
270
+ top: 0;
271
+ right: 0;
272
+ width: 420px;
273
+ max-width: 100vw;
274
+ height: 100vh;
275
+ background: var(--color-card-bg);
276
+ box-shadow: -2px 0 12px var(--color-card-shadow);
277
+ transform: translateX(100%);
278
+ transition: transform 0.25s ease-out;
279
+ z-index: 100;
280
+ display: flex;
281
+ flex-direction: column;
282
+ padding: 1.5rem;
283
+ box-sizing: border-box;
284
+ overflow: hidden;
285
+ }
286
+ .comment-panel.open { transform: translateX(0); }
287
+ .comment-panel-header {
288
+ display: flex;
289
+ justify-content: space-between;
290
+ align-items: center;
291
+ margin-bottom: 1rem;
292
+ padding-bottom: 1rem;
293
+ border-bottom: 1px solid var(--color-border);
294
+ }
295
+ .comment-panel-header h3 { margin: 0; font-size: 1.05em; }
296
+ #close-comment-panel {
297
+ background: transparent;
298
+ border: 0;
299
+ font-size: 1.5em;
300
+ line-height: 1;
301
+ padding: 0.25rem 0.5rem;
302
+ color: var(--color-muted);
303
+ }
304
+ #close-comment-panel:hover { color: var(--color-text); background: transparent; }
305
+
306
+ #comment-list {
307
+ flex: 1;
308
+ overflow-y: auto;
309
+ margin-bottom: 1rem;
310
+ list-style: none;
311
+ padding: 0;
312
+ }
313
+ .comment {
314
+ background: var(--color-code-bg);
315
+ border-radius: 6px;
316
+ padding: 0.75rem;
317
+ margin-bottom: 0.75rem;
318
+ }
319
+ .comment-meta {
320
+ font-size: 0.8em;
321
+ color: var(--color-muted);
322
+ margin-bottom: 0.35rem;
323
+ }
324
+ .comment-text {
325
+ white-space: pre-wrap;
326
+ word-wrap: break-word;
327
+ }
328
+ .empty {
329
+ color: var(--color-muted);
330
+ text-align: center;
331
+ padding: 2rem 1rem;
332
+ font-style: italic;
333
+ list-style: none;
334
+ }
335
+ .comment-form {
336
+ border-top: 1px solid var(--color-border);
337
+ padding-top: 1rem;
338
+ }
339
+ .comment-form textarea {
340
+ width: 100%;
341
+ min-height: 80px;
342
+ font: inherit;
343
+ background: var(--color-bg);
344
+ color: var(--color-text);
345
+ border: 1px solid var(--color-border);
346
+ border-radius: 6px;
347
+ padding: 0.5rem;
348
+ resize: vertical;
349
+ margin-bottom: 0.5rem;
350
+ }
351
+ .comment-form-actions {
352
+ display: flex;
353
+ gap: 0.5rem;
354
+ align-items: center;
355
+ }
356
+ .comment-form-actions .hint {
357
+ font-size: 0.8em;
358
+ color: var(--color-muted);
359
+ margin-left: auto;
360
+ }
361
+
362
+ /* htmx: visual feedback while a request is in flight */
363
+ .htmx-request .save-status { color: var(--color-muted); }
364
+ .htmx-request.comment-btn { opacity: 0.6; }
365
+
366
+ /* ============================================================
367
+ Responsive
368
+ ============================================================ */
369
+ @media (max-width: 600px) {
370
+ body { padding: 1rem; }
371
+ h1 { font-size: 1.6em; }
372
+ h2 { font-size: 1.2em; }
373
+ .comment-panel { width: 100%; }
374
+ main section { padding-left: 1rem; }
375
+ .toc, footer.editor-footer { margin-left: -1rem; margin-right: -1rem; padding-left: 1rem; padding-right: 1rem; }
376
+ }
377
+ </style>
378
+ </head>
379
+ <body data-mode="view">
380
+ <!-- Raw markdown source (CLI bakes in via {{planJson}}).
381
+ We keep an in-page copy so the live preview can render the *latest* text
382
+ the user is typing — even before autosave writes it to disk. -->
383
+ <script id="plan-source" type="application/json">{{planJson}}</script>
384
+ <script id="comments-source" type="application/json">{{commentsJson}}</script>
385
+ <script id="meta-source" type="application/json">{{metaJson}}</script>
386
+
387
+ <header class="plan-header">
388
+ <h1 id="header-title">{{title}}</h1>
389
+ <div class="meta" id="header-meta">
390
+ Slug: {{slug}} · Status: {{status}} · Created: {{created}} · Last edited: {{lastEdited}} · Author: {{author}}
391
+ </div>
392
+ <div class="header-actions">
393
+ <button id="edit-toggle" type="button" title="Edit plan content">✏️ Edit</button>
394
+ <button id="discard-btn" type="button" hidden title="Discard changes">↩️ Discard</button>
395
+ <span id="save-status" class="save-status" aria-live="polite"></span>
396
+ </div>
397
+ </header>
398
+
399
+ <nav class="toc">
400
+ <p class="toc-title">Contents</p>
401
+ <ol id="toc-list"></ol>
402
+ </nav>
403
+
404
+ <main id="main"></main>
405
+
406
+ <footer class="editor-footer">
407
+ <button id="save-btn" type="button" class="primary" title="Save changes">💾 Save</button>
408
+ </footer>
409
+
410
+ <aside id="comment-panel" class="comment-panel" aria-hidden="true">
411
+ <div class="comment-panel-header">
412
+ <h3>Comments on <span id="comment-section-title"></span></h3>
413
+ <button id="close-comment-panel" type="button" aria-label="Close comments">×</button>
414
+ </div>
415
+ <ul id="comment-list"
416
+ hx-get="/api/{{slug}}/comments?format=html"
417
+ hx-trigger="load, refresh-comments from:body"
418
+ hx-swap="innerHTML"></ul>
419
+ <form class="comment-form"
420
+ hx-post="/api/{{slug}}/comments"
421
+ hx-target="#comment-list"
422
+ hx-swap="beforeend"
423
+ hx-on::after-request="this.reset()">
424
+ <input type="hidden" name="sectionId" id="comment-section-id" value="">
425
+ <textarea name="text" id="new-comment" placeholder="Add a comment…"></textarea>
426
+ <div class="comment-form-actions">
427
+ <button type="submit" class="primary">Add</button>
428
+ <span class="hint">⌘/Ctrl+Enter</span>
429
+ </div>
430
+ </form>
431
+ </aside>
432
+
433
+ <script>
434
+ /* ============================================================
435
+ * Plan viewer/editor
436
+ *
437
+ * Interaction model:
438
+ * - 90% of the user-visible interactions (save, load comments, add
439
+ * comment) are driven by htmx attributes on the markup. See the
440
+ * <textarea> below and the <form> above.
441
+ * - The remaining 10% is what htmx cannot do:
442
+ * * live markdown preview while typing (client-side render)
443
+ * * mermaid / syntax-highlight post-processing
444
+ * * section drag/drop reordering
445
+ * * keyboard shortcuts (Cmd+B, etc.)
446
+ * Those live in the small vanilla-JS block at the bottom of this file.
447
+ * ============================================================ */
448
+ (function () {
449
+ 'use strict';
450
+
451
+ // ── Embedded state (CLI bakes these in via {{…}} substitution) ─────
452
+ var PLAN_RAW = {{planJson}};
453
+ var META = {{metaJson}};
454
+
455
+ function resolvePlanMarkdown(planRaw) {
456
+ if (planRaw == null) return '';
457
+ if (typeof planRaw === 'string') return planRaw;
458
+ if (typeof planRaw === 'object' && typeof planRaw.markdown === 'string') {
459
+ return planRaw.markdown;
460
+ }
461
+ return '';
462
+ }
463
+
464
+ var initialMarkdown = resolvePlanMarkdown(PLAN_RAW);
465
+ // Live state — the in-memory current plan. This is what the preview
466
+ // renders from, and what hx-include sends to the server.
467
+ var currentMarkdown = initialMarkdown;
468
+ var activeSectionId = null;
469
+
470
+ // ── Tiny markdown renderer (client-side, for live preview) ────────
471
+ // htmx can't do client-side rendering, so this is one of the few
472
+ // pieces of vanilla JS that has to live here.
473
+ function escapeHtml(s) {
474
+ return String(s)
475
+ .replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
476
+ .replace(/"/g, '&quot;').replace(/'/g, '&#39;');
477
+ }
478
+ function renderInline(text) {
479
+ var s = escapeHtml(text);
480
+ s = s.replace(/`([^`]+)`/g, function (_, code) { return '<code>' + code + '</code>'; });
481
+ s = s.replace(/\*\*([^*\n]+)\*\*/g, '<strong>$1</strong>');
482
+ s = s.replace(/(^|[^*\w])\*([^*\n]+)\*/g, '$1<em>$2</em>');
483
+ s = s.replace(/\[([^\]]+)\]\(([^)\s]+)\)/g, function (_, label, url) {
484
+ var safe = /^(https?:|mailto:|#|\/)/i.test(url) ? url : '#';
485
+ return '<a href="' + safe + '" rel="noopener">' + label + '</a>';
486
+ });
487
+ return s;
488
+ }
489
+ function isBlockStart(line) {
490
+ return /^#{1,6}\s+/.test(line)
491
+ || /^[-*]\s+/.test(line) || /^\d+\.\s+/.test(line)
492
+ || /^```/.test(line) || /^>\s?/.test(line) || /^---+\s*$/.test(line);
493
+ }
494
+ function splitTableRow(line) {
495
+ var s = line.trim();
496
+ if (s.charAt(0) === '|') s = s.slice(1);
497
+ if (s.charAt(s.length - 1) === '|') s = s.slice(0, -1);
498
+ return s.split('|').map(function (c) { return c.trim(); });
499
+ }
500
+ function renderMarkdown(md) {
501
+ if (md == null) return '';
502
+ var lines = String(md).replace(/\r\n/g, '\n').split('\n');
503
+ var out = [];
504
+ var i = 0;
505
+ while (i < lines.length) {
506
+ var line = lines[i];
507
+ if (line.indexOf('```') === 0) {
508
+ var lang = line.slice(3).trim();
509
+ var codeLines = [];
510
+ i++;
511
+ while (i < lines.length && lines[i].indexOf('```') !== 0) { codeLines.push(lines[i]); i++; }
512
+ i++;
513
+ var langAttr = lang ? ' class="lang-' + escapeHtml(lang) + '"' : '';
514
+ out.push('<pre><code' + langAttr + '>' + escapeHtml(codeLines.join('\n')) + '</code></pre>');
515
+ continue;
516
+ }
517
+ var headerMatch = line.match(/^(#{1,6})\s+(.+?)\s*#*\s*$/);
518
+ if (headerMatch) {
519
+ var level = headerMatch[1].length;
520
+ out.push('<h' + level + '>' + renderInline(headerMatch[2]) + '</h' + level + '>');
521
+ i++;
522
+ continue;
523
+ }
524
+ if (/^-{3,}\s*$/.test(line) || /^\*{3,}\s*$/.test(line)) { out.push('<hr>'); i++; continue; }
525
+ if (/^>\s?/.test(line)) {
526
+ var quoteLines = [];
527
+ while (i < lines.length && /^>\s?/.test(lines[i])) { quoteLines.push(lines[i].replace(/^>\s?/, '')); i++; }
528
+ out.push('<blockquote>' + renderInline(quoteLines.join('\n')) + '</blockquote>');
529
+ continue;
530
+ }
531
+ if (/^[-*]\s+/.test(line)) {
532
+ var ulItems = [];
533
+ while (i < lines.length && /^[-*]\s+/.test(lines[i])) { ulItems.push(lines[i].replace(/^[-*]\s+/, '')); i++; }
534
+ out.push('<ul>' + ulItems.map(function (t) { return '<li>' + renderInline(t) + '</li>'; }).join('') + '</ul>');
535
+ continue;
536
+ }
537
+ if (/^\d+\.\s+/.test(line)) {
538
+ var olItems = [];
539
+ while (i < lines.length && /^\d+\.\s+/.test(lines[i])) { olItems.push(lines[i].replace(/^\d+\.\s+/, '')); i++; }
540
+ out.push('<ol>' + olItems.map(function (t) { return '<li>' + renderInline(t) + '</li>'; }).join('') + '</ol>');
541
+ continue;
542
+ }
543
+ if (line.indexOf('|') !== -1 && i + 1 < lines.length) {
544
+ var sep = lines[i + 1].trim();
545
+ if (/^\|?[\s:|-]+\|?$/.test(sep) && sep.indexOf('-') !== -1) {
546
+ var headerCells = splitTableRow(line);
547
+ i += 2;
548
+ var bodyRows = [];
549
+ while (i < lines.length && lines[i].indexOf('|') !== -1 && lines[i].trim() !== '') {
550
+ bodyRows.push(splitTableRow(lines[i])); i++;
551
+ }
552
+ var thead = '<thead><tr>' + headerCells.map(function (c) { return '<th>' + renderInline(c) + '</th>'; }).join('') + '</tr></thead>';
553
+ var tbody = '<tbody>' + bodyRows.map(function (r) {
554
+ return '<tr>' + r.map(function (c) { return '<td>' + renderInline(c) + '</td>'; }).join('') + '</tr>';
555
+ }).join('') + '</tbody>';
556
+ out.push('<table>' + thead + tbody + '</table>');
557
+ continue;
558
+ }
559
+ }
560
+ if (line.trim() === '') { i++; continue; }
561
+ var paraLines = [];
562
+ while (i < lines.length && lines[i].trim() !== '' && !isBlockStart(lines[i])) { paraLines.push(lines[i]); i++; }
563
+ if (paraLines.length > 0) out.push('<p>' + renderInline(paraLines.join(' ')) + '</p>');
564
+ else i++;
565
+ }
566
+ return out.join('\n');
567
+ }
568
+
569
+ // ── Plan parsing (sections + IDs) ────────────────────────────────
570
+ function slugify(text) {
571
+ var s = String(text).toLowerCase().replace(/[^a-z0-9]+/g, '-')
572
+ .replace(/^-+|-+$/g, '').replace(/-{2,}/g, '-');
573
+ return s || 'section';
574
+ }
575
+ function parseSections(markdown) {
576
+ var lines = String(markdown == null ? '' : markdown).replace(/\r\n/g, '\n').split('\n');
577
+ var sections = [];
578
+ var current = null;
579
+ for (var i = 0; i < lines.length; i++) {
580
+ var line = lines[i];
581
+ if (/^##\s+/.test(line)) {
582
+ if (current) sections.push(current);
583
+ current = { title: line.replace(/^##\s+/, '').trim(), body: '' };
584
+ } else if (current) {
585
+ current.body += (current.body ? '\n' : '') + line;
586
+ }
587
+ }
588
+ if (current) sections.push(current);
589
+ for (var j = 0; j < sections.length; j++) sections[j].body = sections[j].body.replace(/\s+$/, '');
590
+ var seen = Object.create(null);
591
+ for (var k = 0; k < sections.length; k++) {
592
+ var base = slugify(sections[k].title);
593
+ var id = base; var n = 2;
594
+ while (seen[id]) { id = base + '-' + n; n++; }
595
+ seen[id] = true;
596
+ sections[k].id = id;
597
+ }
598
+ return sections;
599
+ }
600
+
601
+ // ── DOM helpers ──────────────────────────────────────────────────
602
+ function $(sel) { return document.querySelector(sel); }
603
+ function $$(sel) { return Array.prototype.slice.call(document.querySelectorAll(sel)); }
604
+ function formatDate(iso) {
605
+ if (!iso) return '';
606
+ var d = new Date(iso);
607
+ if (isNaN(d.getTime())) return String(iso);
608
+ return d.toLocaleString();
609
+ }
610
+
611
+ // ── Render: header ───────────────────────────────────────────────
612
+ (function renderHeader() {
613
+ var titleText = (META && META.title) || 'Untitled plan';
614
+ document.title = titleText + ' — Bizar Plan';
615
+ $('#header-title').textContent = titleText;
616
+ var parts = [];
617
+ if (META && META.slug) parts.push('Slug: ' + META.slug);
618
+ if (META && META.status) parts.push('Status: ' + META.status);
619
+ if (META && META.created) parts.push('Created: ' + formatDate(META.created));
620
+ if (META && META.lastEdited) parts.push('Last edited: ' + formatDate(META.lastEdited));
621
+ if (META && META.author) parts.push('Author: ' + META.author);
622
+ if (parts.length > 0) $('#header-meta').textContent = parts.join(' · ');
623
+ })();
624
+
625
+ // ── Render: TOC + sections ───────────────────────────────────────
626
+ function renderTOCAndSections() {
627
+ var sections = parseSections(currentMarkdown);
628
+ var ol = $('#toc-list'); ol.innerHTML = '';
629
+ var main = $('#main'); main.innerHTML = '';
630
+ for (var i = 0; i < sections.length; i++) {
631
+ var sec = sections[i];
632
+ var li = document.createElement('li');
633
+ var a = document.createElement('a');
634
+ a.href = '#' + sec.id;
635
+ a.textContent = sec.title;
636
+ li.appendChild(a);
637
+ ol.appendChild(li);
638
+
639
+ var sectionEl = document.createElement('section');
640
+ sectionEl.id = sec.id;
641
+ sectionEl.dataset.sectionId = sec.id;
642
+ sectionEl.draggable = true; // for the (future) drag-to-reorder
643
+ sectionEl.className = 'draggable-section';
644
+
645
+ var header = document.createElement('div');
646
+ header.className = 'section-header';
647
+ var h2 = document.createElement('h2'); h2.textContent = sec.title;
648
+ header.appendChild(h2);
649
+
650
+ var btn = document.createElement('button');
651
+ btn.className = 'comment-btn';
652
+ btn.type = 'button';
653
+ btn.dataset.sectionId = sec.id;
654
+ btn.setAttribute('aria-label', 'View comments on ' + sec.title);
655
+ // htmx swaps the count into this span — initial value 0, the server
656
+ // will tell us the real count via /api/<slug>/count?sectionId=...
657
+ btn.innerHTML = '💬 <span class="count" '
658
+ + 'hx-get="/api/{{slug}}/count?sectionId=' + encodeURIComponent(sec.id) + '" '
659
+ + 'hx-trigger="load, refresh-counts from:body" '
660
+ + 'hx-swap="innerHTML">0</span>';
661
+ btn.addEventListener('click', function (sid) { return function () { openCommentPanel(sid); }; }(sec.id));
662
+ header.appendChild(btn);
663
+
664
+ sectionEl.appendChild(header);
665
+ var content = document.createElement('div');
666
+ content.className = 'content';
667
+ content.dataset.sectionId = sec.id;
668
+ content.innerHTML = renderMarkdown(sec.body);
669
+ sectionEl.appendChild(content);
670
+ main.appendChild(sectionEl);
671
+ }
672
+ // htmx: process newly-inserted elements so the count hx-get triggers
673
+ if (window.htmx) htmx.process(main);
674
+ }
675
+
676
+ // ── Live preview (vanilla JS — htmx can't do client rendering) ───
677
+ var previewDebounce = null;
678
+ function schedulePreviewUpdate() {
679
+ if (previewDebounce) clearTimeout(previewDebounce);
680
+ previewDebounce = setTimeout(function () {
681
+ renderTOCAndSections();
682
+ postProcessRenderedContent();
683
+ }, 250);
684
+ }
685
+
686
+ // Mermaid / highlight.js hooks. These run after every re-render.
687
+ // If those libraries are present at runtime, this is what wires them
688
+ // up. (We don't ship them — they're optional enhancements.)
689
+ function postProcessRenderedContent() {
690
+ // Mermaid: <pre class="mermaid">…</pre> blocks become rendered diagrams.
691
+ if (window.mermaid) {
692
+ try { window.mermaid.run({ querySelector: '.content pre code.language-mermaid, .content pre.mermaid' }); }
693
+ catch (e) { /* swallow — mermaid's fault, not ours */ }
694
+ }
695
+ // highlight.js: <pre><code class="language-xxx"> blocks.
696
+ if (window.hljs) {
697
+ $$('.content pre code').forEach(function (el) {
698
+ try { window.hljs.highlightElement(el); } catch (e) { /* ignore */ }
699
+ });
700
+ }
701
+ }
702
+
703
+ // ── Edit mode (manual, because htmx can't replace a div with a textarea) ─
704
+ function enterEditMode() {
705
+ document.body.dataset.mode = 'edit';
706
+ $('#edit-toggle').hidden = true;
707
+ $('#discard-btn').hidden = false;
708
+ var sections = parseSections(currentMarkdown);
709
+ for (var i = 0; i < sections.length; i++) {
710
+ var sec = sections[i];
711
+ var content = document.querySelector('.content[data-section-id="' + sec.id + '"]');
712
+ if (!content) continue;
713
+ var ta = document.createElement('textarea');
714
+ ta.className = 'section-editor';
715
+ ta.dataset.sectionId = sec.id;
716
+ ta.name = 'editor';
717
+ ta.value = sec.body;
718
+ ta.rows = Math.max(5, sec.body.split('\n').length + 2);
719
+ ta.setAttribute('aria-label', 'Edit ' + sec.title);
720
+ // htmx: any keystroke triggers an autosave (PUT) with a 1s debounce.
721
+ // The textarea's *value* is what gets sent — hx-include below gathers
722
+ // the markdown from this element, and a hidden mirror carries the
723
+ // full plan (so the server always has the whole document).
724
+ ta.setAttribute('hx-trigger', 'input changed delay:1s from:body');
725
+ ta.setAttribute('hx-put', '/api/{{slug}}/plan');
726
+ ta.setAttribute('hx-swap', 'none');
727
+ content.parentNode.replaceChild(ta, content);
728
+ }
729
+ // Wire up the live-preview update on every keystroke.
730
+ $$('.section-editor').forEach(function (ta) {
731
+ ta.addEventListener('input', function () {
732
+ rebuildCurrentMarkdownFromEditors();
733
+ schedulePreviewUpdate();
734
+ });
735
+ });
736
+ // Process the newly-inserted textareas so htmx starts listening.
737
+ if (window.htmx) htmx.process(document.body);
738
+ }
739
+
740
+ function exitEditMode() {
741
+ document.body.dataset.mode = 'view';
742
+ $('#edit-toggle').hidden = false;
743
+ $('#discard-btn').hidden = true;
744
+ currentMarkdown = initialMarkdown;
745
+ renderTOCAndSections();
746
+ postProcessRenderedContent();
747
+ }
748
+
749
+ function rebuildCurrentMarkdownFromEditors() {
750
+ // The server expects the FULL plan markdown on every PUT, so we
751
+ // rebuild it by walking the sections and re-joining the bodies.
752
+ // To do that we need the section titles — they don't change in
753
+ // edit mode (only the bodies do), so we parse once and reuse.
754
+ var sections = parseSections(currentMarkdown);
755
+ for (var i = 0; i < sections.length; i++) {
756
+ var ta = document.querySelector('.section-editor[data-section-id="' + sections[i].id + '"]');
757
+ if (ta) sections[i].body = ta.value;
758
+ }
759
+ // Stitch back to markdown. Keep the title and preamble as they were.
760
+ var lines = currentMarkdown.split('\n');
761
+ var newParts = [];
762
+ var titleLine = lines[0] && /^#\s+/.test(lines[0]) ? lines[0] : '# ' + ((META && META.title) || 'Untitled');
763
+ newParts.push(titleLine);
764
+ for (var j = 0; j < sections.length; j++) {
765
+ newParts.push('## ' + sections[j].title + '\n\n' + sections[j].body);
766
+ }
767
+ currentMarkdown = newParts.join('\n\n') + '\n';
768
+ }
769
+
770
+ // ── htmx event hooks: save status indicator ──────────────────────
771
+ // The <textarea> uses hx-swap="none" so we don't swap any UI on save,
772
+ // but we still want the user to see "Saving…" / "Saved" / "Save failed".
773
+ document.body.addEventListener('htmx:beforeRequest', function (e) {
774
+ var el = e.target;
775
+ if (el && el.tagName === 'TEXTAREA' && el.classList.contains('section-editor')) {
776
+ var status = $('#save-status');
777
+ if (status) { status.textContent = 'Saving…'; status.className = 'save-status saving'; }
778
+ }
779
+ });
780
+ document.body.addEventListener('htmx:afterRequest', function (e) {
781
+ var el = e.target;
782
+ if (el && el.tagName === 'TEXTAREA' && el.classList.contains('section-editor')) {
783
+ var status = $('#save-status');
784
+ if (!status) return;
785
+ if (e.detail.xhr.status === 200) {
786
+ status.textContent = 'Saved';
787
+ status.className = 'save-status saved';
788
+ setTimeout(function () { status.textContent = ''; status.className = 'save-status'; }, 2000);
789
+ } else {
790
+ status.textContent = 'Save failed (' + e.detail.xhr.status + ')';
791
+ status.className = 'save-status error';
792
+ }
793
+ }
794
+ // After a comment is added, refresh the count badges on the
795
+ // section buttons so the user can see the new total.
796
+ if (el && el.classList && el.classList.contains('comment-form')) {
797
+ document.body.dispatchEvent(new Event('refresh-counts'));
798
+ }
799
+ });
800
+
801
+ // ── Comment panel (vanilla JS — htmx drives the data flow) ───────
802
+ function openCommentPanel(sectionId) {
803
+ activeSectionId = sectionId;
804
+ var sec = null;
805
+ var sections = parseSections(currentMarkdown);
806
+ for (var i = 0; i < sections.length; i++) {
807
+ if (sections[i].id === sectionId) { sec = sections[i]; break; }
808
+ }
809
+ $('#comment-section-title').textContent = sec ? sec.title : sectionId;
810
+ $('#comment-section-id').value = sectionId;
811
+ // Re-load comments for this specific section.
812
+ var list = $('#comment-list');
813
+ list.setAttribute('hx-get', '/api/{{slug}}/comments?format=html&sectionId=' + encodeURIComponent(sectionId));
814
+ if (window.htmx) htmx.ajax('GET', list.getAttribute('hx-get'), { target: list, swap: 'innerHTML' });
815
+ $('#new-comment').value = '';
816
+ var panel = $('#comment-panel');
817
+ panel.classList.add('open');
818
+ panel.setAttribute('aria-hidden', 'false');
819
+ setTimeout(function () { $('#new-comment').focus(); }, 300);
820
+ }
821
+
822
+ function closeCommentPanel() {
823
+ var panel = $('#comment-panel');
824
+ panel.classList.remove('open');
825
+ panel.setAttribute('aria-hidden', 'true');
826
+ activeSectionId = null;
827
+ }
828
+
829
+ // ── Keyboard shortcuts (vanilla JS — htmx can't intercept keys) ─
830
+ // Cmd/Ctrl+Enter inside the comment textarea submits the form (htmx
831
+ // handles the actual POST). Esc closes the panel. Cmd+B/I/K wrap
832
+ // selections in the active section editor with **/ */[link]()`.
833
+ document.addEventListener('keydown', function (e) {
834
+ // Cmd/Ctrl+Enter → submit the comment form
835
+ if ((e.metaKey || e.ctrlKey) && e.key === 'Enter'
836
+ && e.target && e.target.id === 'new-comment') {
837
+ e.preventDefault();
838
+ e.target.form.requestSubmit();
839
+ return;
840
+ }
841
+ // Esc → close the comment panel
842
+ if (e.key === 'Escape' && $('#comment-panel').classList.contains('open')) {
843
+ closeCommentPanel();
844
+ return;
845
+ }
846
+ // Cmd/Ctrl+S → force a save now (bypass the debounce)
847
+ if ((e.metaKey || e.ctrlKey) && e.key === 's') {
848
+ e.preventDefault();
849
+ if (document.body.dataset.mode === 'edit') {
850
+ // Re-typing in the last textarea nudges htmx's debounce
851
+ $$('.section-editor').forEach(function (ta) {
852
+ ta.dispatchEvent(new Event('input', { bubbles: true }));
853
+ });
854
+ }
855
+ return;
856
+ }
857
+ // Cmd/Ctrl+B / I / K in an editor → wrap selection
858
+ if (e.target && e.target.classList && e.target.classList.contains('section-editor')) {
859
+ var wrap = null;
860
+ if ((e.metaKey || e.ctrlKey) && e.key === 'b') wrap = '**';
861
+ else if ((e.metaKey || e.ctrlKey) && e.key === 'i') wrap = '*';
862
+ else if ((e.metaKey || e.ctrlKey) && e.key === 'k') wrap = '[';
863
+ if (wrap) {
864
+ e.preventDefault();
865
+ var ta = e.target;
866
+ var s = ta.selectionStart, t = ta.selectionEnd, v = ta.value;
867
+ var sel = v.substring(s, t);
868
+ if (wrap === '[') {
869
+ // link: [sel](url)
870
+ var url = prompt('Link URL:');
871
+ if (!url) return;
872
+ ta.value = v.substring(0, s) + '[' + sel + '](' + url + ')' + v.substring(t);
873
+ ta.selectionStart = s; ta.selectionEnd = s + 1 + sel.length + 3 + url.length;
874
+ } else {
875
+ ta.value = v.substring(0, s) + wrap + sel + wrap + v.substring(t);
876
+ ta.selectionStart = s + wrap.length; ta.selectionEnd = t + wrap.length;
877
+ }
878
+ ta.dispatchEvent(new Event('input', { bubbles: true }));
879
+ }
880
+ }
881
+ });
882
+
883
+ // ── Section drag-to-reorder (vanilla JS) ─────────────────────────
884
+ // htmx doesn't ship a drag/drop primitive, so this is a tiny
885
+ // ~30-line implementation. We don't persist the new order — that's
886
+ // a v0.2 feature — but we do re-render the sections so the user
887
+ // gets visual feedback.
888
+ var dragSrc = null;
889
+ document.addEventListener('dragstart', function (e) {
890
+ if (e.target && e.target.classList && e.target.classList.contains('draggable-section')) {
891
+ dragSrc = e.target;
892
+ e.dataTransfer.effectAllowed = 'move';
893
+ }
894
+ });
895
+ document.addEventListener('dragover', function (e) {
896
+ if (!dragSrc) return;
897
+ var over = e.target.closest && e.target.closest('section.draggable-section');
898
+ if (over && over !== dragSrc) {
899
+ e.preventDefault();
900
+ e.dataTransfer.dropEffect = 'move';
901
+ }
902
+ });
903
+ document.addEventListener('drop', function (e) {
904
+ if (!dragSrc) return;
905
+ var over = e.target.closest && e.target.closest('section.draggable-section');
906
+ if (over && over !== dragSrc) {
907
+ e.preventDefault();
908
+ var parent = dragSrc.parentNode;
909
+ // Insert `dragSrc` after `over` (or before, depending on Y position)
910
+ var rect = over.getBoundingClientRect();
911
+ var after = (e.clientY - rect.top) > rect.height / 2;
912
+ if (after) parent.insertBefore(dragSrc, over.nextSibling);
913
+ else parent.insertBefore(dragSrc, over);
914
+ }
915
+ dragSrc = null;
916
+ });
917
+
918
+ // ── Wire up DOM events that htmx doesn't handle ──────────────────
919
+ $('#edit-toggle').addEventListener('click', enterEditMode);
920
+ $('#discard-btn').addEventListener('click', exitEditMode);
921
+ $('#close-comment-panel').addEventListener('click', closeCommentPanel);
922
+
923
+ // ── Init ─────────────────────────────────────────────────────────
924
+ function init() {
925
+ renderTOCAndSections();
926
+ postProcessRenderedContent();
927
+ if (window.htmx) htmx.process(document.body); // wire up hx-* in the rendered TOC/sections
928
+ }
929
+ if (document.readyState === 'loading') {
930
+ document.addEventListener('DOMContentLoaded', init);
931
+ } else {
932
+ init();
933
+ }
934
+ })();
935
+ </script>
936
+ </body>
937
+ </html>