feat: sidebar CSS inspector — element picker, box model, rule cascade, quick edit

Extension changes for the visual CSS inspector:
- inspector.js: element picker with hover highlight, CSS selector generation,
  basic mode fallback (getComputedStyle + CSSOM), page alteration handlers
- inspector.css: picker overlay styles (blue highlight + tooltip)
- background.js: inspector message routing (picker <-> server <-> sidepanel)
- sidepanel: Inspector tab with box model viz (gstack palette), matched rules
  with specificity badges, computed styles, click-to-edit quick edit,
  Send to Agent/Code button, empty/loading/error states

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Garry Tan
2026-03-29 20:25:36 -07:00
parent e084ca90fd
commit f395f58406
7 changed files with 1624 additions and 1 deletions
+130
View File
@@ -158,6 +158,73 @@ async function fetchAndRelayRefs() {
} catch {}
}
// ─── Inspector ──────────────────────────────────────────────────
async function injectInspector(tabId) {
try {
await chrome.scripting.executeScript({
target: { tabId, allFrames: true },
files: ['inspector.js'],
});
await chrome.scripting.insertCSS({
target: { tabId, allFrames: true },
files: ['inspector.css'],
});
} catch (err) {
return { error: 'Cannot inspect this page (CSP restriction)' };
}
// Send startPicker to all frames
try {
await chrome.tabs.sendMessage(tabId, { type: 'startPicker' });
} catch {}
return { ok: true };
}
async function stopInspector(tabId) {
try {
await chrome.tabs.sendMessage(tabId, { type: 'stopPicker' });
} catch {}
return { ok: true };
}
async function postInspectorPick(selector, frameInfo, basicData, activeTabUrl) {
const base = getBaseUrl();
if (!base || !authToken) {
// No browse server — return basic data as fallback
return { mode: 'basic', selector, basicData, frameInfo };
}
try {
const resp = await fetch(`${base}/inspector/pick`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${authToken}`,
},
body: JSON.stringify({ selector, activeTabUrl, frameInfo }),
signal: AbortSignal.timeout(10000),
});
if (!resp.ok) {
// Server error — fall back to basic mode
return { mode: 'basic', selector, basicData, frameInfo };
}
const data = await resp.json();
return { mode: 'cdp', ...data };
} catch {
// No server or timeout — fall back to basic mode
return { mode: 'basic', selector, basicData, frameInfo };
}
}
async function sendToContentScript(tabId, message) {
try {
const response = await chrome.tabs.sendMessage(tabId, message);
return response || { ok: true };
} catch {
return { error: 'Content script not available' };
}
}
// ─── Message Handling ──────────────────────────────────────────
chrome.runtime.onMessage.addListener((msg, sender, sendResponse) => {
@@ -194,6 +261,69 @@ chrome.runtime.onMessage.addListener((msg, sender, sendResponse) => {
return;
}
// Inspector: inject + start picker
if (msg.type === 'startInspector') {
chrome.tabs.query({ active: true, currentWindow: true }, (tabs) => {
const tabId = tabs?.[0]?.id;
if (!tabId) { sendResponse({ error: 'No active tab' }); return; }
injectInspector(tabId).then(result => sendResponse(result));
});
return true;
}
// Inspector: stop picker
if (msg.type === 'stopInspector') {
chrome.tabs.query({ active: true, currentWindow: true }, (tabs) => {
const tabId = tabs?.[0]?.id;
if (!tabId) { sendResponse({ error: 'No active tab' }); return; }
stopInspector(tabId).then(result => sendResponse(result));
});
return true;
}
// Inspector: element picked by content script
if (msg.type === 'elementPicked') {
chrome.tabs.query({ active: true, currentWindow: true }, (tabs) => {
const activeTabUrl = tabs?.[0]?.url || null;
const frameInfo = msg.frameSrc ? { frameSrc: msg.frameSrc, frameName: msg.frameName } : null;
postInspectorPick(msg.selector, frameInfo, msg.basicData, activeTabUrl)
.then(result => {
// Forward enriched result to sidepanel
chrome.runtime.sendMessage({
type: 'inspectResult',
data: {
...result,
selector: msg.selector,
tagName: msg.tagName,
classes: msg.classes,
id: msg.id,
dimensions: msg.dimensions,
basicData: msg.basicData,
frameInfo,
},
}).catch(() => {});
sendResponse({ ok: true });
});
});
return true;
}
// Inspector: picker cancelled
if (msg.type === 'pickerCancelled') {
chrome.runtime.sendMessage({ type: 'pickerCancelled' }).catch(() => {});
return;
}
// Inspector: route alteration commands to content script
if (msg.type === 'applyStyle' || msg.type === 'toggleClass' || msg.type === 'injectCSS' || msg.type === 'resetAll') {
chrome.tabs.query({ active: true, currentWindow: true }, (tabs) => {
const tabId = tabs?.[0]?.id;
if (!tabId) { sendResponse({ error: 'No active tab' }); return; }
sendToContentScript(tabId, msg).then(result => sendResponse(result));
});
return true;
}
// Sidebar → browse server command proxy
if (msg.type === 'command') {
executeCommand(msg.command, msg.args).then(result => sendResponse(result));