sidebranch 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,930 @@
1
+ <!doctype html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="utf-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1">
6
+ <title>sidebranch — compare</title>
7
+ <!-- Same mark as the .dock .brand icon below — kept as a data: URI so the
8
+ favicon needs no new daemon route (img-src data: is already allowed by
9
+ this page's CSP; see AGENTS.md's "exactly N embedded assets" note). -->
10
+ <link rel="icon" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'%3E%3Crect width='100' height='100' rx='20' fill='%23191919'/%3E%3Cpath fill='%23fff' transform='translate(14.85 14.85) scale(0.7)' d='M45 11C50.5228 11 55 15.4772 55 21V39H40C34.4772 39 30 43.4772 30 49V78C30 83.5228 34.4772 88 40 88H26C20.4772 88 16 83.5228 16 78V21C16 15.4772 20.4772 11 26 11H45ZM73 39C78.5228 39 83 43.4772 83 49V78C83 83.5228 78.5228 88 73 88H45C50.5228 88 55 83.5228 55 78V39H73Z'/%3E%3C/svg%3E">
11
+ <style>
12
+ @font-face{font-family:"Geist Pixel";src:url("http://localhost:__SIDEBRANCH_PORT__/geist-pixel.woff2") format("woff2");font-display:swap}
13
+ :root{
14
+ --bg:#191919; --surface:#222; --line:rgba(255,255,255,.11);
15
+ --text:#d7dce2; --muted:#8a949e; --ok:#59c26f; --err:#e5534b;
16
+ --ease:cubic-bezier(0.34,0.8,0.23,0.97); --surface-high: #444;
17
+ }
18
+ *{box-sizing:border-box;margin:0;padding:0}
19
+ html,body{height:100%}
20
+ body{background:var(--bg);color:var(--text);
21
+ font:13px/1.5 "Geist Pixel","Geist Mono",ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;
22
+ display:flex;flex-direction:column}
23
+ button,input{font:inherit;color:inherit;background:none;border:0}
24
+ button{cursor:pointer}
25
+ :focus-visible{outline:1px solid rgba(255,255,255,.55);outline-offset:1px}
26
+
27
+ /* ---- stage + per-panel header (controls live directly above their own
28
+ panel, so they always track whatever width that panel currently has —
29
+ fixed px, or "fit" — with no separate alignment logic needed) ---- */
30
+ main{flex:1;overflow:auto;position:relative;display:flex;justify-content:center;align-items:stretch;
31
+ gap:18px;padding:24px 24px 100px}
32
+ /* 220px is the header's own floor (badge + branch trigger's 90px min + sha
33
+ + gaps/padding) — below it the trigger would need to shrink past a usable
34
+ width, which is what used to force the two panes' headers to overlap
35
+ instead of the frames just stopping their shrink and letting `main`
36
+ (overflow:auto) scroll horizontally. */
37
+ .frame{display:flex;flex-direction:column;min-width:220px;flex:none}
38
+ .pane-hd{display:flex;align-items:center;gap:10px;padding:0 8px 12px}
39
+ .pane-hd .k{font-size:12px;padding:3px 9px;border-radius:999px;color:var(--ok);background:rgba(89,194,111,.12);flex:none}
40
+
41
+ /* ---- searchable branch dropdown (replaces the native <select>) ----
42
+ A trigger button styled like the old select, plus a filterable listbox
43
+ popup — the same visual language as the injected widget's branch flyout.
44
+ Flexible instead of a fixed 300px: the trigger shrinks with the frame
45
+ (down to min-width) rather than overflowing it, and grows up to max-width
46
+ when there's room; white-space/text-overflow keep a long branch name to
47
+ one line with an ellipsis. The popup is position:fixed and placed via JS
48
+ (the #tooltip technique) so main's overflow:auto never clips it and it
49
+ survives being reparented into pane A's header in stacked modes. */
50
+ .branch-select{flex:1 1 140px;min-width:90px;max-width:300px;position:relative}
51
+ .bs-trigger{display:flex;align-items:center;gap:8px;width:100%;background:var(--surface);
52
+ border:1px solid rgba(255,255,255,.04);border-radius:8px;padding:8px 10px;font-size:14px;text-align:left;
53
+ transition:border-color .15s var(--ease)}
54
+ .bs-trigger:hover,.bs-trigger[aria-expanded="true"]{border-color:rgba(255,255,255,.25)}
55
+ .bs-value{flex:1;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
56
+ .bs-trigger.placeholder .bs-value{color:var(--muted)}
57
+ .bs-chev{flex:none;color:var(--muted);transition:rotate .15s var(--ease);transform-origin:center}
58
+ .bs-trigger[aria-expanded="true"] .bs-chev{rotate:180deg}
59
+
60
+ .bs-popup{position:fixed;z-index:11;display:flex;flex-direction:column;min-width:220px;max-height:60vh;
61
+ overflow:hidden;background:var(--surface);border:1px solid var(--line);border-radius:8px;
62
+ box-shadow:0 8px 24px rgba(0,0,0,.35);
63
+ opacity:0;transform:scale(.98) translateY(-4px);pointer-events:none;
64
+ transition:opacity .14s var(--ease),transform .14s var(--ease)}
65
+ .bs-popup.open{opacity:1;transform:none;pointer-events:auto}
66
+ .bs-search{display:flex;align-items:center;border-bottom:1px solid var(--line)}
67
+ .bs-filter{flex:1;min-width:0;padding:9px 11px;outline:0;font-size:13px}
68
+ .bs-filter::placeholder{color:var(--muted)}
69
+ /* Replace the UA search-cancel glyph (a gray gradient X) with a plain white,
70
+ round-capped X on a rounded hover chip, matching this file's icon style. */
71
+ .bs-filter::-webkit-search-cancel-button{-webkit-appearance:none;appearance:none;
72
+ width:16px;height:16px;border-radius:999px;cursor:pointer;
73
+ background:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16' fill='none' stroke='%23fff' stroke-width='1.6' stroke-linecap='round'%3E%3Cpath d='M4.5 4.5l7 7M11.5 4.5l-7 7'/%3E%3C/svg%3E") center/11px no-repeat;
74
+ transition:background-color .15s var(--ease)}
75
+ .bs-filter::-webkit-search-cancel-button:hover{background-color:rgba(255,255,255,.14)}
76
+ .bs-fetch{flex:none;display:flex;align-items:center;justify-content:center;width:30px;height:30px;
77
+ margin-right:5px;border-radius:7px;color:var(--muted);
78
+ transition:background .15s var(--ease),color .15s var(--ease)}
79
+ .bs-fetch:hover{background:rgba(255,255,255,.09);color:#fff}
80
+ .bs-fetch:disabled{opacity:.6;cursor:default}
81
+ .bs-fetch.spinning svg{animation:bs-spin .8s linear infinite}
82
+ @keyframes bs-spin{to{transform:rotate(360deg)}}
83
+ .bs-list{overflow-y:auto;flex:1;max-height:300px;padding:4px}
84
+ /* .active = the roving keyboard highlight (aria-activedescendant);
85
+ aria-selected = the branch this pane is actually showing right now. */
86
+ .bs-option{display:flex;align-items:center;gap:8px;width:100%;padding:7px 10px;border-radius:6px;
87
+ text-align:left;white-space:nowrap;cursor:pointer}
88
+ .bs-option .bs-opt-name{flex:1;overflow:hidden;text-overflow:ellipsis}
89
+ .bs-option:hover,.bs-option.active{background:rgba(255,255,255,.08)}
90
+ .bs-option[aria-selected="true"]{background:rgba(89,194,111,.12)}
91
+ .bs-option[aria-selected="true"] .bs-opt-name{color:var(--ok)}
92
+ .bs-check{flex:none;color:var(--ok);opacity:0}
93
+ .bs-option[aria-selected="true"] .bs-check{opacity:1}
94
+ .bs-empty{padding:10px 11px;color:var(--muted)}
95
+ @media (prefers-reduced-motion:reduce){.bs-popup,.bs-chev{transition:none}.bs-fetch.spinning svg{animation:none}}
96
+
97
+ .pane-sha{font-size:12px;color:var(--muted);flex:none;font-variant-numeric:tabular-nums}
98
+ .muted{color:var(--muted);}
99
+
100
+ /* B's select/sha travel as one unit into pane A's header while stacked
101
+ (blend/onion), since frame.b itself is hidden then — see setMode().
102
+ Keeps B always selectable, in every mode. The k-b override (only when
103
+ nested inside pane-hd.a) keeps the two badges visually distinct instead
104
+ of both reading as green "A" pills. */
105
+ .b-controls{display:flex;align-items:center;gap:10px;flex:none;min-width:0;
106
+ opacity:1;transition:opacity .18s var(--ease)}
107
+ /* In side-by-side, B's controls are the pane header's only child — let them
108
+ fill it so B's branch trigger grows to exactly match A's (whose trigger is
109
+ a direct flex child of its own header). While stacked, b-controls rides
110
+ into pane A's header, where it must stay content-width (flex:none default). */
111
+ .pane-hd.b > .b-controls{flex:1}
112
+ .b-controls:not(:first-child){border-left:1px solid var(--line);padding-left:16px}
113
+ .b-controls.fade-out{opacity:0}
114
+ .pane-hd.a .k-b{color:var(--muted);background:rgba(255,255,255,.05)}
115
+
116
+ /* swap sits between the two panels, out of the flex flow so it never
117
+ perturbs the width-sync math between frame.a and frame.b */
118
+ #swap{position:absolute;top:50%;left:50%;transform:translate(-50%, -50%);z-index:6;
119
+ width:32px;height:32px;border-radius:999px;display:flex;align-items:center;justify-content:center;
120
+ background:var(--surface-high);color:var(--muted);
121
+ /* same halo + elevation shadow as .frame .vp, so the swap button reads
122
+ as floating over the panels rather than flat against the page */
123
+ box-shadow:0 0 6px rgba(0,0,0,.2);
124
+ transition:background .15s var(--ease),color .15s var(--ease)}
125
+ #swap:hover{background:#555;color:#fff}
126
+
127
+ .frame .vp{flex:1;background:var(--surface);border-radius:16px;overflow:hidden;position:relative;
128
+ /* no border — a tight 1px "halo" shadow stands in for a hairline
129
+ border, plus a soft, wider shadow for elevation. */
130
+ box-shadow:0 0 0 1px rgba(255,255,255,.09),0 0 6px rgba(0,0,0,.4)}
131
+ /* Rounded corners here have two independent seam sources, both closed
132
+ above rather than masked:
133
+ 1) overflow:hidden anti-aliases its clip boundary by blending the
134
+ clipped child's edge pixels against THIS element's own background.
135
+ With background:#fff behind a dark .empty overlay (or any dark
136
+ page loaded in the iframe), that blend read as a bright hairline
137
+ right on the curve — matching var(--surface), the color actually
138
+ painted behind the curve in every real case, makes the blend a
139
+ no-op instead of a visible line.
140
+ 2) iframes are promoted to their own compositor layer, so the
141
+ ancestor's rounded clip and the iframe's layer boundary can get
142
+ rasterized independently — Chromium/WebKit sometimes leave a
143
+ hairline sliver of whatever's behind the iframe peeking past the
144
+ curve. clip-path (a compositor-level mask, applied uniformly across
145
+ child layers) layered on top of the overflow:hidden clip, plus
146
+ translateZ(0) promoting .vp to its own layer so its clip resolves
147
+ in the same compositing space as the iframe's, close this from the
148
+ ancestor side. Clipping the iframe to the same radius on itself
149
+ (below) only stops the iframe's own paint from exceeding the curve
150
+ — it doesn't control what shows through *between* the two curves,
151
+ which is why it alone wasn't enough. */
152
+ iframe{width:100%;height:100%;border:0;display:block;background:var(--surface);border-radius:inherit}
153
+
154
+ /* ---- zero state ---- */
155
+ .vp .empty:not([hidden]){position:absolute;inset:0;display:flex;flex-direction:column;
156
+ align-items:center;justify-content:center;gap:10px;text-align:center;padding:32px;
157
+ background:var(--surface);color:var(--muted)}
158
+ .empty-spin{font-size:18px;color:rgba(255,255,255,.55);min-height:1.3em;line-height:1}
159
+ .empty-h{font-size:16px;font-weight:600;color:var(--text)}
160
+ .empty-s{font-size:13px;color:var(--muted);max-width:30ch;line-height:1.5}
161
+ .empty.err .empty-h{color:var(--err)}
162
+
163
+ /* ---- drag-to-resize handle, DevTools-style ---- */
164
+ .resize-handle{position:absolute;top:0;bottom:0;right:-9px;width:18px;
165
+ cursor:ew-resize;z-index:5;touch-action:none}
166
+ .resize-handle::after{content:"";position:absolute;top:0;bottom:0;left:50%;width:2px;
167
+ background:var(--surface);transform:translateX(-50%);transition:background .15s var(--ease)}
168
+ .resize-handle:hover::after,.resize-handle.dragging::after{background:rgba(255,255,255,.55)}
169
+ main.stacked .resize-handle{display:none}
170
+ main.stacked #swap{display:none}
171
+
172
+ /* stacked diff modes — #fb is physically moved into frame A's viewport
173
+ (see moveIntoStack/moveOutOfStack in the script) rather than duplicated
174
+ into a second "ghost" iframe, so the overlay always reflects whatever
175
+ is actually loaded in pane B (including any client-side navigation the
176
+ reviewer has done, e.g. logging in) instead of a stale fresh load. */
177
+ main.stacked{align-items:stretch}
178
+ main.stacked .frame.b{position:absolute;visibility:hidden}
179
+ main.stacked .frame.a .vp{isolation:isolate}
180
+ main.stacked .frame.a .vp iframe{position:absolute;inset:0}
181
+
182
+ /* ---- floating bottom dock — never overlaps the panels; main's bottom
183
+ padding above reserves exactly enough room for it. No borders anywhere
184
+ in here — distinction is background-only. ---- */
185
+ .dock{position:fixed;left:50%;bottom:24px;transform:translateX(-50%);z-index:10;
186
+ display:flex;align-items:center;gap:12px;padding:10px;border-radius:999px;
187
+ background:var(--surface);box-shadow:0 4px 34px rgba(0,0,0,.25)}
188
+ .dock .brand{display:flex;align-items:center;color:#fff;padding-left:8px}
189
+ .dock-sep{width:1px;height:18px;background:var(--line);flex:none}
190
+
191
+ /* mode switcher: squared off, icon-only, subtler than the rest of the
192
+ dock's fully-rounded pill controls, with a themed tooltip per mode
193
+ (see the #tooltip rules below) */
194
+ .seg{display:flex;border-radius:7px;overflow:hidden;background:rgba(255,255,255,.04)}
195
+ .seg button{width:34px;height:32px;display:flex;align-items:center;justify-content:center;
196
+ color:var(--muted);transition:background .15s var(--ease),color .15s var(--ease)}
197
+ .seg button[aria-pressed="true"]{background:rgba(255,255,255,.14);color:#fff}
198
+ .seg button:hover{color:#fff}
199
+
200
+ /* themed tooltip for the mode buttons — a native `title` would work, but
201
+ its OS-styled popup and ~1s show-delay clash with the rest of this
202
+ dock's fast, dark-themed flyouts. One shared #tooltip element is
203
+ positioned via JS (getBoundingClientRect) rather than CSS anchored to
204
+ the button itself, since .seg's overflow:hidden (needed for its rounded
205
+ segmented-control shape) would otherwise clip anything popping out of a
206
+ button's own box. */
207
+ #tooltip{position:fixed;left:0;top:0;z-index:12;padding:8px 12px;border-radius:8px;
208
+ background:var(--surface);box-shadow:0 10px 26px rgba(0,0,0,.45);max-width:220px;
209
+ transform-origin:bottom center;transform:translate(-50%,-100%) translateY(4px) scale(.96);
210
+ opacity:0;pointer-events:none;transition:opacity .14s var(--ease),transform .14s var(--ease)}
211
+ #tooltip.open{opacity:1;transform:translate(-50%,-100%) translateY(0) scale(1)}
212
+ #tooltip .tt-title{font-size:12px;font-weight:600;color:#fff;line-height:1.4;white-space:nowrap}
213
+ #tooltip .tt-sub{font-size:12px;color:var(--muted);line-height:1.4;margin-top:2px}
214
+
215
+ .icon-btn{width:32px;height:32px;border-radius:999px;display:flex;align-items:center;justify-content:center;
216
+ color:var(--muted);background:rgba(255,255,255,.04);flex:none;
217
+ transition:background .15s var(--ease),color .15s var(--ease)}
218
+ .icon-btn:hover{background:rgba(255,255,255,.09);color:#fff}
219
+
220
+ /* onion opacity control: fixed to the top, opposite corner from the pane
221
+ selects (which live at top-left once stacked — see .b-controls above),
222
+ so it doesn't compete with the dock at the bottom. Slides down + fades
223
+ in when onion mode is entered, and reverses on the way out. */
224
+ .onion-ctl{position:fixed;top:24px;right:24px;z-index:8;
225
+ display:flex;align-items:center;gap:9px;padding:9px 13px;border-radius:10px;
226
+ background:var(--surface);box-shadow:0 12px 30px rgba(0,0,0,.5);white-space:nowrap;color:var(--muted);
227
+ opacity:0;pointer-events:none;transform:translateY(-10px) scale(.96);
228
+ transition:opacity .18s var(--ease),transform .18s var(--ease)}
229
+ .onion-ctl.visible{opacity:1;transform:translateY(0) scale(1);pointer-events:auto}
230
+ .onion-ctl input[type=range]{
231
+ -webkit-appearance:none;appearance:none;width:120px;height:4px;border-radius:999px;
232
+ background:rgba(255,255,255,.14);outline:none;cursor:pointer}
233
+ .onion-ctl input[type=range]::-webkit-slider-thumb{
234
+ -webkit-appearance:none;width:14px;height:14px;border-radius:50%;background:#fff;
235
+ box-shadow:0 1px 4px rgba(0,0,0,.4);cursor:pointer;transition:transform .15s var(--ease)}
236
+ .onion-ctl input[type=range]:hover::-webkit-slider-thumb{transform:scale(1.15)}
237
+ .onion-ctl input[type=range]::-moz-range-track{background:rgba(255,255,255,.14);height:4px;border-radius:999px}
238
+ .onion-ctl input[type=range]::-moz-range-thumb{
239
+ width:14px;height:14px;border-radius:50%;background:#fff;border:0;box-shadow:0 1px 4px rgba(0,0,0,.4);cursor:pointer}
240
+
241
+ .w-ctl{display:flex;align-items:center;gap:6px}
242
+ /* squared off — deliberately contrasts with the fully-rounded pill
243
+ controls elsewhere in the dock, per request */
244
+ #width-out{width:7ch;text-align:left;font-variant-numeric:tabular-nums;
245
+ border-radius:6px;padding:5px 7px;
246
+ transition:background .15s var(--ease)}
247
+ #width-out:hover{background:rgba(255,255,255,.09)}
248
+ #width-out:focus-visible{outline:1px solid rgba(255,255,255,.55);outline-offset:0}
249
+
250
+ /* ---- status bubble — floats above the dock, never shifts its width ---- */
251
+ #status-bubble{position:fixed;left:50%;bottom:80px;z-index:9;
252
+ display:flex;align-items:center;gap:7px;padding:7px 12px;border-radius:8px;
253
+ background:var(--surface);box-shadow:0 10px 26px rgba(0,0,0,.45);
254
+ white-space:nowrap;opacity:0;pointer-events:none;
255
+ transform:translateX(-50%) translateY(6px) scale(.96);
256
+ transition:opacity .16s var(--ease),transform .16s var(--ease)}
257
+ #status-bubble.open{opacity:1;transform:translateX(-50%) translateY(0) scale(1)}
258
+ #status-bubble.err{color:var(--err)}
259
+ #status-bubble .spinner{color:rgba(255,255,255,.55);min-width:1ch;text-align:center}
260
+ @media (prefers-reduced-motion:reduce){
261
+ .bs-trigger,#swap,.resize-handle::after,.seg button,.icon-btn,.onion-ctl,.b-controls,#width-out,#status-bubble,#tooltip{transition:none}
262
+ }
263
+ </style>
264
+ </head>
265
+ <body>
266
+ <main id="stage">
267
+ <section class="frame a">
268
+ <div class="pane-hd a"><span class="k">A</span>
269
+ <div class="branch-select" data-pane="a"></div>
270
+ <span class="pane-sha" id="sha-a"></span>
271
+ </div>
272
+ <div class="vp">
273
+ <iframe id="fa" title="Pane A"></iframe>
274
+ <div class="empty" id="empty-a" hidden>
275
+ <div class="empty-spin" aria-hidden="true"></div>
276
+ <div class="empty-h"></div>
277
+ <div class="empty-s"></div>
278
+ </div>
279
+ <div class="resize-handle" data-handle title="Drag to resize both panes (double-click to fit)"></div>
280
+ </div>
281
+ </section>
282
+
283
+ <button id="swap" title="Swap A and B" aria-label="Swap panes A and B">
284
+ <svg viewBox="0 0 16 16" width="14" height="14" aria-hidden="true" fill="none" stroke="currentColor" stroke-width="1.3" stroke-linecap="round" stroke-linejoin="round">
285
+ <path d="M3 5h9M9 2.5 12 5 9 7.5"/>
286
+ <path d="M13 11H4M7 8.5 4 11 7 13.5"/>
287
+ </svg>
288
+ </button>
289
+
290
+ <section class="frame b">
291
+ <div class="pane-hd b"><div class="b-controls" id="b-controls">
292
+ <span class="k k-b">B</span>
293
+ <div class="branch-select" data-pane="b"></div>
294
+ <span class="pane-sha" id="sha-b"></span>
295
+ </div></div>
296
+ <div class="vp">
297
+ <iframe id="fb" title="Pane B"></iframe>
298
+ <div class="empty" id="empty-b" hidden>
299
+ <div class="empty-spin" aria-hidden="true"></div>
300
+ <div class="empty-h"></div>
301
+ <div class="empty-s"></div>
302
+ </div>
303
+ <div class="resize-handle" data-handle title="Drag to resize both panes (double-click to fit)"></div>
304
+ </div>
305
+ </section>
306
+ </main>
307
+
308
+ <div id="status-bubble" role="status" aria-live="polite">
309
+ <span class="spinner" aria-hidden="true"></span><span class="msg"></span>
310
+ </div>
311
+
312
+ <div id="tooltip" role="tooltip">
313
+ <div class="tt-title"></div>
314
+ <div class="tt-sub"></div>
315
+ </div>
316
+
317
+ <div class="onion-ctl" id="onion-ctl">
318
+ <label for="opacity">B opacity</label>
319
+ <input type="range" id="opacity" min="0" max="100" value="50">
320
+ </div>
321
+
322
+ <div class="dock" role="toolbar" aria-label="sidebranch compare controls">
323
+ <span class="brand" aria-label="sidebranch">
324
+ <svg viewBox="0 0 100 100" width="16" height="16" fill="currentColor" aria-hidden="true">
325
+ <path d="M45 11C50.5228 11 55 15.4772 55 21V39H40C34.4772 39 30 43.4772 30 49V78C30 83.5228 34.4772 88 40 88H26C20.4772 88 16 83.5228 16 78V21C16 15.4772 20.4772 11 26 11H45ZM73 39C78.5228 39 83 43.4772 83 49V78C83 83.5228 78.5228 88 73 88H45C50.5228 88 55 83.5228 55 78V39H73Z"/>
326
+ </svg>
327
+ </span>
328
+ <div class="dock-sep"></div>
329
+
330
+ <div class="seg" role="group" aria-label="View mode">
331
+ <button data-mode="side" aria-pressed="true" aria-label="Side by side"
332
+ data-tip-title="Side by side" data-tip-sub="View both panes independently, at the same width">
333
+ <svg viewBox="0 0 16 16" width="16" height="16" aria-hidden="true">
334
+ <rect x="1.5" y="3" width="5.5" height="10" rx="1" fill="none" stroke="currentColor" stroke-width="1.3"/>
335
+ <rect x="9" y="3" width="5.5" height="10" rx="1" fill="none" stroke="currentColor" stroke-width="1.3"/>
336
+ </svg>
337
+ </button>
338
+ <button data-mode="blend" aria-pressed="false" aria-label="Blend diff"
339
+ data-tip-title="Blend diff" data-tip-sub="Panes stacked with mix-blend-mode: difference — identical pixels go black, changes glow">
340
+ <svg viewBox="0 0 16 16" width="16" height="16" aria-hidden="true" stroke="currentColor" stroke-width="1.3">
341
+ <circle cx="6.2" cy="6.2" r="4.6" fill="currentColor" fill-opacity="0.18"/>
342
+ <circle cx="9.8" cy="9.8" r="4.6" fill="currentColor" fill-opacity="0.18"/>
343
+ </svg>
344
+ </button>
345
+ <button data-mode="onion" aria-pressed="false" aria-label="Onion"
346
+ data-tip-title="Layer diff" data-tip-sub="Panes stacked with an opacity slider over the top pane">
347
+ <svg viewBox="0 0 16 16" width="16" height="16" aria-hidden="true" fill="none" stroke="currentColor" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round">
348
+ <polygon points="8,2.45 13.5,5.85 8,9.25 2.5,5.85"/>
349
+ <polyline points="2.5,10.15 8,13.55 13.5,10.15"/>
350
+ </svg>
351
+ </button>
352
+ </div>
353
+
354
+ <div class="dock-sep"></div>
355
+
356
+ <div class="w-ctl">
357
+ <span class="muted">w:</span>
358
+ <input type="text" id="width-out" inputmode="numeric"
359
+ aria-label="Pane width in pixels — type a value or drag a pane edge">
360
+ <button class="icon-btn" data-w="375" title="Phone (375px)" aria-label="Set width to 375px (phone)">
361
+ <svg viewBox="0 0 16 16" width="14" height="14" aria-hidden="true" fill="none" stroke="currentColor" stroke-width="1.2">
362
+ <rect x="4.5" y="1.5" width="7" height="13" rx="1.3"/><line x1="6.5" y1="12" x2="9.5" y2="12"/>
363
+ </svg>
364
+ </button>
365
+ <button class="icon-btn" data-w="768" title="Tablet (768px)" aria-label="Set width to 768px (tablet)">
366
+ <svg viewBox="0 0 16 16" width="14" height="14" aria-hidden="true" fill="none" stroke="currentColor" stroke-width="1.2">
367
+ <rect x="2.5" y="1.5" width="11" height="13" rx="1.3"/><line x1="6.5" y1="12.3" x2="9.5" y2="12.3"/>
368
+ </svg>
369
+ </button>
370
+ <button class="icon-btn" data-w="1280" title="Desktop (1280px)" aria-label="Set width to 1280px (desktop)">
371
+ <svg viewBox="0 0 16 16" width="14" height="14" aria-hidden="true" fill="none" stroke="currentColor" stroke-width="1.2">
372
+ <rect x="1.5" y="2.5" width="13" height="8.5" rx="1"/><line x1="5.5" y1="14" x2="10.5" y2="14"/><line x1="8" y1="11" x2="8" y2="14"/>
373
+ </svg>
374
+ </button>
375
+ <button class="icon-btn" data-w="0" title="Fit" aria-label="Fit to available space">
376
+ <svg viewBox="0 0 16 16" width="14" height="14" aria-hidden="true" fill="none" stroke="currentColor" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round">
377
+ <path d="M2 8h12M2 8l2.5-2.5M2 8l2.5 2.5M14 8l-2.5-2.5M14 8l-2.5 2.5"/>
378
+ </svg>
379
+ </button>
380
+ </div>
381
+ </div>
382
+
383
+ <script>
384
+ (() => {
385
+ "use strict";
386
+ const TOKEN = "__SIDEBRANCH_TOKEN__";
387
+ const DAEMON = "http://localhost:__SIDEBRANCH_PORT__";
388
+ const $ = (s) => document.querySelector(s);
389
+ const reducedMotion = matchMedia("(prefers-reduced-motion: reduce)").matches;
390
+ const SPIN_FRAMES = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
391
+
392
+ const api = async (p, opts = {}) => {
393
+ const res = await fetch(DAEMON + p, {
394
+ ...opts,
395
+ headers: { Authorization: "Bearer " + TOKEN,
396
+ ...(opts.body ? { "Content-Type": "application/json" } : {}) },
397
+ });
398
+ const data = await res.json().catch(() => ({}));
399
+ if (!res.ok) throw new Error(data.error || res.statusText);
400
+ return data;
401
+ };
402
+
403
+ let state = null;
404
+ const paneById = (id) => state?.panes?.find((p) => p.id === id) || null;
405
+
406
+ /* ------------------------------ status bubble --------------------------- */
407
+ const statusBubble = $("#status-bubble");
408
+ const statusMsg = statusBubble.querySelector(".msg");
409
+ const statusSpin = statusBubble.querySelector(".spinner");
410
+ let dockSpinTimer = null;
411
+ function setStatus(t, err = false) {
412
+ if (dockSpinTimer) { clearInterval(dockSpinTimer); dockSpinTimer = null; }
413
+ if (!t) { statusBubble.classList.remove("open", "err"); statusSpin.textContent = ""; return; }
414
+ statusBubble.classList.add("open");
415
+ statusBubble.classList.toggle("err", err);
416
+ statusMsg.textContent = t;
417
+ if (!err) {
418
+ if (reducedMotion) { statusSpin.textContent = "•"; }
419
+ else {
420
+ let i = 0; statusSpin.textContent = SPIN_FRAMES[0];
421
+ dockSpinTimer = setInterval(() => { i = (i + 1) % SPIN_FRAMES.length; statusSpin.textContent = SPIN_FRAMES[i]; }, 90);
422
+ }
423
+ } else {
424
+ statusSpin.textContent = "";
425
+ }
426
+ }
427
+
428
+ /* ------------------------------ per-pane empty state --------------------------- */
429
+ const paneSpinTimers = { a: null, b: null };
430
+ function stopPaneSpin(id) {
431
+ if (paneSpinTimers[id]) { clearInterval(paneSpinTimers[id]); paneSpinTimers[id] = null; }
432
+ }
433
+ function startPaneSpin(id, el) {
434
+ stopPaneSpin(id);
435
+ if (reducedMotion) { el.textContent = "•"; return; }
436
+ let i = 0; el.textContent = SPIN_FRAMES[0];
437
+ paneSpinTimers[id] = setInterval(() => { i = (i + 1) % SPIN_FRAMES.length; el.textContent = SPIN_FRAMES[i]; }, 90);
438
+ }
439
+ function setEmpty(id, headline, sub, spinning, isErr) {
440
+ const empty = $("#empty-" + id);
441
+ empty.classList.toggle("err", !!isErr);
442
+ empty.querySelector(".empty-h").textContent = headline;
443
+ empty.querySelector(".empty-s").textContent = sub;
444
+ const spinEl = empty.querySelector(".empty-spin");
445
+ if (spinning) startPaneSpin(id, spinEl);
446
+ else { stopPaneSpin(id); spinEl.textContent = ""; }
447
+ }
448
+
449
+ /* ------------------------- searchable branch dropdown -------------------------
450
+ Replaces the native <select> per pane with a filterable combobox popup —
451
+ the same visual language as the injected widget's branch flyout. The popup
452
+ is position:fixed and placed via getBoundingClientRect (the #tooltip
453
+ technique) so main's overflow:auto never clips it and it survives being
454
+ reparented into pane A's header in stacked modes. Follows the APG combobox
455
+ pattern: a trigger button opens a listbox whose active option is tracked
456
+ with aria-activedescendant while DOM focus stays on the filter input, so
457
+ keyboard and AT behavior stay on par with the native <select> it replaces. */
458
+ function makeBranchSelect(root, paneId) {
459
+ const label = `Pane ${paneId.toUpperCase()} branch`;
460
+ const listId = `bs-${paneId}-list`;
461
+ root.innerHTML = `
462
+ <button type="button" class="bs-trigger placeholder" aria-haspopup="listbox" aria-expanded="false" aria-label="${label}">
463
+ <span class="bs-value">— none —</span>
464
+ <svg class="bs-chev" viewBox="0 0 16 16" width="11" height="11" aria-hidden="true" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"><path d="M4 6l4 4 4-4"/></svg>
465
+ </button>
466
+ <div class="bs-popup" inert>
467
+ <div class="bs-search">
468
+ <input class="bs-filter" type="search" role="combobox" aria-expanded="false" aria-controls="${listId}" aria-autocomplete="list" autocomplete="off" placeholder="Filter branches…" aria-label="Filter ${label} list">
469
+ <button type="button" class="bs-fetch" title="Fetch remotes (git fetch --all)" aria-label="Fetch remotes">
470
+ <svg viewBox="0 0 24 24" width="14" height="14" aria-hidden="true" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 12a9 9 0 0 1 9-9 9.75 9.75 0 0 1 6.74 2.74L21 8"/><path d="M21 3v5h-5"/><path d="M21 12a9 9 0 0 1-9 9 9.75 9.75 0 0 1-6.74-2.74L3 16"/><path d="M8 16H3v5"/></svg>
471
+ </button>
472
+ </div>
473
+ <div class="bs-list" id="${listId}" role="listbox" aria-label="${label}"></div>
474
+ </div>`;
475
+ const trigger = root.querySelector(".bs-trigger");
476
+ const valueEl = root.querySelector(".bs-value");
477
+ const popup = root.querySelector(".bs-popup");
478
+ const filter = root.querySelector(".bs-filter");
479
+ const fetchBtn = root.querySelector(".bs-fetch");
480
+ const list = root.querySelector(".bs-list");
481
+
482
+ let isOpen = false;
483
+ let filterText = "";
484
+ let activeIndex = -1;
485
+ // The branch just picked, shown right away while the daemon rebuilds the
486
+ // pane — state.panes[…].branch only flips once the switch/install finishes,
487
+ // so without this the trigger would lag behind the reviewer's choice.
488
+ let optimistic = null;
489
+
490
+ const branches = () => state?.branches || [];
491
+ const currentBranch = () => paneById(paneId)?.branch || "";
492
+ const isRemoteOnly = (name) => {
493
+ const b = branches().find((x) => x.name === name);
494
+ return b ? !b.local : false;
495
+ };
496
+ /** Optimistic pick until live state catches up, then the real branch. */
497
+ function displayedBranch() {
498
+ const cur = currentBranch();
499
+ if (optimistic && optimistic !== cur) return optimistic;
500
+ optimistic = null;
501
+ return cur;
502
+ }
503
+
504
+ function syncTrigger() {
505
+ const cur = displayedBranch();
506
+ trigger.classList.toggle("placeholder", !cur);
507
+ valueEl.textContent = cur ? cur + (isRemoteOnly(cur) ? " ⇣" : "") : "— none —";
508
+ }
509
+
510
+ function positionPopup() {
511
+ const r = trigger.getBoundingClientRect();
512
+ const width = Math.max(r.width, 220);
513
+ let left = r.left;
514
+ if (left + width > window.innerWidth - 8) left = window.innerWidth - 8 - width;
515
+ popup.style.left = Math.max(8, left) + "px";
516
+ popup.style.top = r.bottom + 4 + "px";
517
+ popup.style.width = width + "px";
518
+ }
519
+
520
+ function setActive(i) {
521
+ const opts = list.querySelectorAll(".bs-option");
522
+ if (!opts.length) { activeIndex = -1; filter.removeAttribute("aria-activedescendant"); return; }
523
+ activeIndex = (i + opts.length) % opts.length;
524
+ opts.forEach((o, idx) => o.classList.toggle("active", idx === activeIndex));
525
+ const active = opts[activeIndex];
526
+ filter.setAttribute("aria-activedescendant", active.id);
527
+ active.scrollIntoView({ block: "nearest" });
528
+ }
529
+
530
+ function renderList() {
531
+ const cur = displayedBranch();
532
+ const q = filterText.toLowerCase();
533
+ const items = branches().filter((b) => b.name.toLowerCase().includes(q));
534
+ if (!items.length) {
535
+ list.replaceChildren(Object.assign(document.createElement("div"), { className: "bs-empty", textContent: "No matching branches." }));
536
+ activeIndex = -1;
537
+ filter.removeAttribute("aria-activedescendant");
538
+ return;
539
+ }
540
+ list.replaceChildren(...items.slice(0, 300).map((b, i) => {
541
+ const opt = document.createElement("div");
542
+ opt.className = "bs-option";
543
+ opt.id = `bs-${paneId}-opt-${i}`;
544
+ opt.setAttribute("role", "option");
545
+ opt.setAttribute("aria-selected", String(b.name === cur));
546
+ const name = document.createElement("span");
547
+ name.className = "bs-opt-name";
548
+ name.textContent = b.name + (b.local ? "" : " ⇣");
549
+ opt.appendChild(name);
550
+ const check = document.createElement("span");
551
+ check.className = "bs-check";
552
+ check.setAttribute("aria-hidden", "true");
553
+ check.innerHTML = `<svg viewBox="0 0 16 16" width="14" height="14" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M3.5 8.5l3 3 6-6.5"/></svg>`;
554
+ opt.appendChild(check);
555
+ opt.addEventListener("click", () => choose(b.name));
556
+ opt.addEventListener("mousemove", () => setActive(i));
557
+ return opt;
558
+ }));
559
+ // Open with the current branch pre-highlighted if it survived the filter,
560
+ // else the first row — so Enter always has a sensible default.
561
+ const curIdx = items.findIndex((b) => b.name === cur);
562
+ setActive(curIdx >= 0 ? curIdx : 0);
563
+ }
564
+
565
+ function choose(name) {
566
+ close();
567
+ trigger.focus();
568
+ if (name && name !== currentBranch()) {
569
+ optimistic = name; // reflect the pick immediately, before the pane rebuilds
570
+ syncTrigger();
571
+ load(paneId, name);
572
+ }
573
+ }
574
+
575
+ function open() {
576
+ if (isOpen) return;
577
+ isOpen = true;
578
+ filterText = "";
579
+ filter.value = "";
580
+ renderList();
581
+ popup.inert = false;
582
+ positionPopup();
583
+ trigger.setAttribute("aria-expanded", "true");
584
+ filter.setAttribute("aria-expanded", "true");
585
+ requestAnimationFrame(() => { popup.classList.add("open"); filter.focus(); });
586
+ document.addEventListener("pointerdown", onOutside, true);
587
+ window.addEventListener("scroll", positionPopup, true);
588
+ window.addEventListener("resize", positionPopup);
589
+ }
590
+
591
+ function close() {
592
+ if (!isOpen) return;
593
+ isOpen = false;
594
+ popup.classList.remove("open");
595
+ popup.inert = true;
596
+ trigger.setAttribute("aria-expanded", "false");
597
+ filter.setAttribute("aria-expanded", "false");
598
+ document.removeEventListener("pointerdown", onOutside, true);
599
+ window.removeEventListener("scroll", positionPopup, true);
600
+ window.removeEventListener("resize", positionPopup);
601
+ }
602
+
603
+ function onOutside(e) {
604
+ if (!e.composedPath().includes(root)) close();
605
+ }
606
+
607
+ trigger.addEventListener("click", () => { if (isOpen) close(); else open(); });
608
+ trigger.addEventListener("keydown", (e) => {
609
+ if (e.key === "ArrowDown" || e.key === "ArrowUp") { e.preventDefault(); open(); }
610
+ });
611
+
612
+ // Close when focus leaves the popup for good — Tab-out, or focus moving into
613
+ // an iframe when the reviewer clicks pane content — but not while moving
614
+ // between the filter and the fetch button inside it.
615
+ popup.addEventListener("focusout", (e) => { if (!popup.contains(e.relatedTarget)) close(); });
616
+
617
+ // Options are non-focusable divs, so mousing down on one would otherwise
618
+ // pull focus off the filter — firing focusout → close() → inert *before*
619
+ // the click lands, swallowing it. Keeping focus on the filter lets the
620
+ // click through to choose() (and keeps the combobox's activedescendant model
621
+ // intact). The keyboard path is unaffected — Enter calls .click() directly.
622
+ list.addEventListener("mousedown", (e) => e.preventDefault());
623
+
624
+ fetchBtn.addEventListener("click", async () => {
625
+ fetchBtn.disabled = true;
626
+ fetchBtn.classList.add("spinning");
627
+ setStatus("Fetching remotes…");
628
+ try {
629
+ await api("/api/fetch", { method: "POST" });
630
+ await refresh(); // re-syncs both dropdowns; re-renders this open list
631
+ setStatus("");
632
+ } catch (err) {
633
+ setStatus(err.message, true);
634
+ } finally {
635
+ fetchBtn.disabled = false;
636
+ fetchBtn.classList.remove("spinning");
637
+ filter.focus();
638
+ }
639
+ });
640
+
641
+ filter.addEventListener("input", () => { filterText = filter.value.trim(); renderList(); });
642
+ filter.addEventListener("keydown", (e) => {
643
+ if (e.key === "ArrowDown") { e.preventDefault(); setActive(activeIndex + 1); }
644
+ else if (e.key === "ArrowUp") { e.preventDefault(); setActive(activeIndex - 1); }
645
+ else if (e.key === "Home") { e.preventDefault(); setActive(0); }
646
+ else if (e.key === "End") { e.preventDefault(); setActive(list.querySelectorAll(".bs-option").length - 1); }
647
+ else if (e.key === "Enter") {
648
+ e.preventDefault();
649
+ const opts = list.querySelectorAll(".bs-option");
650
+ if (activeIndex >= 0 && opts[activeIndex]) opts[activeIndex].click();
651
+ } else if (e.key === "Escape") {
652
+ e.preventDefault(); e.stopPropagation(); close(); trigger.focus();
653
+ }
654
+ });
655
+
656
+ syncTrigger();
657
+ // Refresh the trigger label from live state; re-render the open list so a
658
+ // background poll reflects new branches / a newly-ready pane without
659
+ // disturbing what the reviewer has typed.
660
+ return { sync() { syncTrigger(); if (isOpen) renderList(); } };
661
+ }
662
+
663
+ const selA = makeBranchSelect(document.querySelector('.branch-select[data-pane="a"]'), "a");
664
+ const selB = makeBranchSelect(document.querySelector('.branch-select[data-pane="b"]'), "b");
665
+
666
+ function syncFrames() {
667
+ for (const id of ["a", "b"]) {
668
+ const p = paneById(id);
669
+ const frame = $("#f" + id);
670
+ const empty = $("#empty-" + id);
671
+ $("#sha-" + id).textContent = p?.head?.short ? "@" + p.head.short : "";
672
+ // Frame the view port (framing headers stripped in transit) when the
673
+ // daemon provides one. Without it — "frameProxy": false — a pane that
674
+ // refuses framing gets an explanation, not a blank rectangle.
675
+ const embedUrl = p?.viewUrl || p?.url;
676
+ const blocked = p?.status === "ready" && !p?.viewUrl && p.framing?.blocked ? p.framing : null;
677
+ const url = p?.status === "ready" && embedUrl && !blocked ? embedUrl : "";
678
+ if (url && frame.dataset.url !== url + (p.head?.sha || "")) {
679
+ frame.src = url;
680
+ frame.dataset.url = url + (p.head?.sha || "");
681
+ }
682
+ if (blocked && frame.dataset.url) { frame.removeAttribute("src"); delete frame.dataset.url; }
683
+ empty.hidden = !!url;
684
+ if (url) { stopPaneSpin(id); continue; }
685
+ if (blocked) {
686
+ setEmpty(
687
+ id,
688
+ `Pane ${id.toUpperCase()} refuses to be embedded`,
689
+ `${p.branch} sends ${blocked.header}: ${blocked.value}, which blocks this page from framing it, ` +
690
+ `and "frameProxy" is off. Re-enable it in .sidebranch.json, stop sending that header in dev, ` +
691
+ `or open the pane in its own tab.`,
692
+ false,
693
+ true
694
+ );
695
+ continue;
696
+ }
697
+ if (p?.error) setEmpty(id, `Couldn't load pane ${id.toUpperCase()}`, p.error, false, true);
698
+ else if (p?.status === "installing") setEmpty(id, "Installing dependencies…", "First install on a new branch can take a moment.", true, false);
699
+ else if (p?.status === "starting") setEmpty(id, "Starting dev server…", "Almost there.", true, false);
700
+ else if (p?.status === "switching" || p?.status === "new") setEmpty(id, "Checking out branch…", "Setting up the worktree.", true, false);
701
+ else setEmpty(id, "Nothing loaded yet", `Pick a branch for pane ${id.toUpperCase()} above to preview it here.`, false, false);
702
+ }
703
+ }
704
+
705
+ async function refresh() {
706
+ try {
707
+ state = await api("/api/state");
708
+ selA.sync();
709
+ selB.sync();
710
+ syncFrames();
711
+ } catch (e) { setStatus(e.message, true); }
712
+ }
713
+
714
+ async function load(id, branch) {
715
+ if (!branch) return;
716
+ setStatus(`${id.toUpperCase()} ← ${branch}…`);
717
+ try {
718
+ await api("/api/pane", { method: "POST", body: JSON.stringify({ pane: id, branch }) });
719
+ setStatus("");
720
+ } catch (e) { setStatus(e.message, true); }
721
+ refresh();
722
+ }
723
+
724
+ $("#swap").onclick = () => {
725
+ const a = paneById("a")?.branch, b = paneById("b")?.branch;
726
+ if (a && b) { load("a", b); load("b", a); }
727
+ };
728
+
729
+ /* --------------------------- tied resizing ----------------------------
730
+ Both panes always share one width (kept in sync by construction — there
731
+ is only one value, applied to every `.frame`). The readout always shows
732
+ pane A's actual rendered width, whether that came from a fixed value or
733
+ from flexbox "fit" — a ResizeObserver keeps it live either way, so
734
+ there's never a separate "fit" placeholder state to fall out of sync. */
735
+ const widthOut = $("#width-out");
736
+ const MIN_W = 320, MAX_W = 1920;
737
+ const clampWidth = (px) => Math.min(MAX_W, Math.max(MIN_W, Math.round(px)));
738
+
739
+ function applyWidth(px) {
740
+ const frames = document.querySelectorAll(".frame");
741
+ for (const f of frames) {
742
+ if (px > 0) { f.style.width = px + "px"; f.style.flex = "none"; }
743
+ else { f.style.width = ""; f.style.flex = "1 1 0"; }
744
+ }
745
+ }
746
+ function updateWidthReadout() {
747
+ if (document.activeElement === widthOut) return; // don't fight the user mid-type
748
+ const rect = document.querySelector(".frame.a")?.getBoundingClientRect();
749
+ const px = rect ? Math.round(rect.width) : 0;
750
+ widthOut.value = px > 0 ? String(px) : "";
751
+ }
752
+ applyWidth(0);
753
+ new ResizeObserver(updateWidthReadout).observe(document.querySelector(".frame.a"));
754
+ updateWidthReadout();
755
+
756
+ widthOut.addEventListener("keydown", (e) => {
757
+ if (e.key === "Enter") widthOut.blur();
758
+ else if (e.key === "Escape") { updateWidthReadout(); widthOut.blur(); }
759
+ });
760
+ widthOut.addEventListener("blur", () => {
761
+ const raw = widthOut.value.trim();
762
+ const n = Number(raw);
763
+ if (raw !== "" && Number.isFinite(n) && n > 0) applyWidth(clampWidth(n));
764
+ updateWidthReadout();
765
+ });
766
+ for (const b of document.querySelectorAll("[data-w]"))
767
+ b.onclick = () => applyWidth(Number(b.dataset.w));
768
+
769
+ let drag = null;
770
+ function onHandleDown(e) {
771
+ const frame = document.querySelector(".frame.a");
772
+ drag = { startX: e.clientX, startWidth: frame.getBoundingClientRect().width };
773
+ e.target.setPointerCapture(e.pointerId);
774
+ e.target.classList.add("dragging");
775
+ }
776
+ function onHandleMove(e) {
777
+ if (!drag) return;
778
+ applyWidth(clampWidth(drag.startWidth + (e.clientX - drag.startX)));
779
+ }
780
+ function onHandleUp(e) {
781
+ drag = null;
782
+ e.target.classList.remove("dragging");
783
+ }
784
+ for (const h of document.querySelectorAll("[data-handle]")) {
785
+ h.addEventListener("pointerdown", onHandleDown);
786
+ h.addEventListener("pointermove", onHandleMove);
787
+ h.addEventListener("pointerup", onHandleUp);
788
+ h.addEventListener("pointercancel", onHandleUp);
789
+ h.addEventListener("dblclick", () => applyWidth(0));
790
+ }
791
+
792
+ /* ----------------------------- view modes -----------------------------
793
+ Stacked modes (blend/onion) reuse the SAME #fb element pane B already
794
+ lives in — it's moved on top of #fa, not duplicated — so whatever the
795
+ reviewer already did inside it (logged in, navigated) is still there.
796
+ `moveBefore` (Chrome 133+) moves a connected node without destroying
797
+ its browsing context; falling back to `appendChild` still works, it
798
+ just reloads the frame, same as a normal iframe re-parent. */
799
+ const stage = $("#stage"), onionCtl = $("#onion-ctl"), opacity = $("#opacity");
800
+ const vpA = document.querySelector(".frame.a .vp");
801
+ const vpB = document.querySelector(".frame.b .vp");
802
+ const fb = $("#fb");
803
+ const paneHdA = document.querySelector(".pane-hd.a");
804
+ const paneHdB = document.querySelector(".pane-hd.b");
805
+ const bControls = $("#b-controls");
806
+
807
+ function moveNode(node, newParent) {
808
+ if (node.parentElement === newParent) return;
809
+ if (typeof newParent.moveBefore === "function") newParent.moveBefore(node, null);
810
+ else newParent.appendChild(node);
811
+ }
812
+
813
+ function setMode(mode) {
814
+ document.body.dataset.mode = mode;
815
+ for (const b of document.querySelectorAll(".seg button"))
816
+ b.setAttribute("aria-pressed", String(b.dataset.mode === mode));
817
+ const stacked = mode !== "side";
818
+ moveNode(fb, stacked ? vpA : vpB);
819
+ // frame.b (and its header) is hidden while stacked, so B's branch
820
+ // picker rides along into frame A's header instead — otherwise there'd
821
+ // be no way to change B without dropping back to side-by-side. Fades
822
+ // in/out across the move rather than just snapping to the new spot.
823
+ const bTarget = stacked ? paneHdA : paneHdB;
824
+ if (bControls.parentElement !== bTarget) {
825
+ bControls.classList.add("fade-out");
826
+ moveNode(bControls, bTarget);
827
+ requestAnimationFrame(() => requestAnimationFrame(() => bControls.classList.remove("fade-out")));
828
+ }
829
+ stage.classList.toggle("stacked", stacked);
830
+ // The opacity control is fixed to the top corner, not an inline dock
831
+ // child, so opening it never changes the dock's own width/centering.
832
+ onionCtl.classList.toggle("visible", mode === "onion");
833
+ fb.style.mixBlendMode = mode === "blend" ? "difference" : "";
834
+ fb.style.opacity = mode === "onion" ? String(opacity.value / 100) : "";
835
+ // Pointer events land on the top (B) frame in stacked modes; that keeps
836
+ // interaction meaningful since both frames show the same route.
837
+ fb.style.pointerEvents = stacked ? "auto" : "";
838
+ }
839
+ for (const b of document.querySelectorAll(".seg button"))
840
+ b.onclick = () => setMode(b.dataset.mode);
841
+ opacity.oninput = () => { fb.style.opacity = String(opacity.value / 100); };
842
+
843
+ /* -------------------------- mode-button tooltips -----------------------
844
+ One shared #tooltip, positioned above whatever [data-tip-title] element
845
+ is hovered or focused — see the #tooltip CSS comment for why this can't
846
+ just be a per-button ::after (clipped by .seg's overflow:hidden).
847
+ Hover is delayed (like a native title) so it doesn't flash on every
848
+ mouse pass-through; keyboard focus shows it immediately since a tab
849
+ stop is a deliberate, not incidental, visit.
850
+ "Tooltip mode": once a tooltip has opened, hovering straight into the
851
+ next trigger within TOOLTIP_MODE_GRACE reopens instantly instead of
852
+ waiting out the full delay again — the conventional behavior for a
853
+ group of related triggers (e.g. Radix's Tooltip.Provider
854
+ skipDelayDuration), so skimming several mode buttons in a row doesn't
855
+ re-pay the delay for each one. */
856
+ const TOOLTIP_DELAY = 1000;
857
+ const TOOLTIP_MODE_GRACE = 400;
858
+ const tooltip = $("#tooltip");
859
+ const tooltipTitle = tooltip.querySelector(".tt-title");
860
+ const tooltipSub = tooltip.querySelector(".tt-sub");
861
+ let tooltipShowTimer = null;
862
+ let tooltipModeTimer = null;
863
+ let tooltipModeActive = false;
864
+ function showTooltip(el) {
865
+ tooltipTitle.textContent = el.dataset.tipTitle;
866
+ tooltipSub.textContent = el.dataset.tipSub;
867
+ const rect = el.getBoundingClientRect();
868
+ tooltip.style.left = rect.left + rect.width / 2 + "px";
869
+ tooltip.style.top = rect.top - 9 + "px";
870
+ tooltip.classList.add("open");
871
+ }
872
+ function scheduleTooltip(el) {
873
+ clearTimeout(tooltipShowTimer);
874
+ clearTimeout(tooltipModeTimer);
875
+ if (tooltipModeActive) showTooltip(el);
876
+ else tooltipShowTimer = setTimeout(() => { tooltipModeActive = true; showTooltip(el); }, TOOLTIP_DELAY);
877
+ }
878
+ function hideTooltip() {
879
+ clearTimeout(tooltipShowTimer);
880
+ tooltip.classList.remove("open");
881
+ clearTimeout(tooltipModeTimer);
882
+ tooltipModeTimer = setTimeout(() => { tooltipModeActive = false; }, TOOLTIP_MODE_GRACE);
883
+ }
884
+ for (const el of document.querySelectorAll("[data-tip-title]")) {
885
+ el.setAttribute("aria-describedby", "tooltip");
886
+ el.addEventListener("mouseenter", () => scheduleTooltip(el));
887
+ el.addEventListener("mouseleave", hideTooltip);
888
+ // Only auto-show on a real keyboard visit, not the focus a click also
889
+ // produces — otherwise dismissing on mousedown (below) would just get
890
+ // immediately undone by the focus event a click fires right after it.
891
+ el.addEventListener("focus", () => { if (el.matches(":focus-visible")) showTooltip(el); });
892
+ el.addEventListener("blur", hideTooltip);
893
+ // Clicking into the mode the tooltip was explaining should dismiss it
894
+ // right away — the reviewer already knows what they clicked.
895
+ el.addEventListener("mousedown", hideTooltip);
896
+ el.addEventListener("keydown", (e) => { if (e.key === "Escape") hideTooltip(); });
897
+ }
898
+ setMode("side");
899
+
900
+ /* ------------------------------- events ------------------------------- */
901
+ (async () => {
902
+ try {
903
+ const res = await fetch(DAEMON + "/api/events", { headers: { Authorization: "Bearer " + TOKEN } });
904
+ const reader = res.body.getReader();
905
+ const dec = new TextDecoder();
906
+ let buf = "";
907
+ for (;;) {
908
+ const { value, done } = await reader.read();
909
+ if (done) break;
910
+ buf += dec.decode(value, { stream: true });
911
+ let i;
912
+ while ((i = buf.indexOf("\n\n")) >= 0) {
913
+ const chunk = buf.slice(0, i); buf = buf.slice(i + 2);
914
+ const m = /^data: (.*)$/m.exec(chunk);
915
+ if (!m) continue;
916
+ const ev = JSON.parse(m[1]);
917
+ if (ev.type === "pane:installing") setStatus(`${ev.pane.toUpperCase()}: installing…`);
918
+ else if (ev.type === "pane:starting") setStatus(`${ev.pane.toUpperCase()}: starting server…`);
919
+ else if (ev.type === "pane:ready" || ev.type === "pane:error") { setStatus(ev.error || "", !!ev.error); refresh(); }
920
+ }
921
+ }
922
+ } catch { /* fall back to polling */ }
923
+ })();
924
+
925
+ refresh();
926
+ setInterval(refresh, 6000);
927
+ })();
928
+ </script>
929
+ </body>
930
+ </html>