mirror of
https://github.com/pbakaus/impeccable.git
synced 2026-09-11 21:57:14 +03:00
* extension: kinpaku UI for the popup, DevTools panel, sidebar and overlays The extension still wore magenta (#d6336c on the toolbar badge, oklch(48% 0.25 350) through the DevTools panel and the Elements sidebar) and a cream popup left over from the old dark system. It now wears the brand the site ships in impeccable-site PR #34: paper and instruments, one light theme. - extension/shared/kinpaku.css is the one token layer the three pages link. Values are copied from site/styles/kinpaku-tokens.css, and the header states the four rules that shape the rest: paper and ink, gold as jewelry, patina carries colored text, a dark surface is a control. - Severity has one language. An AI tell gets the detector's gold tag and a lit gold dot; a quality issue gets patina; a scan that did not run gets vermilion. Selectors and details are patina-deep, which clears 4.5:1 on paper, and gold never carries text anywhere. - Popup: neutral paper, the mark as a carved ink tile, an ink primary button and a paper cap for the secondary, and the count in ink over a rule that lights gold when there is something to report. - Panel and sidebar: paper in both DevTools themes, since the brand has no dark theme any more. The panel handles the seam instead of inverting, with a hard top edge under .theme-dark. Segmented controls and the switch are paper hardware: a recessed track, a raised cap, a lit gold dot. - Overlays in the page: a gold hairline plus a soft outer glow instead of a 2px outline, and the label chip is now the tag, ink on gold in the mono face, matching what live mode draws. - The toolbar badge is gold with dark ink text (about 11.8:1); Chrome's default white badge text does not clear 4.5:1 on gold. Behavior is untouched: every id, class hook and message the popup, panel, sidebar and content script depend on is unchanged, and the one markup change beyond the stylesheet links is a severity class on the sidebar's kind label. The extension smoke suite passes on all nine fixtures with no service worker or offscreen errors, `bun run test` is green with a local engine build, and `web-ext lint` reports the same two Firefox-only errors and the same fifteen warnings as origin/main. Co-Authored-By: Claude Code <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Vau2X53xGTjjTCXWMVBoNY * extension: guard setBadgeTextColor, and make the dark seam paint-only Two review findings from #729. setBadgeTextColor arrived in Chrome 110 and does not exist on Firefox's action API. `chrome.action.setBadgeTextColor?.({...}).catch(...)` still evaluates `.catch` on the undefined the optional call returns, so on a browser without the method updateBadge raised a TypeError that escaped into whatever asked for the badge update. Both the method and its return value are checked now, and tests/extension-build.test.mjs pins the shape: no `?.(...).catch`, an existence check around the call, a promise check before `.catch`, and the gold and ink values with the magenta one gone. The dark-DevTools seam is a fixed 2px line at the top of the sheet, above the sticky toolbar because that is what keeps it in place while the panel scrolls under it. It now sets pointer-events: none, so it is paint and nothing else and the toolbar's top row of pixels stays clickable. extension/shared/ joins the detector suite's trigger list, since the shared stylesheet is part of extension packaging. Co-Authored-By: Claude Code <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Vau2X53xGTjjTCXWMVBoNY * overlay: the page banner is a paper bar, not a gold fill The last place gold was still upholstery. A full-width gold bar across someone else's page is a fill, and the system holds gold to a mark, a line, a lit dot, or a tag. The banner is now paper with ink text, a gold hairline along its bottom edge, and the detector's lit gold dot at its head, the same indicator the panel puts on a section. Each page-level finding wears the tag the panel gives it: gold with ink for an AI tell, a quiet gray chip for a quality issue, with the detail beside it in ink rather than folded into the chip. The bar carries a shadow of its own so it still separates from a dark host page, and the standalone controls take muted ink now that they sit on paper instead of gold. crates/live/assets/detect-antipatterns-browser.js is the regenerated tracked artifact, so live mode and the site's detector get the same banner. Extension smoke: all nine fixtures pass, no service worker or offscreen errors. bun run test green with a local engine build. web-ext lint reports the same two Firefox-only errors and fifteen warnings as origin/main. Co-Authored-By: Claude Code <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Vau2X53xGTjjTCXWMVBoNY --------- Co-authored-by: Claude Code <noreply@anthropic.com>
622 lines
23 KiB
JavaScript
622 lines
23 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, the extension's
|
|
// extension/shared/kinpaku.css, and the kit's picker section in
|
|
// site/styles/kinpaku-kit.css.
|
|
//
|
|
// One color across both light and dark host pages. The marker is a gold
|
|
// hairline plus a soft outer glow: the hairline holds its shape on a
|
|
// light page, the glow carries it on a dark one, and neither reads as
|
|
// body text, so the pair doesn't need WCAG AA against the page. The
|
|
// label is the detector's tag: dark ink (LABEL_INK, --ks-on-gold) on
|
|
// leaf gold, about 11.8:1, in the mono face the tag uses everywhere
|
|
// else. 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(14% 0.018 95)';
|
|
const LABEL_BG = BRAND_COLOR;
|
|
const OUTLINE_COLOR = BRAND_COLOR;
|
|
const GLOW = '0 0 0 3px oklch(84% 0.19 80.46 / 0.20), 0 0 12px oklch(84% 0.19 80.46 / 0.45)';
|
|
const GLOW_HOVER = '0 0 0 3px oklch(74% 0.18 80 / 0.28), 0 0 16px oklch(74% 0.18 80 / 0.6)';
|
|
|
|
// The page-level banner is a paper bar, not a gold one: gold is the mark,
|
|
// the tag and the lit dot, and a full-width gold fill across someone's page
|
|
// is the one place it was still upholstery. Paper with ink text, a gold
|
|
// hairline along the bottom edge, a lit gold dot at the head, and each
|
|
// finding wearing the same tag the panel gives it (gold for an AI tell,
|
|
// gray for a quality issue). The bar carries its own shadow so it still
|
|
// separates from a dark host page.
|
|
const PAPER = 'oklch(97.8% 0 0)';
|
|
const PAPER_INK = 'oklch(13% 0 0)';
|
|
const PAPER_MUTED = 'oklch(46% 0 0)';
|
|
const PAPER_GRAY = 'oklch(92% 0 0)';
|
|
const GOLD_LINE = 'oklch(77% 0.13 82)';
|
|
const BANNER_LIFT = '0 1px 0 oklch(13% 0 0 / 0.10), 0 6px 18px oklch(0% 0 0 / 0.35)';
|
|
const MONO = 'ui-monospace, SFMono-Regular, Menlo, Consolas, monospace';
|
|
const SANS = 'system-ui, -apple-system, "Segoe UI", sans-serif';
|
|
|
|
// 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: 1px solid ${OUTLINE_COLOR};
|
|
box-shadow: ${GLOW};
|
|
border-radius: 4px;
|
|
transition: outline-color 0.15s ease, box-shadow 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};
|
|
box-shadow: ${GLOW_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 + 3px (2px overlay offset + 1px hairline)
|
|
const inset = 3;
|
|
const radius = 5; // outline border-radius (4) + hairline width (1)
|
|
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: '10px', fontWeight: '600', letterSpacing: '0.06em',
|
|
textTransform: 'uppercase',
|
|
color: LABEL_INK, lineHeight: '14px',
|
|
background: LABEL_BG,
|
|
fontFamily: 'ui-monospace, SFMono-Regular, Menlo, Consolas, monospace',
|
|
borderRadius: '3px 3px 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: 'oklch(14% 0.018 95 / 0.7)',
|
|
fontSize: '11px', cursor: 'pointer', padding: '3px 4px',
|
|
fontFamily: 'ui-monospace, SFMono-Regular, Menlo, Consolas, monospace', 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: PAPER, color: PAPER_INK,
|
|
borderBottom: `1px solid ${GOLD_LINE}`,
|
|
boxShadow: BANNER_LIFT,
|
|
fontFamily: SANS, fontSize: '12px',
|
|
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)';
|
|
}));
|
|
|
|
// The detector's indicator, the same lit gold dot the panel puts on a
|
|
// section head.
|
|
const dot = document.createElement('span');
|
|
Object.assign(dot.style, {
|
|
flexShrink: '0', width: '7px', height: '7px', margin: '0 0 0 12px',
|
|
borderRadius: '50%', background: BRAND_COLOR,
|
|
boxShadow: '0 0 0 1px oklch(13% 0 0 / 0.12), 0 0 4px oklch(84% 0.19 80 / 0.6)',
|
|
});
|
|
banner.appendChild(dot);
|
|
|
|
// Scrollable findings area
|
|
const scrollArea = document.createElement('div');
|
|
Object.assign(scrollArea.style, {
|
|
flex: '1', minWidth: '0', overflowX: 'auto', overflowY: 'hidden',
|
|
display: 'flex', gap: '12px', alignItems: 'center',
|
|
padding: '0 12px', scrollSnapType: 'x mandatory',
|
|
scrollbarWidth: 'none',
|
|
});
|
|
for (const f of findings) {
|
|
const isSlop = RULE_CATEGORY[f.type] === 'slop';
|
|
const item = document.createElement('span');
|
|
Object.assign(item.style, {
|
|
display: 'inline-flex', alignItems: 'center', gap: '6px',
|
|
whiteSpace: 'nowrap', flexShrink: '0', scrollSnapAlign: 'start',
|
|
});
|
|
|
|
const tag = document.createElement('span');
|
|
tag.textContent = TYPE_LABELS[f.type] || f.type;
|
|
Object.assign(tag.style, {
|
|
display: 'inline-flex', alignItems: 'center', minHeight: '16px',
|
|
padding: '0 6px', borderRadius: '3px',
|
|
background: isSlop ? BRAND_COLOR : PAPER_GRAY,
|
|
color: isSlop ? LABEL_INK : PAPER_MUTED,
|
|
fontFamily: MONO, fontSize: '10px', fontWeight: '600',
|
|
letterSpacing: '0.06em', lineHeight: '1', textTransform: 'uppercase',
|
|
});
|
|
|
|
const detail = document.createElement('span');
|
|
detail.textContent = f.detail;
|
|
Object.assign(detail.style, {
|
|
color: PAPER_INK, fontFamily: SANS, fontSize: '12px',
|
|
});
|
|
|
|
item.appendChild(tag);
|
|
item.appendChild(detail);
|
|
scrollArea.appendChild(item);
|
|
}
|
|
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: PAPER_MUTED, 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: PAPER_MUTED, 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,
|
|
};
|
|
}
|