panel.js

  1/**
  2 * Impeccable DevTools Extension - Panel
  3 *
  4 * Displays findings, provides controls for scanning and overlay toggling,
  5 * and allows clicking findings to inspect elements.
  6 */
  7
  8// Match the DevTools theme (light or dark)
  9if (chrome.devtools.panels.themeName === 'dark') {
 10  document.documentElement.classList.add('theme-dark');
 11}
 12
 13const tabId = chrome.devtools.inspectedWindow.tabId;
 14
 15// Auto-reconnecting port. Service workers in MV3 can be terminated after ~30s of
 16// inactivity (especially when the browser window is unfocused). When they restart,
 17// the existing port becomes invalid. We recreate it lazily on the next use.
 18let port = null;
 19function getPort() {
 20  if (port) return port;
 21  port = chrome.runtime.connect({ name: `impeccable-panel-${tabId}` });
 22  port.onMessage.addListener(handlePortMessage);
 23  port.onDisconnect.addListener(() => { port = null; });
 24  return port;
 25}
 26function postToPort(msg) {
 27  try {
 28    getPort().postMessage(msg);
 29  } catch {
 30    // Port died mid-call. Drop it and try once more with a fresh port.
 31    port = null;
 32    try { getPort().postMessage(msg); } catch { /* give up silently */ }
 33  }
 34}
 35
 36const badge = document.getElementById('badge');
 37const container = document.getElementById('findings-container');
 38const emptyState = document.getElementById('empty-state');
 39const btnRescan = document.getElementById('btn-rescan');
 40const btnToggle = document.getElementById('btn-toggle');
 41const btnCopyAll = document.getElementById('btn-copy-all');
 42const settingsContainer = document.getElementById('settings-container');
 43const settingsList = document.getElementById('settings-list');
 44const btnSettings = document.getElementById('btn-settings');
 45
 46let overlaysVisible = true;
 47let allAntipatterns = [];
 48let disabledRules = [];
 49let currentFindings = [];
 50
 51// Load antipatterns list and disabled rules
 52async function initSettings() {
 53  try {
 54    const resp = await fetch(chrome.runtime.getURL('detector/antipatterns.json'));
 55    allAntipatterns = await resp.json();
 56  } catch { allAntipatterns = []; }
 57
 58  const stored = await chrome.storage.sync.get({
 59    disabledRules: [],
 60    lineLengthMode: 'strict',
 61    spotlightBlur: true,
 62    autoScan: 'panel',
 63  });
 64  disabledRules = stored.disabledRules;
 65  renderSettings();
 66  initLineLengthControl(stored.lineLengthMode);
 67  initSpotlightBlurToggle(stored.spotlightBlur);
 68  initAutoScanControl(stored.autoScan);
 69}
 70
 71function initAutoScanControl(currentMode) {
 72  const group = document.getElementById('auto-scan-mode');
 73  if (!group) return;
 74  for (const btn of group.querySelectorAll('button')) {
 75    btn.classList.toggle('active', btn.dataset.value === currentMode);
 76    btn.addEventListener('click', async () => {
 77      const mode = btn.dataset.value;
 78      for (const b of group.querySelectorAll('button')) {
 79        b.classList.toggle('active', b === btn);
 80      }
 81      await chrome.storage.sync.set({ autoScan: mode });
 82    });
 83  }
 84}
 85
 86function initLineLengthControl(currentMode) {
 87  const group = document.getElementById('line-length-mode');
 88  if (!group) return;
 89  for (const btn of group.querySelectorAll('button')) {
 90    btn.classList.toggle('active', btn.dataset.value === currentMode);
 91    btn.addEventListener('click', async () => {
 92      const mode = btn.dataset.value;
 93      for (const b of group.querySelectorAll('button')) {
 94        b.classList.toggle('active', b === btn);
 95      }
 96      await chrome.storage.sync.set({ lineLengthMode: mode });
 97      chrome.runtime.sendMessage({ action: 'disabled-rules-changed' });
 98    });
 99  }
100}
101
102function initSpotlightBlurToggle(currentValue) {
103  const cb = document.getElementById('spotlight-blur-toggle');
104  if (!cb) return;
105  cb.checked = currentValue;
106  cb.addEventListener('change', async () => {
107    await chrome.storage.sync.set({ spotlightBlur: cb.checked });
108    chrome.runtime.sendMessage({ action: 'disabled-rules-changed' });
109  });
110}
111
112function renderSettings() {
113  settingsList.innerHTML = '';
114
115  const categories = {
116    slop: { label: 'AI tells', items: [] },
117    quality: { label: 'Quality', items: [] },
118  };
119  for (const ap of allAntipatterns) {
120    const cat = ap.category || 'quality';
121    (categories[cat] || categories.quality).items.push(ap);
122  }
123
124  for (const [, group] of Object.entries(categories)) {
125    if (!group.items.length) continue;
126
127    const header = document.createElement('div');
128    header.className = 'settings-header';
129    header.textContent = group.label;
130    settingsList.appendChild(header);
131
132    const grid = document.createElement('div');
133    grid.className = 'settings-grid';
134
135    for (const ap of group.items) {
136      const label = document.createElement('label');
137      label.className = 'setting-rule';
138
139      const checkbox = document.createElement('input');
140      checkbox.type = 'checkbox';
141      checkbox.checked = !disabledRules.includes(ap.id);
142      checkbox.addEventListener('change', () => toggleRule(ap.id, checkbox.checked));
143
144      const text = document.createElement('span');
145      text.textContent = ap.name;
146
147      label.appendChild(checkbox);
148      label.appendChild(text);
149      grid.appendChild(label);
150    }
151    settingsList.appendChild(grid);
152  }
153}
154
155async function toggleRule(ruleId, enabled) {
156  if (enabled) {
157    disabledRules = disabledRules.filter(id => id !== ruleId);
158  } else {
159    if (!disabledRules.includes(ruleId)) disabledRules.push(ruleId);
160  }
161  await chrome.storage.sync.set({ disabledRules });
162  chrome.runtime.sendMessage({ action: 'disabled-rules-changed' });
163}
164
165// Listen for messages from the service worker (called by getPort() on each new connection)
166function handlePortMessage(msg) {
167  if (msg.action === 'page-pointer-active') {
168    // Cursor is active on the page → user has left the panel
169    setHoveredItem(null);
170    return;
171  }
172  if (msg.action === 'findings' || msg.action === 'state') {
173    renderFindings(msg.findings || []);
174    if (msg.overlaysVisible !== undefined) {
175      overlaysVisible = msg.overlaysVisible;
176      updateToggleButton();
177    }
178  }
179  if (msg.action === 'overlays-toggled') {
180    overlaysVisible = msg.visible;
181    updateToggleButton();
182  }
183  if (msg.action === 'navigated') {
184    showScanning();
185  }
186}
187
188// Initial connection
189getPort();
190
191// Heartbeat to keep the MV3 service worker alive while the panel is open.
192// SWs can be terminated after ~30s of inactivity, especially when the browser is unfocused.
193setInterval(() => postToPort({ action: 'ping' }), 20000);
194
195// Controls
196btnRescan.addEventListener('click', () => {
197  showScanning();
198  postToPort({ action: 'scan' });
199});
200
201btnToggle.addEventListener('click', () => {
202  postToPort({ action: 'toggle-overlays' });
203});
204
205btnSettings.addEventListener('click', () => {
206  const isVisible = settingsContainer.style.display !== 'none';
207  settingsContainer.style.display = isVisible ? 'none' : '';
208  btnSettings.classList.toggle('active', !isVisible);
209});
210
211function updateToggleButton() {
212  btnToggle.title = overlaysVisible ? 'Hide overlays' : 'Show overlays';
213  btnToggle.classList.toggle('inactive', !overlaysVisible);
214}
215
216function showScanning() {
217  container.innerHTML = `
218    <div class="scanning-indicator">
219      <div class="scanning-dot"></div>
220      Scanning page...
221    </div>`;
222}
223
224// Maps each anti-pattern to the most relevant Impeccable skill(s) for fixing it.
225// These are suggestions; the user decides whether and how to apply them.
226const FIX_SKILLS = {
227  // AI slop
228  'side-tab':                'distill, polish',
229  'border-accent-on-rounded':'distill, polish',
230  'overused-font':           'typeset',
231  'single-font':             'typeset',
232  'flat-type-hierarchy':     'typeset',
233  'gradient-text':           'typeset, distill',
234  'ai-color-palette':        'colorize, distill',
235  'nested-cards':            'distill, arrange',
236  'monotonous-spacing':      'arrange',
237  'everything-centered':     'arrange',
238  'bounce-easing':           'animate',
239  'dark-glow':               'quieter, distill',
240  'icon-tile-stacked-above-heading': 'distill, arrange',
241  // Quality
242  'pure-black-white':        'colorize',
243  'gray-on-color':           'colorize',
244  'low-contrast':            'colorize, audit',
245  'layout-transition':       'animate, optimize',
246  'line-length':             'arrange, typeset',
247  'cramped-padding':         'arrange, polish',
248  'tight-leading':           'typeset',
249  'skipped-heading':         'audit, harden',
250  'justified-text':          'typeset',
251  'tiny-text':               'typeset',
252  'all-caps-body':           'typeset',
253  'wide-tracking':           'typeset',
254};
255
256function fixSkillFor(type) {
257  const skills = FIX_SKILLS[type] || 'polish';
258  // Prefix each comma-separated skill with a slash for clarity
259  return skills.split(',').map(s => '/' + s.trim()).join(', ');
260}
261
262// Returns a sorted array of unique skills referenced by the given findings,
263// most-frequent first. Each entry already has the leading slash.
264function uniqueSkillsForFindings(findings) {
265  const counts = new Map();
266  for (const item of findings) {
267    for (const f of item.findings) {
268      const list = (FIX_SKILLS[f.type] || 'polish').split(',').map(s => '/' + s.trim());
269      for (const s of list) {
270        counts.set(s, (counts.get(s) || 0) + 1);
271      }
272    }
273  }
274  return [...counts.entries()].sort((a, b) => b[1] - a[1]).map(([s]) => s);
275}
276
277function getInspectedUrl() {
278  return new Promise((resolve) => {
279    // Strip the URL fragment — anchors are noise for "what page is this from"
280    chrome.devtools.inspectedWindow.eval(
281      '(function(){var u=new URL(location.href);u.hash="";return u.toString();})()',
282      (result) => resolve(typeof result === 'string' ? result : '')
283    );
284  });
285}
286
287async function formatFindingsForCopy(findings) {
288  if (!findings.length) return 'Impeccable found no anti-patterns on this page.';
289  const url = await getInspectedUrl();
290  const lines = ['# Impeccable findings'];
291  if (url) lines.push(`URL: ${url}`);
292  lines.push('');
293
294  const groups = { slop: [], quality: [] };
295  for (const item of findings) {
296    for (const f of item.findings) {
297      const cat = f.category || 'quality';
298      groups[cat].push({ ...f, selector: item.selector, isPageLevel: item.isPageLevel });
299    }
300  }
301
302  if (groups.slop.length) {
303    lines.push(`## AI tells (${groups.slop.length})`);
304    for (const f of groups.slop) {
305      const where = f.isPageLevel ? '_(page-level)_' : `\`${f.selector}\``;
306      lines.push(`- **${f.name}** at ${where}: ${f.detail}`);
307    }
308    lines.push('');
309  }
310
311  if (groups.quality.length) {
312    lines.push(`## Quality issues (${groups.quality.length})`);
313    for (const f of groups.quality) {
314      const where = f.isPageLevel ? '_(page-level)_' : `\`${f.selector}\``;
315      lines.push(`- **${f.name}** at ${where}: ${f.detail}`);
316    }
317    lines.push('');
318  }
319
320  // Roll up suggested skills across all findings (most-relevant first)
321  const skills = uniqueSkillsForFindings(findings);
322  if (skills.length) {
323    lines.push(`Suggested Impeccable skills to fix: ${skills.join(', ')}`);
324    lines.push('');
325  }
326
327  lines.push('---');
328  lines.push('Detected by [Impeccable](https://impeccable.style). Skills are suggestions, not required.');
329  return lines.join('\n');
330}
331
332async function formatSingleFindingForCopy(item, finding) {
333  const url = await getInspectedUrl();
334  const where = item.isPageLevel ? '_(page-level)_' : `\`${item.selector}\``;
335  const lines = [`# Impeccable: ${finding.name}`];
336  if (url) lines.push(`URL: ${url}`);
337  lines.push(`Element: ${where}`);
338  lines.push(`Detail: ${finding.detail}`);
339  lines.push('');
340  lines.push(finding.description);
341  lines.push('');
342  lines.push(`Suggested Impeccable skill(s) to fix: ${fixSkillFor(finding.type)}`);
343  return lines.join('\n');
344}
345
346async function copyToClipboard(text, btn) {
347  if (text instanceof Promise) text = await text;
348  try {
349    await navigator.clipboard.writeText(text);
350    if (btn) {
351      const orig = btn.title;
352      btn.title = 'Copied!';
353      btn.classList.add('copied');
354      setTimeout(() => {
355        btn.title = orig;
356        btn.classList.remove('copied');
357      }, 1200);
358    }
359  } catch (err) {
360    console.warn('Copy failed', err);
361  }
362}
363
364btnCopyAll.addEventListener('click', () => {
365  copyToClipboard(formatFindingsForCopy(currentFindings), btnCopyAll);
366});
367
368// Delegated hover tracking on the findings container.
369// Reliably handles cursor moving between items, into children, or out of the panel.
370let currentHoverSelector = null;
371function setHoveredItem(selector) {
372  if (selector === currentHoverSelector) return;
373  currentHoverSelector = selector;
374  if (selector) {
375    postToPort({ action: 'highlight', selector });
376  } else {
377    postToPort({ action: 'unhighlight' });
378  }
379}
380
381container.addEventListener('pointermove', (e) => {
382  const item = e.target.closest('.finding-item');
383  const selector = item && !item.classList.contains('is-hidden') ? item.dataset.selector || null : null;
384  setHoveredItem(selector);
385});
386
387// Slow-cursor fallbacks (these fire reliably for slow movements)
388container.addEventListener('pointerleave', () => setHoveredItem(null));
389window.addEventListener('blur', () => setHoveredItem(null));
390
391
392function renderFindings(findings) {
393  currentFindings = findings;
394  if (!findings.length) {
395    container.innerHTML = '';
396    container.appendChild(emptyState);
397    emptyState.style.display = '';
398    badge.classList.remove('visible');
399    badge.textContent = '0';
400    return;
401  }
402
403  emptyState.style.display = 'none';
404
405  // Count total element-level findings
406  const totalCount = findings.reduce((sum, f) => sum + f.findings.length, 0);
407  badge.textContent = String(totalCount);
408  badge.classList.add('visible');
409
410  // Group findings by category, then by anti-pattern type
411  const categories = { slop: new Map(), quality: new Map() };
412  for (const item of findings) {
413    for (const f of item.findings) {
414      const cat = f.category || 'quality';
415      const groups = categories[cat] || categories.quality;
416      if (!groups.has(f.type)) {
417        groups.set(f.type, { name: f.name, description: f.description, items: [] });
418      }
419      groups.get(f.type).items.push({
420        selector: item.selector,
421        tagName: item.tagName,
422        isPageLevel: item.isPageLevel,
423        isHidden: item.isHidden,
424        detail: f.detail,
425      });
426    }
427  }
428
429  container.innerHTML = '';
430
431  const CATEGORY_LABELS = { slop: 'AI tells', quality: 'Quality issues' };
432  for (const [catKey, groups] of Object.entries(categories)) {
433    if (groups.size === 0) continue;
434
435    const catCount = [...groups.values()].reduce((sum, g) => sum + g.items.length, 0);
436    const section = document.createElement('div');
437    section.className = 'category-section category-' + catKey;
438
439    const catHeader = document.createElement('div');
440    catHeader.className = 'category-header';
441    catHeader.innerHTML = `
442      <span class="category-dot category-dot-${catKey}"></span>
443      <span class="category-name">${CATEGORY_LABELS[catKey]}</span>
444      <span class="category-count">${catCount}</span>`;
445    section.appendChild(catHeader);
446
447    for (const [type, group] of groups) {
448    const groupEl = document.createElement('div');
449    groupEl.className = 'finding-group';
450
451    const header = document.createElement('div');
452    header.className = 'group-header';
453    header.innerHTML = `
454      <span class="group-chevron">&#9660;</span>
455      <span class="group-name">${escapeHtml(group.name)}</span>
456      <span class="group-count">${group.items.length}</span>`;
457    header.addEventListener('click', () => header.classList.toggle('collapsed'));
458    groupEl.appendChild(header);
459
460    const itemsEl = document.createElement('div');
461    itemsEl.className = 'group-items';
462
463    for (const item of group.items) {
464      const itemEl = document.createElement('div');
465      itemEl.className = 'finding-item' + (item.isHidden ? ' is-hidden' : '');
466      const tag = item.isPageLevel
467        ? '<span class="finding-tag tag-page">page</span>'
468        : item.isHidden ? '<span class="finding-tag tag-hidden" title="Element is currently hidden on the page">hidden</span>' : '';
469      itemEl.innerHTML = `
470        ${tag}
471        <div class="finding-row">
472          <span class="finding-selector">${escapeHtml(item.selector)}</span>
473          <button class="finding-copy" title="Copy this finding">
474            <svg width="11" height="11" viewBox="0 0 16 16" fill="none"><path d="M11 1H3a2 2 0 0 0-2 2v10h2V3h8V1zm3 3H7a2 2 0 0 0-2 2v9a2 2 0 0 0 2 2h7a2 2 0 0 0 2-2V6a2 2 0 0 0-2-2zm0 11H7V6h7v9z" fill="currentColor"/></svg>
475          </button>
476        </div>
477        <span class="finding-detail">${escapeHtml(item.detail)}</span>
478        <span class="finding-description">${escapeHtml(group.description)}</span>`;
479
480      const copyBtn = itemEl.querySelector('.finding-copy');
481      const finding = { type, name: group.name, description: group.description, detail: item.detail };
482      copyBtn.addEventListener('click', (e) => {
483        e.stopPropagation();
484        copyToClipboard(formatSingleFindingForCopy(item, finding), copyBtn);
485      });
486
487      if (!item.isPageLevel && !item.isHidden) {
488        itemEl.dataset.selector = item.selector;
489        itemEl.addEventListener('click', () => inspectElement(item.selector));
490      }
491
492      itemsEl.appendChild(itemEl);
493    }
494
495    groupEl.appendChild(itemsEl);
496    section.appendChild(groupEl);
497  }
498
499    container.appendChild(section);
500  }
501}
502
503function inspectElement(selector) {
504  const escaped = selector.replace(/\\/g, '\\\\').replace(/'/g, "\\'");
505  chrome.devtools.inspectedWindow.eval(
506    `(function() {
507      var el = document.querySelector('${escaped}');
508      if (el) { el.scrollIntoView({ behavior: 'smooth', block: 'center' }); inspect(el); }
509    })()`
510  );
511}
512
513function escapeHtml(str) {
514  const div = document.createElement('div');
515  div.textContent = str;
516  return div.innerHTML;
517}
518
519initSettings();