chore: add npm lockfile

This commit is contained in:
TristanEDU 2025-10-11 17:28:08 -07:00
parent 8f0a275fcc
commit 44837f752a
23 changed files with 1146 additions and 212 deletions

View File

@ -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'],
})
})

5
docs/privacy-policy.md Normal file
View File

@ -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

18
docs/release-checklist.md Normal file
View File

@ -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 `<all_urls>`.
## 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.

12
icons/base.svg Normal file
View File

@ -0,0 +1,12 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg xmlns="http://www.w3.org/2000/svg" width="128" height="128" viewBox="0 0 128 128">
<defs>
<linearGradient id="g" x1="0" y1="0" x2="1" y2="1">
<stop offset="0%" stop-color="#6366f1" />
<stop offset="100%" stop-color="#22d3ee" />
</linearGradient>
</defs>
<rect width="128" height="128" rx="24" fill="url(#g)" />
<circle cx="64" cy="64" r="28" fill="#ffffff" opacity="0.92" />
<circle cx="64" cy="64" r="10" fill="#0f172a" opacity="0.9" />
</svg>

After

Width:  |  Height:  |  Size: 519 B

View File

@ -0,0 +1,11 @@
<svg role="img" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
<path d="M3 6.75h7.5" />
<path d="M13.5 6.75H21" />
<circle cx="10.5" cy="6.75" r="2.25" fill="none" />
<path d="M3 12h4.5" />
<path d="M12 12h9" />
<circle cx="7.5" cy="12" r="2.25" fill="none" />
<path d="M3 17.25h10.5" />
<path d="M16.5 17.25H21" />
<circle cx="13.5" cy="17.25" r="2.25" fill="none" />
</svg>

After

Width:  |  Height:  |  Size: 510 B

6
icons/ui/arrow-path.svg Normal file
View File

@ -0,0 +1,6 @@
<svg role="img" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
<path d="M4.5 9a7.5 7.5 0 0112.915-3.75H14.25" fill="none" />
<path d="M17.415 5.25l2.335 2.25-2.335 2.25" fill="none" />
<path d="M19.5 15a7.5 7.5 0 01-12.915 3.75H9.75" fill="none" />
<path d="M6.585 18.75L4.25 16.5l2.335-2.25" fill="none" />
</svg>

After

Width:  |  Height:  |  Size: 439 B

View File

@ -0,0 +1,10 @@
<svg role="img" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
<path d="M9 4.5H4.5V9" />
<path d="M10.5 7.5l-6 6" />
<path d="M15 19.5h4.5V15" />
<path d="M13.5 16.5l6-6" />
<path d="M19.5 9V4.5H15" />
<path d="M13.5 7.5l6 6" />
<path d="M4.5 15v4.5H9" />
<path d="M10.5 16.5l-6-6" />
</svg>

After

Width:  |  Height:  |  Size: 424 B

View File

@ -0,0 +1,10 @@
<svg role="img" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
<path d="M9 4.5H4.5V9" />
<path d="M4.5 4.5l6 6" />
<path d="M15 19.5h4.5V15" />
<path d="M19.5 19.5l-6-6" />
<path d="M19.5 9V4.5H15" />
<path d="M19.5 4.5l-6 6" />
<path d="M4.5 15v4.5H9" />
<path d="M4.5 19.5l6-6" />
</svg>

After

Width:  |  Height:  |  Size: 422 B

View File

@ -0,0 +1,8 @@
<svg role="img" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
<path d="M12 3l3 3H9l3-3z" fill="none" />
<path d="M12 21l3-3H9l3 3z" fill="none" />
<path d="M3 12l3 3V9l-3 3z" fill="none" />
<path d="M21 12l-3 3V9l3 3z" fill="none" />
<path d="M12 4.5v15" />
<path d="M4.5 12h15" />
</svg>

After

Width:  |  Height:  |  Size: 418 B

6
icons/ui/eye-slash.svg Normal file
View File

@ -0,0 +1,6 @@
<svg role="img" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
<path d="M3.75 5.25l16.5 13.5" fill="none" />
<path d="M6.455 6.93C4.56 8.432 3.25 10.386 2.25 12c1.5 3 4.5 6 9.75 6 1.22 0 2.33-.13 3.326-.377" fill="none" />
<path d="M17.59 14.862C20.231 13.332 21.75 12 21.75 12c-1.5-3-4.5-6-9.75-6-.96 0-1.875.09-2.746.257" fill="none" />
<path d="M9.75 11.25A2.25 2.25 0 0012 13.5c.454 0 .876-.13 1.236-.356" fill="none" />
</svg>

After

Width:  |  Height:  |  Size: 556 B

4
icons/ui/eye.svg Normal file
View File

@ -0,0 +1,4 @@
<svg role="img" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
<path d="M2.25 12s3.75-6 9.75-6 9.75 6 9.75 6-3.75 6-9.75 6S2.25 12 2.25 12z" fill="none" />
<circle cx="12" cy="12" r="3" fill="none" />
</svg>

After

Width:  |  Height:  |  Size: 328 B

6
icons/ui/hand.svg Normal file
View File

@ -0,0 +1,6 @@
<svg role="img" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
<path d="M8.25 11.25V6.75a1.5 1.5 0 013 0v3" fill="none" />
<path d="M11.25 10.5V4.5a1.5 1.5 0 013 0v6" fill="none" />
<path d="M14.25 10.5V6a1.5 1.5 0 013 0v6.75" fill="none" />
<path d="M17.25 12.75v-1.5a1.5 1.5 0 013 0v4.5c0 3-2.25 4.5-5.25 4.5H12a4.5 4.5 0 01-4.5-4.5v-3a1.5 1.5 0 013 0v1.5" fill="none" />
</svg>

After

Width:  |  Height:  |  Size: 505 B

5
icons/ui/lock-closed.svg Normal file
View File

@ -0,0 +1,5 @@
<svg role="img" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
<rect x="5.25" y="10.5" width="13.5" height="9" rx="2.25" ry="2.25" fill="none" />
<path d="M8.25 10.5V8.25a3.75 3.75 0 017.5 0V10.5" fill="none" />
<path d="M12 14.25v2.25" />
</svg>

After

Width:  |  Height:  |  Size: 369 B

5
icons/ui/lock-open.svg Normal file
View File

@ -0,0 +1,5 @@
<svg role="img" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
<rect x="5.25" y="10.5" width="13.5" height="9" rx="2.25" ry="2.25" fill="none" />
<path d="M8.25 10.5V8.25a3.75 3.75 0 017.5 0" fill="none" />
<path d="M12 14.25v2.25" />
</svg>

After

Width:  |  Height:  |  Size: 364 B

3
icons/ui/moon.svg Normal file
View File

@ -0,0 +1,3 @@
<svg role="img" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
<path d="M9.75 4.5A7.5 7.5 0 0019.5 14.25 7.5 7.5 0 119.75 4.5z" fill="none" />
</svg>

After

Width:  |  Height:  |  Size: 268 B

View File

@ -0,0 +1,5 @@
<svg role="img" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
<circle cx="12" cy="12" r="9" fill="none" />
<path d="M9.75 9.75a2.25 2.25 0 114.5 0c0 1.5-2.25 1.875-2.25 3.75" fill="none" />
<circle cx="12" cy="17.25" r=".75" />
</svg>

After

Width:  |  Height:  |  Size: 358 B

6
icons/ui/squares-2x2.svg Normal file
View File

@ -0,0 +1,6 @@
<svg role="img" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
<rect x="4.5" y="4.5" width="6" height="6" rx="1.5" ry="1.5" fill="none" />
<rect x="13.5" y="4.5" width="6" height="6" rx="1.5" ry="1.5" fill="none" />
<rect x="4.5" y="13.5" width="6" height="6" rx="1.5" ry="1.5" fill="none" />
<rect x="13.5" y="13.5" width="6" height="6" rx="1.5" ry="1.5" fill="none" />
</svg>

After

Width:  |  Height:  |  Size: 502 B

View File

@ -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": ["<all_urls>"],
"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"
}

13
package-lock.json generated Normal file
View File

@ -0,0 +1,13 @@
{
"name": "liveoverlayextension",
"version": "1.0.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "liveoverlayextension",
"version": "1.0.0",
"license": "ISC"
}
}
}

17
package.json Normal file
View File

@ -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"
}

View File

@ -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";
}
});
}

854
scripts/overlay.js Normal file
View File

@ -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()
}
}
})()

126
tools/gen-icons.mjs Normal file
View File

@ -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 = `<?xml version="1.0" encoding="UTF-8"?>\n<svg xmlns="http://www.w3.org/2000/svg" width="128" height="128" viewBox="0 0 128 128">\n <defs>\n <linearGradient id="g" x1="0" y1="0" x2="1" y2="1">\n <stop offset="0%" stop-color="#6366f1" />\n <stop offset="100%" stop-color="#22d3ee" />\n </linearGradient>\n </defs>\n <rect width="128" height="128" rx="24" fill="url(#g)" />\n <circle cx="64" cy="64" r="28" fill="#ffffff" opacity="0.92" />\n <circle cx="64" cy="64" r="10" fill="#0f172a" opacity="0.9" />\n</svg>`
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()