From 44837f752abb22cfb022d1d72b6f97e02ed7ff21 Mon Sep 17 00:00:00 2001 From: TristanEDU Date: Sat, 11 Oct 2025 17:28:08 -0700 Subject: [PATCH] chore: add npm lockfile --- background.js | 38 +- docs/privacy-policy.md | 5 + docs/release-checklist.md | 18 + icons/base.svg | 12 + icons/ui/adjustments-horizontal.svg | 11 + icons/ui/arrow-path.svg | 6 + icons/ui/arrows-pointing-in.svg | 10 + icons/ui/arrows-pointing-out.svg | 10 + icons/ui/arrows-up-down-left-right.svg | 8 + icons/ui/eye-slash.svg | 6 + icons/ui/eye.svg | 4 + icons/ui/hand.svg | 6 + icons/ui/lock-closed.svg | 5 + icons/ui/lock-open.svg | 5 + icons/ui/moon.svg | 3 + icons/ui/question-mark-circle.svg | 5 + icons/ui/squares-2x2.svg | 6 + manifest.json | 17 +- package-lock.json | 13 + package.json | 17 + scripts/content.js | 173 ----- scripts/overlay.js | 854 +++++++++++++++++++++++++ tools/gen-icons.mjs | 126 ++++ 23 files changed, 1146 insertions(+), 212 deletions(-) create mode 100644 docs/privacy-policy.md create mode 100644 docs/release-checklist.md create mode 100644 icons/base.svg create mode 100644 icons/ui/adjustments-horizontal.svg create mode 100644 icons/ui/arrow-path.svg create mode 100644 icons/ui/arrows-pointing-in.svg create mode 100644 icons/ui/arrows-pointing-out.svg create mode 100644 icons/ui/arrows-up-down-left-right.svg create mode 100644 icons/ui/eye-slash.svg create mode 100644 icons/ui/eye.svg create mode 100644 icons/ui/hand.svg create mode 100644 icons/ui/lock-closed.svg create mode 100644 icons/ui/lock-open.svg create mode 100644 icons/ui/moon.svg create mode 100644 icons/ui/question-mark-circle.svg create mode 100644 icons/ui/squares-2x2.svg create mode 100644 package-lock.json create mode 100644 package.json delete mode 100644 scripts/content.js create mode 100644 scripts/overlay.js create mode 100644 tools/gen-icons.mjs diff --git a/background.js b/background.js index 04bbf89..b7acb90 100644 --- a/background.js +++ b/background.js @@ -1,34 +1,8 @@ -const tabOverlayStatus = {}; - chrome.action.onClicked.addListener(async (tab) => { - const tabId = tab.id; + if (!tab?.id) return - // First, always attempt to remove any existing overlay from this tab - chrome.scripting.executeScript({ - target: { tabId }, - func: () => { - const iframe = document.getElementById("overlay-iframe"); - const controls = Array.from(document.querySelectorAll("div")).find( - (el) => - el.textContent?.includes("Overlay Controls") && - el.style?.position === "fixed" - ); - if (iframe) iframe.remove(); - if (controls) controls.remove(); - }, - }); - - // If the overlay was already active, we just removed it above - if (tabOverlayStatus[tabId]) { - tabOverlayStatus[tabId] = false; - chrome.action.setIcon({ tabId, path: "icon-off.png" }); // optional - } else { - // If not active, inject the overlay - chrome.scripting.executeScript({ - target: { tabId }, - files: ["content.js"], - }); - tabOverlayStatus[tabId] = true; - chrome.action.setIcon({ tabId, path: "icon-on.png" }); // optional - } -}); + await chrome.scripting.executeScript({ + target: { tabId: tab.id }, + files: ['scripts/overlay.js'], + }) +}) diff --git a/docs/privacy-policy.md b/docs/privacy-policy.md new file mode 100644 index 0000000..30f42aa --- /dev/null +++ b/docs/privacy-policy.md @@ -0,0 +1,5 @@ +# Privacy Policy + +Live Overlay does not collect, transmit, or sell personal data. Settings such as the last used URL and UI preferences are stored locally via `chrome.storage.local`. No data leaves your device. + +Contact: support@example.com diff --git a/docs/release-checklist.md b/docs/release-checklist.md new file mode 100644 index 0000000..aa10431 --- /dev/null +++ b/docs/release-checklist.md @@ -0,0 +1,18 @@ +# Live Overlay – Release Checklist + +## Pre-build +- [ ] Run `npm run genicons` and confirm `icons/icon-16.png`, `icon-32.png`, `icon-48.png`, and `icon-128.png` are refreshed. +- [ ] Verify inline SVG toolbar icons render correctly against light and dark web pages. +- [ ] Ensure `manifest.json` lists only `"permissions": ["activeTab","scripting","storage"]` and no `host_permissions` or ``. + +## QA & Validation +- [ ] Load the unpacked extension in `chrome://extensions` (Developer Mode) without warnings. +- [ ] Click the action icon to toggle the overlay on/off; repeat to confirm no duplicate roots remain. +- [ ] Confirm drag, resize, click-through, opacity, invert, grid, and nudge controls function with keyboard focus rings. +- [ ] Reload the tab and browser to ensure settings persist via `chrome.storage.local`. +- [ ] Capture updated promo screenshots and store assets in 440×280 and 1400×560 resolutions. + +## Submission +- [ ] Update `CHANGELOG.md` with the release notes. +- [ ] Review and update `docs/privacy-policy.md` contact details if needed. +- [ ] Package the extension (`.zip`) with the refreshed icons and submit to the Chrome Web Store dashboard. diff --git a/icons/base.svg b/icons/base.svg new file mode 100644 index 0000000..e213702 --- /dev/null +++ b/icons/base.svg @@ -0,0 +1,12 @@ + + + + + + + + + + + + diff --git a/icons/ui/adjustments-horizontal.svg b/icons/ui/adjustments-horizontal.svg new file mode 100644 index 0000000..ee86252 --- /dev/null +++ b/icons/ui/adjustments-horizontal.svg @@ -0,0 +1,11 @@ + diff --git a/icons/ui/arrow-path.svg b/icons/ui/arrow-path.svg new file mode 100644 index 0000000..9c3b9a1 --- /dev/null +++ b/icons/ui/arrow-path.svg @@ -0,0 +1,6 @@ + diff --git a/icons/ui/arrows-pointing-in.svg b/icons/ui/arrows-pointing-in.svg new file mode 100644 index 0000000..69dae3b --- /dev/null +++ b/icons/ui/arrows-pointing-in.svg @@ -0,0 +1,10 @@ + diff --git a/icons/ui/arrows-pointing-out.svg b/icons/ui/arrows-pointing-out.svg new file mode 100644 index 0000000..a251a4d --- /dev/null +++ b/icons/ui/arrows-pointing-out.svg @@ -0,0 +1,10 @@ + diff --git a/icons/ui/arrows-up-down-left-right.svg b/icons/ui/arrows-up-down-left-right.svg new file mode 100644 index 0000000..00bd8b8 --- /dev/null +++ b/icons/ui/arrows-up-down-left-right.svg @@ -0,0 +1,8 @@ + diff --git a/icons/ui/eye-slash.svg b/icons/ui/eye-slash.svg new file mode 100644 index 0000000..1b951b5 --- /dev/null +++ b/icons/ui/eye-slash.svg @@ -0,0 +1,6 @@ + diff --git a/icons/ui/eye.svg b/icons/ui/eye.svg new file mode 100644 index 0000000..5751f7f --- /dev/null +++ b/icons/ui/eye.svg @@ -0,0 +1,4 @@ + diff --git a/icons/ui/hand.svg b/icons/ui/hand.svg new file mode 100644 index 0000000..c7c1da4 --- /dev/null +++ b/icons/ui/hand.svg @@ -0,0 +1,6 @@ + diff --git a/icons/ui/lock-closed.svg b/icons/ui/lock-closed.svg new file mode 100644 index 0000000..51f86c7 --- /dev/null +++ b/icons/ui/lock-closed.svg @@ -0,0 +1,5 @@ + diff --git a/icons/ui/lock-open.svg b/icons/ui/lock-open.svg new file mode 100644 index 0000000..c9a2ecc --- /dev/null +++ b/icons/ui/lock-open.svg @@ -0,0 +1,5 @@ + diff --git a/icons/ui/moon.svg b/icons/ui/moon.svg new file mode 100644 index 0000000..7115ff3 --- /dev/null +++ b/icons/ui/moon.svg @@ -0,0 +1,3 @@ + diff --git a/icons/ui/question-mark-circle.svg b/icons/ui/question-mark-circle.svg new file mode 100644 index 0000000..0f6de5b --- /dev/null +++ b/icons/ui/question-mark-circle.svg @@ -0,0 +1,5 @@ + diff --git a/icons/ui/squares-2x2.svg b/icons/ui/squares-2x2.svg new file mode 100644 index 0000000..f6c0e7e --- /dev/null +++ b/icons/ui/squares-2x2.svg @@ -0,0 +1,6 @@ + diff --git a/manifest.json b/manifest.json index e2e4246..00a2097 100644 --- a/manifest.json +++ b/manifest.json @@ -1,19 +1,22 @@ { "manifest_version": 3, "name": "Live Site Overlay Tool", - "version": "1.1", + "version": "1.1.1", "description": "Overlay one live site over another for pixel-perfect comparison.", - "permissions": ["scripting", "activeTab", "tabs", "storage"], - "host_permissions": [""], + "permissions": ["activeTab", "scripting", "storage"], "action": { "default_title": "Toggle Overlay", "default_icon": { - "16": "icon-on.png", - "32": "icon-on.png", - "48": "icon-on.png", - "128": "icon-on.png" + "16": "icons/icon-16.png", + "32": "icons/icon-32.png" } }, + "icons": { + "16": "icons/icon-16.png", + "32": "icons/icon-32.png", + "48": "icons/icon-48.png", + "128": "icons/icon-128.png" + }, "background": { "service_worker": "background.js" } diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..634a029 --- /dev/null +++ b/package-lock.json @@ -0,0 +1,13 @@ +{ + "name": "liveoverlayextension", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "liveoverlayextension", + "version": "1.0.0", + "license": "ISC" + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..3867594 --- /dev/null +++ b/package.json @@ -0,0 +1,17 @@ +{ + "name": "liveoverlayextension", + "version": "1.0.0", + "description": "A Chrome extension for pixel-perfect visual comparison of two **live websites**. This tool is ideal for frontend developers, designers, and QA testers who need to overlay a staging or production site directly on top of another to validate layout accuracy, design drift, or content alignment — all in real time.", + "main": "background.js", + "directories": { + "doc": "docs" + }, + "scripts": { + "test": "echo \"Error: no test specified\" && exit 1", + "genicons": "node tools/gen-icons.mjs" + }, + "keywords": [], + "author": "", + "license": "ISC", + "type": "commonjs" +} diff --git a/scripts/content.js b/scripts/content.js deleted file mode 100644 index 9e9e9ad..0000000 --- a/scripts/content.js +++ /dev/null @@ -1,173 +0,0 @@ -console.log("LiveOverlayExtension: content script loaded"); - -if (!document.getElementById("overlay-iframe")) { - const iframe = document.createElement("iframe"); - iframe.src = "https://hiousastg.wpenginepowered.com/hole-in-one-insurance/"; - console.log(iframe.src); - iframe.id = "overlay-iframe"; - iframe.style.position = "fixed"; - iframe.style.top = "0"; - iframe.style.left = "0"; - iframe.style.width = "100vw"; - iframe.style.height = "100vh"; - iframe.style.zIndex = "9999"; - iframe.style.pointerEvents = "none"; - iframe.style.opacity = "0.5"; - iframe.style.filter = "none"; - iframe.style.border = "none"; - iframe.style.overflow = "hidden"; - iframe.setAttribute("scrolling", "no"); - - const controls = document.createElement("div"); - controls.style.position = "fixed"; - controls.style.top = "10px"; - controls.style.right = "10px"; - controls.style.zIndex = "10001"; - controls.style.background = "rgba(255, 255, 255, 0.95)"; - controls.style.padding = "10px"; - controls.style.border = "1px solid #ccc"; - controls.style.borderRadius = "5px"; - controls.style.fontSize = "12px"; - controls.style.fontFamily = "sans-serif"; - controls.style.userSelect = "none"; - controls.style.pointerEvents = "auto"; // always allow control panel to be clickable - - const dragHandle = document.createElement("div"); - dragHandle.textContent = "Overlay Controls"; - dragHandle.style.fontWeight = "bold"; - dragHandle.style.marginBottom = "8px"; - dragHandle.style.cursor = "move"; - dragHandle.style.userSelect = "none"; - controls.appendChild(dragHandle); - - const sliderLabel = document.createElement("label"); - sliderLabel.textContent = "Opacity: "; - const slider = document.createElement("input"); - slider.type = "range"; - slider.min = 0; - slider.max = 100; - slider.value = 50; - slider.style.marginBottom = "5px"; - slider.oninput = (e) => { - iframe.style.opacity = e.target.value / 100; - }; - - const urlLabel = document.createElement("label"); - urlLabel.textContent = "Overlay URL: "; - urlLabel.style.display = "block"; - const urlInput = document.createElement("input"); - urlInput.type = "text"; - urlInput.value = iframe.src; - urlInput.style.width = "250px"; - urlInput.style.marginBottom = "5px"; - urlInput.onchange = () => { - iframe.src = urlInput.value; - }; - - const invertLabel = document.createElement("label"); - invertLabel.textContent = " Invert Colors"; - const invertCheckbox = document.createElement("input"); - invertCheckbox.type = "checkbox"; - invertCheckbox.style.marginLeft = "10px"; - invertCheckbox.onchange = () => { - iframe.style.filter = invertCheckbox.checked ? "invert(1)" : "none"; - }; - invertLabel.appendChild(invertCheckbox); - - const scrollLabel = document.createElement("label"); - scrollLabel.textContent = " Enable Scrolling"; - const scrollCheckbox = document.createElement("input"); - scrollCheckbox.type = "checkbox"; - scrollCheckbox.style.marginLeft = "10px"; - scrollCheckbox.checked = true; - scrollCheckbox.onchange = () => { - const value = scrollCheckbox.checked ? "auto" : "hidden"; - document.body.style.overflow = value; - document.documentElement.style.overflow = value; - }; - scrollLabel.appendChild(scrollCheckbox); - - // Overlay Unlock Toggle - const unlockButton = document.createElement("button"); - unlockButton.textContent = "🔓 Unlock Overlay"; - unlockButton.style.marginTop = "8px"; - unlockButton.style.padding = "6px"; - unlockButton.style.cursor = "pointer"; - unlockButton.style.background = "#444"; - unlockButton.style.color = "white"; - unlockButton.style.border = "1px solid #666"; - unlockButton.style.borderRadius = "4px"; - unlockButton.style.width = "100%"; - unlockButton.id = "unlock-overlay-button"; - controls.appendChild(unlockButton); - - // Add controls - controls.appendChild(scrollLabel); - controls.appendChild(document.createElement("br")); - controls.appendChild(sliderLabel); - controls.appendChild(slider); - controls.appendChild(document.createElement("br")); - controls.appendChild(urlLabel); - controls.appendChild(urlInput); - controls.appendChild(document.createElement("br")); - controls.appendChild(invertLabel); - - document.body.appendChild(iframe); - document.body.appendChild(controls); - console.log("Overlay iframe and controls added"); - - // Drag logic - let isDragging = false; - let offsetX = 0; - let offsetY = 0; - - dragHandle.addEventListener("mousedown", (e) => { - isDragging = true; - offsetX = e.clientX - controls.getBoundingClientRect().left; - offsetY = e.clientY - controls.getBoundingClientRect().top; - e.preventDefault(); - }); - - document.addEventListener("mousemove", (e) => { - if (isDragging) { - controls.style.left = `${e.clientX - offsetX}px`; - controls.style.top = `${e.clientY - offsetY}px`; - controls.style.right = "auto"; - } - }); - - document.addEventListener("mouseup", () => { - isDragging = false; - }); - - // Unlock toggle logic - window.overlayUnlocked = false; - - unlockButton.addEventListener("click", () => { - if (!iframe) return; - - window.overlayUnlocked = !window.overlayUnlocked; - - if (window.overlayUnlocked) { - iframe.style.pointerEvents = "auto"; - iframe.style.overflow = "auto"; - iframe.setAttribute("scrolling", "yes"); - - document.body.style.pointerEvents = "none"; - document.documentElement.style.pointerEvents = "none"; - controls.style.pointerEvents = "auto"; - - unlockButton.textContent = "🔒 Lock Overlay"; - } else { - iframe.style.pointerEvents = "none"; - iframe.style.overflow = "hidden"; - iframe.setAttribute("scrolling", "no"); - - document.body.style.pointerEvents = "auto"; - document.documentElement.style.pointerEvents = "auto"; - controls.style.pointerEvents = "auto"; - - unlockButton.textContent = "🔓 Unlock Overlay"; - } - }); -} diff --git a/scripts/overlay.js b/scripts/overlay.js new file mode 100644 index 0000000..65ae499 --- /dev/null +++ b/scripts/overlay.js @@ -0,0 +1,854 @@ +(async () => { + const existing = document.getElementById('live-overlay-root') + if (existing) { + existing.remove() + if (window.__liveOverlayCleanup) { + try { + window.__liveOverlayCleanup() + } catch (err) { + console.warn('LiveOverlay cleanup error', err) + } + delete window.__liveOverlayCleanup + } + return + } + + const DEFAULTS = { + overlayUrl: '', + opacity: 0.6, + invertEnabled: false, + locked: false, + clickThrough: false, + gridEnabled: false, + position: { x: 48, y: 48 }, + size: { width: 1200, height: 800 }, + } + + const iconNames = [ + 'eye', + 'eye-slash', + 'lock-closed', + 'lock-open', + 'hand', + 'adjustments-horizontal', + 'moon', + 'arrows-pointing-out', + 'arrows-pointing-in', + 'arrows-up-down-left-right', + 'squares-2x2', + 'arrow-path', + 'question-mark-circle', + ] + + const iconCache = {} + + async function loadIcon(name) { + if (iconCache[name]) return iconCache[name] + const res = await fetch(chrome.runtime.getURL(`icons/ui/${name}.svg`)) + const text = await res.text() + iconCache[name] = text + return text + } + + await Promise.all(iconNames.map(loadIcon)) + + const stored = await new Promise((resolve) => { + chrome.storage.local.get(DEFAULTS, (value) => { + if (chrome.runtime.lastError) { + console.warn('LiveOverlay storage get error', chrome.runtime.lastError) + resolve({ ...DEFAULTS }) + return + } + resolve(value) + }) + }) + + const state = { + ...DEFAULTS, + ...stored, + position: { ...DEFAULTS.position, ...(stored.position || {}) }, + size: { ...DEFAULTS.size, ...(stored.size || {}) }, + } + + if (!state.overlayUrl) { + state.overlayUrl = window.location.href + } + + let overlayVisible = true + let fullscreen = false + let savedState = { size: { ...state.size }, position: { ...state.position } } + let currentFrameUrl = state.overlayUrl + + const pendingSave = {} + let saveTimer = null + + function scheduleSave(partial) { + Object.assign(pendingSave, partial) + if (saveTimer) { + clearTimeout(saveTimer) + } + saveTimer = setTimeout(() => { + chrome.storage.local.set(pendingSave, () => { + if (chrome.runtime.lastError) { + console.warn('LiveOverlay storage set error', chrome.runtime.lastError) + } + }) + for (const key of Object.keys(pendingSave)) { + delete pendingSave[key] + } + saveTimer = null + }, 200) + } + + function setButtonIcon(button, iconName) { + button.innerHTML = iconCache[iconName] || '' + const svg = button.querySelector('svg') + if (svg) { + svg.setAttribute('aria-hidden', 'true') + svg.setAttribute('focusable', 'false') + svg.setAttribute('width', '20') + svg.setAttribute('height', '20') + svg.setAttribute('stroke', 'currentColor') + if (!svg.getAttribute('fill')) { + svg.setAttribute('fill', 'none') + } + } + } + + function createButton(label, options = {}) { + const { toggle = true } = options + const button = document.createElement('button') + button.type = 'button' + button.className = 'lo-btn lo-control' + button.setAttribute('aria-label', label) + button.title = label + if (toggle) { + button.setAttribute('aria-pressed', 'false') + } + return button + } + + function applyFrameOptions() { + iframe.style.opacity = String(state.opacity) + iframe.style.filter = state.invertEnabled ? 'invert(1)' : 'none' + iframe.style.pointerEvents = state.clickThrough ? 'none' : 'auto' + iframe.setAttribute('aria-hidden', overlayVisible ? 'false' : 'true') + if (state.overlayUrl !== currentFrameUrl) { + currentFrameUrl = state.overlayUrl + iframe.src = state.overlayUrl + } + root.classList.toggle('lo-grid-on', !!state.gridEnabled) + root.classList.toggle('lo-click-through', !!state.clickThrough) + root.classList.toggle('lo-locked', !!state.locked) + opacityValue.textContent = Math.round(state.opacity * 100) + '%' + opacitySlider.value = String(Math.round(state.opacity * 100)) + opacitySlider.setAttribute('aria-valuenow', String(Math.round(state.opacity * 100))) + urlInput.value = state.overlayUrl + updateToggleIcons() + } + + function applySizeAndPosition() { + const maxWidth = Math.max(document.documentElement.clientWidth, window.innerWidth) + const maxHeight = Math.max(document.documentElement.clientHeight, window.innerHeight) + if (!fullscreen) { + state.size.width = Math.min(Math.max(state.size.width, 320), maxWidth) + state.size.height = Math.min(Math.max(state.size.height, 240), maxHeight) + const clampedWidth = Math.min(state.size.width, maxWidth) + const clampedHeight = Math.min(state.size.height, maxHeight) + state.position.x = Math.min(Math.max(state.position.x, 0), Math.max(0, maxWidth - clampedWidth)) + state.position.y = Math.min(Math.max(state.position.y, 0), Math.max(0, maxHeight - clampedHeight)) + } + root.style.width = fullscreen ? '100vw' : `${state.size.width}px` + root.style.height = fullscreen ? '100vh' : `${state.size.height}px` + root.style.left = fullscreen ? '0' : `${state.position.x}px` + root.style.top = fullscreen ? '0' : `${state.position.y}px` + root.classList.toggle('lo-fullscreen', fullscreen) + } + + function adjustPosition(deltaX, deltaY) { + if (state.locked || fullscreen) return + state.position.x += deltaX + state.position.y += deltaY + applySizeAndPosition() + scheduleSave({ position: { ...state.position } }) + if (!fullscreen) { + savedState.position = { ...state.position } + } + } + + function updateToggleIcons() { + lockButton.classList.toggle('is-active', state.locked) + lockButton.setAttribute('aria-pressed', String(state.locked)) + setButtonIcon(lockButton, state.locked ? 'lock-closed' : 'lock-open') + + clickThroughButton.classList.toggle('is-active', state.clickThrough) + clickThroughButton.setAttribute('aria-pressed', String(state.clickThrough)) + + invertButton.classList.toggle('is-active', state.invertEnabled) + invertButton.setAttribute('aria-pressed', String(state.invertEnabled)) + + gridButton.classList.toggle('is-active', state.gridEnabled) + gridButton.setAttribute('aria-pressed', String(state.gridEnabled)) + + toggleVisibilityButton.classList.toggle('is-active', overlayVisible) + toggleVisibilityButton.setAttribute('aria-pressed', String(overlayVisible)) + setButtonIcon(toggleVisibilityButton, overlayVisible ? 'eye' : 'eye-slash') + + resizeButton.classList.toggle('is-active', fullscreen) + resizeButton.setAttribute('aria-pressed', String(fullscreen)) + setButtonIcon(resizeButton, fullscreen ? 'arrows-pointing-in' : 'arrows-pointing-out') + } + + const root = document.createElement('div') + root.id = 'live-overlay-root' + root.setAttribute('role', 'region') + root.setAttribute('aria-label', 'Live overlay container') + + const style = document.createElement('style') + style.textContent = ` + #live-overlay-root { + position: fixed; + top: 48px; + left: 48px; + display: flex; + flex-direction: column; + max-width: 100vw; + max-height: 100vh; + background: rgba(15, 23, 42, 0.85); + color: #f8fafc; + border-radius: 12px; + box-shadow: 0 20px 45px rgba(15, 23, 42, 0.35); + z-index: 2147483647; + border: 1px solid rgba(148, 163, 184, 0.45); + overflow: hidden; + backdrop-filter: blur(4px); + } + #live-overlay-root.lo-click-through iframe { + pointer-events: none; + } + #live-overlay-root.lo-locked .lo-toolbar { + cursor: default; + } + #live-overlay-root.lo-locked .lo-title { + cursor: default; + opacity: 0.7; + } + #live-overlay-root .lo-toolbar { + display: flex; + align-items: center; + gap: 8px; + padding: 8px 10px; + background: rgba(15, 23, 42, 0.92); + border-bottom: 1px solid rgba(148, 163, 184, 0.35); + user-select: none; + touch-action: none; + position: relative; + flex-wrap: wrap; + row-gap: 6px; + } + #live-overlay-root .lo-title { + font-size: 12px; + font-weight: 600; + letter-spacing: 0.08em; + text-transform: uppercase; + margin-right: 6px; + cursor: move; + display: inline-flex; + align-items: center; + gap: 6px; + } + #live-overlay-root .lo-toolbar button.lo-btn { + display: inline-flex; + align-items: center; + justify-content: center; + width: 32px; + height: 32px; + border-radius: 8px; + background: transparent; + color: inherit; + border: 1px solid transparent; + transition: background 0.2s ease, border-color 0.2s ease, color 0.2s ease; + cursor: pointer; + padding: 0; + } + #live-overlay-root .lo-toolbar button.lo-btn:hover, + #live-overlay-root .lo-toolbar button.lo-btn.is-active { + background: rgba(96, 165, 250, 0.18); + border-color: rgba(148, 197, 255, 0.4); + } + #live-overlay-root .lo-toolbar button.lo-btn:focus-visible { + outline: 2px solid #22d3ee; + outline-offset: 2px; + } + #live-overlay-root .lo-toolbar button.lo-btn svg { + pointer-events: none; + } + #live-overlay-root .lo-slider { + display: inline-flex; + align-items: center; + gap: 6px; + margin-left: 6px; + } + #live-overlay-root .lo-slider input[type="range"] { + width: 120px; + accent-color: #6366f1; + } + #live-overlay-root .lo-slider span { + font-size: 12px; + min-width: 36px; + text-align: right; + } + #live-overlay-root .lo-url { + display: inline-flex; + align-items: center; + gap: 6px; + flex: 1; + min-width: 160px; + } + #live-overlay-root .lo-url input { + flex: 1; + min-width: 160px; + padding: 6px 8px; + border-radius: 8px; + border: 1px solid rgba(148, 163, 184, 0.35); + background: rgba(15, 23, 42, 0.65); + color: inherit; + font-size: 12px; + } + #live-overlay-root .lo-url input:focus-visible { + outline: 2px solid #22d3ee; + outline-offset: 2px; + } + #live-overlay-root .lo-frame { + position: relative; + flex: 1 1 auto; + background: rgba(15, 23, 42, 0.85); + min-height: 160px; + } + #live-overlay-root.lo-fullscreen { + border-radius: 0; + border: none; + } + #live-overlay-root.lo-fullscreen .lo-toolbar { + border-radius: 0; + } + #live-overlay-root iframe { + width: 100%; + height: 100%; + border: none; + background: #0f172a; + } + #live-overlay-root .lo-grid { + position: absolute; + inset: 0; + pointer-events: none; + background-image: linear-gradient(to right, rgba(148, 163, 184, 0.3) 1px, transparent 1px), + linear-gradient(to bottom, rgba(148, 163, 184, 0.3) 1px, transparent 1px); + background-size: 48px 48px; + opacity: 0; + transition: opacity 0.2s ease; + } + #live-overlay-root.lo-grid-on .lo-grid { + opacity: 1; + } + #live-overlay-root .lo-resize { + position: absolute; + width: 18px; + height: 18px; + right: 6px; + bottom: 6px; + border-right: 2px solid rgba(148, 197, 255, 0.8); + border-bottom: 2px solid rgba(148, 197, 255, 0.8); + border-radius: 2px; + cursor: se-resize; + pointer-events: auto; + } + #live-overlay-root.lo-locked .lo-resize { + display: none; + } + #live-overlay-root .lo-nudge-panel { + position: absolute; + top: calc(100% + 6px); + right: 10px; + display: none; + flex-direction: column; + gap: 4px; + background: rgba(15, 23, 42, 0.95); + border: 1px solid rgba(148, 163, 184, 0.45); + border-radius: 10px; + padding: 8px; + box-shadow: 0 12px 30px rgba(15, 23, 42, 0.35); + z-index: 2; + } + #live-overlay-root .lo-nudge-panel.is-open { + display: flex; + } + #live-overlay-root .lo-nudge-grid { + display: grid; + grid-template-columns: repeat(3, 28px); + gap: 4px; + justify-items: center; + margin-top: 4px; + } + #live-overlay-root .lo-nudge-grid button { + width: 28px; + height: 28px; + border-radius: 6px; + background: rgba(96, 165, 250, 0.15); + border: 1px solid transparent; + color: inherit; + cursor: pointer; + font-size: 14px; + display: inline-flex; + align-items: center; + justify-content: center; + } + #live-overlay-root .lo-nudge-grid span { + width: 28px; + height: 28px; + display: block; + } + #live-overlay-root .lo-nudge-grid button:hover { + background: rgba(96, 165, 250, 0.25); + } + #live-overlay-root .lo-nudge-grid button:focus-visible { + outline: 2px solid #22d3ee; + outline-offset: 1px; + } + #live-overlay-root .lo-nudge-wrap { + position: relative; + display: inline-flex; + align-items: center; + } + ` + document.head.appendChild(style) + + const toolbar = document.createElement('div') + toolbar.className = 'lo-toolbar' + toolbar.setAttribute('role', 'toolbar') + toolbar.setAttribute('aria-label', 'Live overlay controls') + + const title = document.createElement('div') + title.className = 'lo-title' + title.textContent = 'Live Overlay' + toolbar.appendChild(title) + + const toggleVisibilityButton = createButton('Toggle overlay visibility') + setButtonIcon(toggleVisibilityButton, 'eye') + + const lockButton = createButton('Toggle overlay lock') + const clickThroughButton = createButton('Toggle click-through mode') + setButtonIcon(clickThroughButton, 'hand') + + const invertButton = createButton('Invert overlay colors') + setButtonIcon(invertButton, 'moon') + + const resizeButton = createButton('Toggle overlay size') + setButtonIcon(resizeButton, 'arrows-pointing-out') + + const nudgeWrap = document.createElement('div') + nudgeWrap.className = 'lo-nudge-wrap' + const nudgeButton = createButton('Open nudge controls') + setButtonIcon(nudgeButton, 'arrows-up-down-left-right') + + const gridButton = createButton('Toggle alignment grid') + setButtonIcon(gridButton, 'squares-2x2') + + const resetButton = createButton('Reset overlay settings', { toggle: false }) + setButtonIcon(resetButton, 'arrow-path') + + const helpButton = createButton('Help and usage tips', { toggle: false }) + setButtonIcon(helpButton, 'question-mark-circle') + + const sliderWrap = document.createElement('label') + sliderWrap.className = 'lo-slider' + sliderWrap.setAttribute('aria-label', 'Overlay opacity control') + sliderWrap.title = 'Adjust overlay opacity' + + const sliderIcon = document.createElement('span') + sliderIcon.className = 'lo-slider-icon' + sliderIcon.innerHTML = iconCache['adjustments-horizontal'] + const sliderIconSvg = sliderIcon.querySelector('svg') + if (sliderIconSvg) { + sliderIconSvg.setAttribute('aria-hidden', 'true') + sliderIconSvg.setAttribute('focusable', 'false') + sliderIconSvg.setAttribute('width', '20') + sliderIconSvg.setAttribute('height', '20') + sliderIconSvg.setAttribute('stroke', 'currentColor') + if (!sliderIconSvg.getAttribute('fill')) { + sliderIconSvg.setAttribute('fill', 'none') + } + } + + const opacitySlider = document.createElement('input') + opacitySlider.className = 'lo-control' + opacitySlider.type = 'range' + opacitySlider.min = '0' + opacitySlider.max = '100' + opacitySlider.step = '1' + opacitySlider.value = String(Math.round(state.opacity * 100)) + opacitySlider.setAttribute('aria-valuemin', '0') + opacitySlider.setAttribute('aria-valuemax', '100') + opacitySlider.setAttribute('aria-valuenow', String(Math.round(state.opacity * 100))) + opacitySlider.setAttribute('aria-label', 'Overlay opacity') + + const opacityValue = document.createElement('span') + opacityValue.textContent = Math.round(state.opacity * 100) + '%' + + sliderWrap.appendChild(sliderIcon) + sliderWrap.appendChild(opacitySlider) + sliderWrap.appendChild(opacityValue) + + const urlWrap = document.createElement('div') + urlWrap.className = 'lo-url' + + const urlInput = document.createElement('input') + urlInput.type = 'url' + urlInput.placeholder = 'https://overlay.example.com' + urlInput.value = state.overlayUrl + urlInput.setAttribute('aria-label', 'Overlay URL') + + urlWrap.appendChild(urlInput) + + toolbar.appendChild(toggleVisibilityButton) + toolbar.appendChild(lockButton) + toolbar.appendChild(clickThroughButton) + toolbar.appendChild(invertButton) + toolbar.appendChild(resizeButton) + toolbar.appendChild(nudgeWrap) + toolbar.appendChild(gridButton) + toolbar.appendChild(resetButton) + toolbar.appendChild(helpButton) + toolbar.appendChild(sliderWrap) + toolbar.appendChild(urlWrap) + + const frameWrap = document.createElement('div') + frameWrap.className = 'lo-frame' + + const iframe = document.createElement('iframe') + iframe.src = currentFrameUrl + iframe.title = 'Live overlay frame' + iframe.allow = 'clipboard-read; clipboard-write' + iframe.setAttribute('sandbox', 'allow-scripts allow-same-origin allow-forms allow-pointer-lock') + frameWrap.appendChild(iframe) + + const gridOverlay = document.createElement('div') + gridOverlay.className = 'lo-grid' + frameWrap.appendChild(gridOverlay) + + const resizeHandle = document.createElement('div') + resizeHandle.className = 'lo-resize' + frameWrap.appendChild(resizeHandle) + + const nudgePanel = document.createElement('div') + nudgePanel.className = 'lo-nudge-panel' + const nudgeTitle = document.createElement('span') + nudgeTitle.textContent = 'Nudge overlay' + nudgeTitle.style.fontSize = '11px' + nudgeTitle.style.opacity = '0.8' + nudgePanel.appendChild(nudgeTitle) + + const nudgeGrid = document.createElement('div') + nudgeGrid.className = 'lo-nudge-grid' + const nudgeButtons = [ + { label: '', empty: true }, + { label: '↑', dx: 0, dy: -1, aria: 'Nudge up 1px' }, + { label: '', empty: true }, + { label: '←', dx: -1, dy: 0, aria: 'Nudge left 1px' }, + { label: 'Reset', dx: 0, dy: 0, reset: true, aria: 'Reset overlay position' }, + { label: '→', dx: 1, dy: 0, aria: 'Nudge right 1px' }, + { label: '', empty: true }, + { label: '↓', dx: 0, dy: 1, aria: 'Nudge down 1px' }, + { label: '', empty: true }, + ] + + nudgeButtons.forEach(({ label, dx, dy, reset, aria, empty }) => { + if (empty) { + const spacer = document.createElement('span') + nudgeGrid.appendChild(spacer) + return + } + const btn = document.createElement('button') + btn.type = 'button' + btn.textContent = label + btn.setAttribute('aria-label', aria || label) + btn.addEventListener('click', () => { + if (reset) { + state.position = { ...DEFAULTS.position } + applySizeAndPosition() + scheduleSave({ position: { ...state.position } }) + return + } + adjustPosition(dx, dy) + }) + nudgeGrid.appendChild(btn) + }) + + nudgePanel.appendChild(nudgeGrid) + const nudgeHint = document.createElement('span') + nudgeHint.textContent = 'Hold Shift for 10px steps' + nudgeHint.style.fontSize = '10px' + nudgeHint.style.opacity = '0.65' + nudgeHint.style.textAlign = 'center' + nudgePanel.appendChild(nudgeHint) + nudgeWrap.appendChild(nudgeButton) + nudgeWrap.appendChild(nudgePanel) + + root.appendChild(toolbar) + root.appendChild(frameWrap) + + document.body.appendChild(root) + + applySizeAndPosition() + applyFrameOptions() + updateToggleIcons() + + let dragActive = false + let dragStart = { x: 0, y: 0 } + let origin = { x: 0, y: 0 } + + function handlePointerDown(event) { + if (state.locked || fullscreen) return + if (event.button !== 0) return + if (event.target.closest('.lo-control')) return + dragActive = true + dragStart = { x: event.clientX, y: event.clientY } + origin = { ...state.position } + title.setPointerCapture(event.pointerId) + event.preventDefault() + } + + function handlePointerMove(event) { + if (!dragActive) return + const deltaX = event.clientX - dragStart.x + const deltaY = event.clientY - dragStart.y + state.position.x = origin.x + deltaX + state.position.y = origin.y + deltaY + applySizeAndPosition() + } + + function handlePointerUp(event) { + if (!dragActive) return + dragActive = false + title.releasePointerCapture(event.pointerId) + scheduleSave({ position: { ...state.position } }) + } + + title.addEventListener('pointerdown', handlePointerDown) + title.addEventListener('pointermove', handlePointerMove) + title.addEventListener('pointerup', handlePointerUp) + title.addEventListener('pointercancel', handlePointerUp) + + let resizing = false + let resizeStart = { x: 0, y: 0 } + let sizeOrigin = { width: 0, height: 0 } + + resizeHandle.addEventListener('pointerdown', (event) => { + if (state.locked || fullscreen) return + resizing = true + resizeStart = { x: event.clientX, y: event.clientY } + sizeOrigin = { ...state.size } + resizeHandle.setPointerCapture(event.pointerId) + event.preventDefault() + }) + + resizeHandle.addEventListener('pointermove', (event) => { + if (!resizing) return + const deltaX = event.clientX - resizeStart.x + const deltaY = event.clientY - resizeStart.y + state.size.width = sizeOrigin.width + deltaX + state.size.height = sizeOrigin.height + deltaY + applySizeAndPosition() + }) + + function finishResize(event) { + if (!resizing) return + resizing = false + resizeHandle.releasePointerCapture(event.pointerId) + scheduleSave({ size: { ...state.size } }) + if (!fullscreen) { + savedState.size = { ...state.size } + } + } + + resizeHandle.addEventListener('pointerup', finishResize) + resizeHandle.addEventListener('pointercancel', finishResize) + + toggleVisibilityButton.addEventListener('click', () => { + overlayVisible = !overlayVisible + frameWrap.style.display = overlayVisible ? 'block' : 'none' + iframe.setAttribute('aria-hidden', overlayVisible ? 'false' : 'true') + if (!overlayVisible) { + nudgePanel.classList.remove('is-open') + nudgeButton.classList.remove('is-active') + nudgeButton.setAttribute('aria-pressed', 'false') + } + updateToggleIcons() + }) + + lockButton.addEventListener('click', () => { + state.locked = !state.locked + applyFrameOptions() + if (state.locked) { + nudgePanel.classList.remove('is-open') + nudgeButton.classList.remove('is-active') + nudgeButton.setAttribute('aria-pressed', 'false') + } + scheduleSave({ locked: state.locked }) + }) + + clickThroughButton.addEventListener('click', () => { + state.clickThrough = !state.clickThrough + applyFrameOptions() + scheduleSave({ clickThrough: state.clickThrough }) + }) + + invertButton.addEventListener('click', () => { + state.invertEnabled = !state.invertEnabled + applyFrameOptions() + scheduleSave({ invertEnabled: state.invertEnabled }) + }) + + resizeButton.addEventListener('click', () => { + fullscreen = !fullscreen + if (fullscreen) { + savedSize = { size: { ...state.size }, position: { ...state.position } } + state.position = { x: 0, y: 0 } + } else { + state.size = { ...savedSize.size } + state.position = { ...savedSize.position } + } + applySizeAndPosition() + updateToggleIcons() + scheduleSave({ size: { ...state.size }, position: { ...state.position } }) + if (!fullscreen) { + savedState = { size: { ...state.size }, position: { ...state.position } } + } + }) + + nudgeButton.addEventListener('click', () => { + if (state.locked) return + const isOpen = !nudgePanel.classList.contains('is-open') + nudgePanel.classList.toggle('is-open', isOpen) + nudgeButton.classList.toggle('is-active', isOpen) + nudgeButton.setAttribute('aria-pressed', String(isOpen)) + }) + + gridButton.addEventListener('click', () => { + state.gridEnabled = !state.gridEnabled + applyFrameOptions() + scheduleSave({ gridEnabled: state.gridEnabled }) + }) + + resetButton.addEventListener('click', () => { + Object.assign(state, JSON.parse(JSON.stringify(DEFAULTS))) + state.overlayUrl = window.location.href + currentFrameUrl = state.overlayUrl + savedState = { size: { ...state.size }, position: { ...state.position } } + overlayVisible = true + fullscreen = false + frameWrap.style.display = 'block' + nudgePanel.classList.remove('is-open') + nudgeButton.classList.remove('is-active') + nudgeButton.setAttribute('aria-pressed', 'false') + applySizeAndPosition() + applyFrameOptions() + updateToggleIcons() + const resetPayload = JSON.parse(JSON.stringify({ + ...DEFAULTS, + overlayUrl: state.overlayUrl, + position: { ...state.position }, + size: { ...state.size }, + })) + scheduleSave(resetPayload) + }) + + helpButton.addEventListener('click', () => { + const message = [ + 'Live Overlay tips:', + '• Drag the title bar to reposition.', + '• Use the resize corner or the expand button for full screen.', + '• Toggle click-through mode to interact with the page below.', + '• Settings persist automatically via chrome.storage.local.' + ].join('\n') + alert(message) + }) + + opacitySlider.addEventListener('input', () => { + const value = Number(opacitySlider.value) / 100 + state.opacity = Math.min(Math.max(value, 0), 1) + opacitySlider.setAttribute('aria-valuenow', String(Math.round(state.opacity * 100))) + applyFrameOptions() + }) + + opacitySlider.addEventListener('change', () => { + scheduleSave({ opacity: state.opacity }) + }) + + urlInput.addEventListener('change', () => { + let url = urlInput.value.trim() + if (url && !/^https?:\/\//i.test(url)) { + url = `https://${url}` + urlInput.value = url + } + state.overlayUrl = url || window.location.href + applyFrameOptions() + scheduleSave({ overlayUrl: state.overlayUrl }) + }) + + urlInput.addEventListener('keydown', (event) => { + if (event.key === 'Enter') { + event.preventDefault() + urlInput.dispatchEvent(new Event('change')) + urlInput.blur() + } + }) + + function handleKeydown(event) { + if (state.locked || fullscreen) return + const step = event.shiftKey ? 10 : 1 + switch (event.key) { + case 'ArrowUp': + adjustPosition(0, -step) + event.preventDefault() + break + case 'ArrowDown': + adjustPosition(0, step) + event.preventDefault() + break + case 'ArrowLeft': + adjustPosition(-step, 0) + event.preventDefault() + break + case 'ArrowRight': + adjustPosition(step, 0) + event.preventDefault() + break + default: + break + } + } + + window.addEventListener('keydown', handleKeydown, true) + + window.__liveOverlayCleanup = () => { + window.removeEventListener('keydown', handleKeydown, true) + if (saveTimer) { + clearTimeout(saveTimer) + saveTimer = null + if (Object.keys(pendingSave).length > 0) { + chrome.storage.local.set(pendingSave, () => { + if (chrome.runtime.lastError) { + console.warn('LiveOverlay storage set error', chrome.runtime.lastError) + } + }) + for (const key of Object.keys(pendingSave)) { + delete pendingSave[key] + } + } + } + if (style.isConnected) { + style.remove() + } + } +})() diff --git a/tools/gen-icons.mjs b/tools/gen-icons.mjs new file mode 100644 index 0000000..f3eae9d --- /dev/null +++ b/tools/gen-icons.mjs @@ -0,0 +1,126 @@ +import fs from 'node:fs' +import path from 'node:path' +import { deflateSync } from 'node:zlib' + +const ICON_DIR = path.resolve('icons') +const BASE_SVG_PATH = path.join(ICON_DIR, 'base.svg') +const SIZES = [128, 48, 32, 16] + +const start = [0x63, 0x66, 0xf1] +const end = [0x22, 0xd3, 0xee] +const outerColor = [255, 255, 255] +const innerColor = [15, 23, 42] + +function ensureBaseSvg() { + if (fs.existsSync(BASE_SVG_PATH)) return + fs.mkdirSync(ICON_DIR, { recursive: true }) + const svg = `\n\n \n \n \n \n \n \n \n \n \n` + fs.writeFileSync(BASE_SVG_PATH, svg) +} + +function gradientColor(x, y, size) { + const denom = size > 1 ? size - 1 : 1 + const t = (x / denom + y / denom) / 2 + return start.map((s, i) => Math.round(s + (end[i] - s) * t)) +} + +function blend(base, overlay, alpha) { + return base.map((channel, i) => Math.round(channel * (1 - alpha) + overlay[i] * alpha)) +} + +function buildCrcTable() { + const table = new Uint32Array(256) + for (let n = 0; n < 256; n++) { + let c = n + for (let k = 0; k < 8; k++) { + c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1 + } + table[n] = c >>> 0 + } + return table +} + +const CRC_TABLE = buildCrcTable() + +function crc32(buffer) { + let crc = 0xffffffff + for (let i = 0; i < buffer.length; i++) { + crc = CRC_TABLE[(crc ^ buffer[i]) & 0xff] ^ (crc >>> 8) + } + return (crc ^ 0xffffffff) >>> 0 +} + +function chunk(tag, data) { + const tagBuffer = Buffer.from(tag, 'ascii') + const lengthBuffer = Buffer.alloc(4) + lengthBuffer.writeUInt32BE(data.length) + const crcBuffer = Buffer.alloc(4) + crcBuffer.writeUInt32BE(crc32(Buffer.concat([tagBuffer, data]))) + return Buffer.concat([lengthBuffer, tagBuffer, data, crcBuffer]) +} + +function buildPng(size) { + const width = size + const height = size + const scale = size / 128 + const outerRadius = 28 * scale + const innerRadius = 10 * scale + const cx = (size - 1) / 2 + const cy = (size - 1) / 2 + const stride = 1 + width * 4 + const raw = Buffer.alloc(stride * height) + + for (let y = 0; y < height; y++) { + const rowOffset = y * stride + raw[rowOffset] = 0 + for (let x = 0; x < width; x++) { + const baseColor = gradientColor(x, y, size) + let color = baseColor + const dx = x - cx + const dy = y - cy + const distance = Math.hypot(dx, dy) + if (distance <= outerRadius) { + color = blend(color, outerColor, 0.85) + } + if (distance <= innerRadius) { + color = innerColor + } + const offset = rowOffset + 1 + x * 4 + raw[offset] = color[0] + raw[offset + 1] = color[1] + raw[offset + 2] = color[2] + raw[offset + 3] = 255 + } + } + + const compressed = deflateSync(raw) + const ihdr = Buffer.alloc(13) + ihdr.writeUInt32BE(width, 0) + ihdr.writeUInt32BE(height, 4) + ihdr[8] = 8 + ihdr[9] = 6 + ihdr[10] = 0 + ihdr[11] = 0 + ihdr[12] = 0 + + const png = Buffer.concat([ + Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]), + chunk('IHDR', ihdr), + chunk('IDAT', compressed), + chunk('IEND', Buffer.alloc(0)), + ]) + + fs.mkdirSync(ICON_DIR, { recursive: true }) + const filePath = path.join(ICON_DIR, `icon-${size}.png`) + fs.writeFileSync(filePath, png) + console.log('Generated', filePath) +} + +function main() { + ensureBaseSvg() + for (const size of SIZES) { + buildPng(size) + } +} + +main()