cgcardona / muse public
render_html.py python
1712 lines 57.3 KB
ed16b306 refactor: use shared nav header on demo page 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 — Demo</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 /* ---- Stats header ---- */
181 header {
182 background: var(--bg2);
183 border-bottom: 1px solid var(--border);
184 padding: 16px 40px;
185 }
186 .stats-bar {
187 display: flex;
188 gap: 24px;
189 margin-top: 14px;
190 flex-wrap: wrap;
191 }
192 .stat {
193 display: flex;
194 flex-direction: column;
195 align-items: center;
196 gap: 2px;
197 }
198 .stat-num {
199 font-size: 22px;
200 font-weight: 700;
201 font-family: var(--font-mono);
202 color: var(--accent2);
203 }
204 .stat-label {
205 font-size: 11px;
206 color: var(--text-mute);
207 text-transform: uppercase;
208 letter-spacing: 0.8px;
209 }
210 .stat-sep { color: var(--border); font-size: 22px; align-self: center; }
211
212 /* ---- Main layout ---- */
213 .main-container {
214 display: grid;
215 grid-template-columns: 1fr 380px;
216 gap: 0;
217 height: calc(100vh - 130px);
218 min-height: 600px;
219 }
220
221 /* ---- DAG panel ---- */
222 .dag-panel {
223 border-right: 1px solid var(--border);
224 display: flex;
225 flex-direction: column;
226 overflow: hidden;
227 }
228 .dag-header {
229 display: flex;
230 align-items: center;
231 gap: 12px;
232 padding: 12px 20px;
233 border-bottom: 1px solid var(--border);
234 background: var(--bg2);
235 flex-shrink: 0;
236 }
237 .dag-header h2 {
238 font-size: 13px;
239 font-weight: 600;
240 color: var(--text-mute);
241 text-transform: uppercase;
242 letter-spacing: 0.8px;
243 }
244 .controls { display: flex; gap: 8px; margin-left: auto; align-items: center; }
245 .btn {
246 padding: 6px 14px;
247 border-radius: var(--radius);
248 border: 1px solid var(--border);
249 background: var(--bg3);
250 color: var(--text);
251 cursor: pointer;
252 font-size: 12px;
253 font-family: var(--font-ui);
254 transition: all 0.15s;
255 }
256 .btn:hover { background: var(--border); }
257 .btn.primary { background: var(--accent); border-color: var(--accent); color: #fff; }
258 .btn.primary:hover { background: var(--accent2); }
259 .btn:disabled { opacity: 0.35; cursor: not-allowed; }
260 .btn:disabled:hover { background: var(--bg3); }
261 .step-counter {
262 font-size: 11px;
263 font-family: var(--font-mono);
264 color: var(--text-mute);
265 min-width: 80px;
266 text-align: right;
267 }
268 .dag-scroll {
269 flex: 1;
270 overflow: auto;
271 padding: 20px;
272 }
273 #dag-svg { display: block; }
274 .branch-legend {
275 display: flex;
276 flex-wrap: wrap;
277 gap: 10px;
278 padding: 8px 20px;
279 border-top: 1px solid var(--border);
280 background: var(--bg2);
281 flex-shrink: 0;
282 }
283 .legend-item {
284 display: flex;
285 align-items: center;
286 gap: 6px;
287 font-size: 11px;
288 color: var(--text-mute);
289 }
290 .legend-dot {
291 width: 10px;
292 height: 10px;
293 border-radius: 50%;
294 flex-shrink: 0;
295 }
296
297 /* ---- Log panel ---- */
298 .log-panel {
299 display: flex;
300 flex-direction: column;
301 overflow: hidden;
302 background: var(--bg);
303 }
304 .log-header {
305 padding: 12px 16px;
306 border-bottom: 1px solid var(--border);
307 background: var(--bg2);
308 flex-shrink: 0;
309 }
310 .log-header h2 {
311 font-size: 13px;
312 font-weight: 600;
313 color: var(--text-mute);
314 text-transform: uppercase;
315 letter-spacing: 0.8px;
316 }
317 .log-scroll {
318 flex: 1;
319 overflow-y: auto;
320 padding: 0;
321 }
322 .act-header {
323 padding: 10px 16px 6px;
324 font-size: 11px;
325 font-weight: 700;
326 text-transform: uppercase;
327 letter-spacing: 1px;
328 color: var(--text-dim);
329 border-top: 1px solid var(--border);
330 margin-top: 4px;
331 position: sticky;
332 top: 0;
333 background: var(--bg);
334 z-index: 1;
335 }
336 .act-header:first-child { border-top: none; margin-top: 0; }
337 .event-item {
338 padding: 8px 16px;
339 border-bottom: 1px solid #1a1f26;
340 opacity: 0.3;
341 transition: opacity 0.3s, background 0.2s;
342 cursor: default;
343 }
344 .event-item.revealed { opacity: 1; }
345 .event-item.active { background: rgba(79,142,247,0.08); border-left: 2px solid var(--accent); }
346 .event-item.failed { border-left: 2px solid var(--red); }
347 .event-cmd {
348 font-family: var(--font-mono);
349 font-size: 12px;
350 color: var(--text);
351 margin-bottom: 3px;
352 }
353 .event-cmd .cmd-prefix { color: var(--text-dim); }
354 .event-cmd .cmd-name { color: var(--accent2); font-weight: 600; }
355 .event-cmd .cmd-args { color: var(--text); }
356 .event-output {
357 font-family: var(--font-mono);
358 font-size: 11px;
359 color: var(--text-mute);
360 white-space: pre-wrap;
361 word-break: break-all;
362 max-height: 80px;
363 overflow: hidden;
364 text-overflow: ellipsis;
365 }
366 .event-output.conflict { color: var(--red); }
367 .event-output.success { color: var(--green); }
368 .event-item.rich-act .event-output { max-height: 220px; }
369
370 /* ---- Act jump bar ---- */
371 .act-jump-bar {
372 display: flex;
373 flex-wrap: wrap;
374 gap: 4px;
375 padding: 6px 12px;
376 border-bottom: 1px solid var(--border);
377 background: var(--bg2);
378 flex-shrink: 0;
379 }
380 .act-jump-bar span {
381 font-size: 10px;
382 color: var(--text-dim);
383 align-self: center;
384 margin-right: 4px;
385 font-weight: 600;
386 text-transform: uppercase;
387 letter-spacing: 0.6px;
388 }
389 .act-jump-btn {
390 font-size: 10px;
391 padding: 2px 8px;
392 border-radius: 4px;
393 background: var(--bg3);
394 border: 1px solid var(--border);
395 color: var(--text-mute);
396 cursor: pointer;
397 font-family: var(--font-mono);
398 transition: background 0.15s, color 0.15s;
399 }
400 .act-jump-btn:hover { background: var(--bg); color: var(--accent); border-color: var(--accent); }
401 .act-jump-btn.reveal-all { border-color: var(--green); color: var(--green); }
402 .act-jump-btn.reveal-all:hover { background: rgba(63,185,80,0.08); }
403
404 .event-meta {
405 display: flex;
406 gap: 8px;
407 margin-top: 3px;
408 font-size: 10px;
409 color: var(--text-dim);
410 }
411 .tag-commit { background: rgba(79,142,247,0.15); color: var(--accent2); padding: 1px 5px; border-radius: 3px; font-family: var(--font-mono); }
412 .tag-time { color: var(--text-dim); }
413
414 /* ---- DAG SVG styles ---- */
415 .commit-node { cursor: pointer; }
416 .commit-node:hover circle { filter: brightness(1.3); }
417 .commit-node.highlighted circle { filter: brightness(1.5) drop-shadow(0 0 6px currentColor); }
418 .commit-label { font-size: 10px; fill: var(--text-mute); font-family: var(--font-mono); }
419 .commit-msg { font-size: 10px; fill: var(--text-mute); }
420 .commit-node.highlighted .commit-label,
421 .commit-node.highlighted .commit-msg { fill: var(--text); }
422 text { font-family: -apple-system, system-ui, sans-serif; }
423
424 /* ---- Registry callout ---- */
425 .registry-callout {
426 background: var(--bg2);
427 border-top: 1px solid var(--border);
428 padding: 40px;
429 }
430 .registry-callout-inner {
431 max-width: 1100px;
432 margin: 0 auto;
433 display: flex;
434 align-items: center;
435 gap: 32px;
436 flex-wrap: wrap;
437 }
438 .registry-callout-text { flex: 1; min-width: 200px; }
439 .registry-callout-title {
440 font-size: 16px;
441 font-weight: 700;
442 color: var(--text);
443 margin-bottom: 6px;
444 }
445 .registry-callout-sub {
446 font-size: 13px;
447 color: var(--text-mute);
448 line-height: 1.6;
449 }
450 .registry-callout-btn {
451 flex-shrink: 0;
452 display: inline-block;
453 padding: 10px 22px;
454 background: var(--accent);
455 color: #fff;
456 font-size: 13px;
457 font-weight: 600;
458 border-radius: var(--radius);
459 text-decoration: none;
460 transition: opacity 0.15s;
461 }
462 .registry-callout-btn:hover { opacity: 0.85; }
463
464 /* ---- Domain Dashboard section ---- */
465 .domain-section {
466 background: var(--bg);
467 border-top: 1px solid var(--border);
468 padding: 60px 40px;
469 }
470 .domain-inner { max-width: 1100px; margin: 0 auto; }
471 .domain-section h2, .crdt-section h2 {
472 font-size: 22px;
473 font-weight: 700;
474 margin-bottom: 8px;
475 color: var(--text);
476 }
477 .domain-section .section-intro, .crdt-section .section-intro {
478 color: var(--text-mute);
479 max-width: 680px;
480 margin-bottom: 36px;
481 line-height: 1.7;
482 }
483 .domain-section .section-intro strong, .crdt-section .section-intro strong { color: var(--text); }
484 .domain-grid {
485 display: grid;
486 grid-template-columns: repeat(auto-fill, minmax(320px, 1fr));
487 gap: 20px;
488 }
489 .domain-card {
490 border: 1px solid var(--border);
491 border-radius: var(--radius);
492 background: var(--bg2);
493 overflow: hidden;
494 transition: border-color 0.2s;
495 }
496 .domain-card:hover { border-color: var(--accent); }
497 .domain-card.active-domain { border-color: rgba(249,168,37,0.5); }
498 .domain-card.scaffold-domain { border-style: dashed; opacity: 0.85; }
499 .domain-card-header {
500 padding: 14px 16px;
501 border-bottom: 1px solid var(--border);
502 display: flex;
503 align-items: center;
504 gap: 10px;
505 background: var(--bg3);
506 }
507 .domain-badge {
508 font-family: var(--font-mono);
509 font-size: 11px;
510 padding: 2px 8px;
511 border-radius: 4px;
512 background: rgba(79,142,247,0.12);
513 border: 1px solid rgba(79,142,247,0.3);
514 color: var(--accent2);
515 }
516 .domain-badge.active { background: rgba(249,168,37,0.12); border-color: rgba(249,168,37,0.4); color: #f9a825; }
517 .domain-name {
518 font-weight: 700;
519 font-size: 15px;
520 font-family: var(--font-mono);
521 color: var(--text);
522 }
523 .domain-active-dot {
524 margin-left: auto;
525 width: 8px;
526 height: 8px;
527 border-radius: 50%;
528 background: var(--green);
529 }
530 .domain-card-body { padding: 14px 16px; }
531 .domain-desc {
532 font-size: 13px;
533 color: var(--text-mute);
534 margin-bottom: 12px;
535 line-height: 1.5;
536 }
537 .domain-caps {
538 display: flex;
539 flex-wrap: wrap;
540 gap: 6px;
541 margin-bottom: 12px;
542 }
543 .cap-pill {
544 font-size: 10px;
545 padding: 2px 8px;
546 border-radius: 12px;
547 border: 1px solid var(--border);
548 color: var(--text-mute);
549 background: var(--bg3);
550 }
551 .cap-pill.cap-crdt { border-color: rgba(188,140,255,0.4); color: var(--purple); background: rgba(188,140,255,0.08); }
552 .cap-pill.cap-ot { border-color: rgba(88,166,255,0.4); color: var(--accent2); background: rgba(88,166,255,0.08); }
553 .cap-pill.cap-schema { border-color: rgba(63,185,80,0.4); color: var(--green); background: rgba(63,185,80,0.08); }
554 .cap-pill.cap-delta { border-color: rgba(249,168,37,0.4); color: #f9a825; background: rgba(249,168,37,0.08); }
555 .domain-dims {
556 font-size: 11px;
557 color: var(--text-dim);
558 }
559 .domain-dims strong { color: var(--text-mute); }
560 .domain-new-card {
561 border: 2px dashed var(--border);
562 border-radius: var(--radius);
563 background: transparent;
564 display: flex;
565 flex-direction: column;
566 align-items: center;
567 justify-content: center;
568 padding: 32px 20px;
569 text-align: center;
570 gap: 12px;
571 transition: border-color 0.2s;
572 cursor: default;
573 }
574 .domain-new-card:hover { border-color: var(--accent); }
575 .domain-new-icon { font-size: 28px; color: var(--text-dim); }
576 .domain-new-title { font-size: 14px; font-weight: 600; color: var(--text-mute); }
577 .domain-new-cmd {
578 font-family: var(--font-mono);
579 font-size: 12px;
580 background: var(--bg3);
581 border: 1px solid var(--border);
582 border-radius: 4px;
583 padding: 6px 12px;
584 color: var(--accent2);
585 }
586 .domain-new-link {
587 font-size: 11px;
588 color: var(--text-dim);
589 }
590 .domain-new-link a { color: var(--accent); text-decoration: none; }
591 .domain-new-link a:hover { text-decoration: underline; }
592
593 /* ---- CRDT Primitives section ---- */
594 .crdt-section {
595 background: var(--bg2);
596 border-top: 1px solid var(--border);
597 padding: 60px 40px;
598 }
599 .crdt-inner { max-width: 1100px; margin: 0 auto; }
600 .crdt-grid {
601 display: grid;
602 grid-template-columns: repeat(auto-fill, minmax(260px, 1fr));
603 gap: 20px;
604 }
605 .crdt-card {
606 border: 1px solid var(--border);
607 border-radius: var(--radius);
608 background: var(--bg);
609 overflow: hidden;
610 transition: border-color 0.2s;
611 }
612 .crdt-card:hover { border-color: var(--purple); }
613 .crdt-card-header {
614 padding: 12px 16px;
615 border-bottom: 1px solid var(--border);
616 background: rgba(188,140,255,0.06);
617 display: flex;
618 align-items: center;
619 gap: 8px;
620 }
621 .crdt-type-badge {
622 font-family: var(--font-mono);
623 font-size: 11px;
624 padding: 2px 8px;
625 border-radius: 4px;
626 background: rgba(188,140,255,0.12);
627 border: 1px solid rgba(188,140,255,0.3);
628 color: var(--purple);
629 }
630 .crdt-card-title { font-weight: 700; font-size: 14px; color: var(--text); }
631 .crdt-card-sub { font-size: 11px; color: var(--text-mute); }
632 .crdt-card-body { padding: 14px 16px; }
633 .crdt-output {
634 font-family: var(--font-mono);
635 font-size: 11px;
636 color: var(--text-mute);
637 white-space: pre-wrap;
638 line-height: 1.6;
639 background: var(--bg3);
640 border: 1px solid var(--border);
641 border-radius: 4px;
642 padding: 10px 12px;
643 }
644 .crdt-output .out-win { color: var(--green); }
645 .crdt-output .out-key { color: var(--accent2); }
646
647 /* ---- Architecture section ---- */
648 .arch-section {
649 background: var(--bg2);
650 border-top: 1px solid var(--border);
651 padding: 48px 40px;
652 }
653 .arch-inner { max-width: 1100px; margin: 0 auto; }
654 .arch-section h2 {
655 font-size: 22px;
656 font-weight: 700;
657 margin-bottom: 8px;
658 color: var(--text);
659 }
660 .arch-section .section-intro {
661 color: var(--text-mute);
662 max-width: 680px;
663 margin-bottom: 40px;
664 line-height: 1.7;
665 }
666 .arch-section .section-intro strong { color: var(--text); }
667 .arch-content {
668 display: grid;
669 grid-template-columns: 380px 1fr;
670 gap: 48px;
671 align-items: start;
672 }
673
674 /* Architecture flow diagram */
675 .arch-flow {
676 display: flex;
677 flex-direction: column;
678 align-items: center;
679 gap: 0;
680 }
681 .arch-row { width: 100%; display: flex; justify-content: center; }
682 .plugins-row { gap: 8px; flex-wrap: wrap; }
683 .arch-box {
684 border: 1px solid var(--border);
685 border-radius: var(--radius);
686 padding: 12px 16px;
687 background: var(--bg3);
688 width: 100%;
689 max-width: 340px;
690 transition: border-color 0.2s;
691 }
692 .arch-box:hover { border-color: var(--accent); }
693 .arch-box.cli { border-color: rgba(79,142,247,0.4); }
694 .arch-box.registry { border-color: rgba(188,140,255,0.3); }
695 .arch-box.core { border-color: rgba(63,185,80,0.3); background: rgba(63,185,80,0.05); }
696 .arch-box.protocol { border-color: rgba(79,142,247,0.5); background: rgba(79,142,247,0.05); }
697 .arch-box.plugin { max-width: 160px; width: auto; flex: 1; }
698 .arch-box.plugin.active { border-color: rgba(249,168,37,0.5); background: rgba(249,168,37,0.05); }
699 .arch-box.plugin.planned { opacity: 0.6; border-style: dashed; }
700 .box-title { font-weight: 600; font-size: 13px; color: var(--text); }
701 .box-sub { font-size: 11px; color: var(--text-mute); margin-top: 3px; }
702 .box-detail { font-size: 10px; color: var(--text-dim); margin-top: 4px; line-height: 1.5; }
703 .arch-connector {
704 display: flex;
705 flex-direction: column;
706 align-items: center;
707 height: 24px;
708 color: var(--border);
709 }
710 .connector-line { width: 1px; flex: 1; background: var(--border); }
711 .connector-arrow { font-size: 10px; }
712
713 /* Protocol table */
714 .protocol-table { border: 1px solid var(--border); border-radius: var(--radius); overflow: hidden; }
715 .proto-row {
716 display: grid;
717 grid-template-columns: 80px 220px 1fr;
718 gap: 0;
719 border-bottom: 1px solid var(--border);
720 }
721 .proto-row:last-child { border-bottom: none; }
722 .proto-row.header { background: var(--bg3); }
723 .proto-row > div { padding: 10px 14px; }
724 .proto-method {
725 font-family: var(--font-mono);
726 font-size: 12px;
727 color: var(--accent2);
728 font-weight: 600;
729 border-right: 1px solid var(--border);
730 }
731 .proto-sig {
732 font-family: var(--font-mono);
733 font-size: 11px;
734 color: var(--text-mute);
735 border-right: 1px solid var(--border);
736 word-break: break-all;
737 }
738 .proto-desc { font-size: 12px; color: var(--text-mute); }
739 .proto-row.header .proto-method,
740 .proto-row.header .proto-sig,
741 .proto-row.header .proto-desc {
742 font-family: var(--font-ui);
743 font-size: 11px;
744 font-weight: 700;
745 text-transform: uppercase;
746 letter-spacing: 0.6px;
747 color: var(--text-dim);
748 }
749
750 /* ---- Footer ---- */
751 footer {
752 background: var(--bg);
753 border-top: 1px solid var(--border);
754 padding: 16px 40px;
755 display: flex;
756 justify-content: space-between;
757 align-items: center;
758 font-size: 12px;
759 color: var(--text-dim);
760 }
761 footer a { color: var(--accent2); text-decoration: none; }
762 footer a:hover { text-decoration: underline; }
763
764 /* ---- Scrollbar ---- */
765 ::-webkit-scrollbar { width: 6px; height: 6px; }
766 ::-webkit-scrollbar-track { background: var(--bg); }
767 ::-webkit-scrollbar-thumb { background: var(--border); border-radius: 3px; }
768 ::-webkit-scrollbar-thumb:hover { background: var(--text-dim); }
769
770 /* ---- Tooltip ---- */
771 .tooltip {
772 position: fixed;
773 background: var(--bg2);
774 border: 1px solid var(--border);
775 border-radius: var(--radius);
776 padding: 10px 14px;
777 font-size: 12px;
778 pointer-events: none;
779 opacity: 0;
780 transition: opacity 0.15s;
781 z-index: 100;
782 max-width: 280px;
783 box-shadow: 0 8px 24px rgba(0,0,0,0.4);
784 }
785 .tooltip.visible { opacity: 1; }
786 .tip-id { font-family: var(--font-mono); font-size: 11px; color: var(--accent2); margin-bottom: 4px; }
787 .tip-msg { color: var(--text); margin-bottom: 4px; }
788 .tip-branch { font-size: 11px; margin-bottom: 4px; }
789 .tip-files { font-size: 11px; color: var(--text-mute); font-family: var(--font-mono); }
790
791 /* ---- Dimension dots on DAG nodes ---- */
792 .dim-dots { pointer-events: none; }
793
794 /* ---- Dimension State Matrix section ---- */
795 .dim-section {
796 background: var(--bg);
797 border-top: 2px solid var(--border);
798 padding: 28px 40px 32px;
799 }
800 .dim-inner { max-width: 1200px; margin: 0 auto; }
801 .dim-section-header { display:flex; align-items:baseline; gap:14px; margin-bottom:6px; }
802 .dim-section h2 { font-size:16px; font-weight:700; color:var(--text); }
803 .dim-section .dim-tagline { font-size:12px; color:var(--text-mute); }
804 .dim-matrix-wrap { overflow-x:auto; margin-top:18px; padding-bottom:4px; }
805 .dim-matrix { display:table; border-collapse:separate; border-spacing:0; min-width:100%; }
806 .dim-matrix-row { display:table-row; }
807 .dim-label-cell {
808 display:table-cell; padding:6px 14px 6px 0;
809 font-size:11px; font-weight:600; color:var(--text-mute);
810 text-transform:uppercase; letter-spacing:0.6px;
811 white-space:nowrap; vertical-align:middle; min-width:100px;
812 }
813 .dim-label-dot { display:inline-block; width:9px; height:9px; border-radius:50%; margin-right:6px; vertical-align:middle; }
814 .dim-cell { display:table-cell; padding:4px 3px; vertical-align:middle; text-align:center; min-width:46px; }
815 .dim-cell-inner {
816 width:38px; height:28px; border-radius:5px; margin:0 auto;
817 display:flex; align-items:center; justify-content:center;
818 font-size:11px; font-weight:700;
819 transition:transform 0.2s, box-shadow 0.2s;
820 cursor:default;
821 background:var(--bg3); border:1px solid transparent; color:transparent;
822 }
823 .dim-cell-inner.active { border-color:currentColor; }
824 .dim-cell-inner.conflict-dim { box-shadow:0 0 0 2px #f85149; }
825 .dim-cell-inner.col-highlight { transform:scaleY(1.12); box-shadow:0 0 14px 2px rgba(255,255,255,0.12); }
826 .dim-commit-cell {
827 display:table-cell; padding:8px 3px 0; text-align:center;
828 font-size:9px; font-family:var(--font-mono); color:var(--text-dim);
829 vertical-align:top; transition:color 0.2s;
830 }
831 .dim-commit-cell.col-highlight { color:var(--accent2); font-weight:700; }
832 .dim-commit-label { display:table-cell; padding-top:10px; vertical-align:top; }
833 .dim-legend { display:flex; gap:18px; margin-top:18px; flex-wrap:wrap; font-size:11px; color:var(--text-mute); }
834 .dim-legend-item { display:flex; align-items:center; gap:6px; }
835 .dim-legend-swatch { width:22px; height:14px; border-radius:3px; border:1px solid currentColor; display:inline-block; }
836 .dim-conflict-note {
837 margin-top:16px; padding:12px 16px;
838 background:rgba(248,81,73,0.08); border:1px solid rgba(248,81,73,0.25);
839 border-radius:6px; font-size:12px; color:var(--text-mute);
840 }
841 .dim-conflict-note strong { color:var(--red); }
842 .dim-conflict-note em { color:var(--green); font-style:normal; }
843
844 /* ---- Dimension pills in the operation log ---- */
845 .dim-pills { display:flex; flex-wrap:wrap; gap:3px; margin-top:4px; }
846 .dim-pill {
847 display:inline-block; padding:1px 6px; border-radius:10px;
848 font-size:9px; font-weight:700; letter-spacing:0.4px; text-transform:uppercase;
849 border:1px solid currentColor; opacity:0.85;
850 }
851 .dim-pill.conflict-pill { background:rgba(248,81,73,0.2); color:var(--red) !important; }
852
853 /* ---- shared nav ---- */
854 nav {
855 background: var(--header-bg);
856 border-bottom: 1px solid rgba(255,255,255,0.08);
857 padding: 0 40px;
858 display: flex;
859 align-items: center;
860 gap: 0;
861 height: 52px;
862 position: sticky;
863 top: 0;
864 z-index: 100;
865 }
866 .nav-logo {
867 font-family: var(--mono);
868 font-size: 16px;
869 font-weight: 700;
870 color: #6ea8fe;
871 margin-right: 32px;
872 text-decoration: none;
873 }
874 .nav-logo:hover { text-decoration: none; }
875 .nav-link {
876 font-size: 13px;
877 color: rgba(255,255,255,0.45);
878 padding: 0 14px;
879 height: 100%;
880 display: flex;
881 align-items: center;
882 border-bottom: 2px solid transparent;
883 text-decoration: none;
884 transition: color 0.15s, border-color 0.15s;
885 }
886 .nav-link:hover { color: #e6edf3; text-decoration: none; }
887 .nav-link.current { color: #e6edf3; border-bottom-color: #6ea8fe; }
888 .nav-spacer { flex: 1; }
889 .nav-badge {
890 font-size: 11px;
891 background: rgba(79,142,247,0.12);
892 border: 1px solid rgba(79,142,247,0.3);
893 color: #6ea8fe;
894 border-radius: 4px;
895 padding: 2px 8px;
896 font-family: var(--mono);
897 }
898 </style>
899 </head>
900 <body>
901
902 <nav>
903 <a class="nav-logo" href="index.html">muse</a>
904 <a class="nav-link current" href="demo.html">Demo</a>
905 <a class="nav-link" href="index.html">Domain Registry</a>
906 <a class="nav-link" href="https://github.com/cgcardona/muse/blob/main/docs/guide/plugin-authoring-guide.md">Plugin Guide</a>
907 <div class="nav-spacer"></div>
908 <span class="nav-badge">v{{VERSION}}</span>
909 </nav>
910
911 <header>
912 <div class="stats-bar">
913 <div class="stat"><span class="stat-num">{{COMMITS}}</span><span class="stat-label">Commits</span></div>
914 <div class="stat-sep">·</div>
915 <div class="stat"><span class="stat-num">{{BRANCHES}}</span><span class="stat-label">Branches</span></div>
916 <div class="stat-sep">·</div>
917 <div class="stat"><span class="stat-num">{{MERGES}}</span><span class="stat-label">Merges</span></div>
918 <div class="stat-sep">·</div>
919 <div class="stat"><span class="stat-num">{{CONFLICTS}}</span><span class="stat-label">Conflicts Resolved</span></div>
920 <div class="stat-sep">·</div>
921 <div class="stat"><span class="stat-num">{{OPS}}</span><span class="stat-label">Operations</span></div>
922 </div>
923 </header>
924
925 <div class="main-container">
926 <div class="dag-panel">
927 <div class="dag-header">
928 <h2>Commit Graph</h2>
929 <div class="controls">
930 <button class="btn primary" id="btn-play">&#9654; Play Tour</button>
931 <button class="btn" id="btn-prev" title="Previous step (←)">&#9664;</button>
932 <button class="btn" id="btn-next" title="Next step (→)">&#9654;</button>
933 <button class="btn" id="btn-reset">&#8635; Reset</button>
934 <span class="step-counter" id="step-counter"></span>
935 </div>
936 </div>
937 <div class="dag-scroll" id="dag-scroll">
938 <svg id="dag-svg"></svg>
939 </div>
940 <div class="branch-legend" id="branch-legend"></div>
941 </div>
942
943 <div class="log-panel">
944 <div class="log-header"><h2>Operation Log</h2></div>
945 <div class="act-jump-bar" id="act-jump-bar"></div>
946 <div class="log-scroll" id="log-scroll">
947 <div id="event-list"></div>
948 </div>
949 </div>
950 </div>
951
952
953 <div class="dim-section">
954 <div class="dim-inner">
955 <div class="dim-section-header">
956 <h2>Dimension State Matrix</h2>
957 <span class="dim-tagline">
958 Unlike Git (binary file conflicts), Muse merges each orthogonal dimension independently —
959 only conflicting dimensions require human resolution.
960 </span>
961 </div>
962 <div class="dim-matrix-wrap">
963 <div class="dim-matrix" id="dim-matrix"></div>
964 </div>
965 <div class="dim-legend">
966 <div class="dim-legend-item"><span class="dim-legend-swatch" style="background:rgba(188,140,255,0.35);color:#bc8cff"></span> Melodic</div>
967 <div class="dim-legend-item"><span class="dim-legend-swatch" style="background:rgba(63,185,80,0.35);color:#3fb950"></span> Rhythmic</div>
968 <div class="dim-legend-item"><span class="dim-legend-swatch" style="background:rgba(88,166,255,0.35);color:#58a6ff"></span> Harmonic</div>
969 <div class="dim-legend-item"><span class="dim-legend-swatch" style="background:rgba(249,168,37,0.35);color:#f9a825"></span> Dynamic</div>
970 <div class="dim-legend-item"><span class="dim-legend-swatch" style="background:rgba(239,83,80,0.35);color:#ef5350"></span> Structural</div>
971 <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>
972 <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>
973 </div>
974 <div class="dim-conflict-note">
975 <strong>⚡ Merge conflict (shared-state.mid)</strong> — shared-state.mid had both-sides changes in
976 <strong style="color:#ef5350">structural</strong> (manual resolution required).
977 <em>✓ melodic auto-merged from left</em> · <em>✓ harmonic auto-merged from right</em> —
978 only 1 of 5 dimensions conflicted. Git would have flagged the entire file as a conflict.
979 </div>
980 </div>
981 </div>
982
983 <footer>
984 <span>Generated {{GENERATED_AT}} · {{ELAPSED}}s · {{OPS}} operations</span>
985 <span><a href="https://github.com/cgcardona/muse">github.com/cgcardona/muse</a></span>
986 </footer>
987
988 <div class="tooltip" id="tooltip">
989 <div class="tip-id" id="tip-id"></div>
990 <div class="tip-msg" id="tip-msg"></div>
991 <div class="tip-branch" id="tip-branch"></div>
992 <div class="tip-files" id="tip-files"></div>
993 <div id="tip-dims" style="margin-top:6px;font-size:10px;line-height:1.8"></div>
994 </div>
995
996 {{D3_SCRIPT}}
997
998 <script>
999 /* ===== Embedded tour data ===== */
1000 const DATA = {{DATA_JSON}};
1001
1002 /* ===== Constants ===== */
1003 const ROW_H = 62;
1004 const COL_W = 90;
1005 const PAD = { top: 30, left: 55, right: 160 };
1006 const R_NODE = 11;
1007 const BRANCH_ORDER = ['main','alpha','beta','gamma','conflict/left','conflict/right'];
1008 const PLAY_INTERVAL_MS = 1200;
1009
1010 /* ===== Dimension data ===== */
1011 const DIM_COLORS = {
1012 melodic: '#bc8cff',
1013 rhythmic: '#3fb950',
1014 harmonic: '#58a6ff',
1015 dynamic: '#f9a825',
1016 structural: '#ef5350',
1017 };
1018 const DIMS = ['melodic','rhythmic','harmonic','dynamic','structural'];
1019
1020 // Commit message → dimension mapping (stable across re-runs, independent of hash)
1021 function getDims(commit) {
1022 const m = (commit.message || '').toLowerCase();
1023 if (m.includes('root') || m.includes('initial state'))
1024 return ['melodic','rhythmic','harmonic','dynamic','structural'];
1025 if (m.includes('layer 1') || m.includes('rhythmic dimension'))
1026 return ['rhythmic','structural'];
1027 if (m.includes('layer 2') || m.includes('harmonic dimension'))
1028 return ['harmonic','structural'];
1029 if (m.includes('texture pattern a') || m.includes('sparse'))
1030 return ['melodic','rhythmic'];
1031 if (m.includes('texture pattern b') || m.includes('dense'))
1032 return ['melodic','dynamic'];
1033 if (m.includes('syncopated'))
1034 return ['rhythmic','dynamic'];
1035 if (m.includes('descending'))
1036 return ['melodic','harmonic'];
1037 if (m.includes('ascending'))
1038 return ['melodic'];
1039 if (m.includes("merge branch 'beta'"))
1040 return ['rhythmic','dynamic'];
1041 if (m.includes('left:') || m.includes('version a'))
1042 return ['melodic','structural'];
1043 if (m.includes('right:') || m.includes('version b'))
1044 return ['harmonic','structural'];
1045 if (m.includes('resolve') || m.includes('reconciled'))
1046 return ['structural'];
1047 if (m.includes('cherry-pick') || m.includes('cherry pick'))
1048 return ['melodic'];
1049 if (m.includes('revert'))
1050 return ['melodic'];
1051 return [];
1052 }
1053
1054 function getConflicts(commit) {
1055 const m = (commit.message || '').toLowerCase();
1056 if (m.includes('resolve') && m.includes('reconciled')) return ['structural'];
1057 return [];
1058 }
1059
1060 // Build per-short-ID lookup tables once the DATA is available (populated at init)
1061 const DIM_DATA = {};
1062 const DIM_CONFLICTS = {};
1063 function _initDimMaps() {
1064 DATA.dag.commits.forEach(c => {
1065 DIM_DATA[c.short] = getDims(c);
1066 DIM_CONFLICTS[c.short] = getConflicts(c);
1067 });
1068 // Also key by the short prefix used in events (some may be truncated)
1069 DATA.events.forEach(ev => {
1070 if (ev.commit_id && !DIM_DATA[ev.commit_id]) {
1071 const full = DATA.dag.commits.find(c => c.short.startsWith(ev.commit_id) || ev.commit_id.startsWith(c.short));
1072 if (full) {
1073 DIM_DATA[ev.commit_id] = getDims(full);
1074 DIM_CONFLICTS[ev.commit_id] = getConflicts(full);
1075 }
1076 }
1077 });
1078 }
1079
1080
1081 /* ===== State ===== */
1082 let currentStep = -1;
1083 let isPlaying = false;
1084 let playTimer = null;
1085
1086 /* ===== Utilities ===== */
1087 function escHtml(s) {
1088 return String(s)
1089 .replace(/&/g,'&amp;')
1090 .replace(/</g,'&lt;')
1091 .replace(/>/g,'&gt;')
1092 .replace(/"/g,'&quot;');
1093 }
1094
1095 /* ===== Topological sort ===== */
1096 function topoSort(commits) {
1097 const map = new Map(commits.map(c => [c.id, c]));
1098 const visited = new Set();
1099 const result = [];
1100 function visit(id) {
1101 if (visited.has(id)) return;
1102 visited.add(id);
1103 const c = map.get(id);
1104 if (!c) return;
1105 (c.parents || []).forEach(pid => visit(pid));
1106 result.push(c);
1107 }
1108 commits.forEach(c => visit(c.id));
1109 // Oldest commit at row 0 (top of DAG); newest at the bottom so the DAG
1110 // scrolls down in sync with the operation log during playback.
1111 return result;
1112 }
1113
1114 /* ===== Layout ===== */
1115 function computeLayout(commits) {
1116 const sorted = topoSort(commits);
1117 const branchCols = {};
1118 let nextCol = 0;
1119 // Assign columns in BRANCH_ORDER first, then any extras
1120 BRANCH_ORDER.forEach(b => { branchCols[b] = nextCol++; });
1121 commits.forEach(c => {
1122 if (!(c.branch in branchCols)) branchCols[c.branch] = nextCol++;
1123 });
1124 const numCols = nextCol;
1125 const positions = new Map();
1126 sorted.forEach((c, i) => {
1127 positions.set(c.id, {
1128 x: PAD.left + (branchCols[c.branch] || 0) * COL_W,
1129 y: PAD.top + i * ROW_H,
1130 row: i,
1131 col: branchCols[c.branch] || 0,
1132 });
1133 });
1134 const svgW = PAD.left + numCols * COL_W + PAD.right;
1135 const svgH = PAD.top + sorted.length * ROW_H + PAD.top;
1136 return { sorted, positions, branchCols, svgW, svgH };
1137 }
1138
1139 /* ===== Draw DAG ===== */
1140 function drawDAG() {
1141 const { dag, dag: { commits, branches } } = DATA;
1142 if (!commits.length) return;
1143
1144 const layout = computeLayout(commits);
1145 const { sorted, positions, svgW, svgH } = layout;
1146 const branchColor = new Map(branches.map(b => [b.name, b.color]));
1147 const commitMap = new Map(commits.map(c => [c.id, c]));
1148
1149 const svg = d3.select('#dag-svg')
1150 .attr('width', svgW)
1151 .attr('height', svgH);
1152
1153 // ---- Edges ----
1154 const edgeG = svg.append('g').attr('class', 'edges');
1155 sorted.forEach(commit => {
1156 const pos = positions.get(commit.id);
1157 (commit.parents || []).forEach((pid, pIdx) => {
1158 const ppos = positions.get(pid);
1159 if (!pos || !ppos) return;
1160 const color = pIdx === 0
1161 ? (branchColor.get(commit.branch) || '#555')
1162 : (branchColor.get(commitMap.get(pid)?.branch || '') || '#555');
1163
1164 let pathStr;
1165 if (Math.abs(pos.x - ppos.x) < 4) {
1166 // Same column → straight line
1167 pathStr = `M${pos.x},${pos.y} L${ppos.x},${ppos.y}`;
1168 } else {
1169 // Different columns → S-curve bezier
1170 const mid = (pos.y + ppos.y) / 2;
1171 pathStr = `M${pos.x},${pos.y} C${pos.x},${mid} ${ppos.x},${mid} ${ppos.x},${ppos.y}`;
1172 }
1173 edgeG.append('path')
1174 .attr('d', pathStr)
1175 .attr('stroke', color)
1176 .attr('stroke-width', 1.8)
1177 .attr('fill', 'none')
1178 .attr('opacity', 0.45)
1179 .attr('class', `edge-from-${commit.id.slice(0,8)}`);
1180 });
1181 });
1182
1183 // ---- Nodes ----
1184 const nodeG = svg.append('g').attr('class', 'nodes');
1185 const tooltip = document.getElementById('tooltip');
1186
1187 sorted.forEach(commit => {
1188 const pos = positions.get(commit.id);
1189 if (!pos) return;
1190 const color = branchColor.get(commit.branch) || '#78909c';
1191 const isMerge = (commit.parents || []).length >= 2;
1192
1193 const g = nodeG.append('g')
1194 .attr('class', 'commit-node')
1195 .attr('data-id', commit.id)
1196 .attr('data-short', commit.short)
1197 .attr('transform', `translate(${pos.x},${pos.y})`);
1198
1199 if (isMerge) {
1200 g.append('circle')
1201 .attr('r', R_NODE + 6)
1202 .attr('fill', 'none')
1203 .attr('stroke', color)
1204 .attr('stroke-width', 1.5)
1205 .attr('opacity', 0.35);
1206 }
1207
1208 g.append('circle')
1209 .attr('r', R_NODE)
1210 .attr('fill', color)
1211 .attr('stroke', '#0d1117')
1212 .attr('stroke-width', 2);
1213
1214 // Short ID
1215 g.append('text')
1216 .attr('x', R_NODE + 7)
1217 .attr('y', 0)
1218 .attr('dy', '0.35em')
1219 .attr('class', 'commit-label')
1220 .text(commit.short);
1221
1222 // Message (truncated)
1223 const maxLen = 38;
1224 const msg = commit.message.length > maxLen
1225 ? commit.message.slice(0, maxLen) + '…'
1226 : commit.message;
1227 g.append('text')
1228 .attr('x', R_NODE + 7)
1229 .attr('y', 13)
1230 .attr('class', 'commit-msg')
1231 .text(msg);
1232
1233
1234 // Dimension dots below node
1235 const dims = DIM_DATA[commit.short] || [];
1236 if (dims.length > 0) {
1237 const dotR = 4, dotSp = 11;
1238 const totalW = (DIMS.length - 1) * dotSp;
1239 const dotsG = g.append('g')
1240 .attr('class', 'dim-dots')
1241 .attr('transform', `translate(${-totalW/2},${R_NODE + 9})`);
1242 DIMS.forEach((dim, di) => {
1243 const active = dims.includes(dim);
1244 const isConf = (DIM_CONFLICTS[commit.short] || []).includes(dim);
1245 dotsG.append('circle')
1246 .attr('cx', di * dotSp).attr('cy', 0).attr('r', dotR)
1247 .attr('fill', active ? DIM_COLORS[dim] : '#21262d')
1248 .attr('stroke', isConf ? '#f85149' : (active ? DIM_COLORS[dim] : '#30363d'))
1249 .attr('stroke-width', isConf ? 1.5 : 0.8)
1250 .attr('opacity', active ? 1 : 0.35);
1251 });
1252 }
1253
1254 // Hover tooltip
1255 g.on('mousemove', (event) => {
1256 tooltip.classList.add('visible');
1257 document.getElementById('tip-id').textContent = commit.id;
1258 document.getElementById('tip-msg').textContent = commit.message;
1259 document.getElementById('tip-branch').innerHTML =
1260 `<span style="color:${color}">⬤</span> ${commit.branch}`;
1261 document.getElementById('tip-files').textContent =
1262 commit.files.length
1263 ? commit.files.join('\\n')
1264 : '(empty snapshot)';
1265 const tipDims = DIM_DATA[commit.short] || [];
1266 const tipConf = DIM_CONFLICTS[commit.short] || [];
1267 const tipDimEl = document.getElementById('tip-dims');
1268 if (tipDimEl) {
1269 tipDimEl.innerHTML = tipDims.length
1270 ? tipDims.map(d => {
1271 const c = tipConf.includes(d);
1272 return `<span style="color:${DIM_COLORS[d]};margin-right:6px">● ${d}${c?' ⚡':''}</span>`;
1273 }).join('')
1274 : '';
1275 }
1276 tooltip.style.left = (event.clientX + 12) + 'px';
1277 tooltip.style.top = (event.clientY - 10) + 'px';
1278 }).on('mouseleave', () => {
1279 tooltip.classList.remove('visible');
1280 });
1281 });
1282
1283 // ---- Branch legend ----
1284 const legend = document.getElementById('branch-legend');
1285 DATA.dag.branches.forEach(b => {
1286 const item = document.createElement('div');
1287 item.className = 'legend-item';
1288 item.innerHTML =
1289 `<span class="legend-dot" style="background:${b.color}"></span>` +
1290 `<span>${escHtml(b.name)}</span>`;
1291 legend.appendChild(item);
1292 });
1293 }
1294
1295 /* ===== Act metadata ===== */
1296 const ACT_ICONS = {
1297 1:'🎵', 2:'🌿', 3:'⚡', 4:'🔀', 5:'⏪',
1298 };
1299 const ACT_COLORS = {
1300 1:'#4f8ef7', 2:'#3fb950', 3:'#f85149', 4:'#ab47bc', 5:'#f9a825',
1301 };
1302
1303 /* ===== Act jump navigation ===== */
1304 function buildActJumpBar() {
1305 const bar = document.getElementById('act-jump-bar');
1306 if (!bar) return;
1307
1308 const lbl = document.createElement('span');
1309 lbl.textContent = 'Jump:';
1310 bar.appendChild(lbl);
1311
1312 // Collect unique acts
1313 const acts = [];
1314 let last = -1;
1315 DATA.events.forEach(ev => {
1316 if (ev.act !== last) { acts.push({ num: ev.act, title: ev.act_title }); last = ev.act; }
1317 });
1318
1319 acts.forEach(a => {
1320 const btn = document.createElement('button');
1321 btn.className = 'act-jump-btn';
1322 btn.title = `Jump to Act ${a.num}: ${a.title}`;
1323 const icon = ACT_ICONS[a.num] || '';
1324 btn.innerHTML = `${icon} ${a.num}`;
1325 if (a.num >= 6) btn.style.borderColor = ACT_COLORS[a.num] + '66';
1326 btn.addEventListener('click', () => {
1327 pauseTour();
1328 // Find first event index for this act
1329 const idx = DATA.events.findIndex(ev => ev.act === a.num);
1330 if (idx >= 0) {
1331 // Reveal up to this point
1332 revealStep(idx);
1333 // Scroll the act header into view
1334 const hdr = document.getElementById(`act-hdr-${a.num}`);
1335 if (hdr) hdr.scrollIntoView({ behavior: 'smooth', block: 'start' });
1336 }
1337 });
1338 bar.appendChild(btn);
1339 });
1340
1341 // Reveal All button
1342 const allBtn = document.createElement('button');
1343 allBtn.className = 'act-jump-btn reveal-all';
1344 allBtn.textContent = '✦ Reveal All';
1345 allBtn.title = 'Reveal all 69 events at once';
1346 allBtn.addEventListener('click', () => {
1347 pauseTour();
1348 revealStep(DATA.events.length - 1);
1349 });
1350 bar.appendChild(allBtn);
1351 }
1352
1353 /* ===== Event log ===== */
1354 function buildEventLog() {
1355 const list = document.getElementById('event-list');
1356 let lastAct = -1;
1357
1358 DATA.events.forEach((ev, idx) => {
1359 if (ev.act !== lastAct) {
1360 lastAct = ev.act;
1361
1362 // Act header — always visible (no opacity fade)
1363 const hdr = document.createElement('div');
1364 hdr.className = 'act-header';
1365 hdr.id = `act-hdr-${ev.act}`;
1366 const icon = ACT_ICONS[ev.act] || '';
1367 const col = ACT_COLORS[ev.act] || 'var(--text-dim)';
1368 hdr.innerHTML =
1369 `<span style="color:${col};margin-right:6px">${icon}</span>` +
1370 `Act ${ev.act} <span style="opacity:0.6">—</span> ${ev.act_title}`;
1371 if (ev.act >= 6) {
1372 hdr.style.color = col;
1373 hdr.style.borderTop = `1px solid ${col}33`;
1374 }
1375 list.appendChild(hdr);
1376 }
1377
1378 const isCliCmd = ev.cmd.startsWith('muse ') || ev.cmd.startsWith('git ');
1379
1380 const item = document.createElement('div');
1381 item.className = 'event-item';
1382 item.id = `ev-${idx}`;
1383
1384 if (ev.exit_code !== 0 && ev.output.toLowerCase().includes('conflict')) {
1385 item.classList.add('failed');
1386 }
1387
1388 // Parse cmd
1389 const parts = ev.cmd.split(' ');
1390 const cmdName = parts.slice(0, 2).join(' ');
1391 const cmdArgs = parts.slice(2).join(' ');
1392
1393 // Output class
1394 let outClass = '';
1395 if (ev.output.toLowerCase().includes('conflict')) outClass = 'conflict';
1396 else if (ev.exit_code === 0 && ev.commit_id) outClass = 'success';
1397
1398 const outLines = ev.output.split('\\n').slice(0, 6).join('\\n');
1399
1400 const cmdLine =
1401 `<div class="event-cmd">` +
1402 `<span class="cmd-prefix">$ </span>` +
1403 `<span class="cmd-name">${escHtml(cmdName)}</span>` +
1404 (cmdArgs
1405 ? ` <span class="cmd-args">${escHtml(cmdArgs.slice(0, 80))}${cmdArgs.length > 80 ? '…' : ''}</span>`
1406 : '') +
1407 `</div>`;
1408
1409 item.innerHTML =
1410 cmdLine +
1411 (outLines
1412 ? `<div class="event-output ${outClass}">${escHtml(outLines)}</div>`
1413 : '') +
1414 (() => {
1415 if (!ev.commit_id) return '';
1416 const dims = DIM_DATA[ev.commit_id] || [];
1417 const conf = DIM_CONFLICTS[ev.commit_id] || [];
1418 if (!dims.length) return '';
1419 return '<div class="dim-pills">' + dims.map(d => {
1420 const isc = conf.includes(d);
1421 const col = DIM_COLORS[d];
1422 const cls = isc ? 'dim-pill conflict-pill' : 'dim-pill';
1423 const sty = isc ? '' : `color:${col};border-color:${col};background:${col}22`;
1424 return `<span class="${cls}" style="${sty}">${isc ? '⚡ ' : ''}${d}</span>`;
1425 }).join('') + '</div>';
1426 })() +
1427 `<div class="event-meta">` +
1428 (ev.commit_id ? `<span class="tag-commit">${escHtml(ev.commit_id)}</span>` : '') +
1429 `<span class="tag-time">${ev.duration_ms}ms</span>` +
1430 `</div>`;
1431
1432 list.appendChild(item);
1433 });
1434 }
1435
1436
1437
1438 /* ===== Dimension Timeline ===== */
1439 function buildDimTimeline() {
1440 const matrix = document.getElementById('dim-matrix');
1441 if (!matrix) return;
1442 const sorted = topoSort(DATA.dag.commits);
1443
1444 // Commit ID header row
1445 const hrow = document.createElement('div');
1446 hrow.className = 'dim-matrix-row';
1447 const sp = document.createElement('div');
1448 sp.className = 'dim-label-cell';
1449 hrow.appendChild(sp);
1450 sorted.forEach(c => {
1451 const cell = document.createElement('div');
1452 cell.className = 'dim-commit-cell';
1453 cell.id = `dim-col-label-${c.short}`;
1454 cell.title = c.message;
1455 cell.textContent = c.short.slice(0,6);
1456 hrow.appendChild(cell);
1457 });
1458 matrix.appendChild(hrow);
1459
1460 // One row per dimension
1461 DIMS.forEach(dim => {
1462 const row = document.createElement('div');
1463 row.className = 'dim-matrix-row';
1464 const lbl = document.createElement('div');
1465 lbl.className = 'dim-label-cell';
1466 const dot = document.createElement('span');
1467 dot.className = 'dim-label-dot';
1468 dot.style.background = DIM_COLORS[dim];
1469 lbl.appendChild(dot);
1470 lbl.appendChild(document.createTextNode(dim.charAt(0).toUpperCase() + dim.slice(1)));
1471 row.appendChild(lbl);
1472
1473 sorted.forEach(c => {
1474 const dims = DIM_DATA[c.short] || [];
1475 const conf = DIM_CONFLICTS[c.short] || [];
1476 const active = dims.includes(dim);
1477 const isConf = conf.includes(dim);
1478 const col = DIM_COLORS[dim];
1479 const cell = document.createElement('div');
1480 cell.className = 'dim-cell';
1481 const inner = document.createElement('div');
1482 inner.className = 'dim-cell-inner' + (active ? ' active' : '') + (isConf ? ' conflict-dim' : '');
1483 inner.id = `dim-cell-${dim}-${c.short}`;
1484 if (active) {
1485 inner.style.background = col + '33';
1486 inner.style.color = col;
1487 inner.textContent = isConf ? '⚡' : '●';
1488 }
1489 cell.appendChild(inner);
1490 row.appendChild(cell);
1491 });
1492 matrix.appendChild(row);
1493 });
1494 }
1495
1496 function highlightDimColumn(shortId) {
1497 document.querySelectorAll('.dim-commit-cell.col-highlight, .dim-cell-inner.col-highlight')
1498 .forEach(el => el.classList.remove('col-highlight'));
1499 if (!shortId) return;
1500 const lbl = document.getElementById(`dim-col-label-${shortId}`);
1501 if (lbl) {
1502 lbl.classList.add('col-highlight');
1503 lbl.scrollIntoView({ behavior:'smooth', block:'nearest', inline:'center' });
1504 }
1505 DIMS.forEach(dim => {
1506 const cell = document.getElementById(`dim-cell-${dim}-${shortId}`);
1507 if (cell) cell.classList.add('col-highlight');
1508 });
1509 }
1510
1511 /* ===== Replay animation ===== */
1512 function revealStep(stepIdx) {
1513 if (stepIdx < 0 || stepIdx >= DATA.events.length) return;
1514
1515 const ev = DATA.events[stepIdx];
1516
1517 // Reveal all events up to this step
1518 for (let i = 0; i <= stepIdx; i++) {
1519 const el = document.getElementById(`ev-${i}`);
1520 if (el) el.classList.add('revealed');
1521 }
1522
1523 // Mark current as active (remove previous)
1524 document.querySelectorAll('.event-item.active').forEach(el => el.classList.remove('active'));
1525 const cur = document.getElementById(`ev-${stepIdx}`);
1526 if (cur) {
1527 cur.classList.add('active');
1528 cur.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
1529 }
1530
1531 // Highlight commit node
1532 document.querySelectorAll('.commit-node.highlighted').forEach(el => el.classList.remove('highlighted'));
1533 if (ev.commit_id) {
1534 const node = document.querySelector(`.commit-node[data-short="${ev.commit_id}"]`);
1535 if (node) {
1536 node.classList.add('highlighted');
1537 // Scroll DAG to show the node
1538 const transform = node.getAttribute('transform');
1539 if (transform) {
1540 const m = transform.match(/translate\\(([\\d.]+),([\\d.]+)\\)/);
1541 if (m) {
1542 const scroll = document.getElementById('dag-scroll');
1543 const y = parseFloat(m[2]);
1544 scroll.scrollTo({ top: Math.max(0, y - 200), behavior: 'smooth' });
1545 }
1546 }
1547 }
1548 }
1549
1550 // Highlight dimension matrix column
1551 highlightDimColumn(ev.commit_id || null);
1552
1553 // Update counter and step button states
1554 document.getElementById('step-counter').textContent =
1555 `Step ${stepIdx + 1} / ${DATA.events.length}`;
1556 document.getElementById('btn-prev').disabled = (stepIdx === 0);
1557 document.getElementById('btn-next').disabled = (stepIdx === DATA.events.length - 1);
1558
1559 currentStep = stepIdx;
1560 }
1561
1562 function playTour() {
1563 if (isPlaying) return;
1564 isPlaying = true;
1565 document.getElementById('btn-play').textContent = '⏸ Pause';
1566
1567 function advance() {
1568 if (!isPlaying) return;
1569 const next = currentStep + 1;
1570 if (next >= DATA.events.length) {
1571 pauseTour();
1572 document.getElementById('btn-play').textContent = '✓ Done';
1573 return;
1574 }
1575 revealStep(next);
1576 playTimer = setTimeout(advance, PLAY_INTERVAL_MS);
1577 }
1578 advance();
1579 }
1580
1581 function pauseTour() {
1582 isPlaying = false;
1583 clearTimeout(playTimer);
1584 document.getElementById('btn-play').textContent = '▶ Play Tour';
1585 highlightDimColumn(null);
1586 }
1587
1588 function resetTour() {
1589 pauseTour();
1590 currentStep = -1;
1591 document.querySelectorAll('.event-item').forEach(el => {
1592 el.classList.remove('revealed','active');
1593 });
1594 document.querySelectorAll('.commit-node.highlighted').forEach(el => {
1595 el.classList.remove('highlighted');
1596 });
1597 document.getElementById('step-counter').textContent = '';
1598 document.getElementById('log-scroll').scrollTop = 0;
1599 document.getElementById('dag-scroll').scrollTop = 0;
1600 document.getElementById('btn-play').textContent = '▶ Play Tour';
1601 document.getElementById('btn-prev').disabled = true;
1602 document.getElementById('btn-next').disabled = false;
1603 highlightDimColumn(null);
1604 }
1605
1606 /* ===== Init ===== */
1607 document.addEventListener('DOMContentLoaded', () => {
1608 _initDimMaps();
1609 drawDAG();
1610 buildEventLog();
1611 buildActJumpBar();
1612 buildDimTimeline();
1613
1614 document.getElementById('btn-prev').disabled = true; // nothing to go back to yet
1615
1616 document.getElementById('btn-play').addEventListener('click', () => {
1617 if (isPlaying) pauseTour(); else playTour();
1618 });
1619 document.getElementById('btn-prev').addEventListener('click', () => {
1620 pauseTour();
1621 if (currentStep > 0) revealStep(currentStep - 1);
1622 });
1623 document.getElementById('btn-next').addEventListener('click', () => {
1624 pauseTour();
1625 if (currentStep < DATA.events.length - 1) revealStep(currentStep + 1);
1626 });
1627 document.getElementById('btn-reset').addEventListener('click', resetTour);
1628
1629 // Keyboard shortcuts: ← → for step, Space for play/pause
1630 document.addEventListener('keydown', (e) => {
1631 if (e.target.tagName === 'INPUT' || e.target.tagName === 'TEXTAREA') return;
1632 if (e.key === 'ArrowLeft') {
1633 e.preventDefault();
1634 pauseTour();
1635 if (currentStep > 0) revealStep(currentStep - 1);
1636 } else if (e.key === 'ArrowRight') {
1637 e.preventDefault();
1638 pauseTour();
1639 if (currentStep < DATA.events.length - 1) revealStep(currentStep + 1);
1640 } else if (e.key === ' ') {
1641 e.preventDefault();
1642 if (isPlaying) pauseTour(); else playTour();
1643 }
1644 });
1645 });
1646 </script>
1647 </body>
1648 </html>
1649 """
1650
1651
1652 # ---------------------------------------------------------------------------
1653 # Main render function
1654 # ---------------------------------------------------------------------------
1655
1656
1657 def render(tour: dict, output_path: pathlib.Path) -> None:
1658 """Render the tour data into a self-contained HTML file."""
1659 print(" Rendering HTML visualization...")
1660 d3_script = _fetch_d3()
1661
1662 meta = tour.get("meta", {})
1663 stats = tour.get("stats", {})
1664
1665 # Format generated_at nicely
1666 gen_raw = meta.get("generated_at", "")
1667 try:
1668 from datetime import datetime, timezone
1669 dt = datetime.fromisoformat(gen_raw).astimezone(timezone.utc)
1670 gen_str = dt.strftime("%Y-%m-%d %H:%M UTC")
1671 except Exception:
1672 gen_str = gen_raw[:19]
1673
1674 html = _HTML_TEMPLATE
1675 html = html.replace("{{VERSION}}", str(meta.get("muse_version", "0.1.1")))
1676 html = html.replace("{{DOMAIN}}", str(meta.get("domain", "music")))
1677 html = html.replace("{{ELAPSED}}", str(meta.get("elapsed_s", "?")))
1678 html = html.replace("{{GENERATED_AT}}", gen_str)
1679 html = html.replace("{{COMMITS}}", str(stats.get("commits", 0)))
1680 html = html.replace("{{BRANCHES}}", str(stats.get("branches", 0)))
1681 html = html.replace("{{MERGES}}", str(stats.get("merges", 0)))
1682 html = html.replace("{{CONFLICTS}}", str(stats.get("conflicts_resolved", 0)))
1683 html = html.replace("{{OPS}}", str(stats.get("operations", 0)))
1684 html = html.replace("{{ARCH_HTML}}", _ARCH_HTML)
1685 html = html.replace("{{D3_SCRIPT}}", d3_script)
1686 html = html.replace("{{DATA_JSON}}", json.dumps(tour, separators=(",", ":")))
1687
1688 output_path.write_text(html, encoding="utf-8")
1689 size_kb = output_path.stat().st_size // 1024
1690 print(f" HTML written ({size_kb}KB) → {output_path}")
1691
1692
1693 # ---------------------------------------------------------------------------
1694 # Stand-alone entry point
1695 # ---------------------------------------------------------------------------
1696
1697 if __name__ == "__main__":
1698 import argparse
1699 parser = argparse.ArgumentParser(description="Render tour_de_force.json → HTML")
1700 parser.add_argument("json_file", help="Path to tour_de_force.json")
1701 parser.add_argument("--out", default=None, help="Output HTML path")
1702 args = parser.parse_args()
1703
1704 json_path = pathlib.Path(args.json_file)
1705 if not json_path.exists():
1706 print(f"❌ File not found: {json_path}", file=sys.stderr)
1707 sys.exit(1)
1708
1709 data = json.loads(json_path.read_text())
1710 out_path = pathlib.Path(args.out) if args.out else json_path.with_suffix(".html")
1711 render(data, out_path)
1712 print(f"Open: file://{out_path.resolve()}")