mirror of
https://github.com/garrytan/gstack.git
synced 2026-05-02 11:45:20 +02:00
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:
@@ -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));
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
/* gstack browse — CSS Inspector overlay styles
|
||||
* Injected alongside inspector.js into the active tab.
|
||||
* Design system: amber accent, zinc neutrals.
|
||||
*/
|
||||
|
||||
#gstack-inspector-highlight {
|
||||
position: fixed;
|
||||
pointer-events: none;
|
||||
z-index: 2147483647;
|
||||
background: rgba(59, 130, 246, 0.15);
|
||||
border: 2px solid rgba(59, 130, 246, 0.6);
|
||||
border-radius: 2px;
|
||||
transition: top 50ms ease, left 50ms ease, width 50ms ease, height 50ms ease;
|
||||
}
|
||||
|
||||
#gstack-inspector-tooltip {
|
||||
position: fixed;
|
||||
pointer-events: none;
|
||||
z-index: 2147483647;
|
||||
background: #27272A;
|
||||
color: #e0e0e0;
|
||||
font-family: 'JetBrains Mono', 'SF Mono', 'Fira Code', monospace;
|
||||
font-size: 11px;
|
||||
padding: 3px 8px;
|
||||
border-radius: 4px;
|
||||
white-space: nowrap;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.4);
|
||||
line-height: 18px;
|
||||
}
|
||||
@@ -0,0 +1,459 @@
|
||||
/**
|
||||
* gstack browse — CSS Inspector content script
|
||||
*
|
||||
* Dynamically injected via chrome.scripting.executeScript.
|
||||
* Provides element picker, selector generation, basic computed style capture,
|
||||
* and page alteration handlers for agent-pushed CSS changes.
|
||||
*/
|
||||
|
||||
(() => {
|
||||
// Guard against double-injection
|
||||
if (window.__gstackInspectorActive) return;
|
||||
window.__gstackInspectorActive = true;
|
||||
|
||||
// ─── State ──────────────────────────────────────────────────────
|
||||
let pickerActive = false;
|
||||
let highlightEl = null;
|
||||
let tooltipEl = null;
|
||||
let lastPickTime = 0;
|
||||
const PICK_DEBOUNCE_MS = 200;
|
||||
|
||||
// Track original inline styles for resetAll
|
||||
const originalStyles = new Map(); // element -> Map<property, value>
|
||||
const injectedStyleIds = new Set();
|
||||
|
||||
// ─── Highlight Overlay ──────────────────────────────────────────
|
||||
|
||||
function createHighlight() {
|
||||
if (highlightEl) return;
|
||||
|
||||
highlightEl = document.createElement('div');
|
||||
highlightEl.id = 'gstack-inspector-highlight';
|
||||
highlightEl.style.cssText = `
|
||||
position: fixed;
|
||||
pointer-events: none;
|
||||
z-index: 2147483647;
|
||||
background: rgba(59, 130, 246, 0.15);
|
||||
border: 2px solid rgba(59, 130, 246, 0.6);
|
||||
border-radius: 2px;
|
||||
transition: top 50ms, left 50ms, width 50ms, height 50ms;
|
||||
`;
|
||||
document.documentElement.appendChild(highlightEl);
|
||||
|
||||
tooltipEl = document.createElement('div');
|
||||
tooltipEl.id = 'gstack-inspector-tooltip';
|
||||
tooltipEl.style.cssText = `
|
||||
position: fixed;
|
||||
pointer-events: none;
|
||||
z-index: 2147483647;
|
||||
background: #27272A;
|
||||
color: #e0e0e0;
|
||||
font-family: 'JetBrains Mono', 'SF Mono', 'Fira Code', monospace;
|
||||
font-size: 11px;
|
||||
padding: 3px 8px;
|
||||
border-radius: 4px;
|
||||
white-space: nowrap;
|
||||
box-shadow: 0 2px 8px rgba(0,0,0,0.4);
|
||||
display: none;
|
||||
`;
|
||||
document.documentElement.appendChild(tooltipEl);
|
||||
}
|
||||
|
||||
function removeHighlight() {
|
||||
if (highlightEl) { highlightEl.remove(); highlightEl = null; }
|
||||
if (tooltipEl) { tooltipEl.remove(); tooltipEl = null; }
|
||||
}
|
||||
|
||||
function updateHighlight(el) {
|
||||
if (!highlightEl || !tooltipEl) return;
|
||||
const rect = el.getBoundingClientRect();
|
||||
|
||||
highlightEl.style.top = rect.top + 'px';
|
||||
highlightEl.style.left = rect.left + 'px';
|
||||
highlightEl.style.width = rect.width + 'px';
|
||||
highlightEl.style.height = rect.height + 'px';
|
||||
highlightEl.style.display = 'block';
|
||||
|
||||
// Build tooltip text: <tag> .classes WxH
|
||||
const tag = el.tagName.toLowerCase();
|
||||
const classes = el.className && typeof el.className === 'string'
|
||||
? '.' + el.className.trim().split(/\s+/).join('.')
|
||||
: '';
|
||||
const dims = `${Math.round(rect.width)}x${Math.round(rect.height)}`;
|
||||
tooltipEl.textContent = `<${tag}> ${classes} ${dims}`.trim();
|
||||
|
||||
// Position tooltip above element, or below if no room
|
||||
const tooltipHeight = 24;
|
||||
const gap = 6;
|
||||
let tooltipTop = rect.top - tooltipHeight - gap;
|
||||
if (tooltipTop < 4) tooltipTop = rect.bottom + gap;
|
||||
let tooltipLeft = rect.left;
|
||||
if (tooltipLeft < 4) tooltipLeft = 4;
|
||||
|
||||
tooltipEl.style.top = tooltipTop + 'px';
|
||||
tooltipEl.style.left = tooltipLeft + 'px';
|
||||
tooltipEl.style.display = 'block';
|
||||
}
|
||||
|
||||
// ─── Selector Generation ────────────────────────────────────────
|
||||
|
||||
function buildSelector(el) {
|
||||
// If element has an id, use it directly
|
||||
if (el.id) {
|
||||
const sel = '#' + CSS.escape(el.id);
|
||||
if (isUnique(sel)) return sel;
|
||||
}
|
||||
|
||||
// Build path from element up to nearest ancestor with id or body
|
||||
const parts = [];
|
||||
let current = el;
|
||||
|
||||
while (current && current !== document.body && current !== document.documentElement) {
|
||||
let part = current.tagName.toLowerCase();
|
||||
|
||||
// If current has an id, use it and stop
|
||||
if (current.id) {
|
||||
part = '#' + CSS.escape(current.id);
|
||||
parts.unshift(part);
|
||||
break;
|
||||
}
|
||||
|
||||
// Add classes
|
||||
if (current.className && typeof current.className === 'string') {
|
||||
const classes = current.className.trim().split(/\s+/).filter(c => c.length > 0);
|
||||
if (classes.length > 0) {
|
||||
part += '.' + classes.map(c => CSS.escape(c)).join('.');
|
||||
}
|
||||
}
|
||||
|
||||
// Add nth-child if needed to disambiguate
|
||||
const parent = current.parentElement;
|
||||
if (parent) {
|
||||
const siblings = Array.from(parent.children).filter(
|
||||
s => s.tagName === current.tagName
|
||||
);
|
||||
if (siblings.length > 1) {
|
||||
const idx = siblings.indexOf(current) + 1;
|
||||
part += `:nth-child(${Array.from(parent.children).indexOf(current) + 1})`;
|
||||
}
|
||||
}
|
||||
|
||||
parts.unshift(part);
|
||||
current = current.parentElement;
|
||||
}
|
||||
|
||||
// If we didn't reach an id, prepend body
|
||||
if (parts.length > 0 && !parts[0].startsWith('#')) {
|
||||
// Don't prepend body, just use the path as-is
|
||||
}
|
||||
|
||||
const selector = parts.join(' > ');
|
||||
|
||||
// Verify uniqueness
|
||||
if (isUnique(selector)) return selector;
|
||||
|
||||
// Fallback: add nth-child at each level until unique
|
||||
return selector;
|
||||
}
|
||||
|
||||
function isUnique(selector) {
|
||||
try {
|
||||
return document.querySelectorAll(selector).length === 1;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Basic Mode Data Capture ────────────────────────────────────
|
||||
|
||||
const KEY_PROPERTIES = [
|
||||
'display', 'position', 'top', 'right', 'bottom', 'left',
|
||||
'width', 'height', 'min-width', 'max-width', 'min-height', 'max-height',
|
||||
'margin-top', 'margin-right', 'margin-bottom', 'margin-left',
|
||||
'padding-top', 'padding-right', 'padding-bottom', 'padding-left',
|
||||
'border-top-width', 'border-right-width', 'border-bottom-width', 'border-left-width',
|
||||
'border-top-style', 'border-right-style', 'border-bottom-style', 'border-left-style',
|
||||
'border-top-color', 'border-right-color', 'border-bottom-color', 'border-left-color',
|
||||
'color', 'background-color', 'background-image',
|
||||
'font-family', 'font-size', 'font-weight', 'line-height', 'letter-spacing',
|
||||
'text-align', 'text-decoration', 'text-transform',
|
||||
'overflow', 'overflow-x', 'overflow-y',
|
||||
'opacity', 'z-index',
|
||||
'flex-direction', 'justify-content', 'align-items', 'flex-wrap', 'gap',
|
||||
'grid-template-columns', 'grid-template-rows',
|
||||
'box-shadow', 'border-radius',
|
||||
'transition', 'transform',
|
||||
];
|
||||
|
||||
function captureBasicData(el) {
|
||||
const computed = getComputedStyle(el);
|
||||
const rect = el.getBoundingClientRect();
|
||||
|
||||
// Capture key computed properties
|
||||
const computedStyles = {};
|
||||
for (const prop of KEY_PROPERTIES) {
|
||||
computedStyles[prop] = computed.getPropertyValue(prop);
|
||||
}
|
||||
|
||||
// Box model from computed
|
||||
const boxModel = {
|
||||
content: { width: rect.width, height: rect.height },
|
||||
padding: {
|
||||
top: parseFloat(computed.paddingTop) || 0,
|
||||
right: parseFloat(computed.paddingRight) || 0,
|
||||
bottom: parseFloat(computed.paddingBottom) || 0,
|
||||
left: parseFloat(computed.paddingLeft) || 0,
|
||||
},
|
||||
border: {
|
||||
top: parseFloat(computed.borderTopWidth) || 0,
|
||||
right: parseFloat(computed.borderRightWidth) || 0,
|
||||
bottom: parseFloat(computed.borderBottomWidth) || 0,
|
||||
left: parseFloat(computed.borderLeftWidth) || 0,
|
||||
},
|
||||
margin: {
|
||||
top: parseFloat(computed.marginTop) || 0,
|
||||
right: parseFloat(computed.marginRight) || 0,
|
||||
bottom: parseFloat(computed.marginBottom) || 0,
|
||||
left: parseFloat(computed.marginLeft) || 0,
|
||||
},
|
||||
};
|
||||
|
||||
// Matched CSS rules via CSSOM (same-origin only)
|
||||
const matchedRules = [];
|
||||
try {
|
||||
for (const sheet of document.styleSheets) {
|
||||
try {
|
||||
const rules = sheet.cssRules || sheet.rules;
|
||||
if (!rules) continue;
|
||||
for (const rule of rules) {
|
||||
if (rule.type !== CSSRule.STYLE_RULE) continue;
|
||||
try {
|
||||
if (el.matches(rule.selectorText)) {
|
||||
const properties = [];
|
||||
for (let i = 0; i < rule.style.length; i++) {
|
||||
const prop = rule.style[i];
|
||||
properties.push({
|
||||
name: prop,
|
||||
value: rule.style.getPropertyValue(prop),
|
||||
priority: rule.style.getPropertyPriority(prop),
|
||||
});
|
||||
}
|
||||
matchedRules.push({
|
||||
selector: rule.selectorText,
|
||||
properties,
|
||||
source: sheet.href || 'inline',
|
||||
});
|
||||
}
|
||||
} catch { /* skip rules that can't be matched */ }
|
||||
}
|
||||
} catch { /* cross-origin sheet — silently skip */ }
|
||||
}
|
||||
} catch { /* CSSOM not available */ }
|
||||
|
||||
return { computedStyles, boxModel, matchedRules };
|
||||
}
|
||||
|
||||
// ─── Picker Event Handlers ──────────────────────────────────────
|
||||
|
||||
function onMouseMove(e) {
|
||||
if (!pickerActive) return;
|
||||
// Ignore our own overlay elements
|
||||
const target = e.target;
|
||||
if (target === highlightEl || target === tooltipEl) return;
|
||||
if (target.id === 'gstack-inspector-highlight' || target.id === 'gstack-inspector-tooltip') return;
|
||||
|
||||
updateHighlight(target);
|
||||
}
|
||||
|
||||
function onClick(e) {
|
||||
if (!pickerActive) return;
|
||||
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
e.stopImmediatePropagation();
|
||||
|
||||
// Debounce
|
||||
const now = Date.now();
|
||||
if (now - lastPickTime < PICK_DEBOUNCE_MS) return;
|
||||
lastPickTime = now;
|
||||
|
||||
const target = e.target;
|
||||
if (target === highlightEl || target === tooltipEl) return;
|
||||
if (target.id === 'gstack-inspector-highlight' || target.id === 'gstack-inspector-tooltip') return;
|
||||
|
||||
const selector = buildSelector(target);
|
||||
const basicData = captureBasicData(target);
|
||||
|
||||
// Frame detection
|
||||
const frameInfo = {};
|
||||
if (window !== window.top) {
|
||||
try {
|
||||
frameInfo.frameSrc = window.location.href;
|
||||
frameInfo.frameName = window.name || null;
|
||||
} catch { /* cross-origin frame */ }
|
||||
}
|
||||
|
||||
chrome.runtime.sendMessage({
|
||||
type: 'elementPicked',
|
||||
selector,
|
||||
tagName: target.tagName.toLowerCase(),
|
||||
classes: target.className && typeof target.className === 'string'
|
||||
? target.className.trim().split(/\s+/).filter(c => c.length > 0)
|
||||
: [],
|
||||
id: target.id || null,
|
||||
dimensions: {
|
||||
width: Math.round(target.getBoundingClientRect().width),
|
||||
height: Math.round(target.getBoundingClientRect().height),
|
||||
},
|
||||
basicData,
|
||||
...frameInfo,
|
||||
});
|
||||
|
||||
// Keep highlight on the picked element
|
||||
}
|
||||
|
||||
function onKeyDown(e) {
|
||||
if (!pickerActive) return;
|
||||
if (e.key === 'Escape') {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
stopPicker();
|
||||
chrome.runtime.sendMessage({ type: 'pickerCancelled' });
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Picker Start/Stop ──────────────────────────────────────────
|
||||
|
||||
function startPicker() {
|
||||
if (pickerActive) return;
|
||||
pickerActive = true;
|
||||
createHighlight();
|
||||
document.addEventListener('mousemove', onMouseMove, true);
|
||||
document.addEventListener('click', onClick, true);
|
||||
document.addEventListener('keydown', onKeyDown, true);
|
||||
}
|
||||
|
||||
function stopPicker() {
|
||||
if (!pickerActive) return;
|
||||
pickerActive = false;
|
||||
removeHighlight();
|
||||
document.removeEventListener('mousemove', onMouseMove, true);
|
||||
document.removeEventListener('click', onClick, true);
|
||||
document.removeEventListener('keydown', onKeyDown, true);
|
||||
}
|
||||
|
||||
// ─── Page Alteration Handlers ───────────────────────────────────
|
||||
|
||||
function findElement(selector) {
|
||||
try {
|
||||
return document.querySelector(selector);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function applyStyle(selector, property, value) {
|
||||
// Validate property name: alphanumeric + hyphens only
|
||||
if (!/^[a-zA-Z-]+$/.test(property)) return { error: 'Invalid property name' };
|
||||
|
||||
const el = findElement(selector);
|
||||
if (!el) return { error: 'Element not found' };
|
||||
|
||||
// Track original value for resetAll
|
||||
if (!originalStyles.has(el)) {
|
||||
originalStyles.set(el, new Map());
|
||||
}
|
||||
const origMap = originalStyles.get(el);
|
||||
if (!origMap.has(property)) {
|
||||
origMap.set(property, el.style.getPropertyValue(property));
|
||||
}
|
||||
|
||||
el.style.setProperty(property, value, 'important');
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
function toggleClass(selector, className, action) {
|
||||
const el = findElement(selector);
|
||||
if (!el) return { error: 'Element not found' };
|
||||
|
||||
if (action === 'add') {
|
||||
el.classList.add(className);
|
||||
} else if (action === 'remove') {
|
||||
el.classList.remove(className);
|
||||
} else {
|
||||
el.classList.toggle(className);
|
||||
}
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
function injectCSS(id, css) {
|
||||
const styleId = `gstack-inject-${id}`;
|
||||
let styleEl = document.getElementById(styleId);
|
||||
if (!styleEl) {
|
||||
styleEl = document.createElement('style');
|
||||
styleEl.id = styleId;
|
||||
document.head.appendChild(styleEl);
|
||||
}
|
||||
styleEl.textContent = css;
|
||||
injectedStyleIds.add(styleId);
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
function resetAll() {
|
||||
// Restore original inline styles
|
||||
for (const [el, propMap] of originalStyles) {
|
||||
for (const [prop, origVal] of propMap) {
|
||||
if (origVal) {
|
||||
el.style.setProperty(prop, origVal);
|
||||
} else {
|
||||
el.style.removeProperty(prop);
|
||||
}
|
||||
}
|
||||
}
|
||||
originalStyles.clear();
|
||||
|
||||
// Remove injected style elements
|
||||
for (const id of injectedStyleIds) {
|
||||
const el = document.getElementById(id);
|
||||
if (el) el.remove();
|
||||
}
|
||||
injectedStyleIds.clear();
|
||||
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
// ─── Message Listener ──────────────────────────────────────────
|
||||
|
||||
chrome.runtime.onMessage.addListener((msg, sender, sendResponse) => {
|
||||
if (msg.type === 'startPicker') {
|
||||
startPicker();
|
||||
sendResponse({ ok: true });
|
||||
return;
|
||||
}
|
||||
if (msg.type === 'stopPicker') {
|
||||
stopPicker();
|
||||
sendResponse({ ok: true });
|
||||
return;
|
||||
}
|
||||
if (msg.type === 'applyStyle') {
|
||||
const result = applyStyle(msg.selector, msg.property, msg.value);
|
||||
sendResponse(result);
|
||||
return;
|
||||
}
|
||||
if (msg.type === 'toggleClass') {
|
||||
const result = toggleClass(msg.selector, msg.className, msg.action);
|
||||
sendResponse(result);
|
||||
return;
|
||||
}
|
||||
if (msg.type === 'injectCSS') {
|
||||
const result = injectCSS(msg.id, msg.css);
|
||||
sendResponse(result);
|
||||
return;
|
||||
}
|
||||
if (msg.type === 'resetAll') {
|
||||
const result = resetAll();
|
||||
sendResponse(result);
|
||||
return;
|
||||
}
|
||||
});
|
||||
})();
|
||||
@@ -3,7 +3,7 @@
|
||||
"name": "gstack browse",
|
||||
"version": "0.1.0",
|
||||
"description": "Live activity feed and @ref overlays for gstack browse",
|
||||
"permissions": ["sidePanel", "storage", "activeTab"],
|
||||
"permissions": ["sidePanel", "storage", "activeTab", "scripting"],
|
||||
"host_permissions": ["http://127.0.0.1:*/"],
|
||||
"action": {
|
||||
"default_icon": {
|
||||
|
||||
@@ -697,6 +697,496 @@ footer {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* ─── Inspector Tab ──────────────────────────────────── */
|
||||
|
||||
.inspector-toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 6px 10px;
|
||||
background: var(--bg-surface);
|
||||
border-bottom: 1px solid var(--border);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.inspector-pick-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
height: 28px;
|
||||
padding: 0 10px;
|
||||
background: none;
|
||||
border: 1px solid var(--amber-500);
|
||||
border-radius: var(--radius-sm);
|
||||
color: var(--amber-500);
|
||||
font-family: var(--font-system);
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: all 150ms;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.inspector-pick-btn:hover {
|
||||
background: rgba(245, 158, 11, 0.1);
|
||||
color: var(--amber-400);
|
||||
}
|
||||
|
||||
.inspector-pick-btn.active {
|
||||
background: var(--amber-500);
|
||||
color: #000;
|
||||
}
|
||||
|
||||
.inspector-pick-icon {
|
||||
font-size: 14px;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.inspector-selected {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 11px;
|
||||
color: var(--text-body);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.inspector-mode-badge {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 10px;
|
||||
padding: 1px 6px;
|
||||
border-radius: var(--radius-sm);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.inspector-mode-badge.basic {
|
||||
background: var(--zinc-800);
|
||||
color: var(--zinc-400);
|
||||
}
|
||||
|
||||
.inspector-mode-badge.cdp {
|
||||
background: rgba(34, 197, 94, 0.15);
|
||||
color: var(--success);
|
||||
}
|
||||
|
||||
/* Inspector content area */
|
||||
.inspector-content {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
overflow-x: hidden;
|
||||
}
|
||||
|
||||
/* Empty state */
|
||||
.inspector-empty {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 40px 24px;
|
||||
text-align: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.inspector-empty-icon {
|
||||
font-size: 24px;
|
||||
color: var(--zinc-600);
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.inspector-empty p {
|
||||
color: var(--zinc-400);
|
||||
font-size: 13px;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.inspector-empty .muted {
|
||||
color: var(--zinc-600);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
/* Loading state */
|
||||
.inspector-loading {
|
||||
padding: 16px 12px;
|
||||
}
|
||||
|
||||
.inspector-loading-text {
|
||||
font-size: 12px;
|
||||
color: var(--amber-500);
|
||||
margin-bottom: 12px;
|
||||
animation: pulse 2s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.inspector-skeleton {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.inspector-skeleton-bar {
|
||||
height: 12px;
|
||||
background: var(--zinc-800);
|
||||
border-radius: var(--radius-sm);
|
||||
animation: shimmer 1.5s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.inspector-skeleton-bar:nth-child(1) { width: 80%; }
|
||||
.inspector-skeleton-bar:nth-child(2) { width: 60%; }
|
||||
.inspector-skeleton-bar:nth-child(3) { width: 70%; }
|
||||
|
||||
@keyframes shimmer {
|
||||
0%, 100% { opacity: 0.3; }
|
||||
50% { opacity: 0.7; }
|
||||
}
|
||||
|
||||
/* Error state */
|
||||
.inspector-error {
|
||||
padding: 16px 12px;
|
||||
color: var(--error);
|
||||
font-size: 12px;
|
||||
font-family: var(--font-mono);
|
||||
}
|
||||
|
||||
/* Inspector sections */
|
||||
.inspector-section {
|
||||
border-bottom: 1px solid var(--border-subtle);
|
||||
}
|
||||
|
||||
.inspector-section-header {
|
||||
font-family: var(--font-system);
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: var(--zinc-400);
|
||||
padding: 8px 12px 4px;
|
||||
}
|
||||
|
||||
.inspector-section-toggle {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
width: 100%;
|
||||
padding: 8px 12px;
|
||||
background: none;
|
||||
border: none;
|
||||
font-family: var(--font-system);
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: var(--zinc-400);
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
transition: color 150ms;
|
||||
}
|
||||
|
||||
.inspector-section-toggle:hover {
|
||||
color: var(--text-body);
|
||||
}
|
||||
|
||||
.inspector-toggle-arrow {
|
||||
font-size: 10px;
|
||||
color: var(--zinc-400);
|
||||
flex-shrink: 0;
|
||||
width: 12px;
|
||||
}
|
||||
|
||||
.inspector-section-body {
|
||||
padding: 4px 12px 8px;
|
||||
}
|
||||
|
||||
.inspector-section-body.collapsed {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.inspector-rule-count {
|
||||
font-size: 11px;
|
||||
font-weight: 400;
|
||||
color: var(--zinc-600);
|
||||
margin-left: 4px;
|
||||
}
|
||||
|
||||
.inspector-no-data {
|
||||
color: var(--zinc-600);
|
||||
font-size: 11px;
|
||||
font-style: italic;
|
||||
padding: 4px 0;
|
||||
}
|
||||
|
||||
/* ─── Box Model ──────────────────────────────────────── */
|
||||
|
||||
.inspector-boxmodel {
|
||||
padding: 8px 12px 12px;
|
||||
}
|
||||
|
||||
.boxmodel-margin,
|
||||
.boxmodel-border,
|
||||
.boxmodel-padding,
|
||||
.boxmodel-content {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border: 1px dashed;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.boxmodel-margin {
|
||||
background: rgba(245, 158, 11, 0.08);
|
||||
border-color: rgba(245, 158, 11, 0.3);
|
||||
padding: 14px 20px;
|
||||
border-radius: var(--radius-sm);
|
||||
}
|
||||
|
||||
.boxmodel-border {
|
||||
background: rgba(161, 161, 170, 0.08);
|
||||
border-color: rgba(161, 161, 170, 0.3);
|
||||
padding: 14px 20px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.boxmodel-padding {
|
||||
background: rgba(34, 197, 94, 0.08);
|
||||
border-color: rgba(34, 197, 94, 0.3);
|
||||
padding: 14px 20px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.boxmodel-content {
|
||||
background: rgba(59, 130, 246, 0.08);
|
||||
border-color: rgba(59, 130, 246, 0.3);
|
||||
padding: 8px 12px;
|
||||
width: 100%;
|
||||
min-height: 28px;
|
||||
}
|
||||
|
||||
.boxmodel-content span {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 11px;
|
||||
color: var(--text-body);
|
||||
}
|
||||
|
||||
.boxmodel-label {
|
||||
position: absolute;
|
||||
top: 1px;
|
||||
left: 4px;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 10px;
|
||||
color: var(--zinc-400);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.boxmodel-value {
|
||||
position: absolute;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 11px;
|
||||
color: var(--text-body);
|
||||
}
|
||||
|
||||
.boxmodel-value.boxmodel-top { top: 1px; left: 50%; transform: translateX(-50%); }
|
||||
.boxmodel-value.boxmodel-right { right: 4px; top: 50%; transform: translateY(-50%); }
|
||||
.boxmodel-value.boxmodel-bottom { bottom: 1px; left: 50%; transform: translateX(-50%); }
|
||||
.boxmodel-value.boxmodel-left { left: 4px; top: 50%; transform: translateY(-50%); }
|
||||
|
||||
/* ─── Matched Rules ──────────────────────────────────── */
|
||||
|
||||
.inspector-rule {
|
||||
padding: 6px 0;
|
||||
border-bottom: 1px solid var(--border-subtle);
|
||||
}
|
||||
|
||||
.inspector-rule:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.inspector-rule-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
margin-bottom: 2px;
|
||||
}
|
||||
|
||||
.inspector-selector {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 12px;
|
||||
color: var(--amber-400);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
max-width: 35ch;
|
||||
}
|
||||
|
||||
.inspector-specificity {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 10px;
|
||||
background: var(--zinc-600);
|
||||
color: var(--zinc-400);
|
||||
padding: 0 4px;
|
||||
border-radius: var(--radius-sm);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.inspector-rule-props {
|
||||
padding-left: 12px;
|
||||
}
|
||||
|
||||
.inspector-prop {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 12px;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.inspector-prop.overridden {
|
||||
text-decoration: line-through;
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.inspector-prop-name {
|
||||
color: var(--zinc-400);
|
||||
}
|
||||
|
||||
.inspector-prop-value {
|
||||
color: var(--text-body);
|
||||
}
|
||||
|
||||
.inspector-important {
|
||||
color: var(--error);
|
||||
font-size: 10px;
|
||||
}
|
||||
|
||||
.inspector-rule-source {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 11px;
|
||||
color: var(--zinc-600);
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
/* UA rules */
|
||||
.inspector-ua-rules {
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.inspector-ua-toggle {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
background: none;
|
||||
border: none;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 11px;
|
||||
color: var(--zinc-600);
|
||||
cursor: pointer;
|
||||
padding: 4px 0;
|
||||
transition: color 150ms;
|
||||
}
|
||||
|
||||
.inspector-ua-toggle:hover {
|
||||
color: var(--zinc-400);
|
||||
}
|
||||
|
||||
.inspector-ua-body.collapsed {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* ─── Computed Styles ────────────────────────────────── */
|
||||
|
||||
.inspector-computed-row {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 12px;
|
||||
line-height: 1.6;
|
||||
padding: 0 0 0 4px;
|
||||
}
|
||||
|
||||
.inspector-computed-row .inspector-prop-name {
|
||||
color: var(--zinc-400);
|
||||
}
|
||||
|
||||
.inspector-computed-row .inspector-prop-value {
|
||||
color: var(--text-body);
|
||||
}
|
||||
|
||||
/* ─── Quick Edit ─────────────────────────────────────── */
|
||||
|
||||
.inspector-quickedit-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.inspector-quickedit-row {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 12px;
|
||||
line-height: 1.6;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.inspector-quickedit-row .inspector-prop-name {
|
||||
color: var(--zinc-400);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.inspector-quickedit-value {
|
||||
color: var(--text-body);
|
||||
cursor: pointer;
|
||||
padding: 1px 4px;
|
||||
border-radius: 2px;
|
||||
transition: background 150ms;
|
||||
min-width: 40px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.inspector-quickedit-value:hover {
|
||||
background: var(--bg-hover);
|
||||
}
|
||||
|
||||
.inspector-quickedit-input {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 12px;
|
||||
background: var(--bg-base);
|
||||
border: 1px solid var(--amber-500);
|
||||
border-radius: 2px;
|
||||
color: var(--text-heading);
|
||||
padding: 1px 4px;
|
||||
outline: none;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
/* ─── Send to Agent ──────────────────────────────────── */
|
||||
|
||||
.inspector-send {
|
||||
padding: 8px 12px;
|
||||
background: var(--bg-surface);
|
||||
border-top: 1px solid var(--border);
|
||||
flex-shrink: 0;
|
||||
position: sticky;
|
||||
bottom: 0;
|
||||
}
|
||||
|
||||
.inspector-send-btn {
|
||||
width: 100%;
|
||||
height: 32px;
|
||||
background: var(--amber-500);
|
||||
border: none;
|
||||
border-radius: var(--radius-md);
|
||||
color: #000;
|
||||
font-family: var(--font-system);
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: all 150ms;
|
||||
}
|
||||
|
||||
.inspector-send-btn:hover {
|
||||
background: var(--amber-400);
|
||||
}
|
||||
|
||||
.inspector-send-btn:active {
|
||||
transform: scale(0.98);
|
||||
}
|
||||
|
||||
/* ─── Accessibility ───────────────────────────────────── */
|
||||
:focus-visible {
|
||||
outline: 2px solid var(--amber-500);
|
||||
|
||||
@@ -48,6 +48,83 @@
|
||||
<div class="refs-footer" id="refs-footer"></div>
|
||||
</main>
|
||||
|
||||
<!-- Debug: Inspector Tab (hidden by default) -->
|
||||
<main id="tab-inspector" class="tab-content">
|
||||
<!-- Toolbar: always visible -->
|
||||
<div class="inspector-toolbar" id="inspector-toolbar">
|
||||
<button class="inspector-pick-btn" id="inspector-pick-btn" title="Pick an element (click, then click any element on the page)">
|
||||
<span class="inspector-pick-icon">✛</span> Pick
|
||||
</button>
|
||||
<span class="inspector-selected" id="inspector-selected"></span>
|
||||
<span class="inspector-mode-badge" id="inspector-mode-badge" style="display:none"></span>
|
||||
</div>
|
||||
|
||||
<!-- Inspector content area -->
|
||||
<div class="inspector-content" id="inspector-content">
|
||||
<!-- Empty state (before first pick) -->
|
||||
<div class="inspector-empty" id="inspector-empty">
|
||||
<div class="inspector-empty-icon">✛</div>
|
||||
<p>Pick an element to inspect</p>
|
||||
<p class="muted">Click the button above, then click any element on the page</p>
|
||||
</div>
|
||||
|
||||
<!-- Loading state -->
|
||||
<div class="inspector-loading" id="inspector-loading" style="display:none">
|
||||
<div class="inspector-loading-text">Inspecting...</div>
|
||||
<div class="inspector-skeleton">
|
||||
<div class="inspector-skeleton-bar"></div>
|
||||
<div class="inspector-skeleton-bar"></div>
|
||||
<div class="inspector-skeleton-bar"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Error state -->
|
||||
<div class="inspector-error" id="inspector-error" style="display:none"></div>
|
||||
|
||||
<!-- Inspector data panels -->
|
||||
<div class="inspector-panels" id="inspector-panels" style="display:none">
|
||||
<!-- Box Model -->
|
||||
<div class="inspector-section" id="inspector-boxmodel-section">
|
||||
<div class="inspector-section-header">Box Model</div>
|
||||
<div class="inspector-boxmodel" id="inspector-boxmodel"></div>
|
||||
</div>
|
||||
|
||||
<!-- Matched Rules -->
|
||||
<div class="inspector-section" id="inspector-rules-section">
|
||||
<button class="inspector-section-toggle" data-section="rules" aria-expanded="true">
|
||||
<span class="inspector-toggle-arrow">▼</span>
|
||||
<span>Matched Rules</span>
|
||||
<span class="inspector-rule-count" id="inspector-rule-count"></span>
|
||||
</button>
|
||||
<div class="inspector-section-body" id="inspector-rules" role="tree"></div>
|
||||
</div>
|
||||
|
||||
<!-- Computed Styles -->
|
||||
<div class="inspector-section" id="inspector-computed-section">
|
||||
<button class="inspector-section-toggle collapsed" data-section="computed" aria-expanded="false">
|
||||
<span class="inspector-toggle-arrow">▶</span>
|
||||
<span>Computed</span>
|
||||
</button>
|
||||
<div class="inspector-section-body collapsed" id="inspector-computed"></div>
|
||||
</div>
|
||||
|
||||
<!-- Quick Edit -->
|
||||
<div class="inspector-section" id="inspector-quickedit-section">
|
||||
<button class="inspector-section-toggle collapsed" data-section="quickedit" aria-expanded="false">
|
||||
<span class="inspector-toggle-arrow">▶</span>
|
||||
<span>Quick Edit</span>
|
||||
</button>
|
||||
<div class="inspector-section-body collapsed" id="inspector-quickedit"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Send to Agent: sticky bottom -->
|
||||
<div class="inspector-send" id="inspector-send" style="display:none">
|
||||
<button class="inspector-send-btn" id="inspector-send-btn">Send to Agent</button>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<!-- Experimental chat banner (shown when chatEnabled) -->
|
||||
<div id="experimental-banner" class="experimental-banner" style="display: none;">
|
||||
⚠ Standalone mode — this is a separate agent from your workspace
|
||||
@@ -76,6 +153,7 @@
|
||||
<nav class="tabs debug-tabs" id="debug-tabs" role="tablist" style="display:none">
|
||||
<button class="tab" role="tab" data-tab="activity">Activity</button>
|
||||
<button class="tab" role="tab" data-tab="refs">Refs</button>
|
||||
<button class="tab" role="tab" data-tab="inspector">Inspector</button>
|
||||
<button class="tab close-debug" id="close-debug" title="Close debug">×</button>
|
||||
</nav>
|
||||
|
||||
|
||||
@@ -523,6 +523,429 @@ async function fetchRefs() {
|
||||
} catch {}
|
||||
}
|
||||
|
||||
// ─── Inspector Tab ──────────────────────────────────────────────
|
||||
|
||||
let inspectorPickerActive = false;
|
||||
let inspectorData = null; // last inspect result
|
||||
let inspectorModifications = []; // tracked style changes
|
||||
let inspectorSSE = null;
|
||||
|
||||
// Inspector DOM refs
|
||||
const inspectorPickBtn = document.getElementById('inspector-pick-btn');
|
||||
const inspectorSelected = document.getElementById('inspector-selected');
|
||||
const inspectorModeBadge = document.getElementById('inspector-mode-badge');
|
||||
const inspectorEmpty = document.getElementById('inspector-empty');
|
||||
const inspectorLoading = document.getElementById('inspector-loading');
|
||||
const inspectorError = document.getElementById('inspector-error');
|
||||
const inspectorPanels = document.getElementById('inspector-panels');
|
||||
const inspectorBoxmodel = document.getElementById('inspector-boxmodel');
|
||||
const inspectorRules = document.getElementById('inspector-rules');
|
||||
const inspectorRuleCount = document.getElementById('inspector-rule-count');
|
||||
const inspectorComputed = document.getElementById('inspector-computed');
|
||||
const inspectorQuickedit = document.getElementById('inspector-quickedit');
|
||||
const inspectorSend = document.getElementById('inspector-send');
|
||||
const inspectorSendBtn = document.getElementById('inspector-send-btn');
|
||||
|
||||
// Pick button
|
||||
inspectorPickBtn.addEventListener('click', () => {
|
||||
if (inspectorPickerActive) {
|
||||
inspectorPickerActive = false;
|
||||
inspectorPickBtn.classList.remove('active');
|
||||
chrome.runtime.sendMessage({ type: 'stopInspector' });
|
||||
} else {
|
||||
inspectorPickerActive = true;
|
||||
inspectorPickBtn.classList.add('active');
|
||||
inspectorShowLoading(false); // don't show loading yet, just activate
|
||||
chrome.runtime.sendMessage({ type: 'startInspector' }, (result) => {
|
||||
if (result?.error) {
|
||||
inspectorPickerActive = false;
|
||||
inspectorPickBtn.classList.remove('active');
|
||||
inspectorShowError(result.error);
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
function inspectorShowEmpty() {
|
||||
inspectorEmpty.style.display = '';
|
||||
inspectorLoading.style.display = 'none';
|
||||
inspectorError.style.display = 'none';
|
||||
inspectorPanels.style.display = 'none';
|
||||
inspectorSend.style.display = 'none';
|
||||
}
|
||||
|
||||
function inspectorShowLoading(show) {
|
||||
if (show) {
|
||||
inspectorEmpty.style.display = 'none';
|
||||
inspectorLoading.style.display = '';
|
||||
inspectorError.style.display = 'none';
|
||||
inspectorPanels.style.display = 'none';
|
||||
} else {
|
||||
inspectorLoading.style.display = 'none';
|
||||
}
|
||||
}
|
||||
|
||||
function inspectorShowError(message) {
|
||||
inspectorEmpty.style.display = 'none';
|
||||
inspectorLoading.style.display = 'none';
|
||||
inspectorError.style.display = '';
|
||||
inspectorError.textContent = message;
|
||||
inspectorPanels.style.display = 'none';
|
||||
}
|
||||
|
||||
function inspectorShowData(data) {
|
||||
inspectorData = data;
|
||||
inspectorModifications = [];
|
||||
inspectorEmpty.style.display = 'none';
|
||||
inspectorLoading.style.display = 'none';
|
||||
inspectorError.style.display = 'none';
|
||||
inspectorPanels.style.display = '';
|
||||
inspectorSend.style.display = '';
|
||||
|
||||
// Update toolbar
|
||||
const tag = data.tagName || '?';
|
||||
const cls = data.classes && data.classes.length > 0 ? '.' + data.classes.join('.') : '';
|
||||
const idStr = data.id ? '#' + data.id : '';
|
||||
inspectorSelected.textContent = `<${tag}>${idStr}${cls}`;
|
||||
inspectorSelected.title = data.selector;
|
||||
|
||||
// Mode badge
|
||||
if (data.mode === 'basic') {
|
||||
inspectorModeBadge.textContent = 'Basic mode';
|
||||
inspectorModeBadge.style.display = '';
|
||||
inspectorModeBadge.className = 'inspector-mode-badge basic';
|
||||
} else if (data.mode === 'cdp') {
|
||||
inspectorModeBadge.textContent = 'CDP';
|
||||
inspectorModeBadge.style.display = '';
|
||||
inspectorModeBadge.className = 'inspector-mode-badge cdp';
|
||||
} else {
|
||||
inspectorModeBadge.style.display = 'none';
|
||||
}
|
||||
|
||||
// Render sections
|
||||
renderBoxModel(data);
|
||||
renderMatchedRules(data);
|
||||
renderComputedStyles(data);
|
||||
renderQuickEdit(data);
|
||||
updateSendButton();
|
||||
}
|
||||
|
||||
// ─── Box Model Rendering ────────────────────────────────────────
|
||||
|
||||
function renderBoxModel(data) {
|
||||
const box = data.basicData?.boxModel || data.boxModel;
|
||||
if (!box) { inspectorBoxmodel.innerHTML = '<span class="inspector-no-data">No box model data</span>'; return; }
|
||||
|
||||
const m = box.margin || {};
|
||||
const b = box.border || {};
|
||||
const p = box.padding || {};
|
||||
const c = box.content || {};
|
||||
|
||||
inspectorBoxmodel.innerHTML = `
|
||||
<div class="boxmodel-margin">
|
||||
<span class="boxmodel-label">margin</span>
|
||||
<span class="boxmodel-value boxmodel-top">${fmtBoxVal(m.top)}</span>
|
||||
<span class="boxmodel-value boxmodel-right">${fmtBoxVal(m.right)}</span>
|
||||
<span class="boxmodel-value boxmodel-bottom">${fmtBoxVal(m.bottom)}</span>
|
||||
<span class="boxmodel-value boxmodel-left">${fmtBoxVal(m.left)}</span>
|
||||
<div class="boxmodel-border">
|
||||
<span class="boxmodel-label">border</span>
|
||||
<span class="boxmodel-value boxmodel-top">${fmtBoxVal(b.top)}</span>
|
||||
<span class="boxmodel-value boxmodel-right">${fmtBoxVal(b.right)}</span>
|
||||
<span class="boxmodel-value boxmodel-bottom">${fmtBoxVal(b.bottom)}</span>
|
||||
<span class="boxmodel-value boxmodel-left">${fmtBoxVal(b.left)}</span>
|
||||
<div class="boxmodel-padding">
|
||||
<span class="boxmodel-label">padding</span>
|
||||
<span class="boxmodel-value boxmodel-top">${fmtBoxVal(p.top)}</span>
|
||||
<span class="boxmodel-value boxmodel-right">${fmtBoxVal(p.right)}</span>
|
||||
<span class="boxmodel-value boxmodel-bottom">${fmtBoxVal(p.bottom)}</span>
|
||||
<span class="boxmodel-value boxmodel-left">${fmtBoxVal(p.left)}</span>
|
||||
<div class="boxmodel-content">
|
||||
<span>${Math.round(c.width || 0)} x ${Math.round(c.height || 0)}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
function fmtBoxVal(v) {
|
||||
if (v === undefined || v === null) return '-';
|
||||
const n = typeof v === 'number' ? v : parseFloat(v);
|
||||
if (isNaN(n) || n === 0) return '0';
|
||||
return Math.round(n * 10) / 10;
|
||||
}
|
||||
|
||||
// ─── Matched Rules Rendering ────────────────────────────────────
|
||||
|
||||
function renderMatchedRules(data) {
|
||||
const rules = data.matchedRules || data.basicData?.matchedRules || [];
|
||||
inspectorRuleCount.textContent = rules.length > 0 ? `(${rules.length})` : '';
|
||||
|
||||
if (rules.length === 0) {
|
||||
inspectorRules.innerHTML = '<div class="inspector-no-data">No matched rules</div>';
|
||||
return;
|
||||
}
|
||||
|
||||
// Separate UA rules from author rules
|
||||
const authorRules = [];
|
||||
const uaRules = [];
|
||||
for (const rule of rules) {
|
||||
if (rule.origin === 'user-agent' || rule.isUA) {
|
||||
uaRules.push(rule);
|
||||
} else {
|
||||
authorRules.push(rule);
|
||||
}
|
||||
}
|
||||
|
||||
let html = '';
|
||||
|
||||
// Author rules (expanded)
|
||||
for (const rule of authorRules) {
|
||||
html += renderRule(rule, false);
|
||||
}
|
||||
|
||||
// UA rules (collapsed by default)
|
||||
if (uaRules.length > 0) {
|
||||
html += `
|
||||
<div class="inspector-ua-rules">
|
||||
<button class="inspector-ua-toggle collapsed" aria-expanded="false">
|
||||
<span class="inspector-toggle-arrow">▶</span>
|
||||
User Agent (${uaRules.length})
|
||||
</button>
|
||||
<div class="inspector-ua-body collapsed">
|
||||
`;
|
||||
for (const rule of uaRules) {
|
||||
html += renderRule(rule, true);
|
||||
}
|
||||
html += '</div></div>';
|
||||
}
|
||||
|
||||
inspectorRules.innerHTML = html;
|
||||
|
||||
// Bind UA toggle
|
||||
const uaToggle = inspectorRules.querySelector('.inspector-ua-toggle');
|
||||
if (uaToggle) {
|
||||
uaToggle.addEventListener('click', () => {
|
||||
const body = inspectorRules.querySelector('.inspector-ua-body');
|
||||
const isCollapsed = uaToggle.classList.contains('collapsed');
|
||||
uaToggle.classList.toggle('collapsed', !isCollapsed);
|
||||
uaToggle.setAttribute('aria-expanded', isCollapsed);
|
||||
uaToggle.querySelector('.inspector-toggle-arrow').innerHTML = isCollapsed ? '▼' : '▶';
|
||||
body.classList.toggle('collapsed', !isCollapsed);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function renderRule(rule, isUA) {
|
||||
const selectorText = escapeHtml(rule.selector || '');
|
||||
const truncatedSelector = selectorText.length > 35 ? selectorText.slice(0, 35) + '...' : selectorText;
|
||||
const source = rule.source || '';
|
||||
const sourceDisplay = source.includes('/') ? source.split('/').pop() : source;
|
||||
const specificity = rule.specificity || '';
|
||||
|
||||
let propsHtml = '';
|
||||
const props = rule.properties || [];
|
||||
for (const prop of props) {
|
||||
const overridden = prop.overridden ? ' overridden' : '';
|
||||
const nameHtml = escapeHtml(prop.name);
|
||||
const valText = escapeHtml(prop.value || '');
|
||||
const truncatedVal = valText.length > 30 ? valText.slice(0, 30) + '...' : valText;
|
||||
const priority = prop.priority === 'important' ? ' <span class="inspector-important">!important</span>' : '';
|
||||
propsHtml += `<div class="inspector-prop${overridden}"><span class="inspector-prop-name">${nameHtml}</span>: <span class="inspector-prop-value" title="${valText}">${truncatedVal}</span>${priority};</div>`;
|
||||
}
|
||||
|
||||
return `
|
||||
<div class="inspector-rule" role="treeitem">
|
||||
<div class="inspector-rule-header">
|
||||
<span class="inspector-selector" title="${selectorText}">${truncatedSelector}</span>
|
||||
${specificity ? `<span class="inspector-specificity">${escapeHtml(specificity)}</span>` : ''}
|
||||
</div>
|
||||
<div class="inspector-rule-props">${propsHtml}</div>
|
||||
${sourceDisplay ? `<div class="inspector-rule-source">${escapeHtml(sourceDisplay)}</div>` : ''}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
// ─── Computed Styles Rendering ──────────────────────────────────
|
||||
|
||||
function renderComputedStyles(data) {
|
||||
const styles = data.computedStyles || data.basicData?.computedStyles || {};
|
||||
const keys = Object.keys(styles);
|
||||
|
||||
if (keys.length === 0) {
|
||||
inspectorComputed.innerHTML = '<div class="inspector-no-data">No computed styles</div>';
|
||||
return;
|
||||
}
|
||||
|
||||
let html = '';
|
||||
for (const key of keys) {
|
||||
const val = styles[key];
|
||||
if (!val || val === 'none' || val === 'normal' || val === 'auto' || val === '0px' || val === 'rgba(0, 0, 0, 0)') continue;
|
||||
html += `<div class="inspector-computed-row"><span class="inspector-prop-name">${escapeHtml(key)}</span>: <span class="inspector-prop-value">${escapeHtml(val)}</span></div>`;
|
||||
}
|
||||
|
||||
if (!html) {
|
||||
html = '<div class="inspector-no-data">All values are defaults</div>';
|
||||
}
|
||||
|
||||
inspectorComputed.innerHTML = html;
|
||||
}
|
||||
|
||||
// ─── Quick Edit ─────────────────────────────────────────────────
|
||||
|
||||
function renderQuickEdit(data) {
|
||||
const selector = data.selector;
|
||||
if (!selector) { inspectorQuickedit.innerHTML = ''; return; }
|
||||
|
||||
// Show common editable properties with current values
|
||||
const editableProps = ['color', 'background-color', 'font-size', 'padding', 'margin', 'border', 'display', 'opacity'];
|
||||
const computed = data.computedStyles || data.basicData?.computedStyles || {};
|
||||
|
||||
let html = '<div class="inspector-quickedit-list">';
|
||||
for (const prop of editableProps) {
|
||||
const val = computed[prop] || '';
|
||||
html += `
|
||||
<div class="inspector-quickedit-row" data-prop="${escapeHtml(prop)}">
|
||||
<span class="inspector-prop-name">${escapeHtml(prop)}</span>:
|
||||
<span class="inspector-quickedit-value" data-selector="${escapeHtml(selector)}" data-prop="${escapeHtml(prop)}" tabindex="0" role="button" title="Click to edit">${escapeHtml(val || '(none)')}</span>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
html += '</div>';
|
||||
inspectorQuickedit.innerHTML = html;
|
||||
|
||||
// Bind click-to-edit
|
||||
inspectorQuickedit.querySelectorAll('.inspector-quickedit-value').forEach(el => {
|
||||
el.addEventListener('click', () => startQuickEdit(el));
|
||||
el.addEventListener('keydown', (e) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); startQuickEdit(el); }
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function startQuickEdit(valueEl) {
|
||||
if (valueEl.querySelector('input')) return; // already editing
|
||||
|
||||
const currentVal = valueEl.textContent === '(none)' ? '' : valueEl.textContent;
|
||||
const prop = valueEl.dataset.prop;
|
||||
const selector = valueEl.dataset.selector;
|
||||
|
||||
const input = document.createElement('input');
|
||||
input.type = 'text';
|
||||
input.className = 'inspector-quickedit-input';
|
||||
input.value = currentVal;
|
||||
valueEl.textContent = '';
|
||||
valueEl.appendChild(input);
|
||||
input.focus();
|
||||
input.select();
|
||||
|
||||
function commit() {
|
||||
const newVal = input.value.trim();
|
||||
valueEl.textContent = newVal || '(none)';
|
||||
if (newVal && newVal !== currentVal) {
|
||||
chrome.runtime.sendMessage({
|
||||
type: 'applyStyle',
|
||||
selector,
|
||||
property: prop,
|
||||
value: newVal,
|
||||
});
|
||||
inspectorModifications.push({ property: prop, value: newVal, selector });
|
||||
updateSendButton();
|
||||
}
|
||||
}
|
||||
|
||||
function cancel() {
|
||||
valueEl.textContent = currentVal || '(none)';
|
||||
}
|
||||
|
||||
input.addEventListener('blur', commit);
|
||||
input.addEventListener('keydown', (e) => {
|
||||
if (e.key === 'Enter') { e.preventDefault(); input.blur(); }
|
||||
if (e.key === 'Escape') { e.preventDefault(); input.removeEventListener('blur', commit); cancel(); }
|
||||
});
|
||||
}
|
||||
|
||||
// ─── Send to Agent ──────────────────────────────────────────────
|
||||
|
||||
function updateSendButton() {
|
||||
if (inspectorModifications.length > 0) {
|
||||
inspectorSendBtn.textContent = 'Send to Code';
|
||||
inspectorSendBtn.title = `${inspectorModifications.length} modification(s) to send`;
|
||||
} else {
|
||||
inspectorSendBtn.textContent = 'Send to Agent';
|
||||
inspectorSendBtn.title = 'Send full inspector data';
|
||||
}
|
||||
}
|
||||
|
||||
inspectorSendBtn.addEventListener('click', () => {
|
||||
if (!inspectorData) return;
|
||||
|
||||
let message;
|
||||
if (inspectorModifications.length > 0) {
|
||||
// Format modification diff
|
||||
const diffs = inspectorModifications.map(m =>
|
||||
` ${m.property}: ${m.value} (selector: ${m.selector})`
|
||||
).join('\n');
|
||||
message = `CSS Inspector modifications:\n\nSelector: ${inspectorData.selector}\n\nChanges:\n${diffs}`;
|
||||
|
||||
// Include source file info if available
|
||||
const rules = inspectorData.matchedRules || inspectorData.basicData?.matchedRules || [];
|
||||
const sources = rules.filter(r => r.source && r.source !== 'inline').map(r => r.source);
|
||||
if (sources.length > 0) {
|
||||
message += `\n\nSource files:\n${[...new Set(sources)].map(s => ` ${s}`).join('\n')}`;
|
||||
}
|
||||
} else {
|
||||
// Send full inspector data
|
||||
message = `CSS Inspector data for: ${inspectorData.selector}\n\n${JSON.stringify(inspectorData, null, 2)}`;
|
||||
}
|
||||
|
||||
chrome.runtime.sendMessage({ type: 'sidebar-command', message });
|
||||
});
|
||||
|
||||
// ─── Section Toggles ────────────────────────────────────────────
|
||||
|
||||
document.querySelectorAll('.inspector-section-toggle').forEach(toggle => {
|
||||
toggle.addEventListener('click', () => {
|
||||
const section = toggle.dataset.section;
|
||||
const body = document.getElementById(`inspector-${section}`);
|
||||
const isCollapsed = toggle.classList.contains('collapsed');
|
||||
|
||||
toggle.classList.toggle('collapsed', !isCollapsed);
|
||||
toggle.setAttribute('aria-expanded', isCollapsed);
|
||||
toggle.querySelector('.inspector-toggle-arrow').innerHTML = isCollapsed ? '▼' : '▶';
|
||||
body.classList.toggle('collapsed', !isCollapsed);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Inspector SSE ──────────────────────────────────────────────
|
||||
|
||||
function connectInspectorSSE() {
|
||||
if (!serverUrl || !serverToken) return;
|
||||
if (inspectorSSE) { inspectorSSE.close(); inspectorSSE = null; }
|
||||
|
||||
const tokenParam = serverToken ? `&token=${serverToken}` : '';
|
||||
const url = `${serverUrl}/inspector/events?_=${Date.now()}${tokenParam}`;
|
||||
|
||||
try {
|
||||
inspectorSSE = new EventSource(url);
|
||||
|
||||
inspectorSSE.addEventListener('inspectResult', (e) => {
|
||||
try {
|
||||
const data = JSON.parse(e.data);
|
||||
inspectorShowData(data);
|
||||
} catch {}
|
||||
});
|
||||
|
||||
inspectorSSE.addEventListener('error', () => {
|
||||
// SSE connection failed — inspector works without it (basic mode)
|
||||
if (inspectorSSE) { inspectorSSE.close(); inspectorSSE = null; }
|
||||
});
|
||||
} catch {
|
||||
// SSE not available — that's fine
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Server Discovery ───────────────────────────────────────────
|
||||
|
||||
function updateConnection(url, token) {
|
||||
@@ -535,6 +958,7 @@ function updateConnection(url, token) {
|
||||
document.getElementById('footer-port').textContent = `:${port}`;
|
||||
setConnState('connected');
|
||||
connectSSE();
|
||||
connectInspectorSSE();
|
||||
if (chatPollInterval) clearInterval(chatPollInterval);
|
||||
chatPollInterval = setInterval(pollChat, 1000);
|
||||
pollChat();
|
||||
@@ -623,6 +1047,19 @@ chrome.runtime.onMessage.addListener((msg) => {
|
||||
fetchRefs();
|
||||
}
|
||||
}
|
||||
if (msg.type === 'inspectResult') {
|
||||
inspectorPickerActive = false;
|
||||
inspectorPickBtn.classList.remove('active');
|
||||
if (msg.data) {
|
||||
inspectorShowData(msg.data);
|
||||
} else {
|
||||
inspectorShowError('Element not found, try picking again');
|
||||
}
|
||||
}
|
||||
if (msg.type === 'pickerCancelled') {
|
||||
inspectorPickerActive = false;
|
||||
inspectorPickBtn.classList.remove('active');
|
||||
}
|
||||
});
|
||||
|
||||
// ─── Chat Gate ──────────────────────────────────────────────────
|
||||
|
||||
Reference in New Issue
Block a user