sitedrift 0.3.5 → 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',
@@ -87,72 +88,12 @@
87
88
  : config[side] + normalizeRoute(route);
88
89
  }
89
90
  function framePost(side, type, data = {}) {
90
- if (config.hosted) {
91
- const win = frame(side).contentWindow;
92
- const scrolling = frame(side).contentDocument?.scrollingElement;
93
- if (type === 'scroll' && scrolling) scrolling.scrollTop = Number(data.y) || 0;
94
- if (type === 'reload') win?.location.reload();
95
- return;
96
- }
97
91
  frame(side).contentWindow?.postMessage(
98
92
  { source: 'sitedrift-parent', side, type, ...data },
99
93
  config.hosted ? '*' : config.frameOrigins[side],
100
94
  );
101
95
  }
102
96
 
103
- function hostedSnapshot(side) {
104
- if (!config.hosted) return;
105
- const iframe = frame(side);
106
- const doc = iframe.contentDocument;
107
- const win = iframe.contentWindow;
108
- if (!doc || !win) return;
109
- const q = (selector) => doc.querySelector(selector);
110
- const images = [...doc.querySelectorAll('img')];
111
- const title = (doc.title || '').trim();
112
- const description = q('meta[name="description"]')?.content?.trim() || '';
113
- const canonical = q('link[rel="canonical"]')?.href || '';
114
- const checks = [
115
- ['Title present', !!title],
116
- ['Title 30–60 chars', title.length >= 30 && title.length <= 60, String(title.length)],
117
- ['Meta description', !!description],
118
- ['Description 70–160', description.length >= 70 && description.length <= 160, String(description.length)],
119
- ['Exactly one H1', doc.querySelectorAll('h1').length === 1, `${doc.querySelectorAll('h1').length} found`],
120
- ['Canonical link', !!q('link[rel="canonical"]')],
121
- ['Viewport meta', !!q('meta[name="viewport"]')],
122
- ['html lang', !!doc.documentElement.lang],
123
- ['Open Graph title', !!q('meta[property="og:title"]')],
124
- ['Open Graph image', !!q('meta[property="og:image"]')],
125
- ['Not noindex', !(q('meta[name="robots"]')?.content || '').toLowerCase().includes('noindex')],
126
- ['Favicon', !!q('link[rel~="icon"]')],
127
- ['Images have alt', images.every((image) => image.hasAttribute('alt')), `${images.filter((image) => !image.hasAttribute('alt')).length} missing`],
128
- ].map(([label, ok, note]) => ({ label, ok, note }));
129
- const url = new URL(iframe.src);
130
- const route = (url.pathname.slice(proxyPath(side).length) || '/') + url.search + url.hash;
131
- renderMetadata(side, {
132
- route,
133
- meta: {
134
- title,
135
- description,
136
- canonical,
137
- heading: q('h1')?.textContent?.trim() || '',
138
- siteName: q('meta[property="og:site_name"]')?.content?.trim() || '',
139
- icon: q('link[rel~="icon"]')?.href || '',
140
- checks,
141
- },
142
- });
143
- fetchStatus(side, route);
144
- const reportScroll = () => {
145
- const root = doc.scrollingElement || doc.documentElement;
146
- frameState[side] = {
147
- y: Number(win.scrollY) || 0,
148
- max: Math.max(0, root.scrollHeight - win.innerHeight),
149
- };
150
- syncFrom(side);
151
- };
152
- win.addEventListener('scroll', reportScroll, { passive: true });
153
- reportScroll();
154
- }
155
-
156
97
  function statusBadges(side) {
157
98
  return [
158
99
  document.querySelector('.label[data-label="' + side + '"] .status-badge'),
@@ -160,6 +101,31 @@
160
101
  ].filter(Boolean);
161
102
  }
162
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
+
163
129
  function setStatusBadge(side, status) {
164
130
  const cls = status >= 200 && status < 300 ? 'status-ok'
165
131
  : status >= 300 && status < 400 ? 'status-warn'
@@ -168,6 +134,8 @@
168
134
  for (const badge of statusBadges(side)) {
169
135
  badge.className = 'status-badge show ' + cls;
170
136
  badge.textContent = text;
137
+ badge.title = statusTitle(side, status);
138
+ badge.setAttribute('aria-label', badge.title.replaceAll('\n', '. '));
171
139
  }
172
140
  }
173
141
 
@@ -175,15 +143,26 @@
175
143
  for (const badge of statusBadges(side)) {
176
144
  badge.className = 'status-badge';
177
145
  badge.textContent = '';
146
+ badge.removeAttribute('title');
147
+ badge.removeAttribute('aria-label');
178
148
  }
179
149
  }
180
150
 
181
151
  function fetchStatus(side, route) {
182
152
  const url = statusUrl(side, route);
153
+ const started = performance.now();
183
154
  const read = (method) => fetch(url, { method, cache: 'no-store', redirect: 'manual' });
184
155
  read('HEAD')
185
156
  .then((res) => (res.status === 405 || res.status === 501 ? read('GET') : res))
186
- .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
+ })
187
166
  .catch(() => setStatusBadge(side, 0));
188
167
  }
189
168
 
@@ -299,14 +278,26 @@
299
278
  let canonicalPath = canonical;
300
279
  try { canonicalPath = new URL(canonical).pathname; } catch {}
301
280
  meta[side] = { title, description, canonicalPath, heading };
281
+ statusDetails[side] = { ...statusDetails[side], ...(source.timing || {}) };
302
282
  label.querySelector('.page-heading').textContent = heading;
303
283
  label.querySelector('.page-heading').title = title || heading;
304
284
  updateDocTitle();
305
- 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;
306
291
  label.querySelector('.origin').textContent = config[side] + route;
307
292
  const fav = label.querySelector('.favicon');
308
293
  fav.onerror = () => { fav.onerror = null; fav.src = '/icon.svg'; };
309
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;
310
301
  label.querySelector('.open-side').href = direct(side, route);
311
302
  const card = label.querySelector('.seo-card');
312
303
  const sourceRow = element('div', 'seo-source');
@@ -407,10 +398,14 @@
407
398
  label.querySelector('.pill').textContent = side.toUpperCase();
408
399
  label.querySelector('.page-heading').textContent = 'Loading…';
409
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');
410
404
  label.querySelector('.origin').textContent = config[side] + route;
411
405
  label.querySelector('.favicon').src = '/__' + side + '/favicon.ico';
412
406
  label.querySelector('.open-side').href = direct(side, route);
413
407
  meta[side] = null;
408
+ statusDetails[side] = {};
414
409
  clearStatusBadge(side);
415
410
  }
416
411
  renderMetaDiff();
@@ -561,16 +556,16 @@
561
556
  setLinkedScroll(side, frameState[side].y + delta);
562
557
  } else if (message.type === 'navigate') {
563
558
  go(message.route);
559
+ } else if (message.type === 'dismiss') {
560
+ closePopovers();
564
561
  } else if (message.type === 'key') {
565
562
  runFrameKey(message.key, side, message);
566
563
  }
567
564
  });
568
- if (config.hosted) {
569
- for (const side of ['dev', 'live']) {
570
- frame(side).addEventListener('load', () => hostedSnapshot(side));
571
- }
572
- }
573
565
 
566
+ function closePopovers() {
567
+ for (const details of document.querySelectorAll('details[open]')) details.removeAttribute('open');
568
+ }
574
569
  scrollButton.addEventListener('click', () => {
575
570
  syncScroll = !syncScroll;
576
571
  scrollButton.classList.toggle('active', syncScroll);
@@ -661,6 +656,14 @@
661
656
  renderModes();
662
657
  }
663
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
+ }
664
667
  for (const slider of overlaySliders) slider.addEventListener('input', () => {
665
668
  if (viewMode !== 'overlay') setMode('overlay');
666
669
  if (overlayBlend === 'difference') setOverlayBlend('opacity');
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "sitedrift",
3
- "version": "0.3.5",
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 {