-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathssync-explained.html
More file actions
817 lines (757 loc) · 42.9 KB
/
Copy pathssync-explained.html
File metadata and controls
817 lines (757 loc) · 42.9 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>/ssync, explained — syncgit serial mode</title>
<style>
:root {
--bg: #0e1116;
--panel: #161b22;
--panel-2: #1c2330;
--ink: #e6edf3;
--ink-dim: #9aa7b4;
--ink-faint: #5b6573;
--line: #2a3340;
--mainline: #f5a524; /* the shared mainline pointer */
--peer-a: #58a6ff; /* peer a */
--peer-b: #a371f7; /* peer b */
--peer-c: #3fb950; /* peer c */
--ok: #3fb950;
--warn: #d29922;
--bad: #f85149;
--mono: ui-monospace, "SF Mono", "JetBrains Mono", Menlo, Consolas, monospace;
--sans: ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
}
* { box-sizing: border-box; }
html { scroll-behavior: smooth; }
body {
margin: 0; background: var(--bg); color: var(--ink);
font-family: var(--sans); line-height: 1.6;
-webkit-font-smoothing: antialiased;
}
.wrap { max-width: 1080px; margin: 0 auto; padding: 0 24px 120px; }
/* ---- header ---- */
header.hero {
padding: 72px 24px 40px; text-align: center;
background:
radial-gradient(900px 380px at 50% -10%, rgba(245,165,36,0.10), transparent 60%),
radial-gradient(700px 300px at 80% 0%, rgba(88,166,255,0.08), transparent 60%);
border-bottom: 1px solid var(--line);
}
.eyebrow {
font-family: var(--mono); font-size: 12px; letter-spacing: 0.18em;
text-transform: uppercase; color: var(--mainline); margin: 0 0 14px;
}
h1 { margin: 0; font-size: clamp(34px, 6vw, 60px); font-weight: 800; letter-spacing: -0.02em; }
h1 .slash { color: var(--mainline); }
.sub { margin: 16px auto 0; max-width: 620px; color: var(--ink-dim); font-size: 18px; }
.hero-meta {
margin-top: 22px; font-family: var(--mono); font-size: 12.5px; color: var(--ink-faint);
display: flex; gap: 18px; justify-content: center; flex-wrap: wrap;
}
.hero-meta b { color: var(--ink-dim); font-weight: 500; }
/* ---- sections ---- */
section { margin-top: 72px; }
h2 {
font-size: 13px; font-family: var(--mono); letter-spacing: 0.16em; text-transform: uppercase;
color: var(--ink-faint); margin: 0 0 6px; font-weight: 600;
}
h2 + .lead { margin-top: 0; }
.lead { font-size: 26px; font-weight: 700; letter-spacing: -0.01em; margin: 0 0 18px; }
p { color: var(--ink-dim); }
p.body { color: #c3ccd6; max-width: 70ch; }
code.k {
font-family: var(--mono); font-size: 0.88em; background: var(--panel-2);
border: 1px solid var(--line); border-radius: 5px; padding: 1px 6px; color: #d7e2ee;
white-space: nowrap;
}
.ref { color: var(--mainline); }
a { color: var(--peer-a); }
/* ---- mental model strip ---- */
.modelgrid { display: grid; grid-template-columns: 1fr 1fr; gap: 16px; margin-top: 8px; }
@media (max-width: 720px){ .modelgrid { grid-template-columns: 1fr; } }
.mcard {
background: var(--panel); border: 1px solid var(--line); border-radius: 14px;
padding: 22px 22px 20px;
}
.mcard h3 { margin: 0 0 6px; font-size: 16px; display: flex; align-items: center; gap: 9px; }
.mcard p { margin: 0; font-size: 14.5px; }
.pill {
font-family: var(--mono); font-size: 11px; padding: 2px 8px; border-radius: 999px;
border: 1px solid var(--line);
}
.pill.sync { color: var(--peer-a); border-color: rgba(88,166,255,0.4); }
.pill.ssync { color: var(--mainline); border-color: rgba(245,165,36,0.4); }
/* ---- player ---- */
.player {
margin-top: 10px; background: var(--panel); border: 1px solid var(--line);
border-radius: 16px; overflow: hidden;
}
.player-head {
display: flex; align-items: center; gap: 10px; padding: 14px 18px;
border-bottom: 1px solid var(--line); background: var(--panel-2);
flex-wrap: wrap;
}
.player-title { font-weight: 700; font-size: 15px; }
.player-tag {
font-family: var(--mono); font-size: 11px; color: var(--ink-faint);
margin-left: auto;
}
/* stage: a git-graph canvas */
.stage { position: relative; display: grid; grid-template-columns: 1fr 340px; min-height: 360px; }
@media (max-width: 860px){ .stage { grid-template-columns: 1fr; } }
.graph {
position: relative; padding: 26px 20px; overflow: hidden;
border-right: 1px solid var(--line);
background:
linear-gradient(transparent 0 0),
repeating-linear-gradient(0deg, transparent, transparent 39px, rgba(255,255,255,0.015) 39px, rgba(255,255,255,0.015) 40px);
}
@media (max-width: 860px){ .graph { border-right: none; border-bottom: 1px solid var(--line); } }
svg.wires { position: absolute; inset: 0; width: 100%; height: 100%; pointer-events: none; }
.lane { position: relative; display: flex; align-items: center; height: 52px; }
.lane-label {
width: 92px; flex: none; font-family: var(--mono); font-size: 12px; color: var(--ink-dim);
display: flex; align-items: center; gap: 7px;
}
.dotmark { width: 9px; height: 9px; border-radius: 50%; flex: none; }
.track { position: relative; flex: 1; height: 100%; }
.commit {
position: absolute; top: 50%; transform: translate(-50%,-50%);
width: 30px; height: 30px; border-radius: 50%;
display: grid; place-items: center;
font-family: var(--mono); font-size: 11px; font-weight: 700;
border: 2px solid var(--line); background: var(--panel-2); color: var(--ink-dim);
transition: left .55s cubic-bezier(.4,0,.2,1), transform .35s, opacity .4s, border-color .4s, background .4s, box-shadow .4s;
}
.commit.seed { border-color: #46525f; color: #8b97a3; }
.commit.a { border-color: var(--peer-a); color: #bcdcff; background: rgba(88,166,255,0.12); }
.commit.b { border-color: var(--peer-b); color: #d8c6fb; background: rgba(163,113,247,0.12); }
.commit.ghost { opacity: 0.28; border-style: dashed; }
.commit.pop { animation: pop .5s ease; }
@keyframes pop { 0%{ transform: translate(-50%,-50%) scale(.2); opacity:0 } 60%{ transform: translate(-50%,-50%) scale(1.18) } 100%{ transform: translate(-50%,-50%) scale(1) } }
/* the mainline pointer flag */
.mlflag {
position: absolute; top: 50%; transform: translate(-50%,-50%);
display: flex; align-items: center; gap: 5px;
background: rgba(245,165,36,0.14); border: 1.5px solid var(--mainline);
color: var(--mainline); font-family: var(--mono); font-size: 10.5px; font-weight: 700;
padding: 3px 8px 3px 7px; border-radius: 7px; white-space: nowrap;
transition: left .6s cubic-bezier(.4,0,.2,1), top .5s, opacity .4s;
box-shadow: 0 0 0 0 rgba(245,165,36,0.5);
z-index: 4;
}
.mlflag.cas { animation: casflash .9s ease; }
@keyframes casflash {
0%{ box-shadow: 0 0 0 0 rgba(245,165,36,0.6) }
50%{ box-shadow: 0 0 0 9px rgba(245,165,36,0) }
100%{ box-shadow: 0 0 0 0 rgba(245,165,36,0) }
}
.snap {
position: absolute; top: 50%; transform: translate(-50%, 14px);
font-family: var(--mono); font-size: 9.5px; color: var(--ink-faint);
white-space: nowrap; opacity: 0; transition: opacity .4s, left .55s; z-index: 3;
}
.snap.show { opacity: 0.9; }
/* side panel: narration + command */
.side { padding: 20px 20px 18px; display: flex; flex-direction: column; }
.stepno {
font-family: var(--mono); font-size: 11px; color: var(--ink-faint); letter-spacing: .12em;
text-transform: uppercase;
}
.narr { font-size: 15px; color: #cdd6df; margin: 8px 0 14px; min-height: 96px; }
.narr b { color: var(--ink); }
.narr .who-a { color: var(--peer-a); font-weight: 700; }
.narr .who-b { color: var(--peer-b); font-weight: 700; }
.cmd {
font-family: var(--mono); font-size: 12.5px; background: #0b0e13; border: 1px solid var(--line);
border-radius: 9px; padding: 11px 13px; color: #d7e2ee; margin-bottom: 10px; min-height: 42px;
white-space: pre-wrap; word-break: break-word;
}
.cmd .prompt { color: var(--ink-faint); }
.cmd .out { display:block; color: var(--ink-dim); margin-top: 4px; }
.cmd .exit0 { color: var(--ok); }
.cmd .exit3 { color: var(--warn); }
.cmd .exitbad { color: var(--bad); }
.cmd.empty { color: var(--ink-faint); }
.ctrls { margin-top: auto; display: flex; align-items: center; gap: 8px; padding-top: 14px; }
button.btn {
font-family: var(--sans); font-size: 13.5px; font-weight: 600; cursor: pointer;
background: var(--panel-2); color: var(--ink); border: 1px solid var(--line);
border-radius: 8px; padding: 8px 14px; transition: background .15s, border-color .15s, opacity .15s;
}
button.btn:hover:not(:disabled){ background: #232c3a; border-color: #38465a; }
button.btn:disabled { opacity: 0.4; cursor: default; }
button.btn.primary { background: var(--mainline); color: #1a1205; border-color: var(--mainline); }
button.btn.primary:hover:not(:disabled){ background: #ffb733; }
.dots { display: flex; gap: 6px; margin-left: auto; }
.dot { width: 7px; height: 7px; border-radius: 50%; background: var(--line); transition: background .2s, transform .2s; }
.dot.on { background: var(--mainline); transform: scale(1.25); }
/* scene tabs */
.scenetabs { display: flex; gap: 6px; flex-wrap: wrap; margin-bottom: 14px; }
.scenetab {
font-family: var(--mono); font-size: 12px; cursor: pointer; user-select: none;
background: var(--panel); border: 1px solid var(--line); color: var(--ink-dim);
padding: 7px 13px; border-radius: 8px; transition: all .15s;
}
.scenetab:hover { color: var(--ink); border-color: #38465a; }
.scenetab.active { background: var(--panel-2); color: var(--ink); border-color: #46586e; }
.scenetab .n { color: var(--ink-faint); margin-right: 6px; }
.scenetab.active .n { color: var(--mainline); }
/* ---- ref legend ---- */
.legend { display: flex; gap: 18px; flex-wrap: wrap; font-family: var(--mono); font-size: 12px; color: var(--ink-dim); margin-top: 14px; padding: 14px 18px; background: var(--panel); border: 1px solid var(--line); border-radius: 12px; }
.legend .li { display: flex; align-items: center; gap: 7px; }
.swatch { width: 12px; height: 12px; border-radius: 3px; flex: none; }
/* ---- step table for the protocol ---- */
.steps { margin-top: 10px; border: 1px solid var(--line); border-radius: 14px; overflow: hidden; }
.steprow { display: grid; grid-template-columns: 56px 200px 1fr; gap: 0; border-top: 1px solid var(--line); }
.steprow:first-child { border-top: none; }
.steprow > div { padding: 14px 16px; }
.steprow .num { font-family: var(--mono); color: var(--mainline); font-weight: 700; background: var(--panel); }
.steprow .cmdcell { font-family: var(--mono); font-size: 12.5px; color: #d7e2ee; background: var(--panel); border-left: 1px solid var(--line); border-right: 1px solid var(--line); display: flex; align-items: center; }
.steprow .desc { color: var(--ink-dim); font-size: 14px; }
.steprow .desc b { color: var(--ink); }
@media (max-width: 720px){
.steprow { grid-template-columns: 40px 1fr; }
.steprow .cmdcell { grid-column: 1 / -1; border-left: none; border-right: none; border-top: 1px solid var(--line); }
}
/* ---- comparison ---- */
.cmp { display: grid; grid-template-columns: 1fr 1fr; gap: 16px; }
@media (max-width: 720px){ .cmp { grid-template-columns: 1fr; } }
.cmpcard { background: var(--panel); border: 1px solid var(--line); border-radius: 14px; padding: 22px; }
.cmpcard.sync { border-top: 3px solid var(--peer-a); }
.cmpcard.ssync { border-top: 3px solid var(--mainline); }
.cmpcard h3 { margin: 0 0 4px; font-size: 18px; }
.cmpcard .tagline { font-size: 13px; color: var(--ink-faint); font-family: var(--mono); margin-bottom: 14px; }
.cmpcard ul { margin: 0; padding-left: 18px; }
.cmpcard li { color: var(--ink-dim); font-size: 14px; margin: 7px 0; }
.cmpcard li b { color: var(--ink); }
.callout {
margin-top: 16px; background: linear-gradient(180deg, rgba(245,165,36,0.07), rgba(245,165,36,0.02));
border: 1px solid rgba(245,165,36,0.3); border-left: 3px solid var(--mainline);
border-radius: 10px; padding: 16px 18px; color: #d9c7a8;
}
.callout b { color: var(--mainline); }
.callout.warn { background: linear-gradient(180deg, rgba(210,153,34,0.08), transparent); border-color: rgba(210,153,34,0.35); border-left-color: var(--warn); color: #e0cda0; }
.callout.warn b { color: var(--warn); }
footer { margin-top: 90px; padding-top: 28px; border-top: 1px solid var(--line); color: var(--ink-faint); font-size: 13px; text-align: center; font-family: var(--mono); }
footer code { color: var(--ink-dim); }
</style>
</head>
<body>
<header class="hero">
<p class="eyebrow">syncgit · serial mode</p>
<h1><span class="slash">/ssync</span>, explained</h1>
<p class="sub">How a handful of independent worktrees keep one perfectly linear shared history — using nothing but a single moving git ref and an atomic compare‑and‑swap.</p>
<div class="hero-meta">
<span><b>ref:</b> refs/syncgit/mainline</span>
<span><b>cycle:</b> smerge → verify → spush</span>
<span><b>no:</b> server · daemon · merge commits · GC</span>
</div>
</header>
<div class="wrap">
<!-- ===== THE IDEA ===== -->
<section id="idea">
<h2>The idea in one line</h2>
<p class="lead">All peers rebase onto one shared pointer, then take turns moving it forward.</p>
<p class="body">
In syncgit, every git worktree is an equal peer — there is no central server, and because the worktrees
share one object store, a ref written in one is instantly visible in all of them.
<span class="ref">/ssync</span> uses that to keep a single linear timeline: a pointer called
<code class="k ref">refs/syncgit/mainline</code> that everybody agrees is "the latest shared tip."
To publish, you first <b>rebase your work onto the tip</b> (so you build <em>on top of</em> whatever
landed before you), then <b>atomically bump the pointer</b> to your new commit. The bump only succeeds
if nobody moved the pointer while you were working — which is what makes divergence impossible.
</p>
<div class="modelgrid">
<div class="mcard">
<h3><span class="dotmark" style="background:var(--mainline)"></span> One pointer, shared by all <span class="pill ssync">/ssync</span></h3>
<p><code class="k">smerge</code> rebases your HEAD onto the mainline; <code class="k">spush</code> advances the mainline to your HEAD with a compare‑and‑swap. The mainline's commit order <em>is</em> the global order of sync runs.</p>
</div>
<div class="mcard">
<h3><span class="dotmark" style="background:var(--peer-a)"></span> The other mode <span class="pill sync">/sync</span></h3>
<p>The divergence‑tolerant broadcast mode fans work out to every peer as <code class="k">refs/pr/<id>/<ts></code> refs and absorbs them with a merge‑commit chain. Richer, but heavier. The two modes don't see each other's work — a sync round uses one mode team‑wide.</p>
</div>
</div>
</section>
<!-- ===== INTERACTIVE PLAYER ===== -->
<section id="play">
<h2>Watch it happen</h2>
<p class="lead">Three scenarios, stepped one git operation at a time.</p>
<p class="body">Pick a scenario, then step through. The orange <span style="color:var(--mainline);font-weight:700">⚑ mainline</span> flag is the shared pointer. Watch how <code class="k">smerge</code> moves a peer's commit on top of the tip, and how <code class="k">spush</code>'s compare‑and‑swap decides who wins a race.</p>
<div class="scenetabs" id="scenetabs"></div>
<div class="player">
<div class="player-head">
<span class="player-title" id="sceneTitle">—</span>
<span class="player-tag" id="sceneTag"></span>
</div>
<div class="stage">
<div class="graph" id="graph">
<svg class="wires" id="wires"></svg>
<!-- lanes injected -->
</div>
<div class="side">
<div class="stepno" id="stepno">Step 0</div>
<div class="narr" id="narr"></div>
<div class="cmd empty" id="cmd">press <b>Start</b> ▸</div>
<div class="ctrls">
<button class="btn" id="back" disabled>‹ Back</button>
<button class="btn primary" id="next">Start ▸</button>
<div class="dots" id="dots"></div>
</div>
</div>
</div>
</div>
<div class="legend">
<div class="li"><span class="swatch" style="background:#46525f"></span> seed commit</div>
<div class="li"><span class="swatch" style="background:var(--peer-a)"></span> peer a's work</div>
<div class="li"><span class="swatch" style="background:var(--peer-b)"></span> peer b's work</div>
<div class="li"><span class="swatch" style="background:var(--mainline)"></span> refs/syncgit/mainline</div>
<div class="li"><span style="opacity:.5;border:1px dashed var(--ink-faint);width:12px;height:12px;border-radius:50%;display:inline-block"></span> abandoned / pre‑rebase</div>
</div>
</section>
<!-- ===== THE PROTOCOL ===== -->
<section id="protocol">
<h2>The /ssync round</h2>
<p class="lead">Six steps the agent runs end‑to‑end.</p>
<div class="steps">
<div class="steprow"><div class="num">1</div><div class="cmdcell">syncgit stage</div><div class="desc"><b>Review & stage.</b> Show a categorized diff; <code class="k">git add</code> only real work — never logs, build output, secrets, or <code class="k">.syncgit/</code> itself.</div></div>
<div class="steprow"><div class="num">2</div><div class="cmdcell">git commit</div><div class="desc"><b>Commit</b> the staged slice with a one‑line imperative message. Skip if nothing is staged.</div></div>
<div class="steprow"><div class="num">2.5</div><div class="cmdcell">syncgit squash --mainline</div><div class="desc"><b>Optional squash.</b> Collapse many small commits since your last <code class="k">spush</code> into one. The <code class="k">--mainline</code> flag picks the serial‑mode boundary: <code class="k">merge-base(HEAD, mainline)</code>.</div></div>
<div class="steprow"><div class="num">3</div><div class="cmdcell">syncgit smerge</div><div class="desc"><b>Rebase onto the mainline.</b> Snapshots HEAD to <code class="k ref">refs/syncgit/<self>/pre-merge</code>, records the tip <b>M0</b> to <code class="k ref">…/merge-base</code>, then <code class="k">git rebase M0</code>. On conflict it exits <b>3</b> with the files to resolve.</div></div>
<div class="steprow"><div class="num">4</div><div class="cmdcell">syncgit verify</div><div class="desc"><b>Verify</b> the working state by running <code class="k">.syncgit/verify.sh</code> if present (absence = pass). Fix & retry up to 3×, else <code class="k">abort</code>.</div></div>
<div class="steprow"><div class="num">5</div><div class="cmdcell">syncgit spush</div><div class="desc"><b>Advance the mainline</b> via <code class="k">git update-ref refs/syncgit/mainline HEAD M0</code> — an atomic compare‑and‑swap. If a peer published first the CAS fails (exit <b>3</b>): loop back to step 3.</div></div>
<div class="steprow"><div class="num">6</div><div class="cmdcell">report</div><div class="desc"><b>Report</b> what you committed, whether you rebased onto new peer work, and how many CAS retries it took.</div></div>
</div>
</section>
<!-- ===== WHY IT STAYS LINEAR ===== -->
<section id="why">
<h2>Why it stays linear with zero merge machinery</h2>
<p class="lead">The compare‑and‑swap <em>is</em> the serialization point.</p>
<p class="body">
<code class="k">spush</code> advances the mainline only if it still equals <b>M0</b> — the exact tip you rebased onto in
<code class="k">smerge</code>. So at most one peer can move the pointer away from any given commit. Every successful advance
therefore rebased onto the current tip <em>before</em> moving it, which means each new mainline tip is a strict linear
descendant of the last. Divergence — the case <span class="ref">/sync</span>'s merge‑commit chain exists to absorb — simply
cannot arise here.
</p>
<div class="callout">
<b>The payoff:</b> no merge commits, no octopus merge‑base, no garbage collection, no dual merge strategy.
A plain <code class="k">git rebase</code> suffices, and the mainline's commit order is, by construction, the clean global ordering of every sync run.
</div>
<div class="callout warn">
<b>The trade:</b> serialization assumes peers sync roughly one at a time. Under a true race, all‑but‑one peer's
<code class="k">spush</code> bounces with exit 3 and they re‑run <code class="k">smerge</code>. That's cheap when contention is rare, and
it's why <span class="ref">/sync</span> exists for genuinely divergent, long‑lived parallel work.
</div>
</section>
<!-- ===== SAFETY / ROLLBACK ===== -->
<section id="safety">
<h2>Re‑entrancy & rollback</h2>
<p class="lead">Every smerge is recoverable; every snapshot is per‑peer.</p>
<div class="modelgrid">
<div class="mcard">
<h3>Re‑entrant smerge</h3>
<p>If a snapshot already exists (e.g. after a CAS‑failed <code class="k">spush</code>), <code class="k">smerge</code> keeps the original rollback point and only refreshes <b>M0</b> to the new mainline before rebasing again. Re‑running it is the intended recovery.</p>
</div>
<div class="mcard">
<h3>abort rolls back cleanly</h3>
<p><code class="k">syncgit abort</code> recognizes the per‑self snapshot <code class="k ref">refs/syncgit/<self>/pre-merge</code>, aborts any in‑progress rebase, hard‑resets HEAD, re‑attaches your branch, and clears the <code class="k">merge-base</code> record.</p>
</div>
<div class="mcard">
<h3>Per‑peer namespacing</h3>
<p>Serial snapshots live under <code class="k ref">refs/syncgit/<self>/*</code>, so they never collide with <span class="ref">/sync</span>'s shared <code class="k">refs/syncgit/pre-merge</code>. The two modes can coexist in one repo without stepping on each other.</p>
</div>
<div class="mcard">
<h3>Mode‑mixing guard</h3>
<p><code class="k">smerge</code> emits a non‑fatal warning if unabsorbed <code class="k">refs/pr/*</code> broadcasts exist — a tripwire for accidentally mixing <span class="ref">/sync</span> and <span class="ref">/ssync</span> in the same round.</p>
</div>
</div>
</section>
<!-- ===== COMPARISON ===== -->
<section id="compare">
<h2>/ssync vs /sync</h2>
<p class="lead">Same goal, opposite bet about how peers work.</p>
<div class="cmp">
<div class="cmpcard ssync">
<h3>/ssync — serial</h3>
<div class="tagline">refs/syncgit/mainline</div>
<ul>
<li><b>One shared linear history.</b> A single moving pointer, advanced by CAS.</li>
<li><b>Plain rebase only.</b> No merge commits, no GC, no strategy flags.</li>
<li><b>Assumes peers sync one at a time.</b> Races just retry.</li>
<li><b>Best for</b> the common case: a team making steady, mostly‑sequential progress.</li>
</ul>
</div>
<div class="cmpcard sync">
<h3>/sync — broadcast</h3>
<div class="tagline">refs/pr/<id>/<ts></div>
<ul>
<li><b>Divergence‑tolerant.</b> Each peer broadcasts a PR ref to everyone.</li>
<li><b>Merge‑commit chain</b> preserves every peer's SHA, plus GC & TTL cleanup.</li>
<li><b>No serialization.</b> Peers publish freely and absorb each other later.</li>
<li><b>Best for</b> genuinely parallel, long‑lived divergent work.</li>
</ul>
</div>
</div>
<div class="callout">
<b>One rule:</b> the modes touch disjoint refs and don't observe each other's published work, so a whole sync round must use one mode team‑wide. Don't mix them.
</div>
</section>
<footer>
Built from <code>bin/syncgit</code> · <code>commands/ssync.md</code> · <code>docs/architecture.md</code> · syncgit serial mode
</footer>
</div>
<script>
/* ============================================================================
Interactive /ssync visualizer
A tiny scene engine. Each scene is an ordered list of "steps". Each step is a
full description of the graph state (lanes, commits, the mainline flag,
snapshot labels) plus narration and the command being run. Stepping just
re-renders the target state; CSS transitions animate the difference.
========================================================================== */
// Horizontal positions (percent of track width) for each "slot" in history.
const SLOT = [8, 30, 52, 74, 92];
// ---- Scene definitions -----------------------------------------------------
// commit: {id,label,lane,cls,slot,ghost?,pop?}
// flag: {slot, lane, label, cas?} (the mainline pointer)
// snaps: [{slot, lane, text}] (small ref annotations)
// lanes: ordered list of {key,label,color}
const LANES_AB = [
{key:'main', label:'mainline', color:'var(--mainline)'},
{key:'a', label:'peer a', color:'var(--peer-a)'},
{key:'b', label:'peer b', color:'var(--peer-b)'},
];
function seed(slot=0){ return {id:'S',label:'⌂',lane:'main',cls:'seed',slot}; }
const SCENES = [
{
id:'sequential',
title:'Sequential sync — two peers, in turn',
tag:'the happy path',
lanes: LANES_AB,
steps:[
{
no:'Start', who:null,
narr:`Fresh repo. <code class="k">syncgit init</code> created the <b>mainline</b> pointer at the seed commit. Both peers branch from it. Nobody has synced yet.`,
cmd:`<span class="prompt">$</span> syncgit init --peers a,b<span class="out exit0">→ refs/syncgit/mainline @ seed</span>`,
commits:[ seed(0) ],
flag:{slot:0, lane:'main', label:'mainline'},
snaps:[]
},
{
no:'1 · a works', who:'a',
narr:`<span class="who-a">Peer a</span> does some work and commits <b>A1</b> on its own branch. The mainline hasn't moved — a's commit isn't shared yet.`,
cmd:`<span class="prompt">a $</span> git commit -m "add a.txt"`,
commits:[ seed(0), {id:'A1',label:'A1',lane:'a',cls:'a',slot:1,pop:true} ],
flag:{slot:0, lane:'main', label:'mainline'},
snaps:[]
},
{
no:'2 · a smerge', who:'a',
narr:`<span class="who-a">a</span> runs <code class="k">smerge</code>. Mainline is still at the seed, which a already contains — so the rebase is a <b>no‑op</b>. smerge snapshots HEAD and records <b>M0 = seed</b> for the upcoming compare‑and‑swap.`,
cmd:`<span class="prompt">a $</span> syncgit smerge<span class="out exit0">→ already up to date with mainline (seed)</span>`,
commits:[ seed(0), {id:'A1',label:'A1',lane:'a',cls:'a',slot:1} ],
flag:{slot:0, lane:'main', label:'mainline = M0'},
snaps:[ {slot:1, lane:'a', text:'pre-merge ↩'} ]
},
{
no:'3 · a spush', who:'a',
narr:`<span class="who-a">a</span> runs <code class="k">spush</code>. CAS: <i>advance mainline to A1, but only if it still equals M0 (seed)</i>. It does — so the pointer jumps to <b>A1</b>. a's snapshot is cleared. a's work is now the shared tip.`,
cmd:`<span class="prompt">a $</span> syncgit spush<span class="out exit0">→ advanced mainline to A1 ✓</span>`,
commits:[ seed(0), {id:'A1',label:'A1',lane:'a',cls:'a',slot:1} ],
flag:{slot:1, lane:'main', label:'mainline', cas:true},
snaps:[]
},
{
no:'4 · b works', who:'b',
narr:`Now <span class="who-b">peer b</span> commits <b>B1</b> — but b branched from the <i>seed</i>, before A1 existed. b's history has diverged from the mainline by one commit.`,
cmd:`<span class="prompt">b $</span> git commit -m "add b.txt"`,
commits:[ seed(0), {id:'A1',label:'A1',lane:'main',cls:'a',slot:1}, {id:'B1',label:'B1',lane:'b',cls:'b',slot:1,pop:true} ],
flag:{slot:1, lane:'main', label:'mainline'},
snaps:[]
},
{
no:'5 · b smerge', who:'b',
narr:`<span class="who-b">b</span> runs <code class="k">smerge</code>. Mainline (A1) is <i>not</i> in b's history, so smerge <b>rebases B1 on top of A1</b>. b now contains a.txt <i>and</i> b.txt. M0 is recorded as A1.`,
cmd:`<span class="prompt">b $</span> syncgit smerge<span class="out exit0">→ rebasing onto mainline (A1)… B1 → B1'</span>`,
commits:[ seed(0), {id:'A1',label:'A1',lane:'main',cls:'a',slot:1}, {id:'B1g',label:'B1',lane:'b',cls:'b',slot:1,ghost:true}, {id:'B1',label:"B1'",lane:'b',cls:'b',slot:2,pop:true} ],
flag:{slot:1, lane:'main', label:'mainline = M0'},
snaps:[ {slot:1, lane:'b', text:'↩ pre-merge'} ]
},
{
no:'6 · b spush', who:'b',
narr:`<span class="who-b">b</span> runs <code class="k">spush</code>. CAS succeeds (mainline still = A1), so the pointer advances to <b>B1'</b>. The result is one strictly linear chain: <b>seed → A1 → B1'</b>. No merge commits, no GC.`,
cmd:`<span class="prompt">b $</span> syncgit spush<span class="out exit0">→ advanced mainline to B1' ✓</span>`,
commits:[ seed(0), {id:'A1',label:'A1',lane:'main',cls:'a',slot:1}, {id:'B1',label:"B1'",lane:'main',cls:'b',slot:2} ],
flag:{slot:2, lane:'main', label:'mainline', cas:true},
snaps:[]
},
]
},
{
id:'contention',
title:'Contention — both smerge off the same tip',
tag:'compare-and-swap in action',
lanes: LANES_AB,
steps:[
{
no:'Start', who:null,
narr:`Both peers start from the same mainline tip (the seed) and both have local work. This is the race the CAS is built for.`,
cmd:`<span class="prompt">$</span> syncgit status<span class="out">mainline (/ssync): seed</span>`,
commits:[ seed(0), {id:'A1',label:'A1',lane:'a',cls:'a',slot:1}, {id:'B1',label:'B1',lane:'b',cls:'b',slot:1} ],
flag:{slot:0, lane:'main', label:'mainline'},
snaps:[]
},
{
no:'1 · both smerge', who:null,
narr:`<span class="who-a">a</span> and <span class="who-b">b</span> each run <code class="k">smerge</code> against the <i>same</i> mainline tip (seed). Both rebases are no‑ops, and <b>both record M0 = seed</b>. Two peers now believe the tip they may advance from is the seed.`,
cmd:`<span class="prompt">a $</span> syncgit smerge <span class="out exit0">M0 = seed ↩</span><span class="prompt">b $</span> syncgit smerge <span class="out exit0">M0 = seed ↩</span>`,
commits:[ seed(0), {id:'A1',label:'A1',lane:'a',cls:'a',slot:1}, {id:'B1',label:'B1',lane:'b',cls:'b',slot:1} ],
flag:{slot:0, lane:'main', label:'mainline = M0 (both)'},
snaps:[ {slot:1, lane:'a', text:'↩ a/pre-merge'}, {slot:1, lane:'b', text:'↩ b/pre-merge'} ]
},
{
no:'2 · a spush wins', who:'a',
narr:`<span class="who-a">a</span> pushes first. CAS: mainline == M0 (seed)? Yes → it advances to <b>A1</b>. <span class="who-a">a</span> wins the race and clears its snapshot.`,
cmd:`<span class="prompt">a $</span> syncgit spush<span class="out exit0">→ advanced mainline to A1 ✓ (exit 0)</span>`,
commits:[ seed(0), {id:'A1',label:'A1',lane:'main',cls:'a',slot:1}, {id:'B1',label:'B1',lane:'b',cls:'b',slot:1} ],
flag:{slot:1, lane:'main', label:'mainline', cas:true},
snaps:[ {slot:1, lane:'b', text:'↩ b/pre-merge (stale M0=seed)'} ]
},
{
no:'3 · b spush bounces', who:'b',
narr:`<span class="who-b">b</span> pushes second. CAS: mainline == M0 (seed)? <b>No</b> — it's A1 now. The compare‑and‑swap <b>fails with exit 3</b>. b's pointer does <i>not</i> move; nothing is lost. b is told to re‑run smerge.`,
cmd:`<span class="prompt">b $</span> syncgit spush<span class="out exit3">→ mainline advanced since your smerge (now A1, expected seed) — exit 3</span>`,
commits:[ seed(0), {id:'A1',label:'A1',lane:'main',cls:'a',slot:1}, {id:'B1',label:'B1',lane:'b',cls:'b',slot:1} ],
flag:{slot:1, lane:'main', label:'mainline', cas:true},
snaps:[ {slot:1, lane:'b', text:'↩ b/pre-merge (kept)'} ]
},
{
no:'4 · b re-smerge', who:'b',
narr:`<span class="who-b">b</span> re‑runs <code class="k">smerge</code>. It's <b>re‑entrant</b>: it keeps b's original snapshot but refreshes <b>M0 to A1</b>, then rebases B1 on top of A1. b now contains both files.`,
cmd:`<span class="prompt">b $</span> syncgit smerge<span class="out exit0">→ continuing in-flight smerge; retargeting onto A1… B1 → B1'</span>`,
commits:[ seed(0), {id:'A1',label:'A1',lane:'main',cls:'a',slot:1}, {id:'B1g',label:'B1',lane:'b',cls:'b',slot:1,ghost:true}, {id:'B1',label:"B1'",lane:'b',cls:'b',slot:2,pop:true} ],
flag:{slot:1, lane:'main', label:'mainline = M0'},
snaps:[ {slot:1, lane:'b', text:'↩ b/pre-merge'} ]
},
{
no:'5 · b spush wins', who:'b',
narr:`<span class="who-b">b</span> pushes again. CAS: mainline == M0 (A1)? Yes → advances to <b>B1'</b>. Both peers' work is now linear: <b>seed → A1 → B1'</b>. The race cost exactly one wasted spush and one retry.`,
cmd:`<span class="prompt">b $</span> syncgit spush<span class="out exit0">→ advanced mainline to B1' ✓ (exit 0)</span>`,
commits:[ seed(0), {id:'A1',label:'A1',lane:'main',cls:'a',slot:1}, {id:'B1',label:"B1'",lane:'main',cls:'b',slot:2} ],
flag:{slot:2, lane:'main', label:'mainline', cas:true},
snaps:[]
},
]
},
{
id:'conflict',
title:'Conflict — smerge collides, then abort',
tag:'rollback to a clean state',
lanes: LANES_AB,
steps:[
{
no:'Start', who:null,
narr:`<span class="who-a">a</span> has already synced: it wrote <code class="k">shared.txt = "alpha"</code> and advanced the mainline to <b>A1</b>. Now <span class="who-b">b</span> edits the <i>same</i> file to <code class="k">"beta"</code> on top of the seed.`,
cmd:`<span class="prompt">b $</span> git commit -m "shared.txt = beta"`,
commits:[ seed(0), {id:'A1',label:'A1',lane:'main',cls:'a',slot:1}, {id:'B1',label:'B1',lane:'b',cls:'b',slot:1,pop:true} ],
flag:{slot:1, lane:'main', label:'mainline'},
snaps:[]
},
{
no:'1 · b smerge conflicts', who:'b',
narr:`<span class="who-b">b</span> runs <code class="k">smerge</code>. Rebasing B1 onto A1 means replaying b's <code class="k">"beta"</code> edit over a's <code class="k">"alpha"</code> edit to the same lines — <b>git can't auto‑merge</b>. smerge exits <b>3</b>, leaving a rebase in progress and the conflict files listed.`,
cmd:`<span class="prompt">b $</span> syncgit smerge<span class="out exitbad">CONFLICT in shared.txt — resolve, then git rebase --continue. Or: syncgit abort — (exit 3)</span>`,
commits:[ seed(0), {id:'A1',label:'A1',lane:'main',cls:'a',slot:1}, {id:'B1g',label:'B1',lane:'b',cls:'b',slot:1,ghost:true}, {id:'BX',label:'✗',lane:'b',cls:'b',slot:2,pop:true} ],
flag:{slot:1, lane:'main', label:'mainline = M0'},
snaps:[ {slot:1, lane:'b', text:'↩ b/pre-merge (saved)'} ]
},
{
no:'2 · abort', who:'b',
narr:`Say b can't resolve it cleanly. <code class="k">syncgit abort</code> aborts the in‑progress rebase, <b>hard‑resets HEAD to the per‑self snapshot</b> (b's pre‑merge B1), re‑attaches branch <code class="k">b</code>, and deletes the snapshot + merge‑base refs.`,
cmd:`<span class="prompt">b $</span> syncgit abort<span class="out exit0">→ rolled back to pre-merge state ✓</span>`,
commits:[ seed(0), {id:'A1',label:'A1',lane:'main',cls:'a',slot:1}, {id:'B1',label:'B1',lane:'b',cls:'b',slot:1,pop:true} ],
flag:{slot:1, lane:'main', label:'mainline'},
snaps:[]
},
{
no:'3 · clean again', who:'b',
narr:`b is exactly where it started: <code class="k">shared.txt = "beta"</code>, HEAD on branch <code class="k">b</code> at B1, no leftover <code class="k">refs/syncgit/b/*</code>. b can now resolve the conflict deliberately and re‑run <code class="k">smerge</code> when ready — nothing was corrupted.`,
cmd:`<span class="prompt">b $</span> git status<span class="out exit0">On branch b — working tree clean, no rebase in progress</span>`,
commits:[ seed(0), {id:'A1',label:'A1',lane:'main',cls:'a',slot:1}, {id:'B1',label:'B1',lane:'b',cls:'b',slot:1} ],
flag:{slot:1, lane:'main', label:'mainline'},
snaps:[]
},
]
},
];
// ---- Renderer --------------------------------------------------------------
const graph = document.getElementById('graph');
const wires = document.getElementById('wires');
const tabsEl = document.getElementById('scenetabs');
const titleEl= document.getElementById('sceneTitle');
const tagEl = document.getElementById('sceneTag');
const narrEl = document.getElementById('narr');
const cmdEl = document.getElementById('cmd');
const stepnoEl=document.getElementById('stepno');
const backB = document.getElementById('back');
const nextB = document.getElementById('next');
const dotsEl = document.getElementById('dots');
let sceneIdx = 0;
let stepIdx = 0;
let laneEls = {}; // key -> {row, track}
function buildScene(si){
sceneIdx = si; stepIdx = 0;
const sc = SCENES[si];
titleEl.textContent = sc.title;
tagEl.textContent = sc.tag;
// build lanes
graph.querySelectorAll('.lane').forEach(n=>n.remove());
laneEls = {};
sc.lanes.forEach(L=>{
const lane = document.createElement('div'); lane.className='lane';
const label = document.createElement('div'); label.className='lane-label';
const dot = document.createElement('span'); dot.className='dotmark'; dot.style.background=L.color;
label.appendChild(dot);
label.appendChild(document.createTextNode(L.label));
const track = document.createElement('div'); track.className='track';
lane.appendChild(label); lane.appendChild(track);
graph.appendChild(lane);
laneEls[L.key] = {lane, track};
});
// tabs active state
[...tabsEl.children].forEach((t,i)=>t.classList.toggle('active', i===si));
// dots
dotsEl.innerHTML='';
sc.steps.forEach((_,i)=>{ const d=document.createElement('span'); d.className='dot'; dotsEl.appendChild(d); });
render();
}
function laneY(key){
// vertical center of a lane's track, relative to graph
const t = laneEls[key].track.getBoundingClientRect();
const g = graph.getBoundingClientRect();
return (t.top - g.top) + t.height/2;
}
function slotX(slot){
const anyTrack = Object.values(laneEls)[0].track.getBoundingClientRect();
const g = graph.getBoundingClientRect();
const left = (anyTrack.left - g.left);
return left + (SLOT[slot]/100)*anyTrack.width;
}
function render(){
const sc = SCENES[sceneIdx];
const step = sc.steps[stepIdx];
// narration + command
stepnoEl.textContent = 'Step ' + step.no;
narrEl.innerHTML = step.narr;
cmdEl.classList.toggle('empty', false);
cmdEl.innerHTML = step.cmd;
// controls
backB.disabled = stepIdx===0;
nextB.disabled = false;
nextB.textContent = stepIdx===0 ? 'Start ▸' : (stepIdx===sc.steps.length-1 ? '↻ Replay' : 'Next ▸');
[...dotsEl.children].forEach((d,i)=>d.classList.toggle('on', i===stepIdx));
// commits — render per lane track
// clear existing
Object.values(laneEls).forEach(L=>{
L.track.querySelectorAll('.commit,.snap,.mlflag').forEach(n=>n.remove());
});
// place commits
const placed = {}; // id -> element for wire drawing
step.commits.forEach(c=>{
const L = laneEls[c.lane];
const el = document.createElement('div');
el.className = 'commit '+ (c.cls||'') + (c.ghost?' ghost':'') ;
el.style.left = SLOT[c.slot] + '%';
el.textContent = c.label;
L.track.appendChild(el);
if(c.pop){ el.classList.add('pop'); }
placed[c.id] = c;
});
// snapshots
(step.snaps||[]).forEach(s=>{
const L = laneEls[s.lane];
const el = document.createElement('div');
el.className='snap show';
el.style.left = SLOT[s.slot] + '%';
el.textContent = s.text;
L.track.appendChild(el);
});
// mainline flag — placed in its lane
if(step.flag){
const L = laneEls[step.flag.lane];
const f = document.createElement('div');
f.className='mlflag'+(step.flag.cas?' cas':'');
f.style.left = SLOT[step.flag.slot] + '%';
f.innerHTML = '⚑ '+step.flag.label;
L.track.appendChild(f);
}
// draw wires (parent edges) after layout settles
requestAnimationFrame(()=>drawWires(step));
}
function drawWires(step){
// Build edges: connect along history. We infer simple parent chains by slot order
// within the unified timeline: seed -> A1 -> (B1'|...). Plus ghost->rebased dashed.
wires.innerHTML='';
const g = graph.getBoundingClientRect();
wires.setAttribute('viewBox', `0 0 ${g.width} ${g.height}`);
const byId = {}; step.commits.forEach(c=>byId[c.id]=c);
// central spine: connect the real (non-ghost) commits in increasing slot, choosing
// for each slot the "canonical" commit (prefer main lane, else the highest lane).
const realCommits = step.commits.filter(c=>!c.ghost);
function pos(c){ return { x: slotX(c.slot), y: laneY(c.lane) }; }
function line(p1,p2,opts={}){
const path = document.createElementNS('http://www.w3.org/2000/svg','path');
const dx = (p2.x-p1.x);
const c1x = p1.x + dx*0.5, c2x = p2.x - dx*0.5;
path.setAttribute('d', `M ${p1.x} ${p1.y} C ${c1x} ${p1.y}, ${c2x} ${p2.y}, ${p2.x} ${p2.y}`);
path.setAttribute('fill','none');
path.setAttribute('stroke', opts.color || 'var(--line)');
path.setAttribute('stroke-width', opts.w || 2);
if(opts.dash) path.setAttribute('stroke-dasharray','3 4');
path.setAttribute('opacity', opts.op ?? 0.9);
wires.appendChild(path);
}
// connect consecutive real commits by slot, threading lanes
const sorted = [...realCommits].sort((a,b)=> a.slot-b.slot || 0);
for(let i=1;i<sorted.length;i++){
const prev = sorted[i-1], cur = sorted[i];
if(cur.slot===prev.slot) continue; // same column (e.g. flag-only); skip
const col = cur.cls==='a' ? 'rgba(88,166,255,.55)' : cur.cls==='b' ? 'rgba(163,113,247,.55)' : 'var(--line)';
line(pos(prev), pos(cur), {color: col, w:2.5});
}
// ghost -> its rebased counterpart (same label family), dashed faint
step.commits.filter(c=>c.ghost).forEach(gc=>{
// find the rebased version: a non-ghost commit in same lane at a higher slot
const target = step.commits.find(c=>!c.ghost && c.lane===gc.lane && c.slot>gc.slot);
if(target) line(pos(gc), pos(target), {color:'var(--ink-faint)', w:1.5, dash:true, op:.5});
// also connect ghost back to its base on the spine (seed/A1) for context
});
}
// ---- nav -------------------------------------------------------------------
function next(){
const sc = SCENES[sceneIdx];
if(stepIdx===sc.steps.length-1){ stepIdx=0; } else { stepIdx++; }
render();
}
function back(){ if(stepIdx>0){ stepIdx--; render(); } }
nextB.addEventListener('click', next);
backB.addEventListener('click', back);
window.addEventListener('keydown', e=>{
if(e.key==='ArrowRight'){ next(); }
else if(e.key==='ArrowLeft'){ back(); }
});
window.addEventListener('resize', ()=>requestAnimationFrame(()=>drawWires(SCENES[sceneIdx].steps[stepIdx])));
// ---- tabs ------------------------------------------------------------------
SCENES.forEach((sc,i)=>{
const t = document.createElement('div');
t.className='scenetab'+(i===0?' active':'');
t.innerHTML = `<span class="n">${i+1}</span>${sc.title.split(' — ')[0]}`;
t.addEventListener('click', ()=>buildScene(i));
tabsEl.appendChild(t);
});
buildScene(0);
</script>
</body>
</html>