sitedrift 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/sitedrift.mjs ADDED
@@ -0,0 +1,1808 @@
1
+ #!/usr/bin/env node
2
+
3
+ import http from 'node:http';
4
+ import https from 'node:https';
5
+ import fs from 'node:fs';
6
+ import os from 'node:os';
7
+ import { spawn } from 'node:child_process';
8
+ import { URL } from 'node:url';
9
+
10
+ // --- CLI / config -----------------------------------------------------------
11
+ // Precedence for every setting: CLI flag > SITEDRIFT_* env > SITE_COMPARE_*
12
+ // env (legacy) > built-in default.
13
+
14
+ const aliases = { d: 'dev', l: 'live', p: 'port', o: 'open', h: 'help', v: 'version' };
15
+ const booleans = new Set(['open', 'http', 'help', 'version']);
16
+
17
+ function parseArgs(argv) {
18
+ const opts = {};
19
+ const positionals = [];
20
+ for (let i = 0; i < argv.length; i++) {
21
+ let arg = argv[i];
22
+ if (arg === '--') { positionals.push(...argv.slice(i + 1)); break; }
23
+ if (arg[0] !== '-' || arg === '-') { positionals.push(arg); continue; }
24
+ arg = arg.replace(/^--?/, '');
25
+ let value;
26
+ const eq = arg.indexOf('=');
27
+ if (eq !== -1) { value = arg.slice(eq + 1); arg = arg.slice(0, eq); }
28
+ const name = aliases[arg] || arg;
29
+ if (booleans.has(name)) { opts[name] = true; continue; }
30
+ if (value === undefined) {
31
+ const next = argv[i + 1];
32
+ if (next !== undefined && next[0] !== '-') { value = next; i++; }
33
+ else value = true;
34
+ }
35
+ opts[name] = value;
36
+ }
37
+ return { opts, positionals };
38
+ }
39
+
40
+ function envVal(name) {
41
+ const v = process.env[`SITEDRIFT_${name}`] ?? process.env[`SITE_COMPARE_${name}`];
42
+ return v === undefined || v === '' ? undefined : v;
43
+ }
44
+
45
+ function pick(flag, name, fallback) {
46
+ return opts[flag] ?? envVal(name) ?? fallback;
47
+ }
48
+
49
+ function readVersion() {
50
+ try {
51
+ return JSON.parse(fs.readFileSync(new URL('./package.json', import.meta.url), 'utf8')).version;
52
+ } catch { return '0.0.0'; }
53
+ }
54
+
55
+ function printHelp() {
56
+ console.log(`sitedrift — frame local dev against production, side-by-side on the same route.
57
+
58
+ Usage:
59
+ sitedrift [path] [options]
60
+ npx sitedrift /pricing --dev http://localhost:4321 --live https://example.com --open
61
+
62
+ Options:
63
+ -d, --dev <url> Left-pane (dev) origin [default http://127.0.0.1:4321]
64
+ -l, --live <url> Right-pane (live) origin [default https://example.com]
65
+ -p, --port <n> Listen port [default 4178]
66
+ --host <addr> Bind address [default 127.0.0.1]
67
+ -o, --open Open the viewer in your browser
68
+ --http Force plain HTTP (ignore --cert/--key)
69
+ --cert <file> TLS cert (serve HTTPS; needs --key)
70
+ --key <file> TLS key
71
+ --notes <file> Shared review-notes JSON [default \$TMPDIR/sitedrift-notes.json]
72
+ --brand <text> Strip "| <text>" from pane-header titles
73
+ --author <name> Byline for notes added in the viewer
74
+ --vault <dir> Enable "Send to vault" (writes review markdown here)
75
+ -h, --help Show this help
76
+ -v, --version Print version
77
+
78
+ Every option also reads SITEDRIFT_<NAME> (e.g. SITEDRIFT_DEV). Binds to
79
+ 127.0.0.1 by default — it strips framing/isolation headers, so never expose it
80
+ publicly. See https://github.com/joeseverino/sitedrift`);
81
+ }
82
+
83
+ const argv = process.argv.slice(2);
84
+ const { opts, positionals } = parseArgs(argv);
85
+
86
+ if (opts.help) { printHelp(); process.exit(0); }
87
+ if (opts.version) { console.log(readVersion()); process.exit(0); }
88
+
89
+ const host = pick('host', 'HOST', '127.0.0.1');
90
+ const port = Number(pick('port', 'PORT', 4178));
91
+ const devBase = cleanBase(pick('dev', 'DEV', 'http://127.0.0.1:4321'));
92
+ const liveBase = cleanBase(pick('live', 'LIVE', 'https://example.com'));
93
+ const certFile = opts.http ? undefined : pick('cert', 'CERT', undefined);
94
+ const keyFile = opts.http ? undefined : pick('key', 'KEY', undefined);
95
+ const notesFile = pick('notes', 'NOTES', `${os.tmpdir()}/sitedrift-notes.json`);
96
+ const brand = pick('brand', 'BRAND', '');
97
+ const author = pick('author', 'AUTHOR', 'you');
98
+ const vaultDir = pick('vault', 'VAULT', '');
99
+ const initialPath = positionals[0]
100
+ ? '/' + String(positionals[0]).replace(/^\/+/, '')
101
+ : '';
102
+ const viewerVersion = 22;
103
+
104
+ let iconSvg = '';
105
+ try {
106
+ iconSvg = fs.readFileSync(new URL('./assets/icon.svg', import.meta.url), 'utf8');
107
+ } catch {}
108
+
109
+ function openBrowser(url) {
110
+ const cmd = process.platform === 'darwin' ? 'open'
111
+ : process.platform === 'win32' ? 'start' : 'xdg-open';
112
+ try {
113
+ spawn(cmd, [url], { stdio: 'ignore', detached: true, shell: process.platform === 'win32' }).unref();
114
+ } catch {}
115
+ }
116
+
117
+ function cleanBase(value) {
118
+ const url = new URL(value);
119
+ url.pathname = url.pathname.replace(/\/+$/, '');
120
+ url.search = '';
121
+ url.hash = '';
122
+ return url;
123
+ }
124
+
125
+ function loadNotes() {
126
+ try {
127
+ const data = JSON.parse(fs.readFileSync(notesFile, 'utf8'));
128
+ if (Array.isArray(data)) return data;
129
+ return Array.isArray(data.notes) ? data.notes : [];
130
+ } catch {
131
+ return [];
132
+ }
133
+ }
134
+
135
+ function saveNotes(notes) {
136
+ try {
137
+ const tmp = `${notesFile}.${process.pid}.tmp`;
138
+ fs.writeFileSync(tmp, JSON.stringify(notes, null, 2));
139
+ fs.renameSync(tmp, notesFile);
140
+ } catch {}
141
+ }
142
+
143
+ function noteId() {
144
+ return Date.now().toString(36) + Math.random().toString(36).slice(2, 7);
145
+ }
146
+
147
+ function notesMarkdown(notes) {
148
+ if (!notes.length) return '# sitedrift review notes\n\n_No notes yet._\n';
149
+ const lines = ['# sitedrift review notes', ''];
150
+ for (const note of notes) {
151
+ const box = note.done ? '[x]' : '[ ]';
152
+ const where = [note.route && note.route !== '/' ? note.route : '', note.side ? note.side.toUpperCase() : '']
153
+ .filter(Boolean).join(' ');
154
+ const tag = where ? ` _(${where})_` : '';
155
+ lines.push(`- ${box} **${note.author || 'note'}:** ${note.text}${tag}`);
156
+ }
157
+ lines.push('');
158
+ return lines.join('\n');
159
+ }
160
+
161
+ function readBody(req) {
162
+ return new Promise((resolve) => {
163
+ let data = '';
164
+ req.on('data', (chunk) => {
165
+ data += chunk;
166
+ if (data.length > 1e6) req.destroy();
167
+ });
168
+ req.on('end', () => resolve(data));
169
+ req.on('error', () => resolve(data));
170
+ });
171
+ }
172
+
173
+ function applyNoteOp(op) {
174
+ let notes = loadNotes();
175
+ if (op.op === 'add' && op.text) {
176
+ const text = String(op.text).slice(0, 2000);
177
+ const route = op.route || '/';
178
+ const who = (op.author || author || 'note').slice(0, 24);
179
+ const side = op.side === 'dev' || op.side === 'live' ? op.side : null;
180
+ // Skip an identical open note so repeated `--note` seeding doesn't pile up.
181
+ const duplicate = notes.some((note) => !note.done
182
+ && note.text === text && note.route === route && note.author === who && note.side === side);
183
+ if (!duplicate) {
184
+ notes.push({ id: noteId(), text, author: who, route, side, done: false, ts: Date.now() });
185
+ }
186
+ } else if (op.op === 'remove') {
187
+ notes = notes.filter((note) => note.id !== op.id);
188
+ } else if (op.op === 'toggle') {
189
+ notes = notes.map((note) => (note.id === op.id ? { ...note, done: !note.done } : note));
190
+ } else if (op.op === 'clear') {
191
+ notes = [];
192
+ }
193
+ saveNotes(notes);
194
+ return notes;
195
+ }
196
+
197
+ function send(res, status, body, type = 'text/plain; charset=utf-8') {
198
+ res.writeHead(status, {
199
+ 'Content-Type': type,
200
+ 'Cache-Control': 'no-store',
201
+ });
202
+ res.end(body);
203
+ }
204
+
205
+ function targetFor(side, pathname, search) {
206
+ const base = side === 'dev' ? devBase : liveBase;
207
+ const relative = pathname.replace(new RegExp(`^/__${side}`), '') || '/';
208
+ return new URL(`${relative}${search}`, `${base.href}/`);
209
+ }
210
+
211
+ function rewriteRootPaths(body, side) {
212
+ const prefix = `/__${side}`;
213
+ return body
214
+ .replace(/(\b(?:href|src|action|poster)=["'])\/(?!\/)/gi, `$1${prefix}/`)
215
+ .replace(/\bsrcset=(["'])(.*?)\1/gi, (attribute, quote, value) => {
216
+ const rewritten = value.replace(/(^|,\s*)\/(?!\/)/g, `$1${prefix}/`);
217
+ return `srcset=${quote}${rewritten}${quote}`;
218
+ })
219
+ .replace(/url\((["']?)\/(?!\/)/gi, `url($1${prefix}/`)
220
+ .replace(/(["'`])\/(@(?:id|vite|fs)\/|_astro\/)/g, `$1${prefix}/$2`);
221
+ }
222
+
223
+ async function proxy(req, res, side, requestUrl) {
224
+ const target = targetFor(side, requestUrl.pathname, requestUrl.search);
225
+ const headers = { ...req.headers, host: target.host };
226
+ delete headers['accept-encoding'];
227
+ delete headers.connection;
228
+
229
+ try {
230
+ const upstream = await fetch(target, {
231
+ method: req.method,
232
+ headers,
233
+ redirect: 'manual',
234
+ });
235
+ const responseHeaders = {};
236
+ upstream.headers.forEach((value, key) => {
237
+ if (![
238
+ 'content-encoding',
239
+ 'content-length',
240
+ 'content-security-policy',
241
+ 'content-security-policy-report-only',
242
+ 'cross-origin-embedder-policy',
243
+ 'cross-origin-opener-policy',
244
+ 'cross-origin-resource-policy',
245
+ 'transfer-encoding',
246
+ 'x-frame-options',
247
+ ].includes(key)) {
248
+ responseHeaders[key] = value;
249
+ }
250
+ });
251
+ responseHeaders['cache-control'] = 'no-store';
252
+
253
+ const location = upstream.headers.get('location');
254
+ if (location) {
255
+ const redirected = new URL(location, target);
256
+ if (redirected.origin === target.origin) {
257
+ responseHeaders.location = `/__${side}${redirected.pathname}${redirected.search}${redirected.hash}`;
258
+ }
259
+ }
260
+
261
+ const type = upstream.headers.get('content-type') || '';
262
+ // Rewrite markup/CSS/JS always; rewrite JSON only on the dev side (Vite
263
+ // manifests) so live API payloads with path-like strings aren't corrupted.
264
+ const rewritable = /text\/html|text\/css|javascript/.test(type)
265
+ || (side === 'dev' && /application\/json/.test(type));
266
+ if (rewritable) {
267
+ const body = rewriteRootPaths(await upstream.text(), side);
268
+ res.writeHead(upstream.status, responseHeaders);
269
+ res.end(body);
270
+ return;
271
+ }
272
+
273
+ res.writeHead(upstream.status, responseHeaders);
274
+ res.end(Buffer.from(await upstream.arrayBuffer()));
275
+ } catch (error) {
276
+ send(
277
+ res,
278
+ 502,
279
+ `Could not load ${target.href}\n\n${error.message}\n\nStart the dev server with: site dev`,
280
+ );
281
+ }
282
+ }
283
+
284
+ function viewerHtml() {
285
+ const config = JSON.stringify({
286
+ dev: devBase.href.replace(/\/$/, ''),
287
+ live: liveBase.href.replace(/\/$/, ''),
288
+ brand,
289
+ author,
290
+ vault: !!vaultDir,
291
+ }).replace(/</g, '\\u003c');
292
+
293
+ return `<!doctype html>
294
+ <html lang="en">
295
+ <head>
296
+ <meta charset="utf-8">
297
+ <meta name="viewport" content="width=device-width, initial-scale=1">
298
+ <title>sitedrift</title>
299
+ <link rel="icon" href="/icon.svg">
300
+ <style>
301
+ :root {
302
+ color-scheme: dark;
303
+ --split: 50%;
304
+ --bg: #090a0c;
305
+ --panel: #111318;
306
+ --line: #2a2e37;
307
+ --muted: #8b93a3;
308
+ --text: #f5f7fa;
309
+ --dev: #71d99e;
310
+ --live: #86a8ff;
311
+ --drawer: min(420px, calc(100vw - 24px));
312
+ font: 13px/1.4 Inter, ui-sans-serif, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
313
+ }
314
+ .app { transition: padding-right .2s ease; }
315
+ .app.drawer-dock { padding-right: var(--drawer); }
316
+ * { box-sizing: border-box; }
317
+ html, body { height: 100%; margin: 0; overflow: hidden; background: var(--bg); color: var(--text); }
318
+ button, input { font: inherit; }
319
+ button, summary, .label, .compactbar, .divider, .mark, .pill, .icon-control {
320
+ -webkit-user-select: none; user-select: none; -webkit-tap-highlight-color: transparent;
321
+ }
322
+ button, summary, .open-side { -webkit-touch-callout: none; }
323
+ button:focus, summary:focus { outline: none; }
324
+ button:focus-visible, summary:focus-visible {
325
+ outline: 2px solid #86a8ff; outline-offset: 2px;
326
+ }
327
+ .app { height: 100%; display: grid; grid-template-rows: 52px 46px minmax(0, 1fr); }
328
+ .app.compact { grid-template-rows: 38px minmax(0, 1fr); }
329
+ .app.compact .toolbar, .app.compact .labels { display: none; }
330
+ .compactbar {
331
+ display: none; min-width: 0; align-items: center; gap: 8px; padding: 3px 8px;
332
+ background: rgba(17, 19, 24, .98); border-bottom: 1px solid var(--line);
333
+ }
334
+ .app.compact .compactbar { display: grid; grid-template-columns: 30px minmax(0, 1fr) auto minmax(0, 1fr) auto; }
335
+ .app.compact.solo .compactbar { grid-template-columns: 30px minmax(0, 1fr) auto auto; }
336
+ .compact-side { min-width: 0; display: flex; align-items: center; gap: 6px; }
337
+ .compact-title { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-weight: 650; }
338
+ .caret { width: 30px !important; height: 30px !important; padding: 0 !important; font-size: 16px; }
339
+ .caret svg { display: block; width: 14px; height: 14px; margin: auto; transition: transform .18s ease; }
340
+ .app.compact .caret svg { transform: rotate(180deg); }
341
+ .toolbar {
342
+ display: flex; align-items: center; gap: 8px; padding: 8px 12px;
343
+ background: rgba(17, 19, 24, .96); border-bottom: 1px solid var(--line);
344
+ }
345
+ .mark { display: flex; align-items: center; gap: 9px; margin-right: 5px; white-space: nowrap; }
346
+ .mark-icon { display: block; width: 20px; height: 20px; border-radius: 5px; box-shadow: 0 0 16px #71d99e55; }
347
+ .mark strong { letter-spacing: -.01em; font-size: 14px; }
348
+ .route {
349
+ min-width: 52px; flex: 0 1 220px; height: 34px; padding: 0 11px; color: var(--text);
350
+ background: #090b0e; border: 1px solid var(--line); border-radius: 7px; outline: none;
351
+ transition: flex-basis .2s ease;
352
+ }
353
+ .route:focus { flex-basis: min(560px, 55vw); border-color: #65718a; box-shadow: 0 0 0 3px #65718a22; }
354
+ .toolbar-spacer { flex: 1 1 0; min-width: 0; }
355
+ /* Only the route box (and the spacer) absorb width; controls keep their size. */
356
+ .toolbar > .mark, .toolbar > button, .toolbar > .modes, .toolbar > .overlay-slider, .toolbar > details { flex-shrink: 0; }
357
+ .app.drawer-dock .mark strong { display: none; }
358
+ button {
359
+ height: 34px; padding: 0 11px; color: #dce1e9; background: #191c22;
360
+ border: 1px solid var(--line); border-radius: 7px; cursor: pointer;
361
+ }
362
+ button:hover { background: #222630; border-color: #3b414d; }
363
+ button.active { color: #fff; background: #283044; border-color: #4f6081; }
364
+ button.icon { width: 34px; padding: 0; font-size: 15px; }
365
+ button.icon svg, .icon-control svg { display: block; width: 16px; height: 16px; margin: auto; }
366
+ .sr-only {
367
+ position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px;
368
+ overflow: hidden; clip: rect(0, 0, 0, 0); white-space: nowrap; border: 0;
369
+ }
370
+ button[data-action="notes"] .count {
371
+ position: absolute; top: -5px; right: -5px; display: inline-grid; min-width: 16px; height: 16px; padding: 0 4px;
372
+ place-items: center; color: #0b0d10; background: #dce1e9; border-radius: 9px; font-size: 10px; font-weight: 800;
373
+ }
374
+ button[data-action="notes"] { position: relative; }
375
+ .labels {
376
+ position: relative; z-index: 20; display: grid; grid-template-columns: var(--split) 1fr;
377
+ background: var(--panel); border-bottom: 1px solid var(--line);
378
+ }
379
+ .label {
380
+ position: relative; min-width: 0; display: flex; align-items: center; justify-content: space-between;
381
+ gap: 12px; padding: 0 12px; border-right: 1px solid var(--line);
382
+ }
383
+ .label:last-child { border-right: 0; }
384
+ .identity { min-width: 0; display: grid; grid-template-columns: auto 24px minmax(0, 1fr); align-items: center; gap: 9px; }
385
+ .pill { font-size: 10px; font-weight: 800; letter-spacing: .11em; }
386
+ .pill.dev { color: var(--dev); }
387
+ .pill.live { color: var(--live); }
388
+ .favicon { width: 24px; height: 24px; object-fit: contain; }
389
+ .page-meta { min-width: 0; display: flex; flex-direction: column; line-height: 1.2; }
390
+ .page-heading { min-width: 0; overflow: hidden; text-overflow: ellipsis; color: var(--text); font-weight: 650; white-space: nowrap; }
391
+ .origin { min-width: 0; overflow: hidden; text-overflow: ellipsis; color: var(--muted); white-space: nowrap; font-size: 11px; }
392
+ .label-actions { display: flex; align-items: center; gap: 5px; }
393
+ .open-side { display: inline-flex; align-items: center; gap: 4px; color: var(--muted); text-decoration: none; font-size: 12px; }
394
+ .open-side:hover { color: var(--text); }
395
+ .open-side svg { width: 12px; height: 12px; }
396
+ details { position: relative; }
397
+ summary {
398
+ list-style: none; padding: 5px 7px; color: var(--muted); border: 1px solid transparent;
399
+ border-radius: 6px; cursor: pointer; white-space: nowrap; user-select: none;
400
+ }
401
+ summary::-webkit-details-marker { display: none; }
402
+ summary:hover, details[open] summary { color: var(--text); background: #20242b; border-color: var(--line); }
403
+ .icon-control {
404
+ display: grid; width: 34px; height: 34px; padding: 0; place-items: center;
405
+ color: #dce1e9; background: #191c22; border: 1px solid var(--line); border-radius: 7px;
406
+ }
407
+ .settings-card {
408
+ position: fixed; z-index: 65; top: 48px; right: 52px; width: 230px; padding: 8px;
409
+ background: #15181e; border: 1px solid #343945; border-radius: 10px; box-shadow: 0 18px 50px #0009;
410
+ }
411
+ .setting-row {
412
+ width: 100%; display: flex; align-items: center; justify-content: space-between;
413
+ height: 38px; padding: 0 10px; background: transparent; border-color: transparent; text-align: left;
414
+ }
415
+ .setting-row:hover { background: #20242b; }
416
+ .setting-row .state { color: var(--muted); font-size: 11px; }
417
+ .setting-row.active .state { color: var(--dev); }
418
+ .help-card {
419
+ position: fixed; z-index: 65; top: 48px; right: 8px; width: 300px; padding: 14px;
420
+ background: #15181e; border: 1px solid #343945; border-radius: 10px; box-shadow: 0 18px 50px #0009;
421
+ }
422
+ .help-card strong { display: block; margin-bottom: 9px; }
423
+ .help-blurb { margin: 0 0 12px; color: var(--muted); line-height: 1.5; }
424
+ .help-blurb b { color: var(--text); font-weight: 650; }
425
+ .help-credit {
426
+ margin-top: 13px; padding-top: 11px; border-top: 1px solid var(--line);
427
+ color: var(--muted); font-size: 11px;
428
+ }
429
+ .help-credit a { color: var(--text); text-decoration: none; }
430
+ .help-credit a:hover { color: var(--live); text-decoration: underline; }
431
+ .shortcut-list { display: grid; grid-template-columns: auto 1fr; gap: 7px 12px; margin: 0; }
432
+ .shortcut-list dt { color: #fff; font-weight: 650; }
433
+ .shortcut-list dd { margin: 0; color: var(--muted); }
434
+ kbd { padding: 1px 5px; color: #e8ebf0; background: #242932; border: 1px solid #3d4552; border-radius: 4px; font: 11px ui-monospace, monospace; }
435
+ .seo-summary { display: inline-flex; align-items: center; gap: 4px; }
436
+ .seo-summary svg { width: 11px; height: 11px; }
437
+ .seo-flag {
438
+ display: inline-grid; place-items: center; min-width: 15px; height: 15px; padding: 0 4px;
439
+ border-radius: 8px; background: #e8c468; color: #0b0d10; font-size: 9px; font-weight: 800;
440
+ }
441
+ .seo-flag[hidden] { display: none; }
442
+ .seo-checks { margin-top: 16px; padding-top: 13px; border-top: 1px solid #e6e8eb; }
443
+ .seo-checks-head {
444
+ display: flex; align-items: center; justify-content: space-between; margin-bottom: 9px;
445
+ color: #5f6368; font: 600 11px/1.2 Inter, sans-serif; letter-spacing: .06em; text-transform: uppercase;
446
+ }
447
+ .seo-checks-head .bad { color: #c5221f; }
448
+ .seo-checks-head .good { color: #137333; }
449
+ .seo-check { display: grid; grid-template-columns: 15px 1fr auto; align-items: baseline; gap: 9px; padding: 3px 0; font-size: 13px; line-height: 18px; }
450
+ .seo-check-mark { font-weight: 800; text-align: center; }
451
+ .seo-check.ok .seo-check-mark { color: #137333; }
452
+ .seo-check.bad .seo-check-mark { color: #c5221f; }
453
+ .seo-check.bad .seo-check-label { color: #c5221f; }
454
+ .seo-check-note { color: #80868b; font-size: 11px; white-space: nowrap; }
455
+ .seo-card {
456
+ position: fixed; z-index: 60; top: 108px; left: 12px; width: min(520px, calc(100vw - 24px));
457
+ max-height: calc(100vh - 128px); overflow: auto;
458
+ padding: 18px 20px; color: #202124; background: #fff; border: 1px solid #dfe1e5;
459
+ border-radius: 10px; box-shadow: 0 18px 55px #0007; font-family: Arial, sans-serif;
460
+ }
461
+ .seo-eyebrow { margin-bottom: 12px; color: #5f6368; font: 11px/1.2 Inter, sans-serif; letter-spacing: .08em; text-transform: uppercase; }
462
+ .seo-source { display: grid; grid-template-columns: 28px minmax(0, 1fr) 20px; align-items: center; gap: 10px; }
463
+ .seo-favicon { width: 28px; height: 28px; padding: 4px; object-fit: contain; background: #f1f3f4; border-radius: 50%; }
464
+ .seo-site { color: #202124; font-size: 14px; line-height: 18px; }
465
+ .seo-url { color: #4d5156; font-size: 12px; line-height: 16px; }
466
+ .seo-menu { color: #4d5156; font-size: 20px; line-height: 1; letter-spacing: 1px; }
467
+ .seo-title { margin-top: 5px; color: #1a0dab; font-size: 20px; line-height: 26px; font-weight: 400; }
468
+ .seo-description { margin-top: 3px; color: #4d5156; font-size: 14px; line-height: 22px; }
469
+ .seo-empty { color: #b3261e; font-style: italic; }
470
+ .stage { position: relative; display: grid; grid-template-columns: var(--split) 1fr; min-height: 0; }
471
+ .pane { position: relative; min-width: 0; overflow: hidden; background: #fff; }
472
+ .pane:first-child { border-right: 1px solid var(--line); }
473
+ iframe { display: block; width: 100%; height: 100%; border: 0; background: #fff; }
474
+ .divider {
475
+ position: absolute; z-index: 10; top: 0; bottom: 0; left: var(--split); width: 17px;
476
+ transform: translateX(-50%); cursor: col-resize; touch-action: none;
477
+ }
478
+ .divider:focus { outline: none; }
479
+ .divider:focus-visible { outline: 2px solid #86a8ff; outline-offset: -2px; }
480
+ .divider::before {
481
+ content: ""; position: absolute; top: 0; bottom: 0; left: 8px; width: 1px; background: #596171;
482
+ }
483
+ .grip {
484
+ position: absolute; top: 50%; left: 50%; width: 24px; height: 54px; transform: translate(-50%, -50%);
485
+ border: 1px solid #454c59; border-radius: 12px; background: #171a20;
486
+ box-shadow: 0 8px 30px #0008;
487
+ }
488
+ .grip::after {
489
+ content: ""; position: absolute; inset: 15px 8px;
490
+ background: repeating-linear-gradient(90deg, #788190 0 1px, transparent 1px 4px);
491
+ }
492
+ .dragging iframe { pointer-events: none; }
493
+ .app.mobile .stage {
494
+ grid-template-columns: repeat(2, minmax(320px, 390px)); justify-content: center; align-items: stretch;
495
+ gap: 22px; overflow-x: auto; padding: 14px 22px; background: #08090b;
496
+ }
497
+ .app.mobile .pane { border: 1px solid #343945; border-radius: 13px; box-shadow: 0 14px 42px #0008; }
498
+ .app.mobile .divider { display: none; }
499
+ .app.mobile .labels { grid-template-columns: 1fr 1fr; padding-inline: max(0px, calc((100% - 802px) / 2)); }
500
+ .app.solo .stage { grid-template-columns: minmax(0, 1fr); }
501
+ .app.solo .labels { grid-template-columns: minmax(0, 1fr); }
502
+ .app.solo .divider { display: none; }
503
+ .app.solo[data-focus="dev"] [data-pane="live"],
504
+ .app.solo[data-focus="live"] [data-pane="dev"] {
505
+ position: absolute; inset: 0; visibility: hidden; pointer-events: none;
506
+ }
507
+ .app.solo[data-focus="dev"] .label[data-label="live"],
508
+ .app.solo[data-focus="live"] .label[data-label="dev"],
509
+ .app.solo[data-focus="dev"] [data-compact-side="live"],
510
+ .app.solo[data-focus="live"] [data-compact-side="dev"] { display: none; }
511
+ .app.mobile.solo .stage { grid-template-columns: minmax(320px, 390px); }
512
+ .review-drawer {
513
+ position: fixed; z-index: 50; top: 0; right: 0; bottom: 0; width: var(--drawer);
514
+ display: grid; grid-template-rows: auto minmax(0, 1fr) auto; color: var(--text); background: #111318;
515
+ border-left: 1px solid var(--line); box-shadow: -18px 0 55px #0009; transform: translateX(102%);
516
+ transition: transform .2s ease;
517
+ }
518
+ .review-drawer.open { transform: translateX(0); }
519
+ .drawer-head { display: flex; align-items: center; gap: 8px; padding: 14px 16px; border-bottom: 1px solid var(--line); }
520
+ .drawer-head strong { flex: 1; }
521
+ .drawer-head .icon { width: 30px !important; height: 30px !important; }
522
+ .drawer-head .icon.active { color: #fff; background: #283044; border-color: #4f6081; }
523
+ .drawer-head strong { font-size: 14px; }
524
+ .note-list { margin: 0; padding: 14px 16px 14px 38px; overflow: auto; }
525
+ .note-list li { position: relative; margin-bottom: 10px; padding: 9px 36px 9px 11px; background: #191c22; border: 1px solid var(--line); border-radius: 8px; }
526
+ .note-list:empty::after { content: "No review notes yet."; display: block; margin-left: -22px; color: var(--muted); }
527
+ .remove-note { position: absolute; top: 5px; right: 5px; width: 26px !important; height: 26px !important; padding: 0 !important; }
528
+ .note-compose { display: grid; gap: 8px; padding: 14px 16px; border-top: 1px solid var(--line); }
529
+ .note-compose textarea {
530
+ width: 100%; min-height: 76px; resize: none; overflow-y: hidden; padding: 9px 10px; color: var(--text);
531
+ background: #090b0e; border: 1px solid var(--line); border-radius: 7px; font: inherit;
532
+ }
533
+ .note-grip { height: 9px; margin: -2px 0 1px; cursor: ns-resize; touch-action: none; }
534
+ .note-grip::before {
535
+ content: ""; display: block; width: 34px; height: 3px; margin: 3px auto 0;
536
+ border-radius: 2px; background: var(--line);
537
+ }
538
+ .note-grip:hover::before { background: var(--muted); }
539
+ .note-input { position: relative; }
540
+ .note-input textarea { padding-right: 44px; }
541
+ .note-submit {
542
+ position: absolute; right: 8px; bottom: 8px; width: 30px !important; height: 30px !important; padding: 0 !important;
543
+ }
544
+ .note-submit svg { display: block; width: 16px; height: 16px; margin: auto; }
545
+ .note-actions { display: flex; gap: 8px; }
546
+ .note-actions button { flex: 1; }
547
+ .note-actions:empty { display: none; }
548
+ .toast {
549
+ position: fixed; z-index: 70; left: 50%; bottom: 18px; padding: 8px 12px; color: #fff;
550
+ background: #252a33; border: 1px solid #414957; border-radius: 8px; box-shadow: 0 8px 28px #0008;
551
+ opacity: 0; transform: translate(-50%, 8px); pointer-events: none; transition: .18s ease;
552
+ }
553
+ .toast.show { opacity: 1; transform: translate(-50%, 0); }
554
+ .hint { color: var(--muted); white-space: nowrap; font-size: 11px; }
555
+ .status-badge {
556
+ display: none; align-items: center; height: 18px; padding: 0 6px; border-radius: 6px;
557
+ font-size: 10px; font-weight: 800; letter-spacing: .04em;
558
+ }
559
+ .status-badge.show { display: inline-flex; }
560
+ .status-ok { color: #0b0d10; background: var(--dev); }
561
+ .status-warn { color: #0b0d10; background: #e8c468; }
562
+ .status-err { color: #fff; background: #e0556b; }
563
+ .compact-side .status-badge { height: 16px; }
564
+ .meta-diff {
565
+ display: none; align-items: center; height: 18px; padding: 0 7px; color: #0b0d10;
566
+ background: #e8c468; border-radius: 6px; font-size: 10px; font-weight: 800; cursor: default;
567
+ }
568
+ .meta-diff.show { display: inline-flex; }
569
+ .seo-diff { background: #fdeec9; outline: 2px solid #f0b429; outline-offset: 1px; border-radius: 3px; }
570
+ .modes { display: inline-flex; align-items: center; border: 1px solid var(--line); border-radius: 8px; overflow: hidden; }
571
+ .modes button { height: 34px; padding: 0 12px; color: var(--muted); background: #15181e; border: 0; border-radius: 0; font-weight: 600; }
572
+ .modes button + button { border-left: 1px solid var(--line); }
573
+ .modes button:hover { background: #20242b; color: var(--text); }
574
+ .modes button.active { background: #283044; color: #fff; }
575
+ .overlay-slider { display: none; align-items: center; gap: 8px; padding: 0 2px; }
576
+ .app.overlay .overlay-slider { display: flex; }
577
+ .overlay-slider input[type="range"] { width: 120px; accent-color: var(--live); transition: opacity .15s ease; }
578
+ .app.overlay.diff .overlay-slider input[type="range"] { opacity: .4; pointer-events: none; }
579
+ .overlay-blend.active { color: #fff; background: #283044; border-color: #4f6081; }
580
+ .compact-controls { display: flex; align-items: center; gap: 8px; justify-self: center; }
581
+ .compactbar .modes button { height: 30px; padding: 0 9px; }
582
+ .diff-legend {
583
+ display: none; position: fixed; z-index: 40; left: 50%; bottom: 16px; transform: translateX(-50%);
584
+ padding: 6px 13px; color: #cdd3dd; background: rgba(17, 19, 24, .92); border: 1px solid var(--line);
585
+ border-radius: 999px; font-size: 11px; pointer-events: none; white-space: nowrap;
586
+ }
587
+ .app.overlay.diff .diff-legend { display: block; }
588
+ .app.overlay .stage { grid-template-columns: minmax(0, 1fr); }
589
+ .app.overlay .pane { position: absolute; inset: 0; }
590
+ .app.overlay .pane:first-child { border-right: 0; }
591
+ .app.overlay .overlay-top { opacity: var(--overlay, .5); }
592
+ .app.overlay.diff .stage { background: #000; }
593
+ .app.overlay.diff .overlay-top { opacity: 1; mix-blend-mode: difference; }
594
+ .app.overlay .divider { display: none; }
595
+ .app.overlay .labels { grid-template-columns: 1fr 1fr; }
596
+ .note-list li.done { opacity: .55; }
597
+ .note-list li.done .note-text { text-decoration: line-through; }
598
+ .note-meta { display: flex; align-items: center; gap: 6px; margin-bottom: 5px; }
599
+ .note-author {
600
+ padding: 1px 6px; border-radius: 5px; font-size: 10px; font-weight: 800;
601
+ letter-spacing: .05em; text-transform: uppercase;
602
+ }
603
+ .note-author.joe { color: #0b0d10; background: var(--live); }
604
+ .note-author.claude { color: #0b0d10; background: #d8a0ff; }
605
+ .note-author.other { color: #0b0d10; background: #9aa3b2; }
606
+ .note-where { color: var(--muted); font-size: 10px; }
607
+ .note-text { white-space: pre-wrap; word-break: break-word; }
608
+ .note-go { cursor: pointer; }
609
+ .note-go:hover { color: var(--live); text-decoration: underline; }
610
+ .note-list li { padding-right: 92px; }
611
+ .note-toggle { position: absolute; top: 5px; right: 34px; width: 26px !important; height: 26px !important; padding: 0 !important; }
612
+ .note-copy { position: absolute; top: 5px; right: 63px; width: 26px !important; height: 26px !important; padding: 0 !important; }
613
+ .note-copy svg { display: block; width: 14px; height: 14px; margin: auto; }
614
+ .note-actions { flex-wrap: wrap; }
615
+ @media (max-width: 820px) {
616
+ .mark strong, .hint, button[data-wide] .control-label { display: none; }
617
+ .toolbar { padding-inline: 8px; }
618
+ .open-side { display: none; }
619
+ .app.mobile .stage { justify-content: start; }
620
+ }
621
+ @media (prefers-reduced-motion: reduce) {
622
+ *, *::before, *::after { transition-duration: .01ms !important; animation-duration: .01ms !important; }
623
+ }
624
+ </style>
625
+ </head>
626
+ <body>
627
+ <main class="app">
628
+ <header class="toolbar">
629
+ <button class="caret" data-action="compact" title="Collapse review chrome" aria-label="Collapse review chrome">
630
+ <svg viewBox="0 0 16 16" aria-hidden="true"><path d="m3 10 5-5 5 5" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"/></svg>
631
+ </button>
632
+ <div class="mark"><img class="mark-icon" src="/icon.svg" alt="" width="20" height="20"><strong>sitedrift</strong></div>
633
+ <input class="route" aria-label="Route" value="/" spellcheck="false">
634
+ <button class="icon" data-action="go" title="Load route (Enter)" aria-label="Load route">
635
+ <svg viewBox="0 0 20 20" aria-hidden="true"><path d="M4 10h11m-4-4 4 4-4 4" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round"/></svg>
636
+ </button>
637
+ <span class="toolbar-spacer"></span>
638
+ <div class="modes" role="group" aria-label="View mode">
639
+ <button data-mode="split" title="Side by side">Split</button>
640
+ <button data-mode="solo" title="One pane (S swaps)">Solo</button>
641
+ <button data-mode="overlay" title="Overlay the panes (O)">Overlay</button>
642
+ </div>
643
+ <div class="overlay-slider">
644
+ <input type="range" min="0" max="100" value="50" aria-label="Overlay opacity">
645
+ <button class="overlay-blend" data-action="overlay-blend" title="Difference blend (D) — changed pixels light up" aria-pressed="false">Diff</button>
646
+ </div>
647
+ <button class="icon" data-action="reload" title="Reload both panes (R)" aria-label="Reload both panes">
648
+ <svg viewBox="0 0 20 20" aria-hidden="true"><path d="M15.5 6.5V3m0 0H12m3.5 0-2.2 2.2A6 6 0 1 0 15.8 11" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round"/></svg>
649
+ </button>
650
+ <button class="icon" data-action="scroll" title="Toggle locked scrolling" aria-label="Toggle locked scrolling">
651
+ <span class="sr-only" data-scroll-label>Locked scroll</span>
652
+ <svg viewBox="0 0 20 20" aria-hidden="true"><path d="M7 5.5 10 2l3 3.5M10 2v16m-3-3.5L10 18l3-3.5" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round"/></svg>
653
+ </button>
654
+ <button class="icon" data-action="swap" title="Swap sides (S)" aria-label="Swap sides">
655
+ <svg viewBox="0 0 20 20" aria-hidden="true"><path d="M3 6h12m-3-3 3 3-3 3M17 14H5m3 3-3-3 3-3" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round"/></svg>
656
+ </button>
657
+ <details class="settings">
658
+ <summary class="icon-control" title="Comparison settings" aria-label="Comparison settings">
659
+ <svg viewBox="0 0 20 20" aria-hidden="true"><path d="M4 5h12M7 10h9M4 15h12M7 3v4m6 1v4m-6 1v4" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round"/></svg>
660
+ </summary>
661
+ <div class="settings-card">
662
+ <button class="setting-row" data-action="mobile"><span>Mobile panes</span><span class="state">Off</span></button>
663
+ <button class="setting-row" data-action="mirror"><span>Mirror links</span><span class="state">Off</span></button>
664
+ <button class="setting-row" data-action="scroll-mode"><span>Scroll mode</span><span class="state">Exact</span></button>
665
+ </div>
666
+ </details>
667
+ <button class="icon" data-action="notes" title="Review notes" aria-label="Review notes">
668
+ <svg viewBox="0 0 20 20" aria-hidden="true"><path d="M5 3.5h10a1.5 1.5 0 0 1 1.5 1.5v7A1.5 1.5 0 0 1 15 13.5H9L5 17v-3.5A1.5 1.5 0 0 1 3.5 12V5A1.5 1.5 0 0 1 5 3.5Z" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linejoin="round"/></svg>
669
+ <span class="count">0</span>
670
+ </button>
671
+ <details class="help">
672
+ <summary class="icon-control" title="Help and keyboard shortcuts" aria-label="Help and keyboard shortcuts">
673
+ <svg viewBox="0 0 20 20" aria-hidden="true"><circle cx="10" cy="10" r="7.5" fill="none" stroke="currentColor" stroke-width="1.6"/><path d="M8.3 7.4a1.9 1.9 0 1 1 2.4 1.8c-.7.3-.9.7-.9 1.4M10 14h.01" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round"/></svg>
674
+ </summary>
675
+ <div class="help-card">
676
+ <strong>sitedrift</strong>
677
+ <p class="help-blurb">Local dev and production, locked to the same route and scroll. Compare side&#8209;by&#8209;side, drag the divider, or <b>Overlay</b> the panes — flip overlay to <b>Diff</b> and only the pixels that changed light up.</p>
678
+ <dl class="shortcut-list">
679
+ <dt><kbd>O</kbd></dt><dd>Overlay on/off (restores your layout)</dd>
680
+ <dt><kbd>D</kbd></dt><dd>Difference blend (while overlaid)</dd>
681
+ <dt><kbd>S</kbd></dt><dd>Swap sides (flip in Solo)</dd>
682
+ <dt><kbd>R</kbd></dt><dd>Reload both panes</dd>
683
+ <dt><kbd>0</kbd></dt><dd>Reset divider to 50/50</dd>
684
+ <dt><kbd>/</kbd></dt><dd>Focus the route field</dd>
685
+ <dt><kbd>Space</kbd></dt><dd>Page down / up with Shift</dd>
686
+ <dt><kbd>Esc</kbd></dt><dd>Close notes &amp; popovers</dd>
687
+ </dl>
688
+ <div class="help-credit">Created by <a href="https://github.com/joeseverino" target="_blank" rel="noreferrer">Joe Severino</a> · <span>github.com/joeseverino</span></div>
689
+ </div>
690
+ </details>
691
+ </header>
692
+ <section class="labels">
693
+ <div class="label" data-label="dev">
694
+ <div class="identity">
695
+ <span class="pill dev">DEV</span>
696
+ <img class="favicon" alt="">
697
+ <span class="page-meta"><span class="page-heading">Loading…</span><span class="origin"></span></span>
698
+ </div>
699
+ <div class="label-actions">
700
+ <span class="status-badge" data-status></span>
701
+ <span class="meta-diff" data-metadiff title="Title, description, or canonical differs between sides">≠ meta</span>
702
+ <details><summary class="seo-summary">SEO<span class="seo-flag" hidden></span> <svg viewBox="0 0 12 12" aria-hidden="true"><path d="m2.5 4.5 3.5 3 3.5-3" fill="none" stroke="currentColor" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round"/></svg></summary><div class="seo-card"></div></details>
703
+ <a class="open-side" target="_blank" rel="noreferrer">Open <svg viewBox="0 0 12 12" aria-hidden="true"><path d="M4.5 2h5.5v5.5M10 2 4 8m-2-4v6h6" fill="none" stroke="currentColor" stroke-width="1.3" stroke-linecap="round" stroke-linejoin="round"/></svg></a>
704
+ </div>
705
+ </div>
706
+ <div class="label" data-label="live">
707
+ <div class="identity">
708
+ <span class="pill live">LIVE</span>
709
+ <img class="favicon" alt="">
710
+ <span class="page-meta"><span class="page-heading">Loading…</span><span class="origin"></span></span>
711
+ </div>
712
+ <div class="label-actions">
713
+ <span class="status-badge" data-status></span>
714
+ <span class="meta-diff" data-metadiff title="Title, description, or canonical differs between sides">≠ meta</span>
715
+ <details><summary class="seo-summary">SEO<span class="seo-flag" hidden></span> <svg viewBox="0 0 12 12" aria-hidden="true"><path d="m2.5 4.5 3.5 3 3.5-3" fill="none" stroke="currentColor" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round"/></svg></summary><div class="seo-card"></div></details>
716
+ <a class="open-side" target="_blank" rel="noreferrer">Open <svg viewBox="0 0 12 12" aria-hidden="true"><path d="M4.5 2h5.5v5.5M10 2 4 8m-2-4v6h6" fill="none" stroke="currentColor" stroke-width="1.3" stroke-linecap="round" stroke-linejoin="round"/></svg></a>
717
+ </div>
718
+ </div>
719
+ </section>
720
+ <header class="compactbar">
721
+ <button class="caret" data-action="compact" title="Expand review chrome" aria-label="Expand review chrome">
722
+ <svg viewBox="0 0 16 16" aria-hidden="true"><path d="m3 10 5-5 5 5" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"/></svg>
723
+ </button>
724
+ <div class="compact-side" data-compact-side="dev"><span class="pill dev">DEV</span><span class="compact-title" data-compact-title="dev">Loading…</span><span class="status-badge"></span></div>
725
+ <div class="compact-controls">
726
+ <div class="modes" role="group" aria-label="View mode">
727
+ <button data-mode="split">Split</button>
728
+ <button data-mode="solo">Solo</button>
729
+ <button data-mode="overlay">Overlay</button>
730
+ </div>
731
+ <div class="overlay-slider">
732
+ <input type="range" min="0" max="100" value="50" aria-label="Overlay opacity">
733
+ <button class="overlay-blend" data-action="overlay-blend" title="Difference blend (D)" aria-pressed="false">Diff</button>
734
+ </div>
735
+ <button class="icon" data-action="reload" title="Reload both panes" aria-label="Reload both panes">
736
+ <svg viewBox="0 0 20 20" aria-hidden="true"><path d="M15.5 6.5V3m0 0H12m3.5 0-2.2 2.2A6 6 0 1 0 15.8 11" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round"/></svg>
737
+ </button>
738
+ </div>
739
+ <div class="compact-side" data-compact-side="live"><span class="pill live">LIVE</span><span class="compact-title" data-compact-title="live">Loading…</span><span class="status-badge"></span></div>
740
+ <button class="icon" data-action="notes" title="Review notes" aria-label="Review notes">
741
+ <svg viewBox="0 0 20 20" aria-hidden="true"><path d="M5 3.5h10a1.5 1.5 0 0 1 1.5 1.5v7A1.5 1.5 0 0 1 15 13.5H9L5 17v-3.5A1.5 1.5 0 0 1 3.5 12V5A1.5 1.5 0 0 1 5 3.5Z" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linejoin="round"/></svg>
742
+ </button>
743
+ </header>
744
+ <section class="stage">
745
+ <div class="pane" data-pane="dev"><iframe data-side="dev" title="Development site"></iframe></div>
746
+ <div class="pane" data-pane="live"><iframe data-side="live" title="Live site"></iframe></div>
747
+ <div class="divider" role="separator" aria-label="Resize comparison panes" aria-orientation="vertical" tabindex="0"><span class="grip"></span></div>
748
+ </section>
749
+ <div class="diff-legend" aria-hidden="true">Difference · lit = changed · black = identical</div>
750
+ </main>
751
+ <aside class="review-drawer" aria-label="Review notes">
752
+ <div class="drawer-head">
753
+ <strong>Review notes</strong>
754
+ <button class="icon" data-action="notes-dock" title="Dock: push the panes aside vs float over them" aria-label="Dock notes" aria-pressed="false">
755
+ <svg viewBox="0 0 20 20" aria-hidden="true"><rect x="3" y="4" width="14" height="12" rx="2" fill="none" stroke="currentColor" stroke-width="1.6"/><line x1="12.5" y1="4" x2="12.5" y2="16" stroke="currentColor" stroke-width="1.6"/></svg>
756
+ </button>
757
+ <button class="icon" data-action="notes-close" aria-label="Close notes">×</button>
758
+ </div>
759
+ <ol class="note-list"></ol>
760
+ <div class="note-compose">
761
+ <div class="note-grip" title="Drag to resize" aria-hidden="true"></div>
762
+ <div class="note-input">
763
+ <textarea placeholder="Add a change, question, or thing to verify…"></textarea>
764
+ <button class="note-submit" data-action="note-add" title="Add note (⌘↵)" aria-label="Add note">
765
+ <svg viewBox="0 0 20 20" aria-hidden="true"><path d="M4 10h11m-4-4 4 4-4 4" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"/></svg>
766
+ </button>
767
+ </div>
768
+ <div class="note-actions"><button data-action="note-vault" hidden>Send to vault</button><button data-action="note-export">Export .md</button></div>
769
+ </div>
770
+ </aside>
771
+ <div class="toast" role="status"></div>
772
+ <script>
773
+ const config = ${config};
774
+ const root = document.documentElement;
775
+ const app = document.querySelector('.app');
776
+ const routeInput = document.querySelector('.route');
777
+ const divider = document.querySelector('.divider');
778
+ const scrollButton = document.querySelector('[data-action="scroll"]');
779
+ const scrollModeButton = document.querySelector('[data-action="scroll-mode"]');
780
+ const mirrorButton = document.querySelector('[data-action="mirror"]');
781
+ const mobileButton = document.querySelector('[data-action="mobile"]');
782
+ const modeButtons = [...document.querySelectorAll('[data-mode]')];
783
+ const overlaySliders = [...document.querySelectorAll('.overlay-slider input')];
784
+ const blendButtons = [...document.querySelectorAll('[data-action="overlay-blend"]')];
785
+ const notesDrawer = document.querySelector('.review-drawer');
786
+ const noteList = document.querySelector('.note-list');
787
+ const noteInput = document.querySelector('.note-compose textarea');
788
+ const toast = document.querySelector('.toast');
789
+ const params = new URLSearchParams(location.search);
790
+ const attachedDocuments = new WeakSet();
791
+ const suppressScrollUntil = { dev: 0, live: 0 };
792
+ const scrollFrames = { dev: 0, live: 0 };
793
+ const settleTimers = { dev: [], live: [] };
794
+ let order = params.get('swap') === '1' ? ['live', 'dev'] : ['dev', 'live'];
795
+ let syncScroll = queryOrStoredBool('scroll', 'site-compare-scroll', false);
796
+ let scrollMode = params.get('scrollMode') || localStorage.getItem('site-compare-scroll-mode') || 'exact';
797
+ if (!['exact', 'ratio'].includes(scrollMode)) scrollMode = 'exact';
798
+ let mirrorLinks = queryOrStoredBool('mirror', 'site-compare-mirror', false);
799
+ let mobileMode = (params.get('mode') || localStorage.getItem('site-compare-mode')) === 'mobile';
800
+ let compactMode = queryOrStoredBool('compact', 'site-compare-compact', false);
801
+ let viewMode = params.get('view')
802
+ || (params.get('overlay') === '1' ? 'overlay' : params.get('solo') === '1' ? 'solo' : null)
803
+ || localStorage.getItem('site-compare-view') || 'split';
804
+ let overlayBlend = (params.get('overlayBlend') || localStorage.getItem('site-compare-overlay-blend')) === 'difference' ? 'difference' : 'opacity';
805
+ if (viewMode === 'diff') { viewMode = 'overlay'; overlayBlend = 'difference'; } // back-compat
806
+ if (!['split', 'solo', 'overlay'].includes(viewMode)) viewMode = 'split';
807
+ let overlayAmount = Number(params.get('overlayAmount') ?? localStorage.getItem('site-compare-overlay-amount'));
808
+ if (!Number.isFinite(overlayAmount)) overlayAmount = 50;
809
+ let focusSide = params.get('focus') === 'live' ? 'live' : params.get('focus') === 'dev' ? 'dev' : order[0];
810
+ let reviewNotes = [];
811
+ let notesSignature = '';
812
+ let notesOpen = params.get('notes') === '1';
813
+ let dockMode = queryOrStoredBool('dock', 'site-compare-dock', true);
814
+ let scrollOwner = null;
815
+ const meta = { dev: null, live: null };
816
+
817
+ function queryOrStoredBool(queryName, storageName, fallback) {
818
+ if (params.has(queryName)) return params.get(queryName) === '1';
819
+ const stored = localStorage.getItem(storageName);
820
+ return stored === null ? fallback : stored === '1';
821
+ }
822
+
823
+ function normalizeRoute(value) {
824
+ try {
825
+ if (/^https?:\\/\\//.test(value)) {
826
+ const parsed = new URL(value);
827
+ value = parsed.pathname + parsed.search + parsed.hash;
828
+ }
829
+ } catch {}
830
+ value = value.trim() || '/';
831
+ return value.startsWith('/') ? value : '/' + value;
832
+ }
833
+
834
+ function frame(side) { return document.querySelector('iframe[data-side="' + side + '"]'); }
835
+ function proxied(side, route) { return '/__' + side + normalizeRoute(route); }
836
+ function direct(side, route) { return config[side] + normalizeRoute(route); }
837
+
838
+ function statusBadges(side) {
839
+ return [
840
+ document.querySelector('.label[data-label="' + side + '"] .status-badge'),
841
+ document.querySelector('[data-compact-side="' + side + '"] .status-badge'),
842
+ ].filter(Boolean);
843
+ }
844
+
845
+ function setStatusBadge(side, status) {
846
+ const cls = status >= 200 && status < 300 ? 'status-ok'
847
+ : status >= 300 && status < 400 ? 'status-warn'
848
+ : 'status-err';
849
+ const text = status ? String(status) : 'ERR';
850
+ for (const badge of statusBadges(side)) {
851
+ badge.className = 'status-badge show ' + cls;
852
+ badge.textContent = text;
853
+ }
854
+ }
855
+
856
+ function clearStatusBadge(side) {
857
+ for (const badge of statusBadges(side)) {
858
+ badge.className = 'status-badge';
859
+ badge.textContent = '';
860
+ }
861
+ }
862
+
863
+ function fetchStatus(side, route) {
864
+ const url = proxied(side, route);
865
+ const read = (method) => fetch(url, { method, cache: 'no-store', redirect: 'manual' });
866
+ read('HEAD')
867
+ .then((res) => (res.status === 405 || res.status === 501 ? read('GET') : res))
868
+ .then((res) => setStatusBadge(side, res.status || (res.type === 'opaqueredirect' ? 302 : 0)))
869
+ .catch(() => setStatusBadge(side, 0));
870
+ }
871
+
872
+ function brandStrip(title) {
873
+ if (!config.brand) return title;
874
+ const escaped = config.brand.replace(/[.*+?^$()|[\\]{}\\\\]/g, '\\\\$&');
875
+ return title.replace(new RegExp('\\\\s*[|\\u2013\\u2014-]\\\\s*' + escaped + '.*$', 'i'), '').trim();
876
+ }
877
+
878
+ function updateDocTitle() {
879
+ const primary = meta[order[0]];
880
+ document.title = primary && primary.heading ? primary.heading + ' · sitedrift' : 'sitedrift';
881
+ }
882
+
883
+ function renderMetaDiff() {
884
+ const dev = meta.dev;
885
+ const live = meta.live;
886
+ const diffs = {
887
+ title: !!(dev && live) && (dev.title || '') !== (live.title || ''),
888
+ desc: !!(dev && live) && (dev.description || '') !== (live.description || ''),
889
+ url: !!(dev && live) && (dev.canonicalPath || '') !== (live.canonicalPath || ''),
890
+ };
891
+ const any = diffs.title || diffs.desc || diffs.url;
892
+ for (const chip of document.querySelectorAll('.meta-diff')) chip.classList.toggle('show', any);
893
+ for (const side of ['dev', 'live']) {
894
+ const card = document.querySelector('.label[data-label="' + side + '"] .seo-card');
895
+ if (!card) continue;
896
+ for (const key of ['title', 'desc', 'url']) {
897
+ const el = card.querySelector('[data-seo="' + key + '"]');
898
+ if (el) el.classList.toggle('seo-diff', diffs[key]);
899
+ }
900
+ }
901
+ }
902
+
903
+ function setUrlParam(name, value) {
904
+ const url = new URL(location.href);
905
+ if (value === '' || value === null || value === undefined) url.searchParams.delete(name);
906
+ else url.searchParams.set(name, String(value));
907
+ history.replaceState(null, '', url);
908
+ }
909
+
910
+ function saveBool(queryName, storageName, value) {
911
+ localStorage.setItem(storageName, value ? '1' : '0');
912
+ setUrlParam(queryName, value ? '1' : '0');
913
+ }
914
+
915
+ function showToast(message) {
916
+ toast.textContent = message;
917
+ toast.classList.add('show');
918
+ clearTimeout(showToast.timer);
919
+ showToast.timer = setTimeout(() => toast.classList.remove('show'), 1600);
920
+ }
921
+
922
+ function escapeHtml(value) {
923
+ return String(value || '').replace(/[&<>"']/g, (char) => ({
924
+ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;',
925
+ })[char]);
926
+ }
927
+
928
+ function truncate(value, max) {
929
+ const chars = [...String(value || '')];
930
+ return chars.length <= max ? chars.join('') : chars.slice(0, max - 1).join('').trimEnd() + '…';
931
+ }
932
+
933
+ function crumb(value) {
934
+ try {
935
+ const url = new URL(value);
936
+ const parts = url.pathname.replace(/^\\/|\\/$/g, '').split('/').filter(Boolean)
937
+ .map((part) => decodeURIComponent(part).replaceAll('-', ' '));
938
+ return parts.length ? url.hostname + ' › ' + parts.join(' › ') : url.hostname;
939
+ } catch {
940
+ return value;
941
+ }
942
+ }
943
+
944
+ function seoChecks(doc) {
945
+ const q = (selector) => doc.querySelector(selector);
946
+ const title = (doc.title || '').trim();
947
+ const description = q('meta[name="description"]')?.content?.trim() || '';
948
+ const h1s = doc.querySelectorAll('h1').length;
949
+ const imgs = [...doc.querySelectorAll('img')];
950
+ const noAlt = imgs.filter((img) => img.getAttribute('alt') === null).length;
951
+ const robots = (q('meta[name="robots"]')?.content || '').toLowerCase();
952
+ return [
953
+ { label: 'Title present', ok: !!title },
954
+ { label: 'Title 30–60 chars', ok: title.length >= 30 && title.length <= 60, note: title.length + '' },
955
+ { label: 'Meta description', ok: !!description },
956
+ { label: 'Description 70–160', ok: description.length >= 70 && description.length <= 160, note: description.length + '' },
957
+ { label: 'Exactly one H1', ok: h1s === 1, note: h1s + ' found' },
958
+ { label: 'Canonical link', ok: !!q('link[rel="canonical"]') },
959
+ { label: 'Viewport meta', ok: !!q('meta[name="viewport"]') },
960
+ { label: 'html lang', ok: !!doc.documentElement.getAttribute('lang') },
961
+ { label: 'Open Graph title', ok: !!q('meta[property="og:title"]') },
962
+ { label: 'Open Graph image', ok: !!q('meta[property="og:image"]') },
963
+ { label: 'Not noindex', ok: !robots.includes('noindex') },
964
+ { label: 'Favicon', ok: !!q('link[rel~="icon"]') },
965
+ { label: 'Images have alt', ok: noAlt === 0, note: noAlt ? noAlt + ' missing' : 'all' },
966
+ ];
967
+ }
968
+
969
+ function renderMetadata(side) {
970
+ const iframe = frame(side);
971
+ const doc = iframe.contentDocument;
972
+ if (!doc) return;
973
+ const route = iframe.contentWindow.location.pathname.replace(new RegExp('^/__' + side), '') || '/';
974
+ const label = document.querySelector('.label[data-label="' + side + '"]');
975
+ if (!label) return;
976
+ const title = doc.title.trim();
977
+ const heading = brandStrip(title)
978
+ || doc.querySelector('h1')?.textContent?.trim()
979
+ || 'Untitled page';
980
+ const description = doc.querySelector('meta[name="description"]')?.content?.trim() || '';
981
+ const canonical = doc.querySelector('link[rel="canonical"]')?.href || direct(side, route);
982
+ const siteName = doc.querySelector('meta[property="og:site_name"]')?.content?.trim()
983
+ || config.brand
984
+ || new URL(direct(side, route)).hostname;
985
+ const icon = doc.querySelector('link[rel="icon"][type="image/svg+xml"]')
986
+ || doc.querySelector('link[rel="icon"]');
987
+ const faviconSrc = icon?.href || ('/__' + side + '/favicon.ico');
988
+ let canonicalPath = canonical;
989
+ try { canonicalPath = new URL(canonical).pathname; } catch {}
990
+ meta[side] = { title, description, canonicalPath, heading };
991
+ label.querySelector('.page-heading').textContent = heading;
992
+ label.querySelector('.page-heading').title = title || heading;
993
+ updateDocTitle();
994
+ document.querySelector('[data-compact-title="' + side + '"]').textContent = heading;
995
+ label.querySelector('.origin').textContent = config[side] + route;
996
+ const fav = label.querySelector('.favicon');
997
+ fav.onerror = () => { fav.onerror = null; fav.src = '/icon.svg'; };
998
+ fav.src = faviconSrc;
999
+ label.querySelector('.open-side').href = direct(side, route);
1000
+ label.querySelector('.seo-card').innerHTML =
1001
+ '<div class="seo-eyebrow">' + side.toUpperCase() + ' metadata preview</div>' +
1002
+ '<div class="seo-source">' +
1003
+ '<img class="seo-favicon" alt="" src="' + escapeHtml(faviconSrc) + '">' +
1004
+ '<div><div class="seo-site">' + escapeHtml(siteName) + '</div>' +
1005
+ '<div class="seo-url" data-seo="url">' + escapeHtml(crumb(canonical)) + '</div></div>' +
1006
+ '<div class="seo-menu" aria-hidden="true">⋮</div>' +
1007
+ '</div>' +
1008
+ '<div class="seo-title' + (title ? '' : ' seo-empty') + '" data-seo="title">' +
1009
+ escapeHtml(truncate(title || 'Missing page title', 62)) + '</div>' +
1010
+ '<div class="seo-description' + (description ? '' : ' seo-empty') + '" data-seo="desc">' +
1011
+ escapeHtml(truncate(description || 'Missing meta description', 158)) + '</div>' +
1012
+ seoChecksHtml(doc);
1013
+ const seoFav = label.querySelector('.seo-favicon');
1014
+ if (seoFav) seoFav.onerror = () => { seoFav.onerror = null; seoFav.src = '/icon.svg'; };
1015
+ const fails = seoChecks(doc).filter((check) => !check.ok).length;
1016
+ const flag = label.querySelector('.seo-flag');
1017
+ if (flag) {
1018
+ flag.hidden = fails === 0;
1019
+ flag.textContent = fails ? String(fails) : '';
1020
+ flag.title = fails ? fails + ' SEO check' + (fails === 1 ? '' : 's') + ' failing' : '';
1021
+ }
1022
+ renderMetaDiff();
1023
+ }
1024
+
1025
+ function seoChecksHtml(doc) {
1026
+ const checks = seoChecks(doc);
1027
+ const fails = checks.filter((check) => !check.ok).length;
1028
+ const head = '<div class="seo-checks-head"><span>SEO checks</span>'
1029
+ + (fails
1030
+ ? '<span class="bad">' + fails + ' to fix</span>'
1031
+ : '<span class="good">all good</span>')
1032
+ + '</div>';
1033
+ const rows = checks.map((check) =>
1034
+ '<div class="seo-check ' + (check.ok ? 'ok' : 'bad') + '">'
1035
+ + '<span class="seo-check-mark">' + (check.ok ? '✓' : '✗') + '</span>'
1036
+ + '<span class="seo-check-label">' + escapeHtml(check.label) + '</span>'
1037
+ + (check.note ? '<span class="seo-check-note">' + escapeHtml(check.note) + '</span>' : '')
1038
+ + '</div>').join('');
1039
+ return '<div class="seo-checks">' + head + rows + '</div>';
1040
+ }
1041
+
1042
+ function positionSeoCard(details) {
1043
+ const summary = details.querySelector('summary');
1044
+ const card = details.querySelector('.seo-card');
1045
+ const rect = summary.getBoundingClientRect();
1046
+ // Cap to half the viewport so the two cards can't collide, and anchor each
1047
+ // card's right edge under its SEO button so it drops within its own pane.
1048
+ const width = Math.max(260, Math.min(420, (innerWidth - 32) / 2));
1049
+ card.style.width = width + 'px';
1050
+ const left = Math.max(8, Math.min(rect.right - width, innerWidth - width - 8));
1051
+ card.style.left = left + 'px';
1052
+ card.style.top = Math.min(innerHeight - 120, rect.bottom + 8) + 'px';
1053
+ }
1054
+
1055
+ function googleOpen() {
1056
+ return !!document.querySelector('.label details[open]');
1057
+ }
1058
+
1059
+ function setGoogleOpen(open) {
1060
+ const all = document.querySelectorAll('.label details');
1061
+ for (const details of all) {
1062
+ if (open) details.setAttribute('open', '');
1063
+ else details.removeAttribute('open');
1064
+ }
1065
+ if (open) {
1066
+ requestAnimationFrame(() => {
1067
+ for (const details of document.querySelectorAll('.label details[open]')) positionSeoCard(details);
1068
+ });
1069
+ }
1070
+ }
1071
+
1072
+ function updateLabels(route) {
1073
+ for (const side of ['dev', 'live']) {
1074
+ const label = document.querySelector('.label[data-label="' + side + '"]');
1075
+ label.querySelector('.pill').className = 'pill ' + side;
1076
+ label.querySelector('.pill').textContent = side.toUpperCase();
1077
+ label.querySelector('.page-heading').textContent = 'Loading…';
1078
+ document.querySelector('[data-compact-title="' + side + '"]').textContent = 'Loading…';
1079
+ label.querySelector('.origin').textContent = config[side] + route;
1080
+ label.querySelector('.favicon').src = '/__' + side + '/favicon.ico';
1081
+ label.querySelector('.open-side').href = direct(side, route);
1082
+ meta[side] = null;
1083
+ clearStatusBadge(side);
1084
+ }
1085
+ renderMetaDiff();
1086
+ }
1087
+
1088
+ function applyOrder() {
1089
+ order.forEach((side, index) => {
1090
+ const pane = document.querySelector('[data-pane="' + side + '"]');
1091
+ pane.style.order = String(index);
1092
+ pane.classList.toggle('overlay-top', index === 1);
1093
+ document.querySelector('.label[data-label="' + side + '"]').style.order = String(index);
1094
+ });
1095
+ }
1096
+
1097
+ function go(value = routeInput.value) {
1098
+ const route = normalizeRoute(value);
1099
+ routeInput.value = route;
1100
+ updateLabels(route);
1101
+ frame('dev').src = proxied('dev', route);
1102
+ frame('live').src = proxied('live', route);
1103
+ const url = new URL(location.href);
1104
+ url.searchParams.set('path', route);
1105
+ history.replaceState(null, '', url);
1106
+ }
1107
+
1108
+ function setSplit(percent) {
1109
+ const value = Math.max(15, Math.min(85, percent));
1110
+ root.style.setProperty('--split', value + '%');
1111
+ divider.setAttribute('aria-valuenow', String(Math.round(value)));
1112
+ localStorage.setItem('site-compare-split', String(value));
1113
+ setUrlParam('split', Math.round(value * 10) / 10);
1114
+ }
1115
+
1116
+ // Overlay and diff are only legible if both panes scroll in lockstep, so
1117
+ // they force pixel-exact linked scrolling regardless of the user's toggle.
1118
+ function stacked() { return viewMode === 'overlay'; }
1119
+ function linked() { return syncScroll || stacked(); }
1120
+ function effScrollMode() { return stacked() ? 'exact' : scrollMode; }
1121
+
1122
+ function scrollRoot(win) {
1123
+ return win.document.scrollingElement || win.document.documentElement;
1124
+ }
1125
+
1126
+ function applyScrollPresentation(doc) {
1127
+ let style = doc.getElementById('site-compare-scroll-style');
1128
+ if (!style) {
1129
+ style = doc.createElement('style');
1130
+ style.id = 'site-compare-scroll-style';
1131
+ doc.head.append(style);
1132
+ }
1133
+ style.textContent = linked()
1134
+ ? 'html,body{scrollbar-width:none!important;-ms-overflow-style:none!important}html::-webkit-scrollbar,body::-webkit-scrollbar{display:none!important;width:0!important;height:0!important}'
1135
+ : '';
1136
+ }
1137
+
1138
+ function setLinkedScroll(sourceSide, requestedY) {
1139
+ const source = frame(sourceSide).contentWindow;
1140
+ const otherSide = sourceSide === 'dev' ? 'live' : 'dev';
1141
+ const other = frame(otherSide).contentWindow;
1142
+ if (!source || !other) return;
1143
+ const sourceRoot = scrollRoot(source);
1144
+ const sourceMax = Math.max(0, sourceRoot.scrollHeight - source.innerHeight);
1145
+ const sourceY = Math.max(0, Math.min(sourceMax, requestedY));
1146
+ suppressScrollUntil[sourceSide] = Date.now() + 120;
1147
+ sourceRoot.scrollTop = sourceY;
1148
+ if (effScrollMode() === 'exact') {
1149
+ const otherRoot = scrollRoot(other);
1150
+ const sharedMax = Math.min(sourceMax, Math.max(0, otherRoot.scrollHeight - other.innerHeight));
1151
+ const sharedY = Math.min(sharedMax, sourceY);
1152
+ suppressScrollUntil[otherSide] = Date.now() + 120;
1153
+ sourceRoot.scrollTop = sharedY;
1154
+ otherRoot.scrollTop = sharedY;
1155
+ } else {
1156
+ alignSide(sourceSide, otherSide);
1157
+ }
1158
+ }
1159
+
1160
+ function wheelPixels(event, win) {
1161
+ if (event.deltaMode === 1) return event.deltaY * 18;
1162
+ if (event.deltaMode === 2) return event.deltaY * win.innerHeight;
1163
+ return event.deltaY;
1164
+ }
1165
+
1166
+ function alignSide(sourceSide, targetSide) {
1167
+ const source = frame(sourceSide).contentWindow;
1168
+ const target = frame(targetSide).contentWindow;
1169
+ if (!source || !target) return;
1170
+ let targetY = source.scrollY;
1171
+ if (effScrollMode() === 'ratio') {
1172
+ const sourceRoot = scrollRoot(source);
1173
+ const sourceMax = Math.max(0, sourceRoot.scrollHeight - source.innerHeight);
1174
+ const ratio = sourceMax ? source.scrollY / sourceMax : 0;
1175
+ const targetRoot = scrollRoot(target);
1176
+ const targetMax = Math.max(0, targetRoot.scrollHeight - target.innerHeight);
1177
+ targetY = ratio * targetMax;
1178
+ }
1179
+ suppressScrollUntil[targetSide] = Date.now() + (effScrollMode() === 'exact' ? 120 : 600);
1180
+ scrollRoot(target).scrollTop = targetY;
1181
+ }
1182
+
1183
+ function syncFrom(side, force = false) {
1184
+ const win = frame(side).contentWindow;
1185
+ if (!win || !linked() || Date.now() < suppressScrollUntil[side]) return;
1186
+ if (!scrollOwner) scrollOwner = side;
1187
+ if (!force && scrollOwner !== side) return;
1188
+ if (effScrollMode() === 'exact') {
1189
+ alignSide(side, side === 'dev' ? 'live' : 'dev');
1190
+ return;
1191
+ }
1192
+ cancelAnimationFrame(scrollFrames[side]);
1193
+ scrollFrames[side] = requestAnimationFrame(() => {
1194
+ const otherSide = side === 'dev' ? 'live' : 'dev';
1195
+ alignSide(side, otherSide);
1196
+ for (const timer of settleTimers[side]) clearTimeout(timer);
1197
+ settleTimers[side] = [80, 240].map((delay) => setTimeout(() => {
1198
+ if (scrollOwner === side) alignSide(side, otherSide);
1199
+ }, delay));
1200
+ });
1201
+ }
1202
+
1203
+ function markScrollOwner(side) {
1204
+ scrollOwner = side;
1205
+ }
1206
+
1207
+ function routeFromFrameUrl(side, href) {
1208
+ const url = new URL(href);
1209
+ const prefix = '/__' + side;
1210
+ const pathname = url.pathname.startsWith(prefix) ? url.pathname.slice(prefix.length) || '/' : url.pathname;
1211
+ return pathname + url.search + url.hash;
1212
+ }
1213
+
1214
+ function attachFrameBehavior(side) {
1215
+ const iframe = frame(side);
1216
+ const win = iframe.contentWindow;
1217
+ const doc = iframe.contentDocument;
1218
+ if (!win || !doc || attachedDocuments.has(doc)) return;
1219
+ attachedDocuments.add(doc);
1220
+ doc.documentElement.style.setProperty('scroll-behavior', 'auto', 'important');
1221
+ doc.body?.style.setProperty('scroll-behavior', 'auto', 'important');
1222
+ applyScrollPresentation(doc);
1223
+ doc.addEventListener('wheel', (event) => {
1224
+ if (!linked() || event.deltaY === 0) return;
1225
+ event.preventDefault();
1226
+ markScrollOwner(side);
1227
+ setLinkedScroll(side, win.scrollY + wheelPixels(event, win));
1228
+ }, { passive: false, capture: true });
1229
+ doc.addEventListener('keydown', (event) => {
1230
+ const typing = /^(INPUT|TEXTAREA|SELECT)$/.test(event.target.tagName)
1231
+ || event.target.isContentEditable;
1232
+ if (!typing && !event.metaKey && !event.ctrlKey && !event.altKey) {
1233
+ const key = event.key.toLowerCase();
1234
+ if (key === 'r' || key === 's' || key === '0' || key === '/' || key === 'o' || key === 'd') {
1235
+ event.preventDefault();
1236
+ if (key === 'r') document.querySelector('[data-action="reload"]').click();
1237
+ if (key === 's') document.querySelector('[data-action="swap"]').click();
1238
+ if (key === 'o') setMode(viewMode === 'overlay' ? 'split' : 'overlay');
1239
+ if (key === 'd') {
1240
+ if (viewMode === 'overlay' && overlayBlend === 'difference') setMode('split');
1241
+ else { setMode('overlay'); setOverlayBlend('difference'); }
1242
+ }
1243
+ if (key === '0') setSplit(50);
1244
+ if (key === '/') {
1245
+ routeInput.focus();
1246
+ routeInput.select();
1247
+ }
1248
+ return;
1249
+ }
1250
+ }
1251
+ if (!linked() || event.metaKey || event.ctrlKey || event.altKey
1252
+ || typing) return;
1253
+ let next = null;
1254
+ if (event.key === 'ArrowDown') next = win.scrollY + 44;
1255
+ if (event.key === 'ArrowUp') next = win.scrollY - 44;
1256
+ if (event.key === 'PageDown' || (event.key === ' ' && !event.shiftKey)) next = win.scrollY + win.innerHeight * .85;
1257
+ if (event.key === 'PageUp' || (event.key === ' ' && event.shiftKey)) next = win.scrollY - win.innerHeight * .85;
1258
+ if (event.key === 'Home') next = 0;
1259
+ if (event.key === 'End') next = scrollRoot(win).scrollHeight;
1260
+ if (next === null) return;
1261
+ event.preventDefault();
1262
+ markScrollOwner(side);
1263
+ setLinkedScroll(side, next);
1264
+ }, true);
1265
+ win.addEventListener('scroll', () => syncFrom(side), { passive: true });
1266
+ for (const eventName of ['wheel', 'touchstart', 'pointerdown', 'keydown']) {
1267
+ doc.addEventListener(eventName, () => markScrollOwner(side), { passive: true, capture: true });
1268
+ }
1269
+ doc.addEventListener('click', (event) => {
1270
+ if (!mirrorLinks || event.defaultPrevented || event.button !== 0
1271
+ || event.metaKey || event.ctrlKey || event.shiftKey || event.altKey) return;
1272
+ const link = event.target.closest('a[href]');
1273
+ if (!link || link.target === '_blank' || link.hasAttribute('download')) return;
1274
+ const url = new URL(link.href, win.location.href);
1275
+ if (url.origin !== location.origin || !url.pathname.startsWith('/__' + side)) return;
1276
+ event.preventDefault();
1277
+ go(routeFromFrameUrl(side, url.href));
1278
+ }, true);
1279
+ // Clicking into a pane counts as "clicking out" of the chrome popovers.
1280
+ doc.addEventListener('pointerdown', () => {
1281
+ for (const open of document.querySelectorAll('details.settings[open], details.help[open]')) open.removeAttribute('open');
1282
+ if (googleOpen()) setGoogleOpen(false);
1283
+ if (notesOpen && !dockMode) setNotesOpen(false);
1284
+ }, { passive: true, capture: true });
1285
+ }
1286
+
1287
+ for (const side of ['dev', 'live']) {
1288
+ frame(side).addEventListener('load', () => {
1289
+ try {
1290
+ attachFrameBehavior(side);
1291
+ renderMetadata(side);
1292
+ fetchStatus(side, routeFromFrameUrl(side, frame(side).contentWindow.location.href));
1293
+ } catch {}
1294
+ });
1295
+ }
1296
+
1297
+ scrollButton.addEventListener('click', () => {
1298
+ syncScroll = !syncScroll;
1299
+ scrollButton.classList.toggle('active', syncScroll);
1300
+ saveBool('scroll', 'site-compare-scroll', syncScroll);
1301
+ for (const side of ['dev', 'live']) {
1302
+ const doc = frame(side).contentDocument;
1303
+ if (doc) applyScrollPresentation(doc);
1304
+ }
1305
+ renderSettings();
1306
+ if (syncScroll) syncFrom(focusSide, true);
1307
+ });
1308
+ function renderSetting(button, active, stateText) {
1309
+ button.classList.toggle('active', active);
1310
+ button.querySelector('.state').textContent = stateText;
1311
+ button.setAttribute('aria-pressed', active ? 'true' : 'false');
1312
+ }
1313
+ function renderSettings() {
1314
+ renderSetting(mobileButton, mobileMode, mobileMode ? 'On' : 'Off');
1315
+ renderSetting(mirrorButton, mirrorLinks, mirrorLinks ? 'On' : 'Off');
1316
+ renderSetting(scrollModeButton, scrollMode === 'exact', scrollMode === 'exact' ? 'Exact' : 'Proportional');
1317
+ scrollButton.title = syncScroll ? 'Locked scrolling is on' : 'Locked scrolling is off';
1318
+ scrollButton.setAttribute('aria-pressed', syncScroll ? 'true' : 'false');
1319
+ }
1320
+ function renderScrollMode() {
1321
+ document.querySelector('[data-scroll-label]').textContent =
1322
+ scrollMode === 'exact' ? 'Locked scroll' : 'Ratio scroll';
1323
+ renderSettings();
1324
+ }
1325
+ scrollModeButton.addEventListener('click', () => {
1326
+ scrollMode = scrollMode === 'exact' ? 'ratio' : 'exact';
1327
+ localStorage.setItem('site-compare-scroll-mode', scrollMode);
1328
+ setUrlParam('scrollMode', scrollMode);
1329
+ renderScrollMode();
1330
+ for (const side of ['dev', 'live']) {
1331
+ const doc = frame(side).contentDocument;
1332
+ if (doc) applyScrollPresentation(doc);
1333
+ }
1334
+ if (syncScroll) syncFrom(focusSide, true);
1335
+ });
1336
+ mirrorButton.addEventListener('click', () => {
1337
+ mirrorLinks = !mirrorLinks;
1338
+ saveBool('mirror', 'site-compare-mirror', mirrorLinks);
1339
+ renderSettings();
1340
+ });
1341
+ mobileButton.addEventListener('click', () => {
1342
+ mobileMode = !mobileMode;
1343
+ app.classList.toggle('mobile', mobileMode);
1344
+ localStorage.setItem('site-compare-mode', mobileMode ? 'mobile' : 'desktop');
1345
+ setUrlParam('mode', mobileMode ? 'mobile' : 'desktop');
1346
+ renderSettings();
1347
+ });
1348
+ function setOverlayAmount(value) {
1349
+ overlayAmount = Math.max(0, Math.min(100, Math.round(value)));
1350
+ root.style.setProperty('--overlay', (overlayAmount / 100).toFixed(3));
1351
+ for (const slider of overlaySliders) slider.value = String(overlayAmount);
1352
+ localStorage.setItem('site-compare-overlay-amount', String(overlayAmount));
1353
+ setUrlParam('overlayAmount', overlayAmount);
1354
+ }
1355
+ function renderModes() {
1356
+ for (const button of modeButtons) {
1357
+ const active = button.dataset.mode === viewMode;
1358
+ button.classList.toggle('active', active);
1359
+ button.setAttribute('aria-pressed', active ? 'true' : 'false');
1360
+ }
1361
+ const diffActive = viewMode === 'overlay' && overlayBlend === 'difference';
1362
+ for (const button of blendButtons) {
1363
+ button.classList.toggle('active', diffActive);
1364
+ button.setAttribute('aria-pressed', diffActive ? 'true' : 'false');
1365
+ }
1366
+ }
1367
+ // Split / Solo / Overlay are the mutually-exclusive layouts; Diff is the
1368
+ // overlay's blend (the slider's far end), toggled within Overlay.
1369
+ function setMode(mode) {
1370
+ if (!['split', 'solo', 'overlay'].includes(mode)) mode = 'split';
1371
+ viewMode = mode;
1372
+ app.classList.toggle('solo', mode === 'solo');
1373
+ app.classList.toggle('overlay', mode === 'overlay');
1374
+ app.classList.toggle('diff', mode === 'overlay' && overlayBlend === 'difference');
1375
+ app.dataset.focus = focusSide;
1376
+ localStorage.setItem('site-compare-view', mode);
1377
+ setUrlParam('view', mode === 'split' ? null : mode);
1378
+ renderModes();
1379
+ applyOrder();
1380
+ // Overlay forces scroll-lock, so refresh scrollbar hiding + re-align.
1381
+ for (const side of ['dev', 'live']) {
1382
+ const doc = frame(side).contentDocument;
1383
+ if (doc) applyScrollPresentation(doc);
1384
+ }
1385
+ if (stacked()) alignSide(order[1], order[0]);
1386
+ }
1387
+ function setOverlayBlend(blend) {
1388
+ overlayBlend = blend === 'difference' ? 'difference' : 'opacity';
1389
+ app.classList.toggle('diff', viewMode === 'overlay' && overlayBlend === 'difference');
1390
+ localStorage.setItem('site-compare-overlay-blend', overlayBlend);
1391
+ setUrlParam('overlayBlend', overlayBlend === 'difference' ? 'difference' : null);
1392
+ renderModes();
1393
+ }
1394
+ for (const button of modeButtons) button.addEventListener('click', () => setMode(button.dataset.mode));
1395
+ for (const slider of overlaySliders) slider.addEventListener('input', () => {
1396
+ if (viewMode !== 'overlay') setMode('overlay');
1397
+ if (overlayBlend === 'difference') setOverlayBlend('opacity');
1398
+ setOverlayAmount(Number(slider.value));
1399
+ });
1400
+ for (const button of blendButtons) button.addEventListener('click', () => {
1401
+ if (viewMode !== 'overlay') setMode('overlay');
1402
+ setOverlayBlend(overlayBlend === 'difference' ? 'opacity' : 'difference');
1403
+ });
1404
+
1405
+ function setCompact(value) {
1406
+ compactMode = value;
1407
+ app.classList.toggle('compact', compactMode);
1408
+ saveBool('compact', 'site-compare-compact', compactMode);
1409
+ }
1410
+ for (const button of document.querySelectorAll('[data-action="compact"]')) {
1411
+ button.addEventListener('click', () => setCompact(!compactMode));
1412
+ }
1413
+
1414
+ function applyNotes(notes) {
1415
+ const list = Array.isArray(notes) ? notes : [];
1416
+ const signature = JSON.stringify(list);
1417
+ if (signature === notesSignature) return;
1418
+ notesSignature = signature;
1419
+ reviewNotes = list;
1420
+ renderNotes();
1421
+ }
1422
+
1423
+ async function notesPull() {
1424
+ try {
1425
+ const res = await fetch('/notes', { cache: 'no-store' });
1426
+ const data = await res.json();
1427
+ applyNotes(data.notes);
1428
+ } catch {}
1429
+ }
1430
+
1431
+ async function notesPost(op) {
1432
+ try {
1433
+ const res = await fetch('/notes', {
1434
+ method: 'POST',
1435
+ headers: { 'content-type': 'application/json' },
1436
+ body: JSON.stringify(op),
1437
+ });
1438
+ const data = await res.json();
1439
+ applyNotes(data.notes);
1440
+ } catch {}
1441
+ }
1442
+
1443
+ function authorClass(name) {
1444
+ const who = String(name || '').toLowerCase();
1445
+ return who === 'joe' ? 'joe' : who === 'claude' ? 'claude' : 'other';
1446
+ }
1447
+
1448
+ function renderNotes() {
1449
+ noteList.replaceChildren();
1450
+ for (const note of reviewNotes) {
1451
+ const item = document.createElement('li');
1452
+ if (note.done) item.classList.add('done');
1453
+
1454
+ const metaRow = document.createElement('div');
1455
+ metaRow.className = 'note-meta';
1456
+ const who = document.createElement('span');
1457
+ who.className = 'note-author ' + authorClass(note.author);
1458
+ who.textContent = note.author || 'note';
1459
+ metaRow.append(who);
1460
+ const where = [note.side ? note.side.toUpperCase() : '', note.route && note.route !== '/' ? note.route : '']
1461
+ .filter(Boolean).join(' · ');
1462
+ if (where) {
1463
+ const tag = document.createElement('span');
1464
+ tag.className = 'note-where';
1465
+ tag.textContent = where;
1466
+ metaRow.append(tag);
1467
+ }
1468
+ item.append(metaRow);
1469
+
1470
+ const text = document.createElement('div');
1471
+ text.className = 'note-text';
1472
+ text.textContent = note.text;
1473
+ if (note.route) {
1474
+ text.classList.add('note-go');
1475
+ text.title = 'Go to ' + note.route + (note.side ? ' · ' + note.side.toUpperCase() : '');
1476
+ text.addEventListener('click', () => {
1477
+ if (note.side) { focusSide = note.side; app.dataset.focus = focusSide; renderModes(); }
1478
+ go(note.route);
1479
+ });
1480
+ }
1481
+ item.append(text);
1482
+
1483
+ const toggle = document.createElement('button');
1484
+ toggle.className = 'note-toggle';
1485
+ toggle.textContent = note.done ? '↺' : '✓';
1486
+ toggle.title = note.done ? 'Reopen note' : 'Mark done';
1487
+ toggle.setAttribute('aria-label', toggle.title);
1488
+ toggle.addEventListener('click', () => notesPost({ op: 'toggle', id: note.id }));
1489
+ item.append(toggle);
1490
+
1491
+ const copy = document.createElement('button');
1492
+ copy.className = 'note-copy';
1493
+ copy.title = 'Copy a link to this note';
1494
+ copy.setAttribute('aria-label', 'Copy link to this note');
1495
+ copy.innerHTML = '<svg viewBox="0 0 20 20" aria-hidden="true"><path d="M8 8V5.5A1.5 1.5 0 0 1 9.5 4h5A1.5 1.5 0 0 1 16 5.5v5A1.5 1.5 0 0 1 14.5 12H12" fill="none" stroke="currentColor" stroke-width="1.5"/><rect x="4" y="8" width="8" height="8" rx="1.5" fill="none" stroke="currentColor" stroke-width="1.5"/></svg>';
1496
+ copy.addEventListener('click', async () => {
1497
+ const url = new URL(location.href);
1498
+ url.searchParams.set('path', note.route || '/');
1499
+ await navigator.clipboard.writeText(url.href);
1500
+ showToast('Note link copied');
1501
+ });
1502
+ item.append(copy);
1503
+
1504
+ const remove = document.createElement('button');
1505
+ remove.className = 'remove-note';
1506
+ remove.textContent = '×';
1507
+ remove.setAttribute('aria-label', 'Remove note');
1508
+ remove.addEventListener('click', () => notesPost({ op: 'remove', id: note.id }));
1509
+ item.append(remove);
1510
+
1511
+ noteList.append(item);
1512
+ }
1513
+ const open = reviewNotes.filter((note) => !note.done).length;
1514
+ for (const count of document.querySelectorAll('[data-action="notes"] .count')) {
1515
+ count.textContent = String(open);
1516
+ count.style.display = open ? '' : 'none';
1517
+ }
1518
+ }
1519
+
1520
+ const dockButton = document.querySelector('[data-action="notes-dock"]');
1521
+ function applyDock() {
1522
+ // Dock pushes the panes aside; float overlays them.
1523
+ app.classList.toggle('drawer-dock', notesOpen && dockMode);
1524
+ dockButton.classList.toggle('active', dockMode);
1525
+ dockButton.setAttribute('aria-pressed', dockMode ? 'true' : 'false');
1526
+ }
1527
+ function setNotesOpen(value) {
1528
+ notesOpen = value;
1529
+ notesDrawer.classList.toggle('open', notesOpen);
1530
+ setUrlParam('notes', notesOpen ? '1' : '0');
1531
+ applyDock();
1532
+ if (notesOpen) noteInput.focus();
1533
+ }
1534
+ dockButton.addEventListener('click', () => {
1535
+ dockMode = !dockMode;
1536
+ saveBool('dock', 'site-compare-dock', dockMode);
1537
+ applyDock();
1538
+ });
1539
+ for (const button of document.querySelectorAll('[data-action="notes"]')) {
1540
+ button.addEventListener('click', () => setNotesOpen(!notesOpen));
1541
+ }
1542
+ document.querySelector('[data-action="notes-close"]').addEventListener('click', () => setNotesOpen(false));
1543
+ addEventListener('keydown', (event) => {
1544
+ if (event.key !== 'Escape') return;
1545
+ let handled = false;
1546
+ for (const details of document.querySelectorAll('details[open]')) {
1547
+ details.removeAttribute('open');
1548
+ handled = true;
1549
+ }
1550
+ if (notesOpen) {
1551
+ setNotesOpen(false);
1552
+ handled = true;
1553
+ }
1554
+ if (handled && (event.target === noteInput || event.target === routeInput)) event.target.blur();
1555
+ });
1556
+ // Auto-grow the compose box to its content (scroll past a cap), with a
1557
+ // floor the user can raise by dragging the top grip.
1558
+ const NOTE_MIN = 76;
1559
+ let noteFloor = NOTE_MIN;
1560
+ function autosizeNote() {
1561
+ const hardMax = Math.round(innerHeight * 0.6);
1562
+ noteInput.style.height = 'auto';
1563
+ const needed = noteInput.scrollHeight;
1564
+ const height = Math.min(hardMax, Math.max(NOTE_MIN, noteFloor, needed));
1565
+ noteInput.style.height = height + 'px';
1566
+ noteInput.style.overflowY = needed > height ? 'auto' : 'hidden';
1567
+ }
1568
+ noteInput.addEventListener('input', autosizeNote);
1569
+ const noteGrip = document.querySelector('.note-grip');
1570
+ noteGrip.addEventListener('pointerdown', (event) => {
1571
+ noteGrip.setPointerCapture(event.pointerId);
1572
+ const startY = event.clientY;
1573
+ const startHeight = noteInput.offsetHeight;
1574
+ const onMove = (move) => {
1575
+ noteFloor = Math.max(NOTE_MIN, Math.min(Math.round(innerHeight * 0.6), startHeight + (startY - move.clientY)));
1576
+ autosizeNote();
1577
+ };
1578
+ const onUp = (up) => {
1579
+ noteGrip.releasePointerCapture(up.pointerId);
1580
+ noteGrip.removeEventListener('pointermove', onMove);
1581
+ noteGrip.removeEventListener('pointerup', onUp);
1582
+ };
1583
+ noteGrip.addEventListener('pointermove', onMove);
1584
+ noteGrip.addEventListener('pointerup', onUp);
1585
+ });
1586
+ document.querySelector('[data-action="note-add"]').addEventListener('click', () => {
1587
+ const text = noteInput.value.trim();
1588
+ if (!text) return;
1589
+ noteInput.value = '';
1590
+ autosizeNote();
1591
+ const side = viewMode === 'solo' ? focusSide : null;
1592
+ notesPost({ op: 'add', text, author: config.author || 'joe', route: routeInput.value, side });
1593
+ });
1594
+ noteInput.addEventListener('keydown', (event) => {
1595
+ if ((event.metaKey || event.ctrlKey) && event.key === 'Enter') {
1596
+ document.querySelector('[data-action="note-add"]').click();
1597
+ }
1598
+ });
1599
+ document.querySelector('[data-action="note-export"]').addEventListener('click', () => {
1600
+ const link = document.createElement('a');
1601
+ link.href = '/notes.md';
1602
+ link.download = 'site-compare-notes.md';
1603
+ link.click();
1604
+ showToast('Exported notes .md');
1605
+ });
1606
+ const vaultButton = document.querySelector('[data-action="note-vault"]');
1607
+ if (config.vault) vaultButton.hidden = false;
1608
+ vaultButton.addEventListener('click', async () => {
1609
+ try {
1610
+ const res = await fetch('/notes/save', { method: 'POST', headers: { 'content-type': 'application/json' }, body: '{}' });
1611
+ const data = await res.json();
1612
+ showToast(data.ok ? 'Saved to vault' : (data.error || 'Vault save failed'));
1613
+ } catch {
1614
+ showToast('Vault save failed');
1615
+ }
1616
+ });
1617
+
1618
+ divider.addEventListener('pointerdown', (event) => {
1619
+ divider.setPointerCapture(event.pointerId);
1620
+ app.classList.add('dragging');
1621
+ divider.dataset.pointerDrag = '1';
1622
+ });
1623
+ divider.addEventListener('pointermove', (event) => {
1624
+ if (!divider.hasPointerCapture(event.pointerId)) return;
1625
+ setSplit(event.clientX / innerWidth * 100);
1626
+ });
1627
+ divider.addEventListener('pointerup', (event) => {
1628
+ divider.releasePointerCapture(event.pointerId);
1629
+ app.classList.remove('dragging');
1630
+ divider.blur();
1631
+ delete divider.dataset.pointerDrag;
1632
+ });
1633
+ divider.addEventListener('keydown', (event) => {
1634
+ const current = parseFloat(getComputedStyle(root).getPropertyValue('--split'));
1635
+ if (event.key === 'ArrowLeft') setSplit(current - (event.shiftKey ? 10 : 2));
1636
+ if (event.key === 'ArrowRight') setSplit(current + (event.shiftKey ? 10 : 2));
1637
+ });
1638
+
1639
+ document.querySelector('[data-action="go"]').addEventListener('click', () => go());
1640
+ for (const button of document.querySelectorAll('[data-action="reload"]')) {
1641
+ button.addEventListener('click', () => {
1642
+ for (const side of ['dev', 'live']) frame(side).contentWindow.location.reload();
1643
+ });
1644
+ }
1645
+ document.querySelector('[data-action="swap"]').addEventListener('click', () => {
1646
+ if (viewMode === 'solo') {
1647
+ const nextSide = focusSide === 'dev' ? 'live' : 'dev';
1648
+ if (syncScroll) alignSide(focusSide, nextSide);
1649
+ focusSide = nextSide;
1650
+ app.dataset.focus = focusSide;
1651
+ setUrlParam('focus', focusSide);
1652
+ renderSettings();
1653
+ } else {
1654
+ order.reverse();
1655
+ applyOrder();
1656
+ updateDocTitle();
1657
+ setUrlParam('swap', order[0] === 'live' ? '1' : '0');
1658
+ }
1659
+ });
1660
+ // Opening one Google preview opens both, anchored under their buttons.
1661
+ for (const summary of document.querySelectorAll('.label details > summary')) {
1662
+ summary.addEventListener('click', (event) => {
1663
+ event.preventDefault();
1664
+ setGoogleOpen(!googleOpen());
1665
+ });
1666
+ }
1667
+ document.addEventListener('click', (event) => {
1668
+ for (const details of document.querySelectorAll('details.settings[open], details.help[open]')) {
1669
+ if (!details.contains(event.target)) details.removeAttribute('open');
1670
+ }
1671
+ if (googleOpen() && !event.target.closest('.label')) setGoogleOpen(false);
1672
+ if (notesOpen && !dockMode && !event.target.closest('.review-drawer') && !event.target.closest('[data-action="notes"]')) {
1673
+ setNotesOpen(false);
1674
+ }
1675
+ });
1676
+ document.addEventListener('pointerup', (event) => {
1677
+ if (event.target.closest('input, textarea')) return;
1678
+ const control = event.target.closest('button, summary');
1679
+ if (control && control !== document.activeElement) return;
1680
+ control?.blur();
1681
+ getSelection()?.removeAllRanges();
1682
+ });
1683
+ addEventListener('resize', () => {
1684
+ for (const details of document.querySelectorAll('.label details[open]')) positionSeoCard(details);
1685
+ });
1686
+ routeInput.addEventListener('keydown', (event) => {
1687
+ if (event.key === 'Enter') go();
1688
+ });
1689
+ addEventListener('keydown', (event) => {
1690
+ if (event.target === routeInput || event.target === noteInput) return;
1691
+ if (event.metaKey || event.ctrlKey || event.altKey) return;
1692
+ if (event.key === 'r') document.querySelector('[data-action="reload"]').click();
1693
+ if (event.key === 's') document.querySelector('[data-action="swap"]').click();
1694
+ if (event.key === 'o') setMode(viewMode === 'overlay' ? 'split' : 'overlay');
1695
+ if (event.key === 'd') {
1696
+ if (viewMode === 'overlay' && overlayBlend === 'difference') setMode('split');
1697
+ else { setMode('overlay'); setOverlayBlend('difference'); }
1698
+ }
1699
+ if (event.key === '0') setSplit(50);
1700
+ if (event.key === '/') { event.preventDefault(); routeInput.focus(); routeInput.select(); }
1701
+ });
1702
+
1703
+ const initialSplit = Number(params.get('split') || localStorage.getItem('site-compare-split')) || 50;
1704
+ scrollButton.classList.toggle('active', syncScroll);
1705
+ renderScrollMode();
1706
+ app.classList.toggle('mobile', mobileMode);
1707
+ app.classList.toggle('compact', compactMode);
1708
+ app.dataset.focus = focusSide;
1709
+ setOverlayAmount(overlayAmount);
1710
+ renderSettings();
1711
+ notesDrawer.classList.toggle('open', notesOpen);
1712
+ applyDock();
1713
+ renderNotes();
1714
+ autosizeNote();
1715
+ setSplit(initialSplit);
1716
+ setMode(viewMode);
1717
+ go(params.get('path') || '/');
1718
+ notesPull();
1719
+ setInterval(notesPull, 4000);
1720
+ </script>
1721
+ </body>
1722
+ </html>`;
1723
+ }
1724
+
1725
+ const handler = async (req, res) => {
1726
+ const requestUrl = new URL(req.url || '/', `http://${host}:${port}`);
1727
+ if (requestUrl.pathname === '/health') {
1728
+ send(res, 200, JSON.stringify({
1729
+ dev: devBase.href.replace(/\/$/, ''),
1730
+ live: liveBase.href.replace(/\/$/, ''),
1731
+ version: viewerVersion,
1732
+ }), 'application/json; charset=utf-8');
1733
+ } else if (requestUrl.pathname === '/notes') {
1734
+ if (req.method === 'GET') {
1735
+ send(res, 200, JSON.stringify({ notes: loadNotes() }), 'application/json; charset=utf-8');
1736
+ } else if (req.method === 'POST') {
1737
+ // Require a JSON content-type so cross-origin writes need a preflight the
1738
+ // server (no CORS headers) will fail — closes the text/plain CSRF path.
1739
+ if (!(req.headers['content-type'] || '').includes('application/json')) {
1740
+ send(res, 415, 'notes require Content-Type: application/json');
1741
+ } else {
1742
+ let op = {};
1743
+ try {
1744
+ op = JSON.parse((await readBody(req)) || '{}');
1745
+ } catch {}
1746
+ send(res, 200, JSON.stringify({ notes: applyNoteOp(op) }), 'application/json; charset=utf-8');
1747
+ }
1748
+ } else {
1749
+ send(res, 405, 'method not allowed');
1750
+ }
1751
+ } else if (requestUrl.pathname === '/notes.md') {
1752
+ send(res, 200, notesMarkdown(loadNotes()), 'text/markdown; charset=utf-8');
1753
+ } else if (requestUrl.pathname === '/notes/save') {
1754
+ if (req.method !== 'POST') {
1755
+ send(res, 405, 'method not allowed');
1756
+ } else if (!vaultDir) {
1757
+ send(res, 400, JSON.stringify({ ok: false, error: 'no vault configured' }), 'application/json; charset=utf-8');
1758
+ } else {
1759
+ try {
1760
+ const stamp = new Date().toISOString().slice(0, 16).replace(/[:T]/g, '-');
1761
+ const file = `${vaultDir}/sitedrift-review-${stamp}.md`;
1762
+ fs.writeFileSync(file, notesMarkdown(loadNotes()));
1763
+ send(res, 200, JSON.stringify({ ok: true, path: file }), 'application/json; charset=utf-8');
1764
+ } catch (error) {
1765
+ send(res, 500, JSON.stringify({ ok: false, error: error.message }), 'application/json; charset=utf-8');
1766
+ }
1767
+ }
1768
+ } else if (requestUrl.pathname === '/icon.svg') {
1769
+ if (iconSvg) {
1770
+ res.writeHead(200, { 'Content-Type': 'image/svg+xml; charset=utf-8', 'Cache-Control': 'max-age=86400' });
1771
+ res.end(iconSvg);
1772
+ } else {
1773
+ send(res, 404, 'no icon');
1774
+ }
1775
+ } else if (requestUrl.pathname.startsWith('/__dev')) {
1776
+ await proxy(req, res, 'dev', requestUrl);
1777
+ } else if (requestUrl.pathname.startsWith('/__live')) {
1778
+ await proxy(req, res, 'live', requestUrl);
1779
+ } else {
1780
+ const referer = req.headers.referer || '';
1781
+ if (referer.includes('/__dev/')) {
1782
+ requestUrl.pathname = `/__dev${requestUrl.pathname}`;
1783
+ await proxy(req, res, 'dev', requestUrl);
1784
+ } else if (referer.includes('/__live/')) {
1785
+ requestUrl.pathname = `/__live${requestUrl.pathname}`;
1786
+ await proxy(req, res, 'live', requestUrl);
1787
+ } else {
1788
+ send(res, 200, viewerHtml(), 'text/html; charset=utf-8');
1789
+ }
1790
+ }
1791
+ };
1792
+
1793
+ const server = certFile && keyFile
1794
+ ? https.createServer({
1795
+ cert: fs.readFileSync(certFile),
1796
+ key: fs.readFileSync(keyFile),
1797
+ }, handler)
1798
+ : http.createServer(handler);
1799
+
1800
+ server.listen(port, host, () => {
1801
+ const scheme = certFile && keyFile ? 'https' : 'http';
1802
+ const startUrl = `${scheme}://${host}:${port}/`
1803
+ + (initialPath ? `?path=${encodeURIComponent(initialPath)}` : '');
1804
+ console.log(`sitedrift: ${startUrl}`);
1805
+ console.log(` DEV ${devBase.href}`);
1806
+ console.log(` LIVE ${liveBase.href}`);
1807
+ if (opts.open) openBrowser(startUrl);
1808
+ });