@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.
- package/LICENSE +21 -0
- package/README.md +364 -0
- package/cli/audit.mjs +144 -0
- package/cli/banner.mjs +41 -0
- package/cli/bin.mjs +186 -0
- package/cli/copy.mjs +508 -0
- package/cli/export.mjs +87 -0
- package/cli/init.mjs +147 -0
- package/cli/install.mjs +390 -0
- package/cli/plan-templates.mjs +523 -0
- package/cli/plan.mjs +2087 -0
- package/cli/prompts.mjs +163 -0
- package/cli/update.mjs +273 -0
- package/cli/utils.mjs +153 -0
- package/config/AGENTS.md +282 -0
- package/config/agents/baldr.md +148 -0
- package/config/agents/forseti.md +112 -0
- package/config/agents/frigg.md +101 -0
- package/config/agents/heimdall.md +157 -0
- package/config/agents/hermod.md +144 -0
- package/config/agents/mimir.md +115 -0
- package/config/agents/odin.md +309 -0
- package/config/agents/quick.md +78 -0
- package/config/agents/semble-search.md +44 -0
- package/config/agents/thor.md +97 -0
- package/config/agents/tyr.md +96 -0
- package/config/agents/vidarr.md +100 -0
- package/config/agents/vor.md +140 -0
- package/config/commands/audit.md +1 -0
- package/config/commands/explain.md +1 -0
- package/config/commands/init.md +1 -0
- package/config/commands/learn.md +1 -0
- package/config/commands/pr-review.md +1 -0
- package/config/commands/tailscale-serve.md +96 -0
- package/config/hooks/README.md +29 -0
- package/config/hooks/post-tool-use.md +16 -0
- package/config/hooks/pre-tool-use.md +16 -0
- package/config/opencode.json +52 -0
- package/config/opencode.json.template +52 -0
- package/config/rules/general.md +8 -0
- package/config/rules/git.md +11 -0
- package/config/rules/javascript.md +10 -0
- package/config/rules/python.md +10 -0
- package/config/rules/testing.md +10 -0
- package/config/skills/bizar/README.md +9 -0
- package/config/skills/bizar/SKILL.md +187 -0
- package/config/skills/cpp-coding-standards/README.md +28 -0
- package/config/skills/cpp-coding-standards/SKILL.md +634 -0
- package/config/skills/cpp-coding-standards/agents/openai.yaml +4 -0
- package/config/skills/cpp-coding-standards/references/concurrency.md +320 -0
- package/config/skills/cpp-coding-standards/references/error-handling.md +229 -0
- package/config/skills/cpp-coding-standards/references/memory-safety.md +216 -0
- package/config/skills/cpp-coding-standards/references/modern-idioms.md +282 -0
- package/config/skills/cpp-coding-standards/references/review-checklist.md +96 -0
- package/config/skills/cpp-testing/README.md +28 -0
- package/config/skills/cpp-testing/SKILL.md +304 -0
- package/config/skills/cpp-testing/agents/openai.yaml +4 -0
- package/config/skills/cpp-testing/references/coverage.md +370 -0
- package/config/skills/cpp-testing/references/framework-compare.md +175 -0
- package/config/skills/cpp-testing/references/host-test-for-embedded.md +499 -0
- package/config/skills/cpp-testing/references/mocking.md +364 -0
- package/config/skills/cpp-testing/references/tdd-workflow.md +308 -0
- package/config/skills/embedded-esp-idf/README.md +41 -0
- package/config/skills/embedded-esp-idf/SKILL.md +439 -0
- package/config/skills/embedded-esp-idf/agents/openai.yaml +4 -0
- package/config/skills/embedded-esp-idf/references/freertos-patterns.md +214 -0
- package/config/skills/embedded-esp-idf/references/host-tests.md +164 -0
- package/config/skills/embedded-esp-idf/references/idf-py-commands.md +157 -0
- package/config/skills/embedded-esp-idf/references/kconfig.md +159 -0
- package/config/skills/embedded-esp-idf/references/logging-discipline.md +118 -0
- package/config/skills/embedded-esp-idf/references/memory-and-iram.md +137 -0
- package/config/skills/embedded-esp-idf/references/nvs.md +121 -0
- package/config/skills/embedded-esp-idf/references/packed-structs.md +192 -0
- package/config/skills/embedded-esp-idf/scripts/idf_env.sh +47 -0
- package/config/skills/embedded-esp-idf/scripts/size_check.sh +77 -0
- package/config/skills/self-improvement/SKILL.md +64 -0
- package/package.json +47 -0
- package/templates/plan/htmx.min.js +1 -0
- package/templates/plan/library/bug-investigation.mdx +79 -0
- package/templates/plan/library/decision-record.mdx +71 -0
- package/templates/plan/library/feature-design.mdx +92 -0
- package/templates/plan/meta.json.template +8 -0
- package/templates/plan/plan.canvas.template +1711 -0
- package/templates/plan/plan.html.template +937 -0
- 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, '&').replace(/</g, '<').replace(/>/g, '>')
|
|
476
|
+
.replace(/"/g, '"').replace(/'/g, ''');
|
|
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§ionId=' + 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>
|