mirror of
https://github.com/pbakaus/impeccable.git
synced 2026-09-12 22:26:38 +03:00
The engine no longer lives in a separate repo. `crates/` is a snapshot of the
open crates (foundation, core, common, context, live, hook, skills, comp,
comp-verbs, html, browser, detect, cli) plus `Cargo.lock`, taken as a git
archive of the engine repo at the commit that finished the boundary split.
None of that repo's history comes with it, and none of it should: the closed
half stays private.
The closed half is the rule engine. It ships as a prebuilt native archive per
target, `libimpeccable_detector.a`, published as a `detector-v<X>` GitHub
Release on this repo. `crates/core/build.rs` resolves and links it three ways:
`IMPECCABLE_DETECTOR_LIB=<dir>` for a local detector build, else the
`~/.impeccable/detector/<version>/<target>/` cache, else a download verified
against its `.sha256` sidecar. `crates/core` is a thin shim over a three-symbol
C ABI; nothing above it knows the boundary exists.
What changed versus the engine repo copy:
- Every crate manifest moves from `license-file.workspace` to
`license.workspace` (this workspace declares Apache-2.0), and the workspace
gains the `postcard` dependency the boundary encoding needs.
- The launcher contract test reads `skill/scripts/impeccable{,.cmd}` instead of
a sibling `launcher/` dir, and `engine_binary` downloads from
`github.com/pbakaus/impeccable/releases/download/engine-v<version>/` instead
of the retired dist repo. No oracle golden carried the old URL, so no
re-recording was owed.
- The tests that hunted for a public repo through `IMPECCABLE_PUBLIC_REPO`,
`../impeccable-second` or a hardcoded home directory now resolve the root as
`CARGO_MANIFEST_DIR/../..`, because they are in it. The env var stays as an
override for an out-of-tree checkout.
- The in-page bundle (`detect-antipatterns-browser.js`, 2 MB of generated wasm
glue) is no longer tracked. `crates/core/build.rs` resolves it beside the
archive, hands the path to `impeccable_core::browser::IN_PAGE_BUNDLE_JS`, and
live mode serves that. `scripts/check-detector-release.mjs` now requires it
and its `.sha256` in a detector release.
- The live crate embeds `skill/scripts/live-browser*.js` and
`modern-screenshot.umd.js` directly rather than through vendored copies, so
the binary and the installed skill cannot drift.
- `crates/browser/assets/` (an unused second copy of the bundle) is gone.
- `tests/lib/engine-bin.mjs` also accepts `target/release/impeccable`, so a
plain `cargo build --release -p impeccable` is enough to run `bun run test`.
Verified with the archive from a local detector build: `cargo test --workspace`
267 pass, oracle 795 pass / 0 fail / 0 missing, `bun run build` clean, the
default suite green, and the launcher's `engine-probe` handshake answering
through `skill/scripts/impeccable`.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Vau2X53xGTjjTCXWMVBoNY
569 lines
21 KiB
JavaScript
569 lines
21 KiB
JavaScript
// --- browser-bundle/40-overlay.js ---
|
|
// The overlay UI: outlines + labels per flagged element, the page-level
|
|
// banner, the hover spotlight, visibility toggling. Pure presentation over a
|
|
// findings list — no rules, no thresholds, no snippet strings; the rule
|
|
// names and categories come from the registry it is handed. Ported from
|
|
// cli/engine/browser/injected/index.mjs Section 7.
|
|
//
|
|
// createImpeccableOverlay({ extensionMode, antipatterns }) ->
|
|
// { highlight(el, findings), showPageBanner(findings), clearOverlays(),
|
|
// remove(), toggleOverlays() -> visible, spotlight(target),
|
|
// unspotlight(), highlightSelector(selector), setFirstScanDone(),
|
|
// overlays }
|
|
// Used by the in-page bundle (50-scan.js) and, as `overlay.js`, by the
|
|
// extension's content script.
|
|
|
|
function createImpeccableOverlay({ extensionMode = false, antipatterns = [] } = {}) {
|
|
// Kinpaku gold — pinned to the site's brand token (see
|
|
// site/styles/kinpaku-tokens.css --ks-kinpaku). Keep this in sync with
|
|
// the picker's C.brand in skill/scripts/live-browser.js and the kit's
|
|
// picker section in site/styles/kinpaku-kit.css.
|
|
//
|
|
// One color across both light and dark host pages. The outline is a
|
|
// 2px gesture pointing at an element + a labeled tag — it's a marker,
|
|
// not body text, so it doesn't need WCAG AA against the page. The
|
|
// label text inside the gold tag is dark (LABEL_INK) which has ~16:1
|
|
// against the leaf gold, so reading the rule name is solid in both
|
|
// modes. Hover deepens the gold (preserves chroma — never drops it,
|
|
// dropping chroma washes the gold into a sand/olive tone).
|
|
const BRAND_COLOR = 'oklch(84% 0.19 80.46)';
|
|
const BRAND_COLOR_HOVER = 'oklch(74% 0.18 80)';
|
|
const LABEL_INK = 'oklch(4% 0.004 95)';
|
|
const LABEL_BG = BRAND_COLOR;
|
|
const OUTLINE_COLOR = BRAND_COLOR;
|
|
|
|
// Inject hover styles via CSS (more reliable than JS event listeners)
|
|
const styleEl = document.createElement('style');
|
|
styleEl.textContent = `
|
|
@keyframes impeccable-reveal {
|
|
from { opacity: 0; }
|
|
to { opacity: 1; }
|
|
}
|
|
.impeccable-overlay:not(.impeccable-banner) {
|
|
pointer-events: none;
|
|
outline: 2px solid ${OUTLINE_COLOR};
|
|
border-radius: 4px;
|
|
transition: outline-color 0.15s ease;
|
|
animation: impeccable-reveal 0.4s cubic-bezier(0.16, 1, 0.3, 1) both;
|
|
animation-play-state: paused;
|
|
border-top-left-radius: 0;
|
|
}
|
|
.impeccable-overlay.impeccable-visible {
|
|
animation-play-state: running;
|
|
}
|
|
.impeccable-overlay.impeccable-hover {
|
|
outline-color: ${BRAND_COLOR_HOVER};
|
|
z-index: 100001 !important;
|
|
}
|
|
.impeccable-overlay.impeccable-hover .impeccable-label {
|
|
background: ${BRAND_COLOR_HOVER};
|
|
}
|
|
.impeccable-overlay.impeccable-spotlight {
|
|
z-index: 100002 !important;
|
|
}
|
|
.impeccable-overlay.impeccable-spotlight-dimmed {
|
|
opacity: 0.15 !important;
|
|
animation: none !important;
|
|
filter: blur(3px);
|
|
}
|
|
.impeccable-spotlight-backdrop {
|
|
position: fixed;
|
|
top: 0; left: 0; right: 0; bottom: 0;
|
|
backdrop-filter: blur(3px) brightness(0.6);
|
|
-webkit-backdrop-filter: blur(3px) brightness(0.6);
|
|
pointer-events: none;
|
|
z-index: 99998;
|
|
opacity: 0;
|
|
outline: none !important;
|
|
animation: none !important;
|
|
}
|
|
.impeccable-spotlight-backdrop.impeccable-visible {
|
|
opacity: 1;
|
|
}
|
|
.impeccable-hidden .impeccable-overlay${extensionMode ? '' : ':not(.impeccable-banner)'} {
|
|
display: none !important;
|
|
}
|
|
`;
|
|
(document.head || document.documentElement).appendChild(styleEl);
|
|
|
|
let firstScanDone = false;
|
|
|
|
// Spotlight backdrop element (created lazily on first use)
|
|
let spotlightBackdrop = null;
|
|
let spotlightTarget = null;
|
|
|
|
function getSpotlightBackdrop() {
|
|
if (!spotlightBackdrop) {
|
|
spotlightBackdrop = document.createElement('div');
|
|
spotlightBackdrop.className = 'impeccable-spotlight-backdrop';
|
|
document.body.appendChild(spotlightBackdrop);
|
|
}
|
|
return spotlightBackdrop;
|
|
}
|
|
|
|
function updateSpotlightClipPath() {
|
|
if (!spotlightBackdrop || !spotlightTarget) return;
|
|
const r = spotlightTarget.getBoundingClientRect();
|
|
// Match the overlay's outer edge: element rect + 4px (2px overlay offset + 2px outline width)
|
|
const inset = 4;
|
|
const radius = 6; // outline border-radius (4) + outline width (2)
|
|
const x1 = r.left - inset;
|
|
const y1 = r.top - inset;
|
|
const x2 = r.right + inset;
|
|
const y2 = r.bottom + inset;
|
|
const vw = window.innerWidth;
|
|
const vh = window.innerHeight;
|
|
// Outer rect + rounded inner rect (evenodd creates a hole)
|
|
const path = `M0 0H${vw}V${vh}H0Z M${x1 + radius} ${y1}H${x2 - radius}A${radius} ${radius} 0 0 1 ${x2} ${y1 + radius}V${y2 - radius}A${radius} ${radius} 0 0 1 ${x2 - radius} ${y2}H${x1 + radius}A${radius} ${radius} 0 0 1 ${x1} ${y2 - radius}V${y1 + radius}A${radius} ${radius} 0 0 1 ${x1 + radius} ${y1}Z`;
|
|
spotlightBackdrop.style.clipPath = `path(evenodd, "${path}")`;
|
|
}
|
|
|
|
function showSpotlight(target) {
|
|
if (!target || !target.getBoundingClientRect) return;
|
|
// Respect the spotlightBlur setting: if disabled, don't show the backdrop
|
|
if (window.__IMPECCABLE_CONFIG__?.spotlightBlur === false) {
|
|
spotlightTarget = target;
|
|
return;
|
|
}
|
|
spotlightTarget = target;
|
|
const bd = getSpotlightBackdrop();
|
|
updateSpotlightClipPath();
|
|
bd.classList.add('impeccable-visible');
|
|
}
|
|
|
|
function hideSpotlight() {
|
|
spotlightTarget = null;
|
|
if (spotlightBackdrop) spotlightBackdrop.classList.remove('impeccable-visible');
|
|
}
|
|
|
|
function isInViewport(el) {
|
|
const r = el.getBoundingClientRect();
|
|
return r.top >= 0 && r.left >= 0 && r.bottom <= window.innerHeight && r.right <= window.innerWidth;
|
|
}
|
|
|
|
// Reposition spotlight on scroll/resize
|
|
window.addEventListener('scroll', () => {
|
|
if (spotlightTarget) updateSpotlightClipPath();
|
|
}, { passive: true });
|
|
window.addEventListener('resize', () => {
|
|
if (spotlightTarget) updateSpotlightClipPath();
|
|
});
|
|
|
|
const overlays = [];
|
|
const ANTIPATTERNS = antipatterns || [];
|
|
const TYPE_LABELS = {};
|
|
const RULE_CATEGORY = {};
|
|
for (const ap of ANTIPATTERNS) {
|
|
TYPE_LABELS[ap.id] = ap.name.toLowerCase();
|
|
RULE_CATEGORY[ap.id] = ap.category || 'quality';
|
|
}
|
|
|
|
function isInFixedContext(el) {
|
|
let p = el;
|
|
while (p && p !== document.body) {
|
|
if (getComputedStyle(p).position === 'fixed') return true;
|
|
p = p.parentElement;
|
|
}
|
|
return false;
|
|
}
|
|
|
|
function positionOverlay(overlay) {
|
|
const el = overlay._targetEl;
|
|
if (!el) return;
|
|
const rect = el.getBoundingClientRect();
|
|
if (overlay._isFixed) {
|
|
// Viewport-relative coords for fixed targets
|
|
overlay.style.top = `${rect.top - 2}px`;
|
|
overlay.style.left = `${rect.left - 2}px`;
|
|
} else {
|
|
// Document-relative coords for normal targets
|
|
overlay.style.top = `${rect.top + scrollY - 2}px`;
|
|
overlay.style.left = `${rect.left + scrollX - 2}px`;
|
|
}
|
|
overlay.style.width = `${rect.width + 4}px`;
|
|
overlay.style.height = `${rect.height + 4}px`;
|
|
}
|
|
|
|
function repositionOverlays() {
|
|
for (const o of overlays) {
|
|
if (!o._targetEl || o.classList.contains('impeccable-banner')) continue;
|
|
// Skip overlays whose target is currently hidden (display: none on the overlay)
|
|
if (o.style.display === 'none') continue;
|
|
positionOverlay(o);
|
|
}
|
|
}
|
|
|
|
let resizeRAF;
|
|
const onResize = () => {
|
|
cancelAnimationFrame(resizeRAF);
|
|
resizeRAF = requestAnimationFrame(repositionOverlays);
|
|
};
|
|
window.addEventListener('resize', onResize);
|
|
// Reposition on scroll too -- catches sticky/parallax shifts
|
|
window.addEventListener('scroll', onResize, { passive: true });
|
|
// Reposition when body resizes (lazy-loaded images, dynamic content, fonts loading)
|
|
if (typeof ResizeObserver !== 'undefined') {
|
|
const bodyResizeObserver = new ResizeObserver(onResize);
|
|
bodyResizeObserver.observe(document.body);
|
|
}
|
|
|
|
// Track target element visibility via IntersectionObserver.
|
|
// Uses a huge rootMargin so all *rendered* elements count as intersecting,
|
|
// while display:none / closed <details> / hidden modals etc. do not.
|
|
// This is event-driven -- no polling needed.
|
|
let overlayIndex = 0;
|
|
const visibilityObserver = new IntersectionObserver((entries) => {
|
|
for (const entry of entries) {
|
|
const overlay = entry.target._impeccableOverlay;
|
|
if (!overlay) continue;
|
|
if (entry.isIntersecting) {
|
|
overlay.style.display = '';
|
|
positionOverlay(overlay);
|
|
if (!overlay._revealed) {
|
|
overlay._revealed = true;
|
|
if (firstScanDone) {
|
|
// Subsequent reveals (re-scans, scroll-into-view): instant, no animation
|
|
overlay.style.animation = 'none';
|
|
} else {
|
|
// Initial scan: staggered cascade reveal
|
|
overlay.style.animationDelay = `${Math.min((overlay._staggerIndex || 0) * 60, 600)}ms`;
|
|
}
|
|
requestAnimationFrame(() => {
|
|
overlay.classList.add('impeccable-visible');
|
|
if (overlay._checkLabel) overlay._checkLabel();
|
|
});
|
|
}
|
|
} else {
|
|
overlay.style.display = 'none';
|
|
}
|
|
}
|
|
}, { rootMargin: '99999px' });
|
|
|
|
function detachOverlay(overlay) {
|
|
if (!overlay) return;
|
|
if (typeof overlay._cleanup === 'function') {
|
|
try { overlay._cleanup(); } catch { /* best effort overlay teardown */ }
|
|
}
|
|
if (overlay._targetEl && overlay._targetEl._impeccableOverlay === overlay) {
|
|
visibilityObserver.unobserve(overlay._targetEl);
|
|
delete overlay._targetEl._impeccableOverlay;
|
|
}
|
|
const idx = overlays.indexOf(overlay);
|
|
if (idx >= 0) overlays.splice(idx, 1);
|
|
overlay.remove();
|
|
}
|
|
|
|
// Reposition overlays after CSS transitions end (e.g. reveal animations).
|
|
// Listens at document level so it catches transitions on ancestor elements
|
|
// (the transform may be on a parent, not the flagged element itself).
|
|
document.addEventListener('transitionend', (e) => {
|
|
if (e.propertyName !== 'transform') return;
|
|
for (const o of overlays) {
|
|
if (!o._targetEl || o.classList.contains('impeccable-banner') || o.style.display === 'none') continue;
|
|
if (e.target === o._targetEl || e.target.contains(o._targetEl)) {
|
|
positionOverlay(o);
|
|
}
|
|
}
|
|
});
|
|
|
|
const highlight = function(el, findings) {
|
|
if (el._impeccableOverlay) detachOverlay(el._impeccableOverlay);
|
|
const hasSlop = findings.some(f => RULE_CATEGORY[f.type || f.id] === 'slop');
|
|
|
|
const fixed = isInFixedContext(el);
|
|
const rect = el.getBoundingClientRect();
|
|
const outline = document.createElement('div');
|
|
outline.className = 'impeccable-overlay';
|
|
outline._targetEl = el;
|
|
outline._isFixed = fixed;
|
|
Object.assign(outline.style, {
|
|
position: fixed ? 'fixed' : 'absolute',
|
|
top: fixed ? `${rect.top - 2}px` : `${rect.top + scrollY - 2}px`,
|
|
left: fixed ? `${rect.left - 2}px` : `${rect.left + scrollX - 2}px`,
|
|
width: `${rect.width + 4}px`, height: `${rect.height + 4}px`,
|
|
zIndex: '99999', boxSizing: 'border-box',
|
|
});
|
|
|
|
// Build per-finding label entries: ✦ prefix for slop
|
|
const entries = findings.map(f => {
|
|
const name = TYPE_LABELS[f.type || f.id] || f.type || f.id;
|
|
const prefix = RULE_CATEGORY[f.type || f.id] === 'slop' ? '\u2726 ' : '';
|
|
return { name: prefix + name, detail: f.detail || f.snippet };
|
|
});
|
|
const allText = entries.map(e => e.name).join(', ');
|
|
|
|
const label = document.createElement('div');
|
|
label.className = 'impeccable-label';
|
|
Object.assign(label.style, {
|
|
position: 'absolute', bottom: '100%', left: '-2px',
|
|
display: 'flex', alignItems: 'center',
|
|
whiteSpace: 'nowrap',
|
|
fontSize: '11px', fontWeight: '600', letterSpacing: '0.02em',
|
|
color: LABEL_INK, lineHeight: '14px',
|
|
background: LABEL_BG,
|
|
fontFamily: 'system-ui, sans-serif',
|
|
borderRadius: '4px 4px 0 0',
|
|
});
|
|
|
|
const textSpan = document.createElement('span');
|
|
textSpan.style.padding = '3px 8px';
|
|
textSpan.textContent = allText;
|
|
label.appendChild(textSpan);
|
|
|
|
// State for cycling mode
|
|
let cycleMode = false;
|
|
let cycleIndex = 0;
|
|
let isHovered = false;
|
|
let prevBtn, nextBtn;
|
|
|
|
function updateCycleText() {
|
|
const e = entries[cycleIndex];
|
|
textSpan.textContent = isHovered ? e.detail : e.name;
|
|
}
|
|
|
|
function enableCycleMode() {
|
|
if (cycleMode || entries.length < 2) return;
|
|
cycleMode = true;
|
|
|
|
const btnStyle = {
|
|
background: 'none', border: 'none', color: 'rgba(255,255,255,0.7)',
|
|
fontSize: '11px', cursor: 'pointer', padding: '3px 4px',
|
|
fontFamily: 'system-ui, sans-serif', lineHeight: '14px',
|
|
pointerEvents: 'auto',
|
|
};
|
|
|
|
const navGroup = document.createElement('span');
|
|
Object.assign(navGroup.style, {
|
|
display: 'inline-flex', alignItems: 'center', flexShrink: '0',
|
|
});
|
|
|
|
prevBtn = document.createElement('button');
|
|
prevBtn.textContent = '\u2039';
|
|
Object.assign(prevBtn.style, btnStyle);
|
|
prevBtn.style.paddingLeft = '6px';
|
|
prevBtn.addEventListener('click', (e) => {
|
|
e.stopPropagation();
|
|
cycleIndex = (cycleIndex - 1 + entries.length) % entries.length;
|
|
updateCycleText();
|
|
});
|
|
|
|
nextBtn = document.createElement('button');
|
|
nextBtn.textContent = '\u203A';
|
|
Object.assign(nextBtn.style, btnStyle);
|
|
nextBtn.style.paddingRight = '2px';
|
|
nextBtn.addEventListener('click', (e) => {
|
|
e.stopPropagation();
|
|
cycleIndex = (cycleIndex + 1) % entries.length;
|
|
updateCycleText();
|
|
});
|
|
|
|
navGroup.appendChild(prevBtn);
|
|
navGroup.appendChild(nextBtn);
|
|
label.insertBefore(navGroup, textSpan);
|
|
textSpan.style.padding = '3px 8px 3px 4px';
|
|
updateCycleText();
|
|
}
|
|
|
|
outline.appendChild(label);
|
|
|
|
// Start hidden; the IntersectionObserver will show it once the target is rendered
|
|
outline.style.display = 'none';
|
|
outline._staggerIndex = overlayIndex++;
|
|
el._impeccableOverlay = outline;
|
|
visibilityObserver.observe(el);
|
|
|
|
// After first paint, check label width vs outline
|
|
outline._checkLabel = () => {
|
|
if (entries.length > 1 && label.offsetWidth > outline.offsetWidth) {
|
|
enableCycleMode();
|
|
}
|
|
};
|
|
|
|
// Hover: show detail text, darken
|
|
const onMouseEnter = () => {
|
|
isHovered = true;
|
|
outline.classList.add('impeccable-hover');
|
|
outline.style.outlineColor = BRAND_COLOR_HOVER;
|
|
label.style.background = BRAND_COLOR_HOVER;
|
|
if (cycleMode) {
|
|
updateCycleText();
|
|
} else {
|
|
textSpan.textContent = entries.map(e => e.detail).join(' | ');
|
|
}
|
|
};
|
|
const onMouseLeave = () => {
|
|
isHovered = false;
|
|
outline.classList.remove('impeccable-hover');
|
|
outline.style.outlineColor = '';
|
|
label.style.background = LABEL_BG;
|
|
if (cycleMode) {
|
|
updateCycleText();
|
|
} else {
|
|
textSpan.textContent = allText;
|
|
}
|
|
};
|
|
el.addEventListener('mouseenter', onMouseEnter);
|
|
el.addEventListener('mouseleave', onMouseLeave);
|
|
outline._cleanup = () => {
|
|
el.removeEventListener('mouseenter', onMouseEnter);
|
|
el.removeEventListener('mouseleave', onMouseLeave);
|
|
};
|
|
|
|
document.body.appendChild(outline);
|
|
overlays.push(outline);
|
|
};
|
|
|
|
const showPageBanner = function(findings) {
|
|
if (!findings.length) return;
|
|
const banner = document.createElement('div');
|
|
banner.className = 'impeccable-overlay impeccable-banner';
|
|
Object.assign(banner.style, {
|
|
position: 'fixed', top: '0', left: '0', right: '0', zIndex: '100000',
|
|
background: LABEL_BG, color: LABEL_INK,
|
|
fontFamily: 'system-ui, sans-serif', fontSize: '13px',
|
|
display: 'flex', alignItems: 'center', pointerEvents: 'auto',
|
|
height: '36px', overflow: 'hidden', maxWidth: '100vw',
|
|
transform: 'translateY(-100%)',
|
|
transition: 'transform 0.4s cubic-bezier(0.16, 1, 0.3, 1)',
|
|
});
|
|
requestAnimationFrame(() => requestAnimationFrame(() => {
|
|
banner.style.transform = 'translateY(0)';
|
|
}));
|
|
|
|
// Scrollable findings area
|
|
const scrollArea = document.createElement('div');
|
|
Object.assign(scrollArea.style, {
|
|
flex: '1', minWidth: '0', overflowX: 'auto', overflowY: 'hidden',
|
|
display: 'flex', gap: '8px', alignItems: 'center',
|
|
padding: '0 12px', scrollSnapType: 'x mandatory',
|
|
scrollbarWidth: 'none',
|
|
});
|
|
for (const f of findings) {
|
|
const prefix = RULE_CATEGORY[f.type] === 'slop' ? '\u2726 ' : '';
|
|
const tag = document.createElement('span');
|
|
tag.textContent = `${prefix}${TYPE_LABELS[f.type] || f.type}: ${f.detail}`;
|
|
Object.assign(tag.style, {
|
|
background: 'rgba(255,255,255,0.15)', padding: '2px 8px',
|
|
borderRadius: '3px', fontSize: '12px', fontFamily: 'ui-monospace, monospace',
|
|
whiteSpace: 'nowrap', flexShrink: '0', scrollSnapAlign: 'start',
|
|
});
|
|
scrollArea.appendChild(tag);
|
|
}
|
|
banner.appendChild(scrollArea);
|
|
|
|
// Controls area (only in standalone mode, not extension)
|
|
if (!extensionMode) {
|
|
const controls = document.createElement('div');
|
|
Object.assign(controls.style, {
|
|
display: 'flex', alignItems: 'center', gap: '2px',
|
|
padding: '0 8px', flexShrink: '0',
|
|
});
|
|
|
|
// Toggle visibility button
|
|
const toggle = document.createElement('button');
|
|
toggle.textContent = '\u25C9'; // circle with dot (visible state)
|
|
toggle.title = 'Toggle overlay visibility';
|
|
Object.assign(toggle.style, {
|
|
background: 'none', border: 'none',
|
|
color: 'white', fontSize: '16px', cursor: 'pointer', padding: '0 4px',
|
|
opacity: '0.85', transition: 'opacity 0.15s',
|
|
});
|
|
let overlaysVisible = true;
|
|
toggle.addEventListener('click', () => {
|
|
overlaysVisible = !overlaysVisible;
|
|
document.body.classList.toggle('impeccable-hidden', !overlaysVisible);
|
|
toggle.textContent = overlaysVisible ? '\u25C9' : '\u25CB'; // filled vs empty circle
|
|
toggle.style.opacity = overlaysVisible ? '0.85' : '0.5';
|
|
});
|
|
controls.appendChild(toggle);
|
|
|
|
// Close button
|
|
const close = document.createElement('button');
|
|
close.textContent = '\u00d7';
|
|
close.title = 'Dismiss banner';
|
|
Object.assign(close.style, {
|
|
background: 'none', border: 'none',
|
|
color: 'white', fontSize: '18px', cursor: 'pointer', padding: '0 4px',
|
|
});
|
|
close.addEventListener('click', () => banner.remove());
|
|
controls.appendChild(close);
|
|
|
|
banner.appendChild(controls);
|
|
}
|
|
document.body.appendChild(banner);
|
|
overlays.push(banner);
|
|
};
|
|
|
|
function clearOverlays() {
|
|
for (const o of [...overlays]) detachOverlay(o);
|
|
overlays.length = 0;
|
|
visibilityObserver.disconnect();
|
|
overlayIndex = 0;
|
|
}
|
|
|
|
// Tear the UI down entirely (the extension's `remove` command).
|
|
function remove() {
|
|
clearOverlays();
|
|
styleEl.remove();
|
|
if (spotlightBackdrop) { spotlightBackdrop.remove(); spotlightBackdrop = null; }
|
|
document.body.classList.remove('impeccable-hidden');
|
|
}
|
|
|
|
// Toggle every overlay; returns the new visibility.
|
|
function toggleOverlays() {
|
|
const visible = !document.body.classList.contains('impeccable-hidden');
|
|
document.body.classList.toggle('impeccable-hidden', visible);
|
|
return !visible;
|
|
}
|
|
|
|
// Spotlight the overlay of the element `selector` names (scrolling it into
|
|
// view first so positionOverlay reads the post-scroll rect).
|
|
function highlightSelector(selector) {
|
|
try {
|
|
const target = selector ? document.querySelector(selector) : null;
|
|
if (!target) return;
|
|
if (!isInViewport(target) && target.scrollIntoView) {
|
|
target.scrollIntoView({ behavior: 'instant', block: 'center' });
|
|
}
|
|
for (const o of overlays) {
|
|
if (o.classList.contains('impeccable-banner')) continue;
|
|
const isMatch = o._targetEl === target;
|
|
o.classList.toggle('impeccable-spotlight', isMatch);
|
|
o.classList.toggle('impeccable-spotlight-dimmed', !isMatch);
|
|
if (isMatch) {
|
|
// Force the matching overlay visible immediately, don't wait for IntersectionObserver
|
|
o.style.display = '';
|
|
o.style.animation = 'none';
|
|
o.classList.add('impeccable-visible');
|
|
o._revealed = true;
|
|
positionOverlay(o);
|
|
}
|
|
}
|
|
showSpotlight(target);
|
|
} catch { /* invalid selector */ }
|
|
}
|
|
|
|
function unspotlight() {
|
|
hideSpotlight();
|
|
for (const o of overlays) {
|
|
o.classList.remove('impeccable-spotlight');
|
|
o.classList.remove('impeccable-spotlight-dimmed');
|
|
}
|
|
}
|
|
|
|
return {
|
|
highlight,
|
|
showPageBanner,
|
|
clearOverlays,
|
|
remove,
|
|
toggleOverlays,
|
|
spotlight: showSpotlight,
|
|
unspotlight,
|
|
highlightSelector,
|
|
setFirstScanDone() { firstScanDone = true; },
|
|
overlays,
|
|
TYPE_LABELS,
|
|
RULE_CATEGORY,
|
|
};
|
|
}
|