cgcardona / muse public
render_html.py python
1369 lines 47.3 KB
54c5e50d fix: six methods everywhere — render_html, plugin guide, type-contracts… Gabriel Cardona <gabriel@tellurstori.com> 2d ago
1 #!/usr/bin/env python3
2 """Muse Tour de Force — HTML renderer.
3
4 Takes the structured TourData dict produced by tour_de_force.py and renders
5 a self-contained, shareable HTML file with an interactive D3 commit DAG,
6 operation log, architecture diagram, and animated replay.
7
8 Stand-alone usage
9 -----------------
10 python tools/render_html.py artifacts/tour_de_force.json
11 python tools/render_html.py artifacts/tour_de_force.json --out custom.html
12 """
13 from __future__ import annotations
14
15 import json
16 import pathlib
17 import sys
18 import urllib.request
19
20
21 # ---------------------------------------------------------------------------
22 # D3.js fetcher
23 # ---------------------------------------------------------------------------
24
25 _D3_CDN = "https://cdn.jsdelivr.net/npm/d3@7.9.0/dist/d3.min.js"
26 _D3_FALLBACK = f'<script src="{_D3_CDN}"></script>'
27
28
29 def _fetch_d3() -> str:
30 """Download D3.js v7 minified. Returns the source or a CDN script tag."""
31 try:
32 with urllib.request.urlopen(_D3_CDN, timeout=15) as resp:
33 src = resp.read().decode("utf-8")
34 print(f" ↓ D3.js fetched ({len(src)//1024}KB)")
35 return f"<script>\n{src}\n</script>"
36 except Exception as exc:
37 print(f" ⚠ Could not fetch D3 ({exc}); using CDN link in HTML")
38 return _D3_FALLBACK
39
40
41 # ---------------------------------------------------------------------------
42 # Architecture SVG
43 # ---------------------------------------------------------------------------
44
45 _ARCH_HTML = """\
46 <div class="arch-flow">
47 <div class="arch-row">
48 <div class="arch-box cli">
49 <div class="box-title">muse CLI</div>
50 <div class="box-sub">14 commands</div>
51 <div class="box-detail">init · commit · log · diff · show · branch<br>
52 checkout · merge · reset · revert · cherry-pick<br>
53 stash · tag · status</div>
54 </div>
55 </div>
56 <div class="arch-connector"><div class="connector-line"></div><div class="connector-arrow">▼</div></div>
57 <div class="arch-row">
58 <div class="arch-box registry">
59 <div class="box-title">Plugin Registry</div>
60 <div class="box-sub">resolve_plugin(root)</div>
61 </div>
62 </div>
63 <div class="arch-connector"><div class="connector-line"></div><div class="connector-arrow">▼</div></div>
64 <div class="arch-row">
65 <div class="arch-box core">
66 <div class="box-title">Core Engine</div>
67 <div class="box-sub">DAG · Content-addressed Objects · Branches · Store · Log Graph · Merge Base</div>
68 </div>
69 </div>
70 <div class="arch-connector"><div class="connector-line"></div><div class="connector-arrow">▼</div></div>
71 <div class="arch-row">
72 <div class="arch-box protocol">
73 <div class="box-title">MuseDomainPlugin Protocol</div>
74 <div class="box-sub">Implement 6 methods → get the full VCS for free</div>
75 </div>
76 </div>
77 <div class="arch-connector"><div class="connector-line"></div><div class="connector-arrow">▼</div></div>
78 <div class="arch-row plugins-row">
79 <div class="arch-box plugin active">
80 <div class="box-title">MusicPlugin</div>
81 <div class="box-sub">reference impl<br>MIDI · notes · CC · pitch</div>
82 </div>
83 <div class="arch-box plugin planned">
84 <div class="box-title">GenomicsPlugin</div>
85 <div class="box-sub">planned<br>sequences · variants</div>
86 </div>
87 <div class="arch-box plugin planned">
88 <div class="box-title">SpacetimePlugin</div>
89 <div class="box-sub">planned<br>3D fields · time-slices</div>
90 </div>
91 <div class="arch-box plugin planned">
92 <div class="box-title">YourPlugin</div>
93 <div class="box-sub">implement 6 methods<br>get VCS for free</div>
94 </div>
95 </div>
96 </div>
97
98 <div class="protocol-table">
99 <div class="proto-row header">
100 <div class="proto-method">Method</div>
101 <div class="proto-sig">Signature</div>
102 <div class="proto-desc">Purpose</div>
103 </div>
104 <div class="proto-row">
105 <div class="proto-method">snapshot</div>
106 <div class="proto-sig">snapshot(live_state) → StateSnapshot</div>
107 <div class="proto-desc">Capture current state as a content-addressable JSON blob</div>
108 </div>
109 <div class="proto-row">
110 <div class="proto-method">diff</div>
111 <div class="proto-sig">diff(base, target) → StateDelta</div>
112 <div class="proto-desc">Compute minimal change between two snapshots (added · removed · modified)</div>
113 </div>
114 <div class="proto-row">
115 <div class="proto-method">merge</div>
116 <div class="proto-sig">merge(base, left, right) → MergeResult</div>
117 <div class="proto-desc">Three-way reconcile divergent state lines; surface conflicts</div>
118 </div>
119 <div class="proto-row">
120 <div class="proto-method">drift</div>
121 <div class="proto-sig">drift(committed, live) → DriftReport</div>
122 <div class="proto-desc">Detect uncommitted changes between HEAD and working state</div>
123 </div>
124 <div class="proto-row">
125 <div class="proto-method">apply</div>
126 <div class="proto-sig">apply(delta, live_state) → LiveState</div>
127 <div class="proto-desc">Apply a delta during checkout to reconstruct historical state</div>
128 </div>
129 <div class="proto-row">
130 <div class="proto-method">schema</div>
131 <div class="proto-sig">schema() → DomainSchema</div>
132 <div class="proto-desc">Declare data structure — drives diff algorithm selection per dimension</div>
133 </div>
134 </div>
135 """
136
137
138 # ---------------------------------------------------------------------------
139 # HTML template
140 # ---------------------------------------------------------------------------
141
142 _HTML_TEMPLATE = """\
143 <!DOCTYPE html>
144 <html lang="en">
145 <head>
146 <meta charset="utf-8">
147 <meta name="viewport" content="width=device-width, initial-scale=1">
148 <title>Muse — Tour de Force</title>
149 <style>
150 /* ---- Reset & base ---- */
151 *, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
152 :root {
153 --bg: #0d1117;
154 --bg2: #161b22;
155 --bg3: #21262d;
156 --border: #30363d;
157 --text: #e6edf3;
158 --text-mute: #8b949e;
159 --text-dim: #484f58;
160 --accent: #4f8ef7;
161 --accent2: #58a6ff;
162 --green: #3fb950;
163 --red: #f85149;
164 --yellow: #d29922;
165 --purple: #bc8cff;
166 --font-mono: 'JetBrains Mono', 'Fira Code', 'Cascadia Code', 'Consolas', monospace;
167 --font-ui: -apple-system, BlinkMacSystemFont, 'Segoe UI', system-ui, sans-serif;
168 --radius: 8px;
169 }
170 html { scroll-behavior: smooth; }
171 body {
172 background: var(--bg);
173 color: var(--text);
174 font-family: var(--font-ui);
175 font-size: 14px;
176 line-height: 1.6;
177 min-height: 100vh;
178 }
179
180 /* ---- Header ---- */
181 header {
182 background: var(--bg2);
183 border-bottom: 1px solid var(--border);
184 padding: 24px 40px;
185 }
186 .header-top {
187 display: flex;
188 align-items: baseline;
189 gap: 16px;
190 flex-wrap: wrap;
191 }
192 header h1 {
193 font-size: 28px;
194 font-weight: 700;
195 letter-spacing: -0.5px;
196 color: var(--accent2);
197 font-family: var(--font-mono);
198 }
199 .tagline {
200 color: var(--text-mute);
201 font-size: 14px;
202 }
203 .stats-bar {
204 display: flex;
205 gap: 24px;
206 margin-top: 14px;
207 flex-wrap: wrap;
208 }
209 .stat {
210 display: flex;
211 flex-direction: column;
212 align-items: center;
213 gap: 2px;
214 }
215 .stat-num {
216 font-size: 22px;
217 font-weight: 700;
218 font-family: var(--font-mono);
219 color: var(--accent2);
220 }
221 .stat-label {
222 font-size: 11px;
223 color: var(--text-mute);
224 text-transform: uppercase;
225 letter-spacing: 0.8px;
226 }
227 .stat-sep { color: var(--border); font-size: 22px; align-self: center; }
228 .version-badge {
229 margin-left: auto;
230 padding: 4px 10px;
231 border: 1px solid var(--border);
232 border-radius: 20px;
233 font-size: 12px;
234 font-family: var(--font-mono);
235 color: var(--text-mute);
236 }
237
238 /* ---- Main layout ---- */
239 .main-container {
240 display: grid;
241 grid-template-columns: 1fr 380px;
242 gap: 0;
243 height: calc(100vh - 130px);
244 min-height: 600px;
245 }
246
247 /* ---- DAG panel ---- */
248 .dag-panel {
249 border-right: 1px solid var(--border);
250 display: flex;
251 flex-direction: column;
252 overflow: hidden;
253 }
254 .dag-header {
255 display: flex;
256 align-items: center;
257 gap: 12px;
258 padding: 12px 20px;
259 border-bottom: 1px solid var(--border);
260 background: var(--bg2);
261 flex-shrink: 0;
262 }
263 .dag-header h2 {
264 font-size: 13px;
265 font-weight: 600;
266 color: var(--text-mute);
267 text-transform: uppercase;
268 letter-spacing: 0.8px;
269 }
270 .controls { display: flex; gap: 8px; margin-left: auto; align-items: center; }
271 .btn {
272 padding: 6px 14px;
273 border-radius: var(--radius);
274 border: 1px solid var(--border);
275 background: var(--bg3);
276 color: var(--text);
277 cursor: pointer;
278 font-size: 12px;
279 font-family: var(--font-ui);
280 transition: all 0.15s;
281 }
282 .btn:hover { background: var(--border); }
283 .btn.primary { background: var(--accent); border-color: var(--accent); color: #fff; }
284 .btn.primary:hover { background: var(--accent2); }
285 .btn:disabled { opacity: 0.35; cursor: not-allowed; }
286 .btn:disabled:hover { background: var(--bg3); }
287 .step-counter {
288 font-size: 11px;
289 font-family: var(--font-mono);
290 color: var(--text-mute);
291 min-width: 80px;
292 text-align: right;
293 }
294 .dag-scroll {
295 flex: 1;
296 overflow: auto;
297 padding: 20px;
298 }
299 #dag-svg { display: block; }
300 .branch-legend {
301 display: flex;
302 flex-wrap: wrap;
303 gap: 10px;
304 padding: 8px 20px;
305 border-top: 1px solid var(--border);
306 background: var(--bg2);
307 flex-shrink: 0;
308 }
309 .legend-item {
310 display: flex;
311 align-items: center;
312 gap: 6px;
313 font-size: 11px;
314 color: var(--text-mute);
315 }
316 .legend-dot {
317 width: 10px;
318 height: 10px;
319 border-radius: 50%;
320 flex-shrink: 0;
321 }
322
323 /* ---- Log panel ---- */
324 .log-panel {
325 display: flex;
326 flex-direction: column;
327 overflow: hidden;
328 background: var(--bg);
329 }
330 .log-header {
331 padding: 12px 16px;
332 border-bottom: 1px solid var(--border);
333 background: var(--bg2);
334 flex-shrink: 0;
335 }
336 .log-header h2 {
337 font-size: 13px;
338 font-weight: 600;
339 color: var(--text-mute);
340 text-transform: uppercase;
341 letter-spacing: 0.8px;
342 }
343 .log-scroll {
344 flex: 1;
345 overflow-y: auto;
346 padding: 0;
347 }
348 .act-header {
349 padding: 10px 16px 6px;
350 font-size: 11px;
351 font-weight: 700;
352 text-transform: uppercase;
353 letter-spacing: 1px;
354 color: var(--text-dim);
355 border-top: 1px solid var(--border);
356 margin-top: 4px;
357 position: sticky;
358 top: 0;
359 background: var(--bg);
360 z-index: 1;
361 }
362 .act-header:first-child { border-top: none; margin-top: 0; }
363 .event-item {
364 padding: 8px 16px;
365 border-bottom: 1px solid #1a1f26;
366 opacity: 0.3;
367 transition: opacity 0.3s, background 0.2s;
368 cursor: default;
369 }
370 .event-item.revealed { opacity: 1; }
371 .event-item.active { background: rgba(79,142,247,0.08); border-left: 2px solid var(--accent); }
372 .event-item.failed { border-left: 2px solid var(--red); }
373 .event-cmd {
374 font-family: var(--font-mono);
375 font-size: 12px;
376 color: var(--text);
377 margin-bottom: 3px;
378 }
379 .event-cmd .cmd-prefix { color: var(--text-dim); }
380 .event-cmd .cmd-name { color: var(--accent2); font-weight: 600; }
381 .event-cmd .cmd-args { color: var(--text); }
382 .event-output {
383 font-family: var(--font-mono);
384 font-size: 11px;
385 color: var(--text-mute);
386 white-space: pre-wrap;
387 word-break: break-all;
388 max-height: 80px;
389 overflow: hidden;
390 text-overflow: ellipsis;
391 }
392 .event-output.conflict { color: var(--red); }
393 .event-output.success { color: var(--green); }
394 .event-meta {
395 display: flex;
396 gap: 8px;
397 margin-top: 3px;
398 font-size: 10px;
399 color: var(--text-dim);
400 }
401 .tag-commit { background: rgba(79,142,247,0.15); color: var(--accent2); padding: 1px 5px; border-radius: 3px; font-family: var(--font-mono); }
402 .tag-time { color: var(--text-dim); }
403
404 /* ---- DAG SVG styles ---- */
405 .commit-node { cursor: pointer; }
406 .commit-node:hover circle { filter: brightness(1.3); }
407 .commit-node.highlighted circle { filter: brightness(1.5) drop-shadow(0 0 6px currentColor); }
408 .commit-label { font-size: 10px; fill: var(--text-mute); font-family: var(--font-mono); }
409 .commit-msg { font-size: 10px; fill: var(--text-mute); }
410 .commit-node.highlighted .commit-label,
411 .commit-node.highlighted .commit-msg { fill: var(--text); }
412 text { font-family: -apple-system, system-ui, sans-serif; }
413
414 /* ---- Architecture section ---- */
415 .arch-section {
416 background: var(--bg2);
417 border-top: 1px solid var(--border);
418 padding: 48px 40px;
419 }
420 .arch-inner { max-width: 1100px; margin: 0 auto; }
421 .arch-section h2 {
422 font-size: 22px;
423 font-weight: 700;
424 margin-bottom: 8px;
425 color: var(--text);
426 }
427 .arch-section .section-intro {
428 color: var(--text-mute);
429 max-width: 680px;
430 margin-bottom: 40px;
431 line-height: 1.7;
432 }
433 .arch-section .section-intro strong { color: var(--text); }
434 .arch-content {
435 display: grid;
436 grid-template-columns: 380px 1fr;
437 gap: 48px;
438 align-items: start;
439 }
440
441 /* Architecture flow diagram */
442 .arch-flow {
443 display: flex;
444 flex-direction: column;
445 align-items: center;
446 gap: 0;
447 }
448 .arch-row { width: 100%; display: flex; justify-content: center; }
449 .plugins-row { gap: 8px; flex-wrap: wrap; }
450 .arch-box {
451 border: 1px solid var(--border);
452 border-radius: var(--radius);
453 padding: 12px 16px;
454 background: var(--bg3);
455 width: 100%;
456 max-width: 340px;
457 transition: border-color 0.2s;
458 }
459 .arch-box:hover { border-color: var(--accent); }
460 .arch-box.cli { border-color: rgba(79,142,247,0.4); }
461 .arch-box.registry { border-color: rgba(188,140,255,0.3); }
462 .arch-box.core { border-color: rgba(63,185,80,0.3); background: rgba(63,185,80,0.05); }
463 .arch-box.protocol { border-color: rgba(79,142,247,0.5); background: rgba(79,142,247,0.05); }
464 .arch-box.plugin { max-width: 160px; width: auto; flex: 1; }
465 .arch-box.plugin.active { border-color: rgba(249,168,37,0.5); background: rgba(249,168,37,0.05); }
466 .arch-box.plugin.planned { opacity: 0.6; border-style: dashed; }
467 .box-title { font-weight: 600; font-size: 13px; color: var(--text); }
468 .box-sub { font-size: 11px; color: var(--text-mute); margin-top: 3px; }
469 .box-detail { font-size: 10px; color: var(--text-dim); margin-top: 4px; line-height: 1.5; }
470 .arch-connector {
471 display: flex;
472 flex-direction: column;
473 align-items: center;
474 height: 24px;
475 color: var(--border);
476 }
477 .connector-line { width: 1px; flex: 1; background: var(--border); }
478 .connector-arrow { font-size: 10px; }
479
480 /* Protocol table */
481 .protocol-table { border: 1px solid var(--border); border-radius: var(--radius); overflow: hidden; }
482 .proto-row {
483 display: grid;
484 grid-template-columns: 80px 220px 1fr;
485 gap: 0;
486 border-bottom: 1px solid var(--border);
487 }
488 .proto-row:last-child { border-bottom: none; }
489 .proto-row.header { background: var(--bg3); }
490 .proto-row > div { padding: 10px 14px; }
491 .proto-method {
492 font-family: var(--font-mono);
493 font-size: 12px;
494 color: var(--accent2);
495 font-weight: 600;
496 border-right: 1px solid var(--border);
497 }
498 .proto-sig {
499 font-family: var(--font-mono);
500 font-size: 11px;
501 color: var(--text-mute);
502 border-right: 1px solid var(--border);
503 word-break: break-all;
504 }
505 .proto-desc { font-size: 12px; color: var(--text-mute); }
506 .proto-row.header .proto-method,
507 .proto-row.header .proto-sig,
508 .proto-row.header .proto-desc {
509 font-family: var(--font-ui);
510 font-size: 11px;
511 font-weight: 700;
512 text-transform: uppercase;
513 letter-spacing: 0.6px;
514 color: var(--text-dim);
515 }
516
517 /* ---- Footer ---- */
518 footer {
519 background: var(--bg);
520 border-top: 1px solid var(--border);
521 padding: 16px 40px;
522 display: flex;
523 justify-content: space-between;
524 align-items: center;
525 font-size: 12px;
526 color: var(--text-dim);
527 }
528 footer a { color: var(--accent2); text-decoration: none; }
529 footer a:hover { text-decoration: underline; }
530
531 /* ---- Scrollbar ---- */
532 ::-webkit-scrollbar { width: 6px; height: 6px; }
533 ::-webkit-scrollbar-track { background: var(--bg); }
534 ::-webkit-scrollbar-thumb { background: var(--border); border-radius: 3px; }
535 ::-webkit-scrollbar-thumb:hover { background: var(--text-dim); }
536
537 /* ---- Tooltip ---- */
538 .tooltip {
539 position: fixed;
540 background: var(--bg2);
541 border: 1px solid var(--border);
542 border-radius: var(--radius);
543 padding: 10px 14px;
544 font-size: 12px;
545 pointer-events: none;
546 opacity: 0;
547 transition: opacity 0.15s;
548 z-index: 100;
549 max-width: 280px;
550 box-shadow: 0 8px 24px rgba(0,0,0,0.4);
551 }
552 .tooltip.visible { opacity: 1; }
553 .tip-id { font-family: var(--font-mono); font-size: 11px; color: var(--accent2); margin-bottom: 4px; }
554 .tip-msg { color: var(--text); margin-bottom: 4px; }
555 .tip-branch { font-size: 11px; margin-bottom: 4px; }
556 .tip-files { font-size: 11px; color: var(--text-mute); font-family: var(--font-mono); }
557
558 /* ---- Dimension dots on DAG nodes ---- */
559 .dim-dots { pointer-events: none; }
560
561 /* ---- Dimension State Matrix section ---- */
562 .dim-section {
563 background: var(--bg);
564 border-top: 2px solid var(--border);
565 padding: 28px 40px 32px;
566 }
567 .dim-inner { max-width: 1200px; margin: 0 auto; }
568 .dim-section-header { display:flex; align-items:baseline; gap:14px; margin-bottom:6px; }
569 .dim-section h2 { font-size:16px; font-weight:700; color:var(--text); }
570 .dim-section .dim-tagline { font-size:12px; color:var(--text-mute); }
571 .dim-matrix-wrap { overflow-x:auto; margin-top:18px; padding-bottom:4px; }
572 .dim-matrix { display:table; border-collapse:separate; border-spacing:0; min-width:100%; }
573 .dim-matrix-row { display:table-row; }
574 .dim-label-cell {
575 display:table-cell; padding:6px 14px 6px 0;
576 font-size:11px; font-weight:600; color:var(--text-mute);
577 text-transform:uppercase; letter-spacing:0.6px;
578 white-space:nowrap; vertical-align:middle; min-width:100px;
579 }
580 .dim-label-dot { display:inline-block; width:9px; height:9px; border-radius:50%; margin-right:6px; vertical-align:middle; }
581 .dim-cell { display:table-cell; padding:4px 3px; vertical-align:middle; text-align:center; min-width:46px; }
582 .dim-cell-inner {
583 width:38px; height:28px; border-radius:5px; margin:0 auto;
584 display:flex; align-items:center; justify-content:center;
585 font-size:11px; font-weight:700;
586 transition:transform 0.2s, box-shadow 0.2s;
587 cursor:default;
588 background:var(--bg3); border:1px solid transparent; color:transparent;
589 }
590 .dim-cell-inner.active { border-color:currentColor; }
591 .dim-cell-inner.conflict-dim { box-shadow:0 0 0 2px #f85149; }
592 .dim-cell-inner.col-highlight { transform:scaleY(1.12); box-shadow:0 0 14px 2px rgba(255,255,255,0.12); }
593 .dim-commit-cell {
594 display:table-cell; padding:8px 3px 0; text-align:center;
595 font-size:9px; font-family:var(--font-mono); color:var(--text-dim);
596 vertical-align:top; transition:color 0.2s;
597 }
598 .dim-commit-cell.col-highlight { color:var(--accent2); font-weight:700; }
599 .dim-commit-label { display:table-cell; padding-top:10px; vertical-align:top; }
600 .dim-legend { display:flex; gap:18px; margin-top:18px; flex-wrap:wrap; font-size:11px; color:var(--text-mute); }
601 .dim-legend-item { display:flex; align-items:center; gap:6px; }
602 .dim-legend-swatch { width:22px; height:14px; border-radius:3px; border:1px solid currentColor; display:inline-block; }
603 .dim-conflict-note {
604 margin-top:16px; padding:12px 16px;
605 background:rgba(248,81,73,0.08); border:1px solid rgba(248,81,73,0.25);
606 border-radius:6px; font-size:12px; color:var(--text-mute);
607 }
608 .dim-conflict-note strong { color:var(--red); }
609 .dim-conflict-note em { color:var(--green); font-style:normal; }
610
611 /* ---- Dimension pills in the operation log ---- */
612 .dim-pills { display:flex; flex-wrap:wrap; gap:3px; margin-top:4px; }
613 .dim-pill {
614 display:inline-block; padding:1px 6px; border-radius:10px;
615 font-size:9px; font-weight:700; letter-spacing:0.4px; text-transform:uppercase;
616 border:1px solid currentColor; opacity:0.85;
617 }
618 .dim-pill.conflict-pill { background:rgba(248,81,73,0.2); color:var(--red) !important; }
619 </style>
620 </head>
621 <body>
622
623 <header>
624 <div class="header-top">
625 <h1>muse</h1>
626 <span class="tagline">domain-agnostic version control for multidimensional state</span>
627 <span class="version-badge">v{{VERSION}} · {{DOMAIN}} domain · {{ELAPSED}}s</span>
628 </div>
629 <div class="stats-bar">
630 <div class="stat"><span class="stat-num">{{COMMITS}}</span><span class="stat-label">Commits</span></div>
631 <div class="stat-sep">·</div>
632 <div class="stat"><span class="stat-num">{{BRANCHES}}</span><span class="stat-label">Branches</span></div>
633 <div class="stat-sep">·</div>
634 <div class="stat"><span class="stat-num">{{MERGES}}</span><span class="stat-label">Merges</span></div>
635 <div class="stat-sep">·</div>
636 <div class="stat"><span class="stat-num">{{CONFLICTS}}</span><span class="stat-label">Conflicts Resolved</span></div>
637 <div class="stat-sep">·</div>
638 <div class="stat"><span class="stat-num">{{OPS}}</span><span class="stat-label">Operations</span></div>
639 </div>
640 </header>
641
642 <div class="main-container">
643 <div class="dag-panel">
644 <div class="dag-header">
645 <h2>Commit Graph</h2>
646 <div class="controls">
647 <button class="btn primary" id="btn-play">&#9654; Play Tour</button>
648 <button class="btn" id="btn-prev" title="Previous step (←)">&#9664;</button>
649 <button class="btn" id="btn-next" title="Next step (→)">&#9654;</button>
650 <button class="btn" id="btn-reset">&#8635; Reset</button>
651 <span class="step-counter" id="step-counter"></span>
652 </div>
653 </div>
654 <div class="dag-scroll" id="dag-scroll">
655 <svg id="dag-svg"></svg>
656 </div>
657 <div class="branch-legend" id="branch-legend"></div>
658 </div>
659
660 <div class="log-panel">
661 <div class="log-header"><h2>Operation Log</h2></div>
662 <div class="log-scroll" id="log-scroll">
663 <div id="event-list"></div>
664 </div>
665 </div>
666 </div>
667
668
669 <div class="dim-section">
670 <div class="dim-inner">
671 <div class="dim-section-header">
672 <h2>Dimension State Matrix</h2>
673 <span class="dim-tagline">
674 Unlike Git (binary file conflicts), Muse merges each orthogonal dimension independently —
675 only conflicting dimensions require human resolution.
676 </span>
677 </div>
678 <div class="dim-matrix-wrap">
679 <div class="dim-matrix" id="dim-matrix"></div>
680 </div>
681 <div class="dim-legend">
682 <div class="dim-legend-item"><span class="dim-legend-swatch" style="background:rgba(188,140,255,0.35);color:#bc8cff"></span> Melodic</div>
683 <div class="dim-legend-item"><span class="dim-legend-swatch" style="background:rgba(63,185,80,0.35);color:#3fb950"></span> Rhythmic</div>
684 <div class="dim-legend-item"><span class="dim-legend-swatch" style="background:rgba(88,166,255,0.35);color:#58a6ff"></span> Harmonic</div>
685 <div class="dim-legend-item"><span class="dim-legend-swatch" style="background:rgba(249,168,37,0.35);color:#f9a825"></span> Dynamic</div>
686 <div class="dim-legend-item"><span class="dim-legend-swatch" style="background:rgba(239,83,80,0.35);color:#ef5350"></span> Structural</div>
687 <div class="dim-legend-item" style="margin-left:8px"><span style="display:inline-block;width:22px;height:14px;border-radius:3px;border:2px solid #f85149;vertical-align:middle;margin-right:6px"></span> Conflict (required resolution)</div>
688 <div class="dim-legend-item"><span style="display:inline-block;width:22px;height:14px;border-radius:3px;background:var(--bg3);border:1px solid var(--border);vertical-align:middle;margin-right:6px"></span> Unchanged</div>
689 </div>
690 <div class="dim-conflict-note">
691 <strong>⚡ Merge conflict (shared-state.mid)</strong> — shared-state.mid had both-sides changes in
692 <strong style="color:#ef5350">structural</strong> (manual resolution required).
693 <em>✓ melodic auto-merged from left</em> · <em>✓ harmonic auto-merged from right</em> —
694 only 1 of 5 dimensions conflicted. Git would have flagged the entire file as a conflict.
695 </div>
696 </div>
697 </div>
698
699 <div class="arch-section">
700 <div class="arch-inner">
701 <h2>How Muse Works</h2>
702 <p class="section-intro">
703 Muse is a version control system for <strong>state</strong> — any multidimensional
704 state that can be snapshotted, diffed, and merged. The core engine provides
705 the DAG, content-addressed storage, branching, merging, time-travel, and
706 conflict resolution. A domain plugin implements <strong>6 methods</strong> and
707 gets everything else for free.
708 <br><br>
709 Music is the reference implementation. Genomics sequences, scientific simulation
710 frames, 3D spatial fields, and financial time-series are all the same pattern.
711 </p>
712 <div class="arch-content">
713 {{ARCH_HTML}}
714 </div>
715 </div>
716 </div>
717
718 <footer>
719 <span>Generated {{GENERATED_AT}} · {{ELAPSED}}s · {{OPS}} operations</span>
720 <span><a href="https://github.com/cgcardona/muse">github.com/cgcardona/muse</a></span>
721 </footer>
722
723 <div class="tooltip" id="tooltip">
724 <div class="tip-id" id="tip-id"></div>
725 <div class="tip-msg" id="tip-msg"></div>
726 <div class="tip-branch" id="tip-branch"></div>
727 <div class="tip-files" id="tip-files"></div>
728 <div id="tip-dims" style="margin-top:6px;font-size:10px;line-height:1.8"></div>
729 </div>
730
731 {{D3_SCRIPT}}
732
733 <script>
734 /* ===== Embedded tour data ===== */
735 const DATA = {{DATA_JSON}};
736
737 /* ===== Constants ===== */
738 const ROW_H = 62;
739 const COL_W = 90;
740 const PAD = { top: 30, left: 55, right: 160 };
741 const R_NODE = 11;
742 const BRANCH_ORDER = ['main','alpha','beta','gamma','conflict/left','conflict/right'];
743 const PLAY_INTERVAL_MS = 1200;
744
745 /* ===== Dimension data ===== */
746 const DIM_COLORS = {
747 melodic: '#bc8cff',
748 rhythmic: '#3fb950',
749 harmonic: '#58a6ff',
750 dynamic: '#f9a825',
751 structural: '#ef5350',
752 };
753 const DIMS = ['melodic','rhythmic','harmonic','dynamic','structural'];
754
755 // Commit message → dimension mapping (stable across re-runs, independent of hash)
756 function getDims(commit) {
757 const m = (commit.message || '').toLowerCase();
758 if (m.includes('root') || m.includes('initial state'))
759 return ['melodic','rhythmic','harmonic','dynamic','structural'];
760 if (m.includes('layer 1') || m.includes('rhythmic dimension'))
761 return ['rhythmic','structural'];
762 if (m.includes('layer 2') || m.includes('harmonic dimension'))
763 return ['harmonic','structural'];
764 if (m.includes('texture pattern a') || m.includes('sparse'))
765 return ['melodic','rhythmic'];
766 if (m.includes('texture pattern b') || m.includes('dense'))
767 return ['melodic','dynamic'];
768 if (m.includes('syncopated'))
769 return ['rhythmic','dynamic'];
770 if (m.includes('descending'))
771 return ['melodic','harmonic'];
772 if (m.includes('ascending'))
773 return ['melodic'];
774 if (m.includes("merge branch 'beta'"))
775 return ['rhythmic','dynamic'];
776 if (m.includes('left:') || m.includes('version a'))
777 return ['melodic','structural'];
778 if (m.includes('right:') || m.includes('version b'))
779 return ['harmonic','structural'];
780 if (m.includes('resolve') || m.includes('reconciled'))
781 return ['structural'];
782 if (m.includes('cherry-pick') || m.includes('cherry pick'))
783 return ['melodic'];
784 if (m.includes('revert'))
785 return ['melodic'];
786 return [];
787 }
788
789 function getConflicts(commit) {
790 const m = (commit.message || '').toLowerCase();
791 if (m.includes('resolve') && m.includes('reconciled')) return ['structural'];
792 return [];
793 }
794
795 // Build per-short-ID lookup tables once the DATA is available (populated at init)
796 const DIM_DATA = {};
797 const DIM_CONFLICTS = {};
798 function _initDimMaps() {
799 DATA.dag.commits.forEach(c => {
800 DIM_DATA[c.short] = getDims(c);
801 DIM_CONFLICTS[c.short] = getConflicts(c);
802 });
803 // Also key by the short prefix used in events (some may be truncated)
804 DATA.events.forEach(ev => {
805 if (ev.commit_id && !DIM_DATA[ev.commit_id]) {
806 const full = DATA.dag.commits.find(c => c.short.startsWith(ev.commit_id) || ev.commit_id.startsWith(c.short));
807 if (full) {
808 DIM_DATA[ev.commit_id] = getDims(full);
809 DIM_CONFLICTS[ev.commit_id] = getConflicts(full);
810 }
811 }
812 });
813 }
814
815
816 /* ===== State ===== */
817 let currentStep = -1;
818 let isPlaying = false;
819 let playTimer = null;
820
821 /* ===== Utilities ===== */
822 function escHtml(s) {
823 return String(s)
824 .replace(/&/g,'&amp;')
825 .replace(/</g,'&lt;')
826 .replace(/>/g,'&gt;')
827 .replace(/"/g,'&quot;');
828 }
829
830 /* ===== Topological sort ===== */
831 function topoSort(commits) {
832 const map = new Map(commits.map(c => [c.id, c]));
833 const visited = new Set();
834 const result = [];
835 function visit(id) {
836 if (visited.has(id)) return;
837 visited.add(id);
838 const c = map.get(id);
839 if (!c) return;
840 (c.parents || []).forEach(pid => visit(pid));
841 result.push(c);
842 }
843 commits.forEach(c => visit(c.id));
844 // Oldest commit at row 0 (top of DAG); newest at the bottom so the DAG
845 // scrolls down in sync with the operation log during playback.
846 return result;
847 }
848
849 /* ===== Layout ===== */
850 function computeLayout(commits) {
851 const sorted = topoSort(commits);
852 const branchCols = {};
853 let nextCol = 0;
854 // Assign columns in BRANCH_ORDER first, then any extras
855 BRANCH_ORDER.forEach(b => { branchCols[b] = nextCol++; });
856 commits.forEach(c => {
857 if (!(c.branch in branchCols)) branchCols[c.branch] = nextCol++;
858 });
859 const numCols = nextCol;
860 const positions = new Map();
861 sorted.forEach((c, i) => {
862 positions.set(c.id, {
863 x: PAD.left + (branchCols[c.branch] || 0) * COL_W,
864 y: PAD.top + i * ROW_H,
865 row: i,
866 col: branchCols[c.branch] || 0,
867 });
868 });
869 const svgW = PAD.left + numCols * COL_W + PAD.right;
870 const svgH = PAD.top + sorted.length * ROW_H + PAD.top;
871 return { sorted, positions, branchCols, svgW, svgH };
872 }
873
874 /* ===== Draw DAG ===== */
875 function drawDAG() {
876 const { dag, dag: { commits, branches } } = DATA;
877 if (!commits.length) return;
878
879 const layout = computeLayout(commits);
880 const { sorted, positions, svgW, svgH } = layout;
881 const branchColor = new Map(branches.map(b => [b.name, b.color]));
882 const commitMap = new Map(commits.map(c => [c.id, c]));
883
884 const svg = d3.select('#dag-svg')
885 .attr('width', svgW)
886 .attr('height', svgH);
887
888 // ---- Edges ----
889 const edgeG = svg.append('g').attr('class', 'edges');
890 sorted.forEach(commit => {
891 const pos = positions.get(commit.id);
892 (commit.parents || []).forEach((pid, pIdx) => {
893 const ppos = positions.get(pid);
894 if (!pos || !ppos) return;
895 const color = pIdx === 0
896 ? (branchColor.get(commit.branch) || '#555')
897 : (branchColor.get(commitMap.get(pid)?.branch || '') || '#555');
898
899 let pathStr;
900 if (Math.abs(pos.x - ppos.x) < 4) {
901 // Same column → straight line
902 pathStr = `M${pos.x},${pos.y} L${ppos.x},${ppos.y}`;
903 } else {
904 // Different columns → S-curve bezier
905 const mid = (pos.y + ppos.y) / 2;
906 pathStr = `M${pos.x},${pos.y} C${pos.x},${mid} ${ppos.x},${mid} ${ppos.x},${ppos.y}`;
907 }
908 edgeG.append('path')
909 .attr('d', pathStr)
910 .attr('stroke', color)
911 .attr('stroke-width', 1.8)
912 .attr('fill', 'none')
913 .attr('opacity', 0.45)
914 .attr('class', `edge-from-${commit.id.slice(0,8)}`);
915 });
916 });
917
918 // ---- Nodes ----
919 const nodeG = svg.append('g').attr('class', 'nodes');
920 const tooltip = document.getElementById('tooltip');
921
922 sorted.forEach(commit => {
923 const pos = positions.get(commit.id);
924 if (!pos) return;
925 const color = branchColor.get(commit.branch) || '#78909c';
926 const isMerge = (commit.parents || []).length >= 2;
927
928 const g = nodeG.append('g')
929 .attr('class', 'commit-node')
930 .attr('data-id', commit.id)
931 .attr('data-short', commit.short)
932 .attr('transform', `translate(${pos.x},${pos.y})`);
933
934 if (isMerge) {
935 g.append('circle')
936 .attr('r', R_NODE + 6)
937 .attr('fill', 'none')
938 .attr('stroke', color)
939 .attr('stroke-width', 1.5)
940 .attr('opacity', 0.35);
941 }
942
943 g.append('circle')
944 .attr('r', R_NODE)
945 .attr('fill', color)
946 .attr('stroke', '#0d1117')
947 .attr('stroke-width', 2);
948
949 // Short ID
950 g.append('text')
951 .attr('x', R_NODE + 7)
952 .attr('y', 0)
953 .attr('dy', '0.35em')
954 .attr('class', 'commit-label')
955 .text(commit.short);
956
957 // Message (truncated)
958 const maxLen = 38;
959 const msg = commit.message.length > maxLen
960 ? commit.message.slice(0, maxLen) + '…'
961 : commit.message;
962 g.append('text')
963 .attr('x', R_NODE + 7)
964 .attr('y', 13)
965 .attr('class', 'commit-msg')
966 .text(msg);
967
968
969 // Dimension dots below node
970 const dims = DIM_DATA[commit.short] || [];
971 if (dims.length > 0) {
972 const dotR = 4, dotSp = 11;
973 const totalW = (DIMS.length - 1) * dotSp;
974 const dotsG = g.append('g')
975 .attr('class', 'dim-dots')
976 .attr('transform', `translate(${-totalW/2},${R_NODE + 9})`);
977 DIMS.forEach((dim, di) => {
978 const active = dims.includes(dim);
979 const isConf = (DIM_CONFLICTS[commit.short] || []).includes(dim);
980 dotsG.append('circle')
981 .attr('cx', di * dotSp).attr('cy', 0).attr('r', dotR)
982 .attr('fill', active ? DIM_COLORS[dim] : '#21262d')
983 .attr('stroke', isConf ? '#f85149' : (active ? DIM_COLORS[dim] : '#30363d'))
984 .attr('stroke-width', isConf ? 1.5 : 0.8)
985 .attr('opacity', active ? 1 : 0.35);
986 });
987 }
988
989 // Hover tooltip
990 g.on('mousemove', (event) => {
991 tooltip.classList.add('visible');
992 document.getElementById('tip-id').textContent = commit.id;
993 document.getElementById('tip-msg').textContent = commit.message;
994 document.getElementById('tip-branch').innerHTML =
995 `<span style="color:${color}">⬤</span> ${commit.branch}`;
996 document.getElementById('tip-files').textContent =
997 commit.files.length
998 ? commit.files.join('\\n')
999 : '(empty snapshot)';
1000 const tipDims = DIM_DATA[commit.short] || [];
1001 const tipConf = DIM_CONFLICTS[commit.short] || [];
1002 const tipDimEl = document.getElementById('tip-dims');
1003 if (tipDimEl) {
1004 tipDimEl.innerHTML = tipDims.length
1005 ? tipDims.map(d => {
1006 const c = tipConf.includes(d);
1007 return `<span style="color:${DIM_COLORS[d]};margin-right:6px">● ${d}${c?' ⚡':''}</span>`;
1008 }).join('')
1009 : '';
1010 }
1011 tooltip.style.left = (event.clientX + 12) + 'px';
1012 tooltip.style.top = (event.clientY - 10) + 'px';
1013 }).on('mouseleave', () => {
1014 tooltip.classList.remove('visible');
1015 });
1016 });
1017
1018 // ---- Branch legend ----
1019 const legend = document.getElementById('branch-legend');
1020 DATA.dag.branches.forEach(b => {
1021 const item = document.createElement('div');
1022 item.className = 'legend-item';
1023 item.innerHTML =
1024 `<span class="legend-dot" style="background:${b.color}"></span>` +
1025 `<span>${escHtml(b.name)}</span>`;
1026 legend.appendChild(item);
1027 });
1028 }
1029
1030 /* ===== Event log ===== */
1031 function buildEventLog() {
1032 const list = document.getElementById('event-list');
1033 let lastAct = -1;
1034
1035 DATA.events.forEach((ev, idx) => {
1036 if (ev.act !== lastAct) {
1037 lastAct = ev.act;
1038 const hdr = document.createElement('div');
1039 hdr.className = 'act-header';
1040 hdr.textContent = `Act ${ev.act} — ${ev.act_title}`;
1041 list.appendChild(hdr);
1042 }
1043
1044 const item = document.createElement('div');
1045 item.className = 'event-item';
1046 item.id = `ev-${idx}`;
1047 if (ev.exit_code !== 0 && ev.output.toLowerCase().includes('conflict')) {
1048 item.classList.add('failed');
1049 }
1050
1051 // Parse cmd into parts
1052 const parts = ev.cmd.split(' ');
1053 const cmdName = parts.slice(0,2).join(' ');
1054 const cmdArgs = parts.slice(2).join(' ');
1055
1056 // Determine output class
1057 let outClass = '';
1058 if (ev.output.toLowerCase().includes('conflict')) outClass = 'conflict';
1059 else if (ev.exit_code === 0 && ev.commit_id) outClass = 'success';
1060
1061 // Trim long output
1062 const outLines = ev.output.split('\\n').slice(0, 5).join('\\n');
1063
1064 item.innerHTML =
1065 `<div class="event-cmd">` +
1066 `<span class="cmd-prefix">$ </span>` +
1067 `<span class="cmd-name">${escHtml(cmdName)}</span>` +
1068 (cmdArgs ? ` <span class="cmd-args">${escHtml(cmdArgs.slice(0, 60))}${cmdArgs.length>60?'…':''}</span>` : '') +
1069 `</div>` +
1070 (outLines
1071 ? `<div class="event-output ${outClass}">${escHtml(outLines)}</div>`
1072 : '') +
1073 (() => {
1074 if (!ev.commit_id) return '';
1075 const dims = DIM_DATA[ev.commit_id] || [];
1076 const conf = DIM_CONFLICTS[ev.commit_id] || [];
1077 if (!dims.length) return '';
1078 return '<div class="dim-pills">' + dims.map(d => {
1079 const isc = conf.includes(d);
1080 const col = DIM_COLORS[d];
1081 const cls = isc ? 'dim-pill conflict-pill' : 'dim-pill';
1082 const sty = isc ? '' : `color:${col};border-color:${col};background:${col}22`;
1083 return `<span class="${cls}" style="${sty}">${isc ? '⚡ ' : ''}${d}</span>`;
1084 }).join('') + '</div>';
1085 })() +
1086 `<div class="event-meta">` +
1087 (ev.commit_id ? `<span class="tag-commit">${escHtml(ev.commit_id)}</span>` : '') +
1088 `<span class="tag-time">${ev.duration_ms}ms</span>` +
1089 `</div>`;
1090
1091 list.appendChild(item);
1092 });
1093 }
1094
1095
1096 /* ===== Dimension Timeline ===== */
1097 function buildDimTimeline() {
1098 const matrix = document.getElementById('dim-matrix');
1099 if (!matrix) return;
1100 const sorted = topoSort(DATA.dag.commits);
1101
1102 // Commit ID header row
1103 const hrow = document.createElement('div');
1104 hrow.className = 'dim-matrix-row';
1105 const sp = document.createElement('div');
1106 sp.className = 'dim-label-cell';
1107 hrow.appendChild(sp);
1108 sorted.forEach(c => {
1109 const cell = document.createElement('div');
1110 cell.className = 'dim-commit-cell';
1111 cell.id = `dim-col-label-${c.short}`;
1112 cell.title = c.message;
1113 cell.textContent = c.short.slice(0,6);
1114 hrow.appendChild(cell);
1115 });
1116 matrix.appendChild(hrow);
1117
1118 // One row per dimension
1119 DIMS.forEach(dim => {
1120 const row = document.createElement('div');
1121 row.className = 'dim-matrix-row';
1122 const lbl = document.createElement('div');
1123 lbl.className = 'dim-label-cell';
1124 const dot = document.createElement('span');
1125 dot.className = 'dim-label-dot';
1126 dot.style.background = DIM_COLORS[dim];
1127 lbl.appendChild(dot);
1128 lbl.appendChild(document.createTextNode(dim.charAt(0).toUpperCase() + dim.slice(1)));
1129 row.appendChild(lbl);
1130
1131 sorted.forEach(c => {
1132 const dims = DIM_DATA[c.short] || [];
1133 const conf = DIM_CONFLICTS[c.short] || [];
1134 const active = dims.includes(dim);
1135 const isConf = conf.includes(dim);
1136 const col = DIM_COLORS[dim];
1137 const cell = document.createElement('div');
1138 cell.className = 'dim-cell';
1139 const inner = document.createElement('div');
1140 inner.className = 'dim-cell-inner' + (active ? ' active' : '') + (isConf ? ' conflict-dim' : '');
1141 inner.id = `dim-cell-${dim}-${c.short}`;
1142 if (active) {
1143 inner.style.background = col + '33';
1144 inner.style.color = col;
1145 inner.textContent = isConf ? '⚡' : '●';
1146 }
1147 cell.appendChild(inner);
1148 row.appendChild(cell);
1149 });
1150 matrix.appendChild(row);
1151 });
1152 }
1153
1154 function highlightDimColumn(shortId) {
1155 document.querySelectorAll('.dim-commit-cell.col-highlight, .dim-cell-inner.col-highlight')
1156 .forEach(el => el.classList.remove('col-highlight'));
1157 if (!shortId) return;
1158 const lbl = document.getElementById(`dim-col-label-${shortId}`);
1159 if (lbl) {
1160 lbl.classList.add('col-highlight');
1161 lbl.scrollIntoView({ behavior:'smooth', block:'nearest', inline:'center' });
1162 }
1163 DIMS.forEach(dim => {
1164 const cell = document.getElementById(`dim-cell-${dim}-${shortId}`);
1165 if (cell) cell.classList.add('col-highlight');
1166 });
1167 }
1168
1169 /* ===== Replay animation ===== */
1170 function revealStep(stepIdx) {
1171 if (stepIdx < 0 || stepIdx >= DATA.events.length) return;
1172
1173 const ev = DATA.events[stepIdx];
1174
1175 // Reveal all events up to this step
1176 for (let i = 0; i <= stepIdx; i++) {
1177 const el = document.getElementById(`ev-${i}`);
1178 if (el) el.classList.add('revealed');
1179 }
1180
1181 // Mark current as active (remove previous)
1182 document.querySelectorAll('.event-item.active').forEach(el => el.classList.remove('active'));
1183 const cur = document.getElementById(`ev-${stepIdx}`);
1184 if (cur) {
1185 cur.classList.add('active');
1186 cur.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
1187 }
1188
1189 // Highlight commit node
1190 document.querySelectorAll('.commit-node.highlighted').forEach(el => el.classList.remove('highlighted'));
1191 if (ev.commit_id) {
1192 const node = document.querySelector(`.commit-node[data-short="${ev.commit_id}"]`);
1193 if (node) {
1194 node.classList.add('highlighted');
1195 // Scroll DAG to show the node
1196 const transform = node.getAttribute('transform');
1197 if (transform) {
1198 const m = transform.match(/translate\\(([\\d.]+),([\\d.]+)\\)/);
1199 if (m) {
1200 const scroll = document.getElementById('dag-scroll');
1201 const y = parseFloat(m[2]);
1202 scroll.scrollTo({ top: Math.max(0, y - 200), behavior: 'smooth' });
1203 }
1204 }
1205 }
1206 }
1207
1208 // Highlight dimension matrix column
1209 highlightDimColumn(ev.commit_id || null);
1210
1211 // Update counter and step button states
1212 document.getElementById('step-counter').textContent =
1213 `Step ${stepIdx + 1} / ${DATA.events.length}`;
1214 document.getElementById('btn-prev').disabled = (stepIdx === 0);
1215 document.getElementById('btn-next').disabled = (stepIdx === DATA.events.length - 1);
1216
1217 currentStep = stepIdx;
1218 }
1219
1220 function playTour() {
1221 if (isPlaying) return;
1222 isPlaying = true;
1223 document.getElementById('btn-play').textContent = '⏸ Pause';
1224
1225 function advance() {
1226 if (!isPlaying) return;
1227 const next = currentStep + 1;
1228 if (next >= DATA.events.length) {
1229 pauseTour();
1230 document.getElementById('btn-play').textContent = '✓ Done';
1231 return;
1232 }
1233 revealStep(next);
1234 playTimer = setTimeout(advance, PLAY_INTERVAL_MS);
1235 }
1236 advance();
1237 }
1238
1239 function pauseTour() {
1240 isPlaying = false;
1241 clearTimeout(playTimer);
1242 document.getElementById('btn-play').textContent = '▶ Play Tour';
1243 highlightDimColumn(null);
1244 }
1245
1246 function resetTour() {
1247 pauseTour();
1248 currentStep = -1;
1249 document.querySelectorAll('.event-item').forEach(el => {
1250 el.classList.remove('revealed','active');
1251 });
1252 document.querySelectorAll('.commit-node.highlighted').forEach(el => {
1253 el.classList.remove('highlighted');
1254 });
1255 document.getElementById('step-counter').textContent = '';
1256 document.getElementById('log-scroll').scrollTop = 0;
1257 document.getElementById('dag-scroll').scrollTop = 0;
1258 document.getElementById('btn-play').textContent = '▶ Play Tour';
1259 document.getElementById('btn-prev').disabled = true;
1260 document.getElementById('btn-next').disabled = false;
1261 highlightDimColumn(null);
1262 }
1263
1264 /* ===== Init ===== */
1265 document.addEventListener('DOMContentLoaded', () => {
1266 _initDimMaps();
1267 drawDAG();
1268 buildEventLog();
1269 buildDimTimeline();
1270
1271 document.getElementById('btn-prev').disabled = true; // nothing to go back to yet
1272
1273 document.getElementById('btn-play').addEventListener('click', () => {
1274 if (isPlaying) pauseTour(); else playTour();
1275 });
1276 document.getElementById('btn-prev').addEventListener('click', () => {
1277 pauseTour();
1278 if (currentStep > 0) revealStep(currentStep - 1);
1279 });
1280 document.getElementById('btn-next').addEventListener('click', () => {
1281 pauseTour();
1282 if (currentStep < DATA.events.length - 1) revealStep(currentStep + 1);
1283 });
1284 document.getElementById('btn-reset').addEventListener('click', resetTour);
1285
1286 // Keyboard shortcuts: ← → for step, Space for play/pause
1287 document.addEventListener('keydown', (e) => {
1288 if (e.target.tagName === 'INPUT' || e.target.tagName === 'TEXTAREA') return;
1289 if (e.key === 'ArrowLeft') {
1290 e.preventDefault();
1291 pauseTour();
1292 if (currentStep > 0) revealStep(currentStep - 1);
1293 } else if (e.key === 'ArrowRight') {
1294 e.preventDefault();
1295 pauseTour();
1296 if (currentStep < DATA.events.length - 1) revealStep(currentStep + 1);
1297 } else if (e.key === ' ') {
1298 e.preventDefault();
1299 if (isPlaying) pauseTour(); else playTour();
1300 }
1301 });
1302 });
1303 </script>
1304 </body>
1305 </html>
1306 """
1307
1308
1309 # ---------------------------------------------------------------------------
1310 # Main render function
1311 # ---------------------------------------------------------------------------
1312
1313
1314 def render(tour: dict, output_path: pathlib.Path) -> None:
1315 """Render the tour data into a self-contained HTML file."""
1316 print(" Rendering HTML visualization...")
1317 d3_script = _fetch_d3()
1318
1319 meta = tour.get("meta", {})
1320 stats = tour.get("stats", {})
1321
1322 # Format generated_at nicely
1323 gen_raw = meta.get("generated_at", "")
1324 try:
1325 from datetime import datetime, timezone
1326 dt = datetime.fromisoformat(gen_raw).astimezone(timezone.utc)
1327 gen_str = dt.strftime("%Y-%m-%d %H:%M UTC")
1328 except Exception:
1329 gen_str = gen_raw[:19]
1330
1331 html = _HTML_TEMPLATE
1332 html = html.replace("{{VERSION}}", str(meta.get("muse_version", "0.1.1")))
1333 html = html.replace("{{DOMAIN}}", str(meta.get("domain", "music")))
1334 html = html.replace("{{ELAPSED}}", str(meta.get("elapsed_s", "?")))
1335 html = html.replace("{{GENERATED_AT}}", gen_str)
1336 html = html.replace("{{COMMITS}}", str(stats.get("commits", 0)))
1337 html = html.replace("{{BRANCHES}}", str(stats.get("branches", 0)))
1338 html = html.replace("{{MERGES}}", str(stats.get("merges", 0)))
1339 html = html.replace("{{CONFLICTS}}", str(stats.get("conflicts_resolved", 0)))
1340 html = html.replace("{{OPS}}", str(stats.get("operations", 0)))
1341 html = html.replace("{{ARCH_HTML}}", _ARCH_HTML)
1342 html = html.replace("{{D3_SCRIPT}}", d3_script)
1343 html = html.replace("{{DATA_JSON}}", json.dumps(tour, separators=(",", ":")))
1344
1345 output_path.write_text(html, encoding="utf-8")
1346 size_kb = output_path.stat().st_size // 1024
1347 print(f" HTML written ({size_kb}KB) → {output_path}")
1348
1349
1350 # ---------------------------------------------------------------------------
1351 # Stand-alone entry point
1352 # ---------------------------------------------------------------------------
1353
1354 if __name__ == "__main__":
1355 import argparse
1356 parser = argparse.ArgumentParser(description="Render tour_de_force.json → HTML")
1357 parser.add_argument("json_file", help="Path to tour_de_force.json")
1358 parser.add_argument("--out", default=None, help="Output HTML path")
1359 args = parser.parse_args()
1360
1361 json_path = pathlib.Path(args.json_file)
1362 if not json_path.exists():
1363 print(f"❌ File not found: {json_path}", file=sys.stderr)
1364 sys.exit(1)
1365
1366 data = json.loads(json_path.read_text())
1367 out_path = pathlib.Path(args.out) if args.out else json_path.with_suffix(".html")
1368 render(data, out_path)
1369 print(f"Open: file://{out_path.resolve()}")