sitedrift 0.3.6 → 0.3.7

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/assets/viewer.css CHANGED
@@ -133,7 +133,11 @@ button:focus-visible, summary:focus-visible {
133
133
  border-bottom: 1px solid var(--line);
134
134
  }
135
135
  .compact-side { min-inline-size: 0; display: flex; align-items: center; gap: 6px; }
136
+ .app.solo .compact-side { cursor: pointer; }
137
+ .compact-favicon { display: block; flex: 0 0 auto; inline-size: 16px; block-size: 16px; border-radius: 4px; }
138
+ .compact-copy { min-inline-size: 0; display: flex; align-items: baseline; gap: 7px; overflow: hidden; }
136
139
  .compact-title { min-inline-size: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-weight: 650; }
140
+ .compact-origin { min-inline-size: 0; overflow: hidden; color: var(--muted); text-overflow: ellipsis; white-space: nowrap; font-size: 10px; }
137
141
  .compact-controls { display: flex; align-items: center; gap: 8px; justify-self: center; }
138
142
 
139
143
  .caret {
@@ -323,6 +327,10 @@ button[data-action="notes"] {
323
327
  .status-err { color: #fff; background: var(--err); }
324
328
  .compact-side .status-badge { block-size: 16px; }
325
329
 
330
+ @media (width <= 600px) {
331
+ .compact-origin { display: none; }
332
+ }
333
+
326
334
  .meta-diff {
327
335
  display: none;
328
336
  align-items: center;
@@ -105,7 +105,7 @@
105
105
  <button class="caret" data-action="compact" title="Expand review chrome" aria-label="Expand review chrome">
106
106
  <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>
107
107
  </button>
108
- <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>
108
+ <div class="compact-side" data-compact-side="dev"><span class="pill dev">DEV</span><img class="compact-favicon" data-compact-favicon="dev" alt=""><span class="compact-copy"><span class="compact-title" data-compact-title="dev">Loading…</span><span class="compact-origin" data-compact-origin="dev"></span></span><span class="status-badge"></span></div>
109
109
  <div class="compact-controls">
110
110
  <div class="modes" role="group" aria-label="View mode">
111
111
  <button data-mode="split">Split</button>
@@ -120,7 +120,7 @@
120
120
  <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>
121
121
  </button>
122
122
  </div>
123
- <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>
123
+ <div class="compact-side" data-compact-side="live"><span class="pill live">LIVE</span><img class="compact-favicon" data-compact-favicon="live" alt=""><span class="compact-copy"><span class="compact-title" data-compact-title="live">Loading…</span><span class="compact-origin" data-compact-origin="live"></span></span><span class="status-badge"></span></div>
124
124
  <button class="icon" data-action="notes" title="Review notes" aria-label="Review notes">
125
125
  <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>
126
126
  </button>
package/assets/viewer.js CHANGED
@@ -30,10 +30,10 @@
30
30
  const settleTimers = { dev: [], live: [] };
31
31
  const frameState = { dev: { y: 0, max: 0 }, live: { y: 0, max: 0 } };
32
32
  let order = params.get('swap') === '1' ? ['live', 'dev'] : ['dev', 'live'];
33
- let syncScroll = queryOrStoredBool('scroll', 'site-compare-scroll', false);
33
+ let syncScroll = queryOrStoredBool('scroll', 'site-compare-scroll', !!config.hosted);
34
34
  let scrollMode = params.get('scrollMode') || localStorage.getItem('site-compare-scroll-mode') || 'exact';
35
35
  if (!['exact', 'ratio'].includes(scrollMode)) scrollMode = 'exact';
36
- let mirrorLinks = queryOrStoredBool('mirror', 'site-compare-mirror', false);
36
+ let mirrorLinks = queryOrStoredBool('mirror', 'site-compare-mirror', !!config.hosted);
37
37
  let mobileMode = (params.get('mode') || localStorage.getItem('site-compare-mode')) === 'mobile';
38
38
  let compactMode = queryOrStoredBool('compact', 'site-compare-compact', !!config.hosted);
39
39
  const storedView = localStorage.getItem('site-compare-view');
@@ -54,6 +54,7 @@
54
54
  let dockMode = queryOrStoredBool('dock', 'site-compare-dock', true);
55
55
  let scrollOwner = null;
56
56
  const meta = { dev: null, live: null };
57
+ const statusDetails = { dev: {}, live: {} };
57
58
  const apiHeaders = {
58
59
  authorization: 'Bearer ' + config.token,
59
60
  'content-type': 'application/json',
@@ -100,6 +101,31 @@
100
101
  ].filter(Boolean);
101
102
  }
102
103
 
104
+ function formatMs(value) {
105
+ return Number.isFinite(value) && value >= 0 ? `${Math.round(value)} ms` : '';
106
+ }
107
+
108
+ function formatBytes(value) {
109
+ if (!Number.isFinite(value) || value <= 0) return '';
110
+ if (value < 1024) return `${Math.round(value)} B`;
111
+ if (value < 1024 * 1024) return `${(value / 1024).toFixed(1)} KB`;
112
+ return `${(value / 1024 / 1024).toFixed(1)} MB`;
113
+ }
114
+
115
+ function statusTitle(side, status) {
116
+ const detail = statusDetails[side];
117
+ const lines = [`${side.toUpperCase()} returned ${status || 'an error'}`];
118
+ if (detail.requestMs) lines.push(`Status check: ${formatMs(detail.requestMs)}`);
119
+ if (detail.response) lines.push(`Document response: ${formatMs(detail.response)}`);
120
+ if (detail.dom) lines.push(`DOM ready: ${formatMs(detail.dom)}`);
121
+ if (detail.load) lines.push(`Window load: ${formatMs(detail.load)}`);
122
+ const size = formatBytes(detail.transfer || detail.decoded);
123
+ if (size) lines.push(`${detail.transfer ? 'Transferred' : 'Decoded size'}: ${size}`);
124
+ if (detail.type) lines.push(`Content-Type: ${detail.type}`);
125
+ if (detail.cache) lines.push(`Cache-Control: ${detail.cache}`);
126
+ return lines.join('\n');
127
+ }
128
+
103
129
  function setStatusBadge(side, status) {
104
130
  const cls = status >= 200 && status < 300 ? 'status-ok'
105
131
  : status >= 300 && status < 400 ? 'status-warn'
@@ -108,6 +134,8 @@
108
134
  for (const badge of statusBadges(side)) {
109
135
  badge.className = 'status-badge show ' + cls;
110
136
  badge.textContent = text;
137
+ badge.title = statusTitle(side, status);
138
+ badge.setAttribute('aria-label', badge.title.replaceAll('\n', '. '));
111
139
  }
112
140
  }
113
141
 
@@ -115,15 +143,26 @@
115
143
  for (const badge of statusBadges(side)) {
116
144
  badge.className = 'status-badge';
117
145
  badge.textContent = '';
146
+ badge.removeAttribute('title');
147
+ badge.removeAttribute('aria-label');
118
148
  }
119
149
  }
120
150
 
121
151
  function fetchStatus(side, route) {
122
152
  const url = statusUrl(side, route);
153
+ const started = performance.now();
123
154
  const read = (method) => fetch(url, { method, cache: 'no-store', redirect: 'manual' });
124
155
  read('HEAD')
125
156
  .then((res) => (res.status === 405 || res.status === 501 ? read('GET') : res))
126
- .then((res) => setStatusBadge(side, res.status || (res.type === 'opaqueredirect' ? 302 : 0)))
157
+ .then((res) => {
158
+ statusDetails[side] = {
159
+ ...statusDetails[side],
160
+ requestMs: performance.now() - started,
161
+ type: res.headers.get('content-type') || '',
162
+ cache: res.headers.get('cache-control') || '',
163
+ };
164
+ setStatusBadge(side, res.status || (res.type === 'opaqueredirect' ? 302 : 0));
165
+ })
127
166
  .catch(() => setStatusBadge(side, 0));
128
167
  }
129
168
 
@@ -239,14 +278,26 @@
239
278
  let canonicalPath = canonical;
240
279
  try { canonicalPath = new URL(canonical).pathname; } catch {}
241
280
  meta[side] = { title, description, canonicalPath, heading };
281
+ statusDetails[side] = { ...statusDetails[side], ...(source.timing || {}) };
242
282
  label.querySelector('.page-heading').textContent = heading;
243
283
  label.querySelector('.page-heading').title = title || heading;
244
284
  updateDocTitle();
245
- document.querySelector('[data-compact-title="' + side + '"]').textContent = heading;
285
+ const compactTitle = document.querySelector('[data-compact-title="' + side + '"]');
286
+ compactTitle.textContent = heading;
287
+ compactTitle.title = title || heading;
288
+ const compactOrigin = document.querySelector('[data-compact-origin="' + side + '"]');
289
+ compactOrigin.textContent = new URL(config[side]).host + route;
290
+ compactOrigin.title = config[side] + route;
246
291
  label.querySelector('.origin').textContent = config[side] + route;
247
292
  const fav = label.querySelector('.favicon');
248
293
  fav.onerror = () => { fav.onerror = null; fav.src = '/icon.svg'; };
249
294
  fav.src = faviconSrc;
295
+ const compactFav = document.querySelector('[data-compact-favicon="' + side + '"]');
296
+ compactFav.onerror = () => {
297
+ compactFav.onerror = null;
298
+ compactFav.src = config.hosted ? '/__sitedrift/assets/icon.svg' : '/icon.svg';
299
+ };
300
+ compactFav.src = faviconSrc;
250
301
  label.querySelector('.open-side').href = direct(side, route);
251
302
  const card = label.querySelector('.seo-card');
252
303
  const sourceRow = element('div', 'seo-source');
@@ -347,10 +398,14 @@
347
398
  label.querySelector('.pill').textContent = side.toUpperCase();
348
399
  label.querySelector('.page-heading').textContent = 'Loading…';
349
400
  document.querySelector('[data-compact-title="' + side + '"]').textContent = 'Loading…';
401
+ document.querySelector('[data-compact-origin="' + side + '"]').textContent =
402
+ new URL(config[side]).host + route;
403
+ document.querySelector('[data-compact-favicon="' + side + '"]').removeAttribute('src');
350
404
  label.querySelector('.origin').textContent = config[side] + route;
351
405
  label.querySelector('.favicon').src = '/__' + side + '/favicon.ico';
352
406
  label.querySelector('.open-side').href = direct(side, route);
353
407
  meta[side] = null;
408
+ statusDetails[side] = {};
354
409
  clearStatusBadge(side);
355
410
  }
356
411
  renderMetaDiff();
@@ -501,10 +556,16 @@
501
556
  setLinkedScroll(side, frameState[side].y + delta);
502
557
  } else if (message.type === 'navigate') {
503
558
  go(message.route);
559
+ } else if (message.type === 'dismiss') {
560
+ closePopovers();
504
561
  } else if (message.type === 'key') {
505
562
  runFrameKey(message.key, side, message);
506
563
  }
507
564
  });
565
+
566
+ function closePopovers() {
567
+ for (const details of document.querySelectorAll('details[open]')) details.removeAttribute('open');
568
+ }
508
569
  scrollButton.addEventListener('click', () => {
509
570
  syncScroll = !syncScroll;
510
571
  scrollButton.classList.toggle('active', syncScroll);
@@ -595,6 +656,14 @@
595
656
  renderModes();
596
657
  }
597
658
  for (const button of modeButtons) button.addEventListener('click', () => setMode(button.dataset.mode));
659
+ for (const identity of document.querySelectorAll('.compact-side')) {
660
+ identity.addEventListener('click', () => {
661
+ if (viewMode !== 'solo') return;
662
+ focusSide = identity.dataset.compactSide === 'dev' ? 'live' : 'dev';
663
+ app.dataset.focus = focusSide;
664
+ renderModes();
665
+ });
666
+ }
598
667
  for (const slider of overlaySliders) slider.addEventListener('input', () => {
599
668
  if (viewMode !== 'overlay') setMode('overlay');
600
669
  if (overlayBlend === 'difference') setOverlayBlend('opacity');
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "sitedrift",
3
- "version": "0.3.6",
3
+ "version": "0.3.7",
4
4
  "description": "Catch the drift between dev and live — frame your local site and production side-by-side on the same route, locked scroll, with a difference-blend overlay. Zero runtime dependencies.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -11,6 +11,14 @@ export function frameBridge(side, prefix = `/__${side}`) {
11
11
  const title=(document.title||'').trim();
12
12
  const description=q('meta[name="description"]')?.content?.trim()||'';
13
13
  const canonical=q('link[rel="canonical"]')?.href||'';
14
+ const navigation=performance.getEntriesByType('navigation')[0];
15
+ const timing=navigation?{
16
+ response:Math.round(navigation.responseEnd),
17
+ dom:Math.round(navigation.domContentLoadedEventEnd),
18
+ load:Math.round(navigation.loadEventEnd||navigation.duration),
19
+ transfer:Number(navigation.transferSize)||0,
20
+ decoded:Number(navigation.decodedBodySize)||0
21
+ }:null;
14
22
  const checks=[
15
23
  ['Title present',!!title],['Title 30–60 chars',title.length>=30&&title.length<=60,title.length+''],
16
24
  ['Meta description',!!description],['Description 70–160',description.length>=70&&description.length<=160,description.length+''],
@@ -23,7 +31,7 @@ export function frameBridge(side, prefix = `/__${side}`) {
23
31
  ['Images have alt',imgs.every((img)=>img.hasAttribute('alt')),imgs.filter((img)=>!img.hasAttribute('alt')).length+' missing']
24
32
  ].map(([label,ok,note])=>({label,ok,note}));
25
33
  send('ready',{route:route(),meta:{title,description,canonical,heading:q('h1')?.textContent?.trim()||'',
26
- siteName:q('meta[property="og:site_name"]')?.content?.trim()||'',icon:q('link[rel~="icon"]')?.href||'',checks}});
34
+ siteName:q('meta[property="og:site_name"]')?.content?.trim()||'',icon:q('link[rel~="icon"]')?.href||'',checks,timing}});
27
35
  send('scroll',{y:scrollY,max:Math.max(0,root().scrollHeight-innerHeight)});
28
36
  };
29
37
  addEventListener('message',(event)=>{
@@ -43,6 +51,7 @@ export function frameBridge(side, prefix = `/__${side}`) {
43
51
  if(linked&&!typing&&!event.metaKey&&!event.ctrlKey&&!event.altKey)send('key',{key:event.key,shift:event.shiftKey,y:scrollY,height:innerHeight,max:Math.max(0,root().scrollHeight-innerHeight)});
44
52
  },true);
45
53
  addEventListener('click',(event)=>{
54
+ send('dismiss');
46
55
  if(!mirror||event.defaultPrevented||event.button!==0||event.metaKey||event.ctrlKey||event.shiftKey||event.altKey)return;
47
56
  const link=event.target.closest('a[href]');if(!link||link.target==='_blank'||link.hasAttribute('download'))return;
48
57
  const url=new URL(link.href,location.href);if(url.origin!==location.origin||!url.pathname.startsWith(prefix))return;
package/src/viewer.mjs CHANGED
@@ -2,7 +2,7 @@ import fs from 'node:fs';
2
2
 
3
3
  // Bumped when the viewer assets change; busts the ?v= cache and reported in
4
4
  // /health so the `site compare` wrapper knows when to restart the server.
5
- export const VIEWER_VERSION = 29;
5
+ export const VIEWER_VERSION = 30;
6
6
 
7
7
  function readAsset(path) {
8
8
  try {