mirror of
https://github.com/pbakaus/impeccable.git
synced 2026-09-11 21:57:14 +03:00
feat(live): make picker chrome modal-host friendly (Radix, Headless UI, vaul)
Closes #113. Picker chrome could become unclickable inside Radix Dialog portals, and clicking it dismissed the host dialog. Three orthogonal issues surfaced during manual verification: 1. Modal-aware chrome - Add `defangOutsideHandlers` and apply it to bar, picker, params panel, annotation overlay, global bar, and design panel host. - Sets `pointer-events: auto !important` on interactive chrome so Radix's `body { pointer-events: none }` modal scroll-lock can't silence our UI. - Stops `pointerdown` / `mousedown` / `focusin` propagation at the chrome boundary so DismissableLayer / FocusScope outside-handlers never fire for clicks that land on us. 2. detectPageTheme: misread transparent body as black - `getComputedStyle(body).backgroundColor` returns `rgba(0,0,0,0)` when no bg is set; the prior regex captured (0,0,0) and ignored alpha, calling every default-bg page "dark." - Honor alpha, walk body → html, fall back to `prefers-color-scheme` only when both are transparent. 3. Exit X invisible on host pages with `button { padding: ... }` - Every other chrome button sets padding inline; exitBtn didn't. Host resets like `button { padding: 0.5rem 1rem }` (in the new fixture, common in the wild) inflated the 24x24 button into 56x40 and pushed the SVG into a non-rendering region — DevTools showed the right styles, the X just didn't paint. - Pin `padding: 0` + `box-sizing: border-box`, match the toggle icon spec (14 / stroke 1.5 / textDim → text on hover). 4. Toast no longer obscures the global bar - Position the toast above globalBarEl's actual rect instead of a fixed bottom: 16px that overlapped the bar's bottom: 14px. Test coverage: new `vite8-react-radix-dialog` fixture exercises the full pick → Go → cycle → Accept loop with `@radix-ui/react-dialog` + `Portal` + `Overlay` + `Content`. Without the fix, clicking Go dismisses the dialog and unmounts the picked element. All 20 live e2e fixtures pass; all 180 unit tests pass. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.7
parent
39bec7c08c
commit
630e586b01
@@ -197,6 +197,45 @@
|
||||
|
||||
function id8() { return crypto.randomUUID().replace(/-/g, '').slice(0, 8); }
|
||||
|
||||
// Modal-aware chrome: keep our floating UI clickable inside Radix /
|
||||
// Headless UI / vaul portals.
|
||||
//
|
||||
// Two host-page behaviors break us when the picked element lives inside a
|
||||
// modal dialog:
|
||||
//
|
||||
// 1. Modal scroll-lock disables outside pointer events. Radix's
|
||||
// `DismissableLayer` sets `document.body.style.pointerEvents = 'none'`
|
||||
// while a modal is open and only restores `auto` on the layer. Our
|
||||
// chrome inherits `none` from <body> and becomes unclickable.
|
||||
// 2. The dialog's outside-interaction handler (Radix's
|
||||
// `usePointerDownOutside`) listens at document level and dismisses
|
||||
// the dialog whenever a `pointerdown` lands outside the layer node.
|
||||
// Our chrome is a sibling of <body>, so Radix classifies our clicks
|
||||
// as outside and tears the dialog down mid-task.
|
||||
//
|
||||
// We can't reliably re-parent our chrome into the dialog subtree (z-index
|
||||
// stacking, scroll containers, theming all become host-page concerns), so
|
||||
// we defang both behaviors at our root:
|
||||
//
|
||||
// - `pointer-events: auto !important` overrides the inherited `none`.
|
||||
// - Stop `pointerdown` / `mousedown` propagation so the document-level
|
||||
// dismiss listener never fires for our clicks.
|
||||
// - Stop `focusin` propagation so any focus shifts inside our chrome
|
||||
// don't read as "focus moved outside the dialog" to focus traps.
|
||||
//
|
||||
// Click events still bubble normally — only the early pointer/focus
|
||||
// signals that drive outside-interaction detection are silenced.
|
||||
function defangOutsideHandlers(rootEl, { setPointerEvents = true } = {}) {
|
||||
if (!rootEl) return;
|
||||
if (setPointerEvents) {
|
||||
rootEl.style.setProperty('pointer-events', 'auto', 'important');
|
||||
}
|
||||
const stop = (e) => e.stopPropagation();
|
||||
rootEl.addEventListener('pointerdown', stop);
|
||||
rootEl.addEventListener('mousedown', stop);
|
||||
rootEl.addEventListener('focusin', stop);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Highlight overlay
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -336,6 +375,11 @@
|
||||
annotOverlayEl.addEventListener('pointerup', onAnnotUp);
|
||||
annotOverlayEl.addEventListener('pointercancel', onAnnotUp);
|
||||
document.body.appendChild(annotOverlayEl);
|
||||
// Modal-host friendliness: pointer-events is already 'auto' on this
|
||||
// overlay; we only need to silence the host's outside-interaction
|
||||
// listeners. Don't override pointer-events here (the overlay toggles
|
||||
// visibility via display:none, which is fine).
|
||||
defangOutsideHandlers(annotOverlayEl, { setPointerEvents: false });
|
||||
}
|
||||
|
||||
function updateClearChip() {
|
||||
@@ -811,6 +855,7 @@
|
||||
maxWidth: '520px', minWidth: '320px',
|
||||
});
|
||||
document.body.appendChild(barEl);
|
||||
defangOutsideHandlers(barEl);
|
||||
}
|
||||
|
||||
function positionBar() {
|
||||
@@ -1330,6 +1375,7 @@
|
||||
|
||||
pickerEl.appendChild(grid);
|
||||
document.body.appendChild(pickerEl);
|
||||
defangOutsideHandlers(pickerEl);
|
||||
|
||||
// Cache the palette on the picker so toggleActionPicker's state refresh
|
||||
// uses the same theme-aware colors when it repaints chips.
|
||||
@@ -1443,6 +1489,10 @@
|
||||
|
||||
paramsPanelEl.appendChild(paramsPanelBody);
|
||||
document.body.appendChild(paramsPanelEl);
|
||||
// Don't override pointer-events: the panel toggles between 'none' (closed,
|
||||
// click-through) and 'auto' (open) on its own. Just silence the host's
|
||||
// outside-interaction listeners while the panel is open.
|
||||
defangOutsideHandlers(paramsPanelEl, { setPointerEvents: false });
|
||||
paramsPanelInner = paramsPanelEl; // compatibility alias for the rest of the code
|
||||
}
|
||||
|
||||
@@ -3024,8 +3074,16 @@ void main() {
|
||||
|
||||
function showToast(message, duration) {
|
||||
if (toastEl) toastEl.remove();
|
||||
// Stack the toast above the global bar (which sits at bottom:14px) so
|
||||
// the two never overlap. Read the bar's actual rect — its height varies
|
||||
// with hover-expanded labels — and fall back to a sensible default
|
||||
// when the bar isn't mounted yet.
|
||||
const barRect = globalBarEl?.getBoundingClientRect();
|
||||
const barTopFromBottom = barRect && barRect.height > 0
|
||||
? Math.max(16, window.innerHeight - barRect.top + 12)
|
||||
: 16;
|
||||
toastEl = el('div', {
|
||||
position: 'fixed', bottom: '16px', left: '50%',
|
||||
position: 'fixed', bottom: barTopFromBottom + 'px', left: '50%',
|
||||
transform: 'translateX(-50%) translateY(8px)',
|
||||
background: C.ink, color: C.white,
|
||||
fontFamily: FONT, fontSize: '12px',
|
||||
@@ -3148,13 +3206,33 @@ void main() {
|
||||
// page bg. Used for screenshots and theme QA.
|
||||
const override = localStorage.getItem('impeccable-dev-theme');
|
||||
if (override === 'light' || override === 'dark') return override;
|
||||
const bg = getComputedStyle(document.body).backgroundColor
|
||||
|| getComputedStyle(document.documentElement).backgroundColor;
|
||||
const m = bg.match(/rgba?\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)/);
|
||||
if (!m) return 'light';
|
||||
const [, r, g, b] = m;
|
||||
|
||||
// Walk body → html, taking the first opaque background. The browser's
|
||||
// default body / html background is `rgba(0, 0, 0, 0)`, which a naive
|
||||
// regex would read as black and mislabel a perfectly white page as
|
||||
// dark. Honoring alpha avoids that — and falling through to <html>
|
||||
// catches the common pattern of a bg only on <html> (or only on body).
|
||||
function readOpaque(el) {
|
||||
if (!el) return null;
|
||||
const bg = getComputedStyle(el).backgroundColor;
|
||||
const m = bg.match(/rgba?\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)(?:\s*,\s*([\d.]+))?\s*\)/);
|
||||
if (!m) return null;
|
||||
const alpha = m[4] == null ? 1 : parseFloat(m[4]);
|
||||
if (alpha < 0.5) return null; // transparent / nearly transparent → skip
|
||||
return [+m[1], +m[2], +m[3]];
|
||||
}
|
||||
|
||||
const rgb = readOpaque(document.body) || readOpaque(document.documentElement);
|
||||
// Both transparent → fall back to the browser's effective canvas color.
|
||||
// White is the universal default; only one in a thousand sites swaps it
|
||||
// via `color-scheme: dark` on <html>, and `prefers-color-scheme` lets
|
||||
// us catch that case.
|
||||
if (!rgb) {
|
||||
return matchMedia?.('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
|
||||
}
|
||||
const [r, g, b] = rgb;
|
||||
// Perceptual luminance (Rec. 709)
|
||||
const L = (0.2126 * +r + 0.7152 * +g + 0.0722 * +b) / 255;
|
||||
const L = (0.2126 * r + 0.7152 * g + 0.0722 * b) / 255;
|
||||
return L > 0.55 ? 'light' : 'dark';
|
||||
} catch { return 'light'; }
|
||||
}
|
||||
@@ -3357,15 +3435,24 @@ void main() {
|
||||
});
|
||||
inner.appendChild(divider);
|
||||
|
||||
// Exit (subtle × on the right) — SVG for baseline-free centering
|
||||
// Exit × on the right — intentionally subtle (textDim at rest, text on
|
||||
// hover) so it sits behind the active toggles in visual hierarchy.
|
||||
//
|
||||
// Explicit padding + box-sizing here is load-bearing: a host page like
|
||||
// `button { padding: 0.5rem 1rem; }` (very common in resets) would
|
||||
// otherwise inflate this 24x24 button into 56x40 and push the SVG out
|
||||
// of the visible bar — the X stays invisible even though the styles in
|
||||
// DevTools look fine. Every other chrome button sets padding inline;
|
||||
// this one needed it too.
|
||||
const exitBtn = el('button', {
|
||||
display: 'inline-flex', alignItems: 'center', justifyContent: 'center',
|
||||
width: '26px', height: '26px', borderRadius: '6px',
|
||||
padding: '0', boxSizing: 'border-box',
|
||||
width: '24px', height: '24px', borderRadius: '6px',
|
||||
border: 'none', background: 'transparent',
|
||||
color: P.textDim, fontFamily: FONT, fontSize: '0', lineHeight: '0',
|
||||
cursor: 'pointer', transition: 'color 0.12s ease, background 0.12s ease',
|
||||
});
|
||||
exitBtn.innerHTML = '<svg width="12" height="12" viewBox="0 0 12 12" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round"><line x1="2.5" y1="2.5" x2="9.5" y2="9.5"/><line x1="9.5" y1="2.5" x2="2.5" y2="9.5"/></svg>';
|
||||
exitBtn.innerHTML = '<svg width="14" height="14" viewBox="0 0 14 14" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"><line x1="3" y1="3" x2="11" y2="11"/><line x1="11" y1="3" x2="3" y2="11"/></svg>';
|
||||
exitBtn.title = 'Exit live mode';
|
||||
exitBtn.addEventListener('mouseenter', () => { exitBtn.style.color = P.text; exitBtn.style.background = P.exitHover; });
|
||||
exitBtn.addEventListener('mouseleave', () => { exitBtn.style.color = P.textDim; exitBtn.style.background = 'transparent'; });
|
||||
@@ -3383,6 +3470,7 @@ void main() {
|
||||
});
|
||||
|
||||
document.body.appendChild(globalBarEl);
|
||||
defangOutsideHandlers(globalBarEl);
|
||||
|
||||
requestAnimationFrame(() => {
|
||||
globalBarEl.style.opacity = '1';
|
||||
@@ -3595,6 +3683,11 @@ void main() {
|
||||
designShadow.appendChild(root);
|
||||
|
||||
document.body.appendChild(designHost);
|
||||
// The host is pointer-events: none; the panel inside the shadow DOM
|
||||
// manages its own auto/none. Events bubble through the shadow boundary,
|
||||
// so attaching here silences host-page outside-interaction handlers
|
||||
// without touching the host's click-through behavior.
|
||||
defangOutsideHandlers(designHost, { setPointerEvents: false });
|
||||
|
||||
loadDesignPrefs();
|
||||
renderDesignChrome();
|
||||
|
||||
@@ -197,6 +197,45 @@
|
||||
|
||||
function id8() { return crypto.randomUUID().replace(/-/g, '').slice(0, 8); }
|
||||
|
||||
// Modal-aware chrome: keep our floating UI clickable inside Radix /
|
||||
// Headless UI / vaul portals.
|
||||
//
|
||||
// Two host-page behaviors break us when the picked element lives inside a
|
||||
// modal dialog:
|
||||
//
|
||||
// 1. Modal scroll-lock disables outside pointer events. Radix's
|
||||
// `DismissableLayer` sets `document.body.style.pointerEvents = 'none'`
|
||||
// while a modal is open and only restores `auto` on the layer. Our
|
||||
// chrome inherits `none` from <body> and becomes unclickable.
|
||||
// 2. The dialog's outside-interaction handler (Radix's
|
||||
// `usePointerDownOutside`) listens at document level and dismisses
|
||||
// the dialog whenever a `pointerdown` lands outside the layer node.
|
||||
// Our chrome is a sibling of <body>, so Radix classifies our clicks
|
||||
// as outside and tears the dialog down mid-task.
|
||||
//
|
||||
// We can't reliably re-parent our chrome into the dialog subtree (z-index
|
||||
// stacking, scroll containers, theming all become host-page concerns), so
|
||||
// we defang both behaviors at our root:
|
||||
//
|
||||
// - `pointer-events: auto !important` overrides the inherited `none`.
|
||||
// - Stop `pointerdown` / `mousedown` propagation so the document-level
|
||||
// dismiss listener never fires for our clicks.
|
||||
// - Stop `focusin` propagation so any focus shifts inside our chrome
|
||||
// don't read as "focus moved outside the dialog" to focus traps.
|
||||
//
|
||||
// Click events still bubble normally — only the early pointer/focus
|
||||
// signals that drive outside-interaction detection are silenced.
|
||||
function defangOutsideHandlers(rootEl, { setPointerEvents = true } = {}) {
|
||||
if (!rootEl) return;
|
||||
if (setPointerEvents) {
|
||||
rootEl.style.setProperty('pointer-events', 'auto', 'important');
|
||||
}
|
||||
const stop = (e) => e.stopPropagation();
|
||||
rootEl.addEventListener('pointerdown', stop);
|
||||
rootEl.addEventListener('mousedown', stop);
|
||||
rootEl.addEventListener('focusin', stop);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Highlight overlay
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -336,6 +375,11 @@
|
||||
annotOverlayEl.addEventListener('pointerup', onAnnotUp);
|
||||
annotOverlayEl.addEventListener('pointercancel', onAnnotUp);
|
||||
document.body.appendChild(annotOverlayEl);
|
||||
// Modal-host friendliness: pointer-events is already 'auto' on this
|
||||
// overlay; we only need to silence the host's outside-interaction
|
||||
// listeners. Don't override pointer-events here (the overlay toggles
|
||||
// visibility via display:none, which is fine).
|
||||
defangOutsideHandlers(annotOverlayEl, { setPointerEvents: false });
|
||||
}
|
||||
|
||||
function updateClearChip() {
|
||||
@@ -811,6 +855,7 @@
|
||||
maxWidth: '520px', minWidth: '320px',
|
||||
});
|
||||
document.body.appendChild(barEl);
|
||||
defangOutsideHandlers(barEl);
|
||||
}
|
||||
|
||||
function positionBar() {
|
||||
@@ -1330,6 +1375,7 @@
|
||||
|
||||
pickerEl.appendChild(grid);
|
||||
document.body.appendChild(pickerEl);
|
||||
defangOutsideHandlers(pickerEl);
|
||||
|
||||
// Cache the palette on the picker so toggleActionPicker's state refresh
|
||||
// uses the same theme-aware colors when it repaints chips.
|
||||
@@ -1443,6 +1489,10 @@
|
||||
|
||||
paramsPanelEl.appendChild(paramsPanelBody);
|
||||
document.body.appendChild(paramsPanelEl);
|
||||
// Don't override pointer-events: the panel toggles between 'none' (closed,
|
||||
// click-through) and 'auto' (open) on its own. Just silence the host's
|
||||
// outside-interaction listeners while the panel is open.
|
||||
defangOutsideHandlers(paramsPanelEl, { setPointerEvents: false });
|
||||
paramsPanelInner = paramsPanelEl; // compatibility alias for the rest of the code
|
||||
}
|
||||
|
||||
@@ -3024,8 +3074,16 @@ void main() {
|
||||
|
||||
function showToast(message, duration) {
|
||||
if (toastEl) toastEl.remove();
|
||||
// Stack the toast above the global bar (which sits at bottom:14px) so
|
||||
// the two never overlap. Read the bar's actual rect — its height varies
|
||||
// with hover-expanded labels — and fall back to a sensible default
|
||||
// when the bar isn't mounted yet.
|
||||
const barRect = globalBarEl?.getBoundingClientRect();
|
||||
const barTopFromBottom = barRect && barRect.height > 0
|
||||
? Math.max(16, window.innerHeight - barRect.top + 12)
|
||||
: 16;
|
||||
toastEl = el('div', {
|
||||
position: 'fixed', bottom: '16px', left: '50%',
|
||||
position: 'fixed', bottom: barTopFromBottom + 'px', left: '50%',
|
||||
transform: 'translateX(-50%) translateY(8px)',
|
||||
background: C.ink, color: C.white,
|
||||
fontFamily: FONT, fontSize: '12px',
|
||||
@@ -3148,13 +3206,33 @@ void main() {
|
||||
// page bg. Used for screenshots and theme QA.
|
||||
const override = localStorage.getItem('impeccable-dev-theme');
|
||||
if (override === 'light' || override === 'dark') return override;
|
||||
const bg = getComputedStyle(document.body).backgroundColor
|
||||
|| getComputedStyle(document.documentElement).backgroundColor;
|
||||
const m = bg.match(/rgba?\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)/);
|
||||
if (!m) return 'light';
|
||||
const [, r, g, b] = m;
|
||||
|
||||
// Walk body → html, taking the first opaque background. The browser's
|
||||
// default body / html background is `rgba(0, 0, 0, 0)`, which a naive
|
||||
// regex would read as black and mislabel a perfectly white page as
|
||||
// dark. Honoring alpha avoids that — and falling through to <html>
|
||||
// catches the common pattern of a bg only on <html> (or only on body).
|
||||
function readOpaque(el) {
|
||||
if (!el) return null;
|
||||
const bg = getComputedStyle(el).backgroundColor;
|
||||
const m = bg.match(/rgba?\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)(?:\s*,\s*([\d.]+))?\s*\)/);
|
||||
if (!m) return null;
|
||||
const alpha = m[4] == null ? 1 : parseFloat(m[4]);
|
||||
if (alpha < 0.5) return null; // transparent / nearly transparent → skip
|
||||
return [+m[1], +m[2], +m[3]];
|
||||
}
|
||||
|
||||
const rgb = readOpaque(document.body) || readOpaque(document.documentElement);
|
||||
// Both transparent → fall back to the browser's effective canvas color.
|
||||
// White is the universal default; only one in a thousand sites swaps it
|
||||
// via `color-scheme: dark` on <html>, and `prefers-color-scheme` lets
|
||||
// us catch that case.
|
||||
if (!rgb) {
|
||||
return matchMedia?.('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
|
||||
}
|
||||
const [r, g, b] = rgb;
|
||||
// Perceptual luminance (Rec. 709)
|
||||
const L = (0.2126 * +r + 0.7152 * +g + 0.0722 * +b) / 255;
|
||||
const L = (0.2126 * r + 0.7152 * g + 0.0722 * b) / 255;
|
||||
return L > 0.55 ? 'light' : 'dark';
|
||||
} catch { return 'light'; }
|
||||
}
|
||||
@@ -3357,15 +3435,24 @@ void main() {
|
||||
});
|
||||
inner.appendChild(divider);
|
||||
|
||||
// Exit (subtle × on the right) — SVG for baseline-free centering
|
||||
// Exit × on the right — intentionally subtle (textDim at rest, text on
|
||||
// hover) so it sits behind the active toggles in visual hierarchy.
|
||||
//
|
||||
// Explicit padding + box-sizing here is load-bearing: a host page like
|
||||
// `button { padding: 0.5rem 1rem; }` (very common in resets) would
|
||||
// otherwise inflate this 24x24 button into 56x40 and push the SVG out
|
||||
// of the visible bar — the X stays invisible even though the styles in
|
||||
// DevTools look fine. Every other chrome button sets padding inline;
|
||||
// this one needed it too.
|
||||
const exitBtn = el('button', {
|
||||
display: 'inline-flex', alignItems: 'center', justifyContent: 'center',
|
||||
width: '26px', height: '26px', borderRadius: '6px',
|
||||
padding: '0', boxSizing: 'border-box',
|
||||
width: '24px', height: '24px', borderRadius: '6px',
|
||||
border: 'none', background: 'transparent',
|
||||
color: P.textDim, fontFamily: FONT, fontSize: '0', lineHeight: '0',
|
||||
cursor: 'pointer', transition: 'color 0.12s ease, background 0.12s ease',
|
||||
});
|
||||
exitBtn.innerHTML = '<svg width="12" height="12" viewBox="0 0 12 12" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round"><line x1="2.5" y1="2.5" x2="9.5" y2="9.5"/><line x1="9.5" y1="2.5" x2="2.5" y2="9.5"/></svg>';
|
||||
exitBtn.innerHTML = '<svg width="14" height="14" viewBox="0 0 14 14" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"><line x1="3" y1="3" x2="11" y2="11"/><line x1="11" y1="3" x2="3" y2="11"/></svg>';
|
||||
exitBtn.title = 'Exit live mode';
|
||||
exitBtn.addEventListener('mouseenter', () => { exitBtn.style.color = P.text; exitBtn.style.background = P.exitHover; });
|
||||
exitBtn.addEventListener('mouseleave', () => { exitBtn.style.color = P.textDim; exitBtn.style.background = 'transparent'; });
|
||||
@@ -3383,6 +3470,7 @@ void main() {
|
||||
});
|
||||
|
||||
document.body.appendChild(globalBarEl);
|
||||
defangOutsideHandlers(globalBarEl);
|
||||
|
||||
requestAnimationFrame(() => {
|
||||
globalBarEl.style.opacity = '1';
|
||||
@@ -3595,6 +3683,11 @@ void main() {
|
||||
designShadow.appendChild(root);
|
||||
|
||||
document.body.appendChild(designHost);
|
||||
// The host is pointer-events: none; the panel inside the shadow DOM
|
||||
// manages its own auto/none. Events bubble through the shadow boundary,
|
||||
// so attaching here silences host-page outside-interaction handlers
|
||||
// without touching the host's click-through behavior.
|
||||
defangOutsideHandlers(designHost, { setPointerEvents: false });
|
||||
|
||||
loadDesignPrefs();
|
||||
renderDesignChrome();
|
||||
|
||||
@@ -197,6 +197,45 @@
|
||||
|
||||
function id8() { return crypto.randomUUID().replace(/-/g, '').slice(0, 8); }
|
||||
|
||||
// Modal-aware chrome: keep our floating UI clickable inside Radix /
|
||||
// Headless UI / vaul portals.
|
||||
//
|
||||
// Two host-page behaviors break us when the picked element lives inside a
|
||||
// modal dialog:
|
||||
//
|
||||
// 1. Modal scroll-lock disables outside pointer events. Radix's
|
||||
// `DismissableLayer` sets `document.body.style.pointerEvents = 'none'`
|
||||
// while a modal is open and only restores `auto` on the layer. Our
|
||||
// chrome inherits `none` from <body> and becomes unclickable.
|
||||
// 2. The dialog's outside-interaction handler (Radix's
|
||||
// `usePointerDownOutside`) listens at document level and dismisses
|
||||
// the dialog whenever a `pointerdown` lands outside the layer node.
|
||||
// Our chrome is a sibling of <body>, so Radix classifies our clicks
|
||||
// as outside and tears the dialog down mid-task.
|
||||
//
|
||||
// We can't reliably re-parent our chrome into the dialog subtree (z-index
|
||||
// stacking, scroll containers, theming all become host-page concerns), so
|
||||
// we defang both behaviors at our root:
|
||||
//
|
||||
// - `pointer-events: auto !important` overrides the inherited `none`.
|
||||
// - Stop `pointerdown` / `mousedown` propagation so the document-level
|
||||
// dismiss listener never fires for our clicks.
|
||||
// - Stop `focusin` propagation so any focus shifts inside our chrome
|
||||
// don't read as "focus moved outside the dialog" to focus traps.
|
||||
//
|
||||
// Click events still bubble normally — only the early pointer/focus
|
||||
// signals that drive outside-interaction detection are silenced.
|
||||
function defangOutsideHandlers(rootEl, { setPointerEvents = true } = {}) {
|
||||
if (!rootEl) return;
|
||||
if (setPointerEvents) {
|
||||
rootEl.style.setProperty('pointer-events', 'auto', 'important');
|
||||
}
|
||||
const stop = (e) => e.stopPropagation();
|
||||
rootEl.addEventListener('pointerdown', stop);
|
||||
rootEl.addEventListener('mousedown', stop);
|
||||
rootEl.addEventListener('focusin', stop);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Highlight overlay
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -336,6 +375,11 @@
|
||||
annotOverlayEl.addEventListener('pointerup', onAnnotUp);
|
||||
annotOverlayEl.addEventListener('pointercancel', onAnnotUp);
|
||||
document.body.appendChild(annotOverlayEl);
|
||||
// Modal-host friendliness: pointer-events is already 'auto' on this
|
||||
// overlay; we only need to silence the host's outside-interaction
|
||||
// listeners. Don't override pointer-events here (the overlay toggles
|
||||
// visibility via display:none, which is fine).
|
||||
defangOutsideHandlers(annotOverlayEl, { setPointerEvents: false });
|
||||
}
|
||||
|
||||
function updateClearChip() {
|
||||
@@ -811,6 +855,7 @@
|
||||
maxWidth: '520px', minWidth: '320px',
|
||||
});
|
||||
document.body.appendChild(barEl);
|
||||
defangOutsideHandlers(barEl);
|
||||
}
|
||||
|
||||
function positionBar() {
|
||||
@@ -1330,6 +1375,7 @@
|
||||
|
||||
pickerEl.appendChild(grid);
|
||||
document.body.appendChild(pickerEl);
|
||||
defangOutsideHandlers(pickerEl);
|
||||
|
||||
// Cache the palette on the picker so toggleActionPicker's state refresh
|
||||
// uses the same theme-aware colors when it repaints chips.
|
||||
@@ -1443,6 +1489,10 @@
|
||||
|
||||
paramsPanelEl.appendChild(paramsPanelBody);
|
||||
document.body.appendChild(paramsPanelEl);
|
||||
// Don't override pointer-events: the panel toggles between 'none' (closed,
|
||||
// click-through) and 'auto' (open) on its own. Just silence the host's
|
||||
// outside-interaction listeners while the panel is open.
|
||||
defangOutsideHandlers(paramsPanelEl, { setPointerEvents: false });
|
||||
paramsPanelInner = paramsPanelEl; // compatibility alias for the rest of the code
|
||||
}
|
||||
|
||||
@@ -3024,8 +3074,16 @@ void main() {
|
||||
|
||||
function showToast(message, duration) {
|
||||
if (toastEl) toastEl.remove();
|
||||
// Stack the toast above the global bar (which sits at bottom:14px) so
|
||||
// the two never overlap. Read the bar's actual rect — its height varies
|
||||
// with hover-expanded labels — and fall back to a sensible default
|
||||
// when the bar isn't mounted yet.
|
||||
const barRect = globalBarEl?.getBoundingClientRect();
|
||||
const barTopFromBottom = barRect && barRect.height > 0
|
||||
? Math.max(16, window.innerHeight - barRect.top + 12)
|
||||
: 16;
|
||||
toastEl = el('div', {
|
||||
position: 'fixed', bottom: '16px', left: '50%',
|
||||
position: 'fixed', bottom: barTopFromBottom + 'px', left: '50%',
|
||||
transform: 'translateX(-50%) translateY(8px)',
|
||||
background: C.ink, color: C.white,
|
||||
fontFamily: FONT, fontSize: '12px',
|
||||
@@ -3148,13 +3206,33 @@ void main() {
|
||||
// page bg. Used for screenshots and theme QA.
|
||||
const override = localStorage.getItem('impeccable-dev-theme');
|
||||
if (override === 'light' || override === 'dark') return override;
|
||||
const bg = getComputedStyle(document.body).backgroundColor
|
||||
|| getComputedStyle(document.documentElement).backgroundColor;
|
||||
const m = bg.match(/rgba?\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)/);
|
||||
if (!m) return 'light';
|
||||
const [, r, g, b] = m;
|
||||
|
||||
// Walk body → html, taking the first opaque background. The browser's
|
||||
// default body / html background is `rgba(0, 0, 0, 0)`, which a naive
|
||||
// regex would read as black and mislabel a perfectly white page as
|
||||
// dark. Honoring alpha avoids that — and falling through to <html>
|
||||
// catches the common pattern of a bg only on <html> (or only on body).
|
||||
function readOpaque(el) {
|
||||
if (!el) return null;
|
||||
const bg = getComputedStyle(el).backgroundColor;
|
||||
const m = bg.match(/rgba?\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)(?:\s*,\s*([\d.]+))?\s*\)/);
|
||||
if (!m) return null;
|
||||
const alpha = m[4] == null ? 1 : parseFloat(m[4]);
|
||||
if (alpha < 0.5) return null; // transparent / nearly transparent → skip
|
||||
return [+m[1], +m[2], +m[3]];
|
||||
}
|
||||
|
||||
const rgb = readOpaque(document.body) || readOpaque(document.documentElement);
|
||||
// Both transparent → fall back to the browser's effective canvas color.
|
||||
// White is the universal default; only one in a thousand sites swaps it
|
||||
// via `color-scheme: dark` on <html>, and `prefers-color-scheme` lets
|
||||
// us catch that case.
|
||||
if (!rgb) {
|
||||
return matchMedia?.('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
|
||||
}
|
||||
const [r, g, b] = rgb;
|
||||
// Perceptual luminance (Rec. 709)
|
||||
const L = (0.2126 * +r + 0.7152 * +g + 0.0722 * +b) / 255;
|
||||
const L = (0.2126 * r + 0.7152 * g + 0.0722 * b) / 255;
|
||||
return L > 0.55 ? 'light' : 'dark';
|
||||
} catch { return 'light'; }
|
||||
}
|
||||
@@ -3357,15 +3435,24 @@ void main() {
|
||||
});
|
||||
inner.appendChild(divider);
|
||||
|
||||
// Exit (subtle × on the right) — SVG for baseline-free centering
|
||||
// Exit × on the right — intentionally subtle (textDim at rest, text on
|
||||
// hover) so it sits behind the active toggles in visual hierarchy.
|
||||
//
|
||||
// Explicit padding + box-sizing here is load-bearing: a host page like
|
||||
// `button { padding: 0.5rem 1rem; }` (very common in resets) would
|
||||
// otherwise inflate this 24x24 button into 56x40 and push the SVG out
|
||||
// of the visible bar — the X stays invisible even though the styles in
|
||||
// DevTools look fine. Every other chrome button sets padding inline;
|
||||
// this one needed it too.
|
||||
const exitBtn = el('button', {
|
||||
display: 'inline-flex', alignItems: 'center', justifyContent: 'center',
|
||||
width: '26px', height: '26px', borderRadius: '6px',
|
||||
padding: '0', boxSizing: 'border-box',
|
||||
width: '24px', height: '24px', borderRadius: '6px',
|
||||
border: 'none', background: 'transparent',
|
||||
color: P.textDim, fontFamily: FONT, fontSize: '0', lineHeight: '0',
|
||||
cursor: 'pointer', transition: 'color 0.12s ease, background 0.12s ease',
|
||||
});
|
||||
exitBtn.innerHTML = '<svg width="12" height="12" viewBox="0 0 12 12" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round"><line x1="2.5" y1="2.5" x2="9.5" y2="9.5"/><line x1="9.5" y1="2.5" x2="2.5" y2="9.5"/></svg>';
|
||||
exitBtn.innerHTML = '<svg width="14" height="14" viewBox="0 0 14 14" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"><line x1="3" y1="3" x2="11" y2="11"/><line x1="11" y1="3" x2="3" y2="11"/></svg>';
|
||||
exitBtn.title = 'Exit live mode';
|
||||
exitBtn.addEventListener('mouseenter', () => { exitBtn.style.color = P.text; exitBtn.style.background = P.exitHover; });
|
||||
exitBtn.addEventListener('mouseleave', () => { exitBtn.style.color = P.textDim; exitBtn.style.background = 'transparent'; });
|
||||
@@ -3383,6 +3470,7 @@ void main() {
|
||||
});
|
||||
|
||||
document.body.appendChild(globalBarEl);
|
||||
defangOutsideHandlers(globalBarEl);
|
||||
|
||||
requestAnimationFrame(() => {
|
||||
globalBarEl.style.opacity = '1';
|
||||
@@ -3595,6 +3683,11 @@ void main() {
|
||||
designShadow.appendChild(root);
|
||||
|
||||
document.body.appendChild(designHost);
|
||||
// The host is pointer-events: none; the panel inside the shadow DOM
|
||||
// manages its own auto/none. Events bubble through the shadow boundary,
|
||||
// so attaching here silences host-page outside-interaction handlers
|
||||
// without touching the host's click-through behavior.
|
||||
defangOutsideHandlers(designHost, { setPointerEvents: false });
|
||||
|
||||
loadDesignPrefs();
|
||||
renderDesignChrome();
|
||||
|
||||
@@ -197,6 +197,45 @@
|
||||
|
||||
function id8() { return crypto.randomUUID().replace(/-/g, '').slice(0, 8); }
|
||||
|
||||
// Modal-aware chrome: keep our floating UI clickable inside Radix /
|
||||
// Headless UI / vaul portals.
|
||||
//
|
||||
// Two host-page behaviors break us when the picked element lives inside a
|
||||
// modal dialog:
|
||||
//
|
||||
// 1. Modal scroll-lock disables outside pointer events. Radix's
|
||||
// `DismissableLayer` sets `document.body.style.pointerEvents = 'none'`
|
||||
// while a modal is open and only restores `auto` on the layer. Our
|
||||
// chrome inherits `none` from <body> and becomes unclickable.
|
||||
// 2. The dialog's outside-interaction handler (Radix's
|
||||
// `usePointerDownOutside`) listens at document level and dismisses
|
||||
// the dialog whenever a `pointerdown` lands outside the layer node.
|
||||
// Our chrome is a sibling of <body>, so Radix classifies our clicks
|
||||
// as outside and tears the dialog down mid-task.
|
||||
//
|
||||
// We can't reliably re-parent our chrome into the dialog subtree (z-index
|
||||
// stacking, scroll containers, theming all become host-page concerns), so
|
||||
// we defang both behaviors at our root:
|
||||
//
|
||||
// - `pointer-events: auto !important` overrides the inherited `none`.
|
||||
// - Stop `pointerdown` / `mousedown` propagation so the document-level
|
||||
// dismiss listener never fires for our clicks.
|
||||
// - Stop `focusin` propagation so any focus shifts inside our chrome
|
||||
// don't read as "focus moved outside the dialog" to focus traps.
|
||||
//
|
||||
// Click events still bubble normally — only the early pointer/focus
|
||||
// signals that drive outside-interaction detection are silenced.
|
||||
function defangOutsideHandlers(rootEl, { setPointerEvents = true } = {}) {
|
||||
if (!rootEl) return;
|
||||
if (setPointerEvents) {
|
||||
rootEl.style.setProperty('pointer-events', 'auto', 'important');
|
||||
}
|
||||
const stop = (e) => e.stopPropagation();
|
||||
rootEl.addEventListener('pointerdown', stop);
|
||||
rootEl.addEventListener('mousedown', stop);
|
||||
rootEl.addEventListener('focusin', stop);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Highlight overlay
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -336,6 +375,11 @@
|
||||
annotOverlayEl.addEventListener('pointerup', onAnnotUp);
|
||||
annotOverlayEl.addEventListener('pointercancel', onAnnotUp);
|
||||
document.body.appendChild(annotOverlayEl);
|
||||
// Modal-host friendliness: pointer-events is already 'auto' on this
|
||||
// overlay; we only need to silence the host's outside-interaction
|
||||
// listeners. Don't override pointer-events here (the overlay toggles
|
||||
// visibility via display:none, which is fine).
|
||||
defangOutsideHandlers(annotOverlayEl, { setPointerEvents: false });
|
||||
}
|
||||
|
||||
function updateClearChip() {
|
||||
@@ -811,6 +855,7 @@
|
||||
maxWidth: '520px', minWidth: '320px',
|
||||
});
|
||||
document.body.appendChild(barEl);
|
||||
defangOutsideHandlers(barEl);
|
||||
}
|
||||
|
||||
function positionBar() {
|
||||
@@ -1330,6 +1375,7 @@
|
||||
|
||||
pickerEl.appendChild(grid);
|
||||
document.body.appendChild(pickerEl);
|
||||
defangOutsideHandlers(pickerEl);
|
||||
|
||||
// Cache the palette on the picker so toggleActionPicker's state refresh
|
||||
// uses the same theme-aware colors when it repaints chips.
|
||||
@@ -1443,6 +1489,10 @@
|
||||
|
||||
paramsPanelEl.appendChild(paramsPanelBody);
|
||||
document.body.appendChild(paramsPanelEl);
|
||||
// Don't override pointer-events: the panel toggles between 'none' (closed,
|
||||
// click-through) and 'auto' (open) on its own. Just silence the host's
|
||||
// outside-interaction listeners while the panel is open.
|
||||
defangOutsideHandlers(paramsPanelEl, { setPointerEvents: false });
|
||||
paramsPanelInner = paramsPanelEl; // compatibility alias for the rest of the code
|
||||
}
|
||||
|
||||
@@ -3024,8 +3074,16 @@ void main() {
|
||||
|
||||
function showToast(message, duration) {
|
||||
if (toastEl) toastEl.remove();
|
||||
// Stack the toast above the global bar (which sits at bottom:14px) so
|
||||
// the two never overlap. Read the bar's actual rect — its height varies
|
||||
// with hover-expanded labels — and fall back to a sensible default
|
||||
// when the bar isn't mounted yet.
|
||||
const barRect = globalBarEl?.getBoundingClientRect();
|
||||
const barTopFromBottom = barRect && barRect.height > 0
|
||||
? Math.max(16, window.innerHeight - barRect.top + 12)
|
||||
: 16;
|
||||
toastEl = el('div', {
|
||||
position: 'fixed', bottom: '16px', left: '50%',
|
||||
position: 'fixed', bottom: barTopFromBottom + 'px', left: '50%',
|
||||
transform: 'translateX(-50%) translateY(8px)',
|
||||
background: C.ink, color: C.white,
|
||||
fontFamily: FONT, fontSize: '12px',
|
||||
@@ -3148,13 +3206,33 @@ void main() {
|
||||
// page bg. Used for screenshots and theme QA.
|
||||
const override = localStorage.getItem('impeccable-dev-theme');
|
||||
if (override === 'light' || override === 'dark') return override;
|
||||
const bg = getComputedStyle(document.body).backgroundColor
|
||||
|| getComputedStyle(document.documentElement).backgroundColor;
|
||||
const m = bg.match(/rgba?\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)/);
|
||||
if (!m) return 'light';
|
||||
const [, r, g, b] = m;
|
||||
|
||||
// Walk body → html, taking the first opaque background. The browser's
|
||||
// default body / html background is `rgba(0, 0, 0, 0)`, which a naive
|
||||
// regex would read as black and mislabel a perfectly white page as
|
||||
// dark. Honoring alpha avoids that — and falling through to <html>
|
||||
// catches the common pattern of a bg only on <html> (or only on body).
|
||||
function readOpaque(el) {
|
||||
if (!el) return null;
|
||||
const bg = getComputedStyle(el).backgroundColor;
|
||||
const m = bg.match(/rgba?\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)(?:\s*,\s*([\d.]+))?\s*\)/);
|
||||
if (!m) return null;
|
||||
const alpha = m[4] == null ? 1 : parseFloat(m[4]);
|
||||
if (alpha < 0.5) return null; // transparent / nearly transparent → skip
|
||||
return [+m[1], +m[2], +m[3]];
|
||||
}
|
||||
|
||||
const rgb = readOpaque(document.body) || readOpaque(document.documentElement);
|
||||
// Both transparent → fall back to the browser's effective canvas color.
|
||||
// White is the universal default; only one in a thousand sites swaps it
|
||||
// via `color-scheme: dark` on <html>, and `prefers-color-scheme` lets
|
||||
// us catch that case.
|
||||
if (!rgb) {
|
||||
return matchMedia?.('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
|
||||
}
|
||||
const [r, g, b] = rgb;
|
||||
// Perceptual luminance (Rec. 709)
|
||||
const L = (0.2126 * +r + 0.7152 * +g + 0.0722 * +b) / 255;
|
||||
const L = (0.2126 * r + 0.7152 * g + 0.0722 * b) / 255;
|
||||
return L > 0.55 ? 'light' : 'dark';
|
||||
} catch { return 'light'; }
|
||||
}
|
||||
@@ -3357,15 +3435,24 @@ void main() {
|
||||
});
|
||||
inner.appendChild(divider);
|
||||
|
||||
// Exit (subtle × on the right) — SVG for baseline-free centering
|
||||
// Exit × on the right — intentionally subtle (textDim at rest, text on
|
||||
// hover) so it sits behind the active toggles in visual hierarchy.
|
||||
//
|
||||
// Explicit padding + box-sizing here is load-bearing: a host page like
|
||||
// `button { padding: 0.5rem 1rem; }` (very common in resets) would
|
||||
// otherwise inflate this 24x24 button into 56x40 and push the SVG out
|
||||
// of the visible bar — the X stays invisible even though the styles in
|
||||
// DevTools look fine. Every other chrome button sets padding inline;
|
||||
// this one needed it too.
|
||||
const exitBtn = el('button', {
|
||||
display: 'inline-flex', alignItems: 'center', justifyContent: 'center',
|
||||
width: '26px', height: '26px', borderRadius: '6px',
|
||||
padding: '0', boxSizing: 'border-box',
|
||||
width: '24px', height: '24px', borderRadius: '6px',
|
||||
border: 'none', background: 'transparent',
|
||||
color: P.textDim, fontFamily: FONT, fontSize: '0', lineHeight: '0',
|
||||
cursor: 'pointer', transition: 'color 0.12s ease, background 0.12s ease',
|
||||
});
|
||||
exitBtn.innerHTML = '<svg width="12" height="12" viewBox="0 0 12 12" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round"><line x1="2.5" y1="2.5" x2="9.5" y2="9.5"/><line x1="9.5" y1="2.5" x2="2.5" y2="9.5"/></svg>';
|
||||
exitBtn.innerHTML = '<svg width="14" height="14" viewBox="0 0 14 14" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"><line x1="3" y1="3" x2="11" y2="11"/><line x1="11" y1="3" x2="3" y2="11"/></svg>';
|
||||
exitBtn.title = 'Exit live mode';
|
||||
exitBtn.addEventListener('mouseenter', () => { exitBtn.style.color = P.text; exitBtn.style.background = P.exitHover; });
|
||||
exitBtn.addEventListener('mouseleave', () => { exitBtn.style.color = P.textDim; exitBtn.style.background = 'transparent'; });
|
||||
@@ -3383,6 +3470,7 @@ void main() {
|
||||
});
|
||||
|
||||
document.body.appendChild(globalBarEl);
|
||||
defangOutsideHandlers(globalBarEl);
|
||||
|
||||
requestAnimationFrame(() => {
|
||||
globalBarEl.style.opacity = '1';
|
||||
@@ -3595,6 +3683,11 @@ void main() {
|
||||
designShadow.appendChild(root);
|
||||
|
||||
document.body.appendChild(designHost);
|
||||
// The host is pointer-events: none; the panel inside the shadow DOM
|
||||
// manages its own auto/none. Events bubble through the shadow boundary,
|
||||
// so attaching here silences host-page outside-interaction handlers
|
||||
// without touching the host's click-through behavior.
|
||||
defangOutsideHandlers(designHost, { setPointerEvents: false });
|
||||
|
||||
loadDesignPrefs();
|
||||
renderDesignChrome();
|
||||
|
||||
@@ -197,6 +197,45 @@
|
||||
|
||||
function id8() { return crypto.randomUUID().replace(/-/g, '').slice(0, 8); }
|
||||
|
||||
// Modal-aware chrome: keep our floating UI clickable inside Radix /
|
||||
// Headless UI / vaul portals.
|
||||
//
|
||||
// Two host-page behaviors break us when the picked element lives inside a
|
||||
// modal dialog:
|
||||
//
|
||||
// 1. Modal scroll-lock disables outside pointer events. Radix's
|
||||
// `DismissableLayer` sets `document.body.style.pointerEvents = 'none'`
|
||||
// while a modal is open and only restores `auto` on the layer. Our
|
||||
// chrome inherits `none` from <body> and becomes unclickable.
|
||||
// 2. The dialog's outside-interaction handler (Radix's
|
||||
// `usePointerDownOutside`) listens at document level and dismisses
|
||||
// the dialog whenever a `pointerdown` lands outside the layer node.
|
||||
// Our chrome is a sibling of <body>, so Radix classifies our clicks
|
||||
// as outside and tears the dialog down mid-task.
|
||||
//
|
||||
// We can't reliably re-parent our chrome into the dialog subtree (z-index
|
||||
// stacking, scroll containers, theming all become host-page concerns), so
|
||||
// we defang both behaviors at our root:
|
||||
//
|
||||
// - `pointer-events: auto !important` overrides the inherited `none`.
|
||||
// - Stop `pointerdown` / `mousedown` propagation so the document-level
|
||||
// dismiss listener never fires for our clicks.
|
||||
// - Stop `focusin` propagation so any focus shifts inside our chrome
|
||||
// don't read as "focus moved outside the dialog" to focus traps.
|
||||
//
|
||||
// Click events still bubble normally — only the early pointer/focus
|
||||
// signals that drive outside-interaction detection are silenced.
|
||||
function defangOutsideHandlers(rootEl, { setPointerEvents = true } = {}) {
|
||||
if (!rootEl) return;
|
||||
if (setPointerEvents) {
|
||||
rootEl.style.setProperty('pointer-events', 'auto', 'important');
|
||||
}
|
||||
const stop = (e) => e.stopPropagation();
|
||||
rootEl.addEventListener('pointerdown', stop);
|
||||
rootEl.addEventListener('mousedown', stop);
|
||||
rootEl.addEventListener('focusin', stop);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Highlight overlay
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -336,6 +375,11 @@
|
||||
annotOverlayEl.addEventListener('pointerup', onAnnotUp);
|
||||
annotOverlayEl.addEventListener('pointercancel', onAnnotUp);
|
||||
document.body.appendChild(annotOverlayEl);
|
||||
// Modal-host friendliness: pointer-events is already 'auto' on this
|
||||
// overlay; we only need to silence the host's outside-interaction
|
||||
// listeners. Don't override pointer-events here (the overlay toggles
|
||||
// visibility via display:none, which is fine).
|
||||
defangOutsideHandlers(annotOverlayEl, { setPointerEvents: false });
|
||||
}
|
||||
|
||||
function updateClearChip() {
|
||||
@@ -811,6 +855,7 @@
|
||||
maxWidth: '520px', minWidth: '320px',
|
||||
});
|
||||
document.body.appendChild(barEl);
|
||||
defangOutsideHandlers(barEl);
|
||||
}
|
||||
|
||||
function positionBar() {
|
||||
@@ -1330,6 +1375,7 @@
|
||||
|
||||
pickerEl.appendChild(grid);
|
||||
document.body.appendChild(pickerEl);
|
||||
defangOutsideHandlers(pickerEl);
|
||||
|
||||
// Cache the palette on the picker so toggleActionPicker's state refresh
|
||||
// uses the same theme-aware colors when it repaints chips.
|
||||
@@ -1443,6 +1489,10 @@
|
||||
|
||||
paramsPanelEl.appendChild(paramsPanelBody);
|
||||
document.body.appendChild(paramsPanelEl);
|
||||
// Don't override pointer-events: the panel toggles between 'none' (closed,
|
||||
// click-through) and 'auto' (open) on its own. Just silence the host's
|
||||
// outside-interaction listeners while the panel is open.
|
||||
defangOutsideHandlers(paramsPanelEl, { setPointerEvents: false });
|
||||
paramsPanelInner = paramsPanelEl; // compatibility alias for the rest of the code
|
||||
}
|
||||
|
||||
@@ -3024,8 +3074,16 @@ void main() {
|
||||
|
||||
function showToast(message, duration) {
|
||||
if (toastEl) toastEl.remove();
|
||||
// Stack the toast above the global bar (which sits at bottom:14px) so
|
||||
// the two never overlap. Read the bar's actual rect — its height varies
|
||||
// with hover-expanded labels — and fall back to a sensible default
|
||||
// when the bar isn't mounted yet.
|
||||
const barRect = globalBarEl?.getBoundingClientRect();
|
||||
const barTopFromBottom = barRect && barRect.height > 0
|
||||
? Math.max(16, window.innerHeight - barRect.top + 12)
|
||||
: 16;
|
||||
toastEl = el('div', {
|
||||
position: 'fixed', bottom: '16px', left: '50%',
|
||||
position: 'fixed', bottom: barTopFromBottom + 'px', left: '50%',
|
||||
transform: 'translateX(-50%) translateY(8px)',
|
||||
background: C.ink, color: C.white,
|
||||
fontFamily: FONT, fontSize: '12px',
|
||||
@@ -3148,13 +3206,33 @@ void main() {
|
||||
// page bg. Used for screenshots and theme QA.
|
||||
const override = localStorage.getItem('impeccable-dev-theme');
|
||||
if (override === 'light' || override === 'dark') return override;
|
||||
const bg = getComputedStyle(document.body).backgroundColor
|
||||
|| getComputedStyle(document.documentElement).backgroundColor;
|
||||
const m = bg.match(/rgba?\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)/);
|
||||
if (!m) return 'light';
|
||||
const [, r, g, b] = m;
|
||||
|
||||
// Walk body → html, taking the first opaque background. The browser's
|
||||
// default body / html background is `rgba(0, 0, 0, 0)`, which a naive
|
||||
// regex would read as black and mislabel a perfectly white page as
|
||||
// dark. Honoring alpha avoids that — and falling through to <html>
|
||||
// catches the common pattern of a bg only on <html> (or only on body).
|
||||
function readOpaque(el) {
|
||||
if (!el) return null;
|
||||
const bg = getComputedStyle(el).backgroundColor;
|
||||
const m = bg.match(/rgba?\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)(?:\s*,\s*([\d.]+))?\s*\)/);
|
||||
if (!m) return null;
|
||||
const alpha = m[4] == null ? 1 : parseFloat(m[4]);
|
||||
if (alpha < 0.5) return null; // transparent / nearly transparent → skip
|
||||
return [+m[1], +m[2], +m[3]];
|
||||
}
|
||||
|
||||
const rgb = readOpaque(document.body) || readOpaque(document.documentElement);
|
||||
// Both transparent → fall back to the browser's effective canvas color.
|
||||
// White is the universal default; only one in a thousand sites swaps it
|
||||
// via `color-scheme: dark` on <html>, and `prefers-color-scheme` lets
|
||||
// us catch that case.
|
||||
if (!rgb) {
|
||||
return matchMedia?.('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
|
||||
}
|
||||
const [r, g, b] = rgb;
|
||||
// Perceptual luminance (Rec. 709)
|
||||
const L = (0.2126 * +r + 0.7152 * +g + 0.0722 * +b) / 255;
|
||||
const L = (0.2126 * r + 0.7152 * g + 0.0722 * b) / 255;
|
||||
return L > 0.55 ? 'light' : 'dark';
|
||||
} catch { return 'light'; }
|
||||
}
|
||||
@@ -3357,15 +3435,24 @@ void main() {
|
||||
});
|
||||
inner.appendChild(divider);
|
||||
|
||||
// Exit (subtle × on the right) — SVG for baseline-free centering
|
||||
// Exit × on the right — intentionally subtle (textDim at rest, text on
|
||||
// hover) so it sits behind the active toggles in visual hierarchy.
|
||||
//
|
||||
// Explicit padding + box-sizing here is load-bearing: a host page like
|
||||
// `button { padding: 0.5rem 1rem; }` (very common in resets) would
|
||||
// otherwise inflate this 24x24 button into 56x40 and push the SVG out
|
||||
// of the visible bar — the X stays invisible even though the styles in
|
||||
// DevTools look fine. Every other chrome button sets padding inline;
|
||||
// this one needed it too.
|
||||
const exitBtn = el('button', {
|
||||
display: 'inline-flex', alignItems: 'center', justifyContent: 'center',
|
||||
width: '26px', height: '26px', borderRadius: '6px',
|
||||
padding: '0', boxSizing: 'border-box',
|
||||
width: '24px', height: '24px', borderRadius: '6px',
|
||||
border: 'none', background: 'transparent',
|
||||
color: P.textDim, fontFamily: FONT, fontSize: '0', lineHeight: '0',
|
||||
cursor: 'pointer', transition: 'color 0.12s ease, background 0.12s ease',
|
||||
});
|
||||
exitBtn.innerHTML = '<svg width="12" height="12" viewBox="0 0 12 12" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round"><line x1="2.5" y1="2.5" x2="9.5" y2="9.5"/><line x1="9.5" y1="2.5" x2="2.5" y2="9.5"/></svg>';
|
||||
exitBtn.innerHTML = '<svg width="14" height="14" viewBox="0 0 14 14" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"><line x1="3" y1="3" x2="11" y2="11"/><line x1="11" y1="3" x2="3" y2="11"/></svg>';
|
||||
exitBtn.title = 'Exit live mode';
|
||||
exitBtn.addEventListener('mouseenter', () => { exitBtn.style.color = P.text; exitBtn.style.background = P.exitHover; });
|
||||
exitBtn.addEventListener('mouseleave', () => { exitBtn.style.color = P.textDim; exitBtn.style.background = 'transparent'; });
|
||||
@@ -3383,6 +3470,7 @@ void main() {
|
||||
});
|
||||
|
||||
document.body.appendChild(globalBarEl);
|
||||
defangOutsideHandlers(globalBarEl);
|
||||
|
||||
requestAnimationFrame(() => {
|
||||
globalBarEl.style.opacity = '1';
|
||||
@@ -3595,6 +3683,11 @@ void main() {
|
||||
designShadow.appendChild(root);
|
||||
|
||||
document.body.appendChild(designHost);
|
||||
// The host is pointer-events: none; the panel inside the shadow DOM
|
||||
// manages its own auto/none. Events bubble through the shadow boundary,
|
||||
// so attaching here silences host-page outside-interaction handlers
|
||||
// without touching the host's click-through behavior.
|
||||
defangOutsideHandlers(designHost, { setPointerEvents: false });
|
||||
|
||||
loadDesignPrefs();
|
||||
renderDesignChrome();
|
||||
|
||||
@@ -197,6 +197,45 @@
|
||||
|
||||
function id8() { return crypto.randomUUID().replace(/-/g, '').slice(0, 8); }
|
||||
|
||||
// Modal-aware chrome: keep our floating UI clickable inside Radix /
|
||||
// Headless UI / vaul portals.
|
||||
//
|
||||
// Two host-page behaviors break us when the picked element lives inside a
|
||||
// modal dialog:
|
||||
//
|
||||
// 1. Modal scroll-lock disables outside pointer events. Radix's
|
||||
// `DismissableLayer` sets `document.body.style.pointerEvents = 'none'`
|
||||
// while a modal is open and only restores `auto` on the layer. Our
|
||||
// chrome inherits `none` from <body> and becomes unclickable.
|
||||
// 2. The dialog's outside-interaction handler (Radix's
|
||||
// `usePointerDownOutside`) listens at document level and dismisses
|
||||
// the dialog whenever a `pointerdown` lands outside the layer node.
|
||||
// Our chrome is a sibling of <body>, so Radix classifies our clicks
|
||||
// as outside and tears the dialog down mid-task.
|
||||
//
|
||||
// We can't reliably re-parent our chrome into the dialog subtree (z-index
|
||||
// stacking, scroll containers, theming all become host-page concerns), so
|
||||
// we defang both behaviors at our root:
|
||||
//
|
||||
// - `pointer-events: auto !important` overrides the inherited `none`.
|
||||
// - Stop `pointerdown` / `mousedown` propagation so the document-level
|
||||
// dismiss listener never fires for our clicks.
|
||||
// - Stop `focusin` propagation so any focus shifts inside our chrome
|
||||
// don't read as "focus moved outside the dialog" to focus traps.
|
||||
//
|
||||
// Click events still bubble normally — only the early pointer/focus
|
||||
// signals that drive outside-interaction detection are silenced.
|
||||
function defangOutsideHandlers(rootEl, { setPointerEvents = true } = {}) {
|
||||
if (!rootEl) return;
|
||||
if (setPointerEvents) {
|
||||
rootEl.style.setProperty('pointer-events', 'auto', 'important');
|
||||
}
|
||||
const stop = (e) => e.stopPropagation();
|
||||
rootEl.addEventListener('pointerdown', stop);
|
||||
rootEl.addEventListener('mousedown', stop);
|
||||
rootEl.addEventListener('focusin', stop);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Highlight overlay
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -336,6 +375,11 @@
|
||||
annotOverlayEl.addEventListener('pointerup', onAnnotUp);
|
||||
annotOverlayEl.addEventListener('pointercancel', onAnnotUp);
|
||||
document.body.appendChild(annotOverlayEl);
|
||||
// Modal-host friendliness: pointer-events is already 'auto' on this
|
||||
// overlay; we only need to silence the host's outside-interaction
|
||||
// listeners. Don't override pointer-events here (the overlay toggles
|
||||
// visibility via display:none, which is fine).
|
||||
defangOutsideHandlers(annotOverlayEl, { setPointerEvents: false });
|
||||
}
|
||||
|
||||
function updateClearChip() {
|
||||
@@ -811,6 +855,7 @@
|
||||
maxWidth: '520px', minWidth: '320px',
|
||||
});
|
||||
document.body.appendChild(barEl);
|
||||
defangOutsideHandlers(barEl);
|
||||
}
|
||||
|
||||
function positionBar() {
|
||||
@@ -1330,6 +1375,7 @@
|
||||
|
||||
pickerEl.appendChild(grid);
|
||||
document.body.appendChild(pickerEl);
|
||||
defangOutsideHandlers(pickerEl);
|
||||
|
||||
// Cache the palette on the picker so toggleActionPicker's state refresh
|
||||
// uses the same theme-aware colors when it repaints chips.
|
||||
@@ -1443,6 +1489,10 @@
|
||||
|
||||
paramsPanelEl.appendChild(paramsPanelBody);
|
||||
document.body.appendChild(paramsPanelEl);
|
||||
// Don't override pointer-events: the panel toggles between 'none' (closed,
|
||||
// click-through) and 'auto' (open) on its own. Just silence the host's
|
||||
// outside-interaction listeners while the panel is open.
|
||||
defangOutsideHandlers(paramsPanelEl, { setPointerEvents: false });
|
||||
paramsPanelInner = paramsPanelEl; // compatibility alias for the rest of the code
|
||||
}
|
||||
|
||||
@@ -3024,8 +3074,16 @@ void main() {
|
||||
|
||||
function showToast(message, duration) {
|
||||
if (toastEl) toastEl.remove();
|
||||
// Stack the toast above the global bar (which sits at bottom:14px) so
|
||||
// the two never overlap. Read the bar's actual rect — its height varies
|
||||
// with hover-expanded labels — and fall back to a sensible default
|
||||
// when the bar isn't mounted yet.
|
||||
const barRect = globalBarEl?.getBoundingClientRect();
|
||||
const barTopFromBottom = barRect && barRect.height > 0
|
||||
? Math.max(16, window.innerHeight - barRect.top + 12)
|
||||
: 16;
|
||||
toastEl = el('div', {
|
||||
position: 'fixed', bottom: '16px', left: '50%',
|
||||
position: 'fixed', bottom: barTopFromBottom + 'px', left: '50%',
|
||||
transform: 'translateX(-50%) translateY(8px)',
|
||||
background: C.ink, color: C.white,
|
||||
fontFamily: FONT, fontSize: '12px',
|
||||
@@ -3148,13 +3206,33 @@ void main() {
|
||||
// page bg. Used for screenshots and theme QA.
|
||||
const override = localStorage.getItem('impeccable-dev-theme');
|
||||
if (override === 'light' || override === 'dark') return override;
|
||||
const bg = getComputedStyle(document.body).backgroundColor
|
||||
|| getComputedStyle(document.documentElement).backgroundColor;
|
||||
const m = bg.match(/rgba?\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)/);
|
||||
if (!m) return 'light';
|
||||
const [, r, g, b] = m;
|
||||
|
||||
// Walk body → html, taking the first opaque background. The browser's
|
||||
// default body / html background is `rgba(0, 0, 0, 0)`, which a naive
|
||||
// regex would read as black and mislabel a perfectly white page as
|
||||
// dark. Honoring alpha avoids that — and falling through to <html>
|
||||
// catches the common pattern of a bg only on <html> (or only on body).
|
||||
function readOpaque(el) {
|
||||
if (!el) return null;
|
||||
const bg = getComputedStyle(el).backgroundColor;
|
||||
const m = bg.match(/rgba?\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)(?:\s*,\s*([\d.]+))?\s*\)/);
|
||||
if (!m) return null;
|
||||
const alpha = m[4] == null ? 1 : parseFloat(m[4]);
|
||||
if (alpha < 0.5) return null; // transparent / nearly transparent → skip
|
||||
return [+m[1], +m[2], +m[3]];
|
||||
}
|
||||
|
||||
const rgb = readOpaque(document.body) || readOpaque(document.documentElement);
|
||||
// Both transparent → fall back to the browser's effective canvas color.
|
||||
// White is the universal default; only one in a thousand sites swaps it
|
||||
// via `color-scheme: dark` on <html>, and `prefers-color-scheme` lets
|
||||
// us catch that case.
|
||||
if (!rgb) {
|
||||
return matchMedia?.('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
|
||||
}
|
||||
const [r, g, b] = rgb;
|
||||
// Perceptual luminance (Rec. 709)
|
||||
const L = (0.2126 * +r + 0.7152 * +g + 0.0722 * +b) / 255;
|
||||
const L = (0.2126 * r + 0.7152 * g + 0.0722 * b) / 255;
|
||||
return L > 0.55 ? 'light' : 'dark';
|
||||
} catch { return 'light'; }
|
||||
}
|
||||
@@ -3357,15 +3435,24 @@ void main() {
|
||||
});
|
||||
inner.appendChild(divider);
|
||||
|
||||
// Exit (subtle × on the right) — SVG for baseline-free centering
|
||||
// Exit × on the right — intentionally subtle (textDim at rest, text on
|
||||
// hover) so it sits behind the active toggles in visual hierarchy.
|
||||
//
|
||||
// Explicit padding + box-sizing here is load-bearing: a host page like
|
||||
// `button { padding: 0.5rem 1rem; }` (very common in resets) would
|
||||
// otherwise inflate this 24x24 button into 56x40 and push the SVG out
|
||||
// of the visible bar — the X stays invisible even though the styles in
|
||||
// DevTools look fine. Every other chrome button sets padding inline;
|
||||
// this one needed it too.
|
||||
const exitBtn = el('button', {
|
||||
display: 'inline-flex', alignItems: 'center', justifyContent: 'center',
|
||||
width: '26px', height: '26px', borderRadius: '6px',
|
||||
padding: '0', boxSizing: 'border-box',
|
||||
width: '24px', height: '24px', borderRadius: '6px',
|
||||
border: 'none', background: 'transparent',
|
||||
color: P.textDim, fontFamily: FONT, fontSize: '0', lineHeight: '0',
|
||||
cursor: 'pointer', transition: 'color 0.12s ease, background 0.12s ease',
|
||||
});
|
||||
exitBtn.innerHTML = '<svg width="12" height="12" viewBox="0 0 12 12" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round"><line x1="2.5" y1="2.5" x2="9.5" y2="9.5"/><line x1="9.5" y1="2.5" x2="2.5" y2="9.5"/></svg>';
|
||||
exitBtn.innerHTML = '<svg width="14" height="14" viewBox="0 0 14 14" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"><line x1="3" y1="3" x2="11" y2="11"/><line x1="11" y1="3" x2="3" y2="11"/></svg>';
|
||||
exitBtn.title = 'Exit live mode';
|
||||
exitBtn.addEventListener('mouseenter', () => { exitBtn.style.color = P.text; exitBtn.style.background = P.exitHover; });
|
||||
exitBtn.addEventListener('mouseleave', () => { exitBtn.style.color = P.textDim; exitBtn.style.background = 'transparent'; });
|
||||
@@ -3383,6 +3470,7 @@ void main() {
|
||||
});
|
||||
|
||||
document.body.appendChild(globalBarEl);
|
||||
defangOutsideHandlers(globalBarEl);
|
||||
|
||||
requestAnimationFrame(() => {
|
||||
globalBarEl.style.opacity = '1';
|
||||
@@ -3595,6 +3683,11 @@ void main() {
|
||||
designShadow.appendChild(root);
|
||||
|
||||
document.body.appendChild(designHost);
|
||||
// The host is pointer-events: none; the panel inside the shadow DOM
|
||||
// manages its own auto/none. Events bubble through the shadow boundary,
|
||||
// so attaching here silences host-page outside-interaction handlers
|
||||
// without touching the host's click-through behavior.
|
||||
defangOutsideHandlers(designHost, { setPointerEvents: false });
|
||||
|
||||
loadDesignPrefs();
|
||||
renderDesignChrome();
|
||||
|
||||
@@ -197,6 +197,45 @@
|
||||
|
||||
function id8() { return crypto.randomUUID().replace(/-/g, '').slice(0, 8); }
|
||||
|
||||
// Modal-aware chrome: keep our floating UI clickable inside Radix /
|
||||
// Headless UI / vaul portals.
|
||||
//
|
||||
// Two host-page behaviors break us when the picked element lives inside a
|
||||
// modal dialog:
|
||||
//
|
||||
// 1. Modal scroll-lock disables outside pointer events. Radix's
|
||||
// `DismissableLayer` sets `document.body.style.pointerEvents = 'none'`
|
||||
// while a modal is open and only restores `auto` on the layer. Our
|
||||
// chrome inherits `none` from <body> and becomes unclickable.
|
||||
// 2. The dialog's outside-interaction handler (Radix's
|
||||
// `usePointerDownOutside`) listens at document level and dismisses
|
||||
// the dialog whenever a `pointerdown` lands outside the layer node.
|
||||
// Our chrome is a sibling of <body>, so Radix classifies our clicks
|
||||
// as outside and tears the dialog down mid-task.
|
||||
//
|
||||
// We can't reliably re-parent our chrome into the dialog subtree (z-index
|
||||
// stacking, scroll containers, theming all become host-page concerns), so
|
||||
// we defang both behaviors at our root:
|
||||
//
|
||||
// - `pointer-events: auto !important` overrides the inherited `none`.
|
||||
// - Stop `pointerdown` / `mousedown` propagation so the document-level
|
||||
// dismiss listener never fires for our clicks.
|
||||
// - Stop `focusin` propagation so any focus shifts inside our chrome
|
||||
// don't read as "focus moved outside the dialog" to focus traps.
|
||||
//
|
||||
// Click events still bubble normally — only the early pointer/focus
|
||||
// signals that drive outside-interaction detection are silenced.
|
||||
function defangOutsideHandlers(rootEl, { setPointerEvents = true } = {}) {
|
||||
if (!rootEl) return;
|
||||
if (setPointerEvents) {
|
||||
rootEl.style.setProperty('pointer-events', 'auto', 'important');
|
||||
}
|
||||
const stop = (e) => e.stopPropagation();
|
||||
rootEl.addEventListener('pointerdown', stop);
|
||||
rootEl.addEventListener('mousedown', stop);
|
||||
rootEl.addEventListener('focusin', stop);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Highlight overlay
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -336,6 +375,11 @@
|
||||
annotOverlayEl.addEventListener('pointerup', onAnnotUp);
|
||||
annotOverlayEl.addEventListener('pointercancel', onAnnotUp);
|
||||
document.body.appendChild(annotOverlayEl);
|
||||
// Modal-host friendliness: pointer-events is already 'auto' on this
|
||||
// overlay; we only need to silence the host's outside-interaction
|
||||
// listeners. Don't override pointer-events here (the overlay toggles
|
||||
// visibility via display:none, which is fine).
|
||||
defangOutsideHandlers(annotOverlayEl, { setPointerEvents: false });
|
||||
}
|
||||
|
||||
function updateClearChip() {
|
||||
@@ -811,6 +855,7 @@
|
||||
maxWidth: '520px', minWidth: '320px',
|
||||
});
|
||||
document.body.appendChild(barEl);
|
||||
defangOutsideHandlers(barEl);
|
||||
}
|
||||
|
||||
function positionBar() {
|
||||
@@ -1330,6 +1375,7 @@
|
||||
|
||||
pickerEl.appendChild(grid);
|
||||
document.body.appendChild(pickerEl);
|
||||
defangOutsideHandlers(pickerEl);
|
||||
|
||||
// Cache the palette on the picker so toggleActionPicker's state refresh
|
||||
// uses the same theme-aware colors when it repaints chips.
|
||||
@@ -1443,6 +1489,10 @@
|
||||
|
||||
paramsPanelEl.appendChild(paramsPanelBody);
|
||||
document.body.appendChild(paramsPanelEl);
|
||||
// Don't override pointer-events: the panel toggles between 'none' (closed,
|
||||
// click-through) and 'auto' (open) on its own. Just silence the host's
|
||||
// outside-interaction listeners while the panel is open.
|
||||
defangOutsideHandlers(paramsPanelEl, { setPointerEvents: false });
|
||||
paramsPanelInner = paramsPanelEl; // compatibility alias for the rest of the code
|
||||
}
|
||||
|
||||
@@ -3024,8 +3074,16 @@ void main() {
|
||||
|
||||
function showToast(message, duration) {
|
||||
if (toastEl) toastEl.remove();
|
||||
// Stack the toast above the global bar (which sits at bottom:14px) so
|
||||
// the two never overlap. Read the bar's actual rect — its height varies
|
||||
// with hover-expanded labels — and fall back to a sensible default
|
||||
// when the bar isn't mounted yet.
|
||||
const barRect = globalBarEl?.getBoundingClientRect();
|
||||
const barTopFromBottom = barRect && barRect.height > 0
|
||||
? Math.max(16, window.innerHeight - barRect.top + 12)
|
||||
: 16;
|
||||
toastEl = el('div', {
|
||||
position: 'fixed', bottom: '16px', left: '50%',
|
||||
position: 'fixed', bottom: barTopFromBottom + 'px', left: '50%',
|
||||
transform: 'translateX(-50%) translateY(8px)',
|
||||
background: C.ink, color: C.white,
|
||||
fontFamily: FONT, fontSize: '12px',
|
||||
@@ -3148,13 +3206,33 @@ void main() {
|
||||
// page bg. Used for screenshots and theme QA.
|
||||
const override = localStorage.getItem('impeccable-dev-theme');
|
||||
if (override === 'light' || override === 'dark') return override;
|
||||
const bg = getComputedStyle(document.body).backgroundColor
|
||||
|| getComputedStyle(document.documentElement).backgroundColor;
|
||||
const m = bg.match(/rgba?\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)/);
|
||||
if (!m) return 'light';
|
||||
const [, r, g, b] = m;
|
||||
|
||||
// Walk body → html, taking the first opaque background. The browser's
|
||||
// default body / html background is `rgba(0, 0, 0, 0)`, which a naive
|
||||
// regex would read as black and mislabel a perfectly white page as
|
||||
// dark. Honoring alpha avoids that — and falling through to <html>
|
||||
// catches the common pattern of a bg only on <html> (or only on body).
|
||||
function readOpaque(el) {
|
||||
if (!el) return null;
|
||||
const bg = getComputedStyle(el).backgroundColor;
|
||||
const m = bg.match(/rgba?\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)(?:\s*,\s*([\d.]+))?\s*\)/);
|
||||
if (!m) return null;
|
||||
const alpha = m[4] == null ? 1 : parseFloat(m[4]);
|
||||
if (alpha < 0.5) return null; // transparent / nearly transparent → skip
|
||||
return [+m[1], +m[2], +m[3]];
|
||||
}
|
||||
|
||||
const rgb = readOpaque(document.body) || readOpaque(document.documentElement);
|
||||
// Both transparent → fall back to the browser's effective canvas color.
|
||||
// White is the universal default; only one in a thousand sites swaps it
|
||||
// via `color-scheme: dark` on <html>, and `prefers-color-scheme` lets
|
||||
// us catch that case.
|
||||
if (!rgb) {
|
||||
return matchMedia?.('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
|
||||
}
|
||||
const [r, g, b] = rgb;
|
||||
// Perceptual luminance (Rec. 709)
|
||||
const L = (0.2126 * +r + 0.7152 * +g + 0.0722 * +b) / 255;
|
||||
const L = (0.2126 * r + 0.7152 * g + 0.0722 * b) / 255;
|
||||
return L > 0.55 ? 'light' : 'dark';
|
||||
} catch { return 'light'; }
|
||||
}
|
||||
@@ -3357,15 +3435,24 @@ void main() {
|
||||
});
|
||||
inner.appendChild(divider);
|
||||
|
||||
// Exit (subtle × on the right) — SVG for baseline-free centering
|
||||
// Exit × on the right — intentionally subtle (textDim at rest, text on
|
||||
// hover) so it sits behind the active toggles in visual hierarchy.
|
||||
//
|
||||
// Explicit padding + box-sizing here is load-bearing: a host page like
|
||||
// `button { padding: 0.5rem 1rem; }` (very common in resets) would
|
||||
// otherwise inflate this 24x24 button into 56x40 and push the SVG out
|
||||
// of the visible bar — the X stays invisible even though the styles in
|
||||
// DevTools look fine. Every other chrome button sets padding inline;
|
||||
// this one needed it too.
|
||||
const exitBtn = el('button', {
|
||||
display: 'inline-flex', alignItems: 'center', justifyContent: 'center',
|
||||
width: '26px', height: '26px', borderRadius: '6px',
|
||||
padding: '0', boxSizing: 'border-box',
|
||||
width: '24px', height: '24px', borderRadius: '6px',
|
||||
border: 'none', background: 'transparent',
|
||||
color: P.textDim, fontFamily: FONT, fontSize: '0', lineHeight: '0',
|
||||
cursor: 'pointer', transition: 'color 0.12s ease, background 0.12s ease',
|
||||
});
|
||||
exitBtn.innerHTML = '<svg width="12" height="12" viewBox="0 0 12 12" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round"><line x1="2.5" y1="2.5" x2="9.5" y2="9.5"/><line x1="9.5" y1="2.5" x2="2.5" y2="9.5"/></svg>';
|
||||
exitBtn.innerHTML = '<svg width="14" height="14" viewBox="0 0 14 14" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"><line x1="3" y1="3" x2="11" y2="11"/><line x1="11" y1="3" x2="3" y2="11"/></svg>';
|
||||
exitBtn.title = 'Exit live mode';
|
||||
exitBtn.addEventListener('mouseenter', () => { exitBtn.style.color = P.text; exitBtn.style.background = P.exitHover; });
|
||||
exitBtn.addEventListener('mouseleave', () => { exitBtn.style.color = P.textDim; exitBtn.style.background = 'transparent'; });
|
||||
@@ -3383,6 +3470,7 @@ void main() {
|
||||
});
|
||||
|
||||
document.body.appendChild(globalBarEl);
|
||||
defangOutsideHandlers(globalBarEl);
|
||||
|
||||
requestAnimationFrame(() => {
|
||||
globalBarEl.style.opacity = '1';
|
||||
@@ -3595,6 +3683,11 @@ void main() {
|
||||
designShadow.appendChild(root);
|
||||
|
||||
document.body.appendChild(designHost);
|
||||
// The host is pointer-events: none; the panel inside the shadow DOM
|
||||
// manages its own auto/none. Events bubble through the shadow boundary,
|
||||
// so attaching here silences host-page outside-interaction handlers
|
||||
// without touching the host's click-through behavior.
|
||||
defangOutsideHandlers(designHost, { setPointerEvents: false });
|
||||
|
||||
loadDesignPrefs();
|
||||
renderDesignChrome();
|
||||
|
||||
@@ -197,6 +197,45 @@
|
||||
|
||||
function id8() { return crypto.randomUUID().replace(/-/g, '').slice(0, 8); }
|
||||
|
||||
// Modal-aware chrome: keep our floating UI clickable inside Radix /
|
||||
// Headless UI / vaul portals.
|
||||
//
|
||||
// Two host-page behaviors break us when the picked element lives inside a
|
||||
// modal dialog:
|
||||
//
|
||||
// 1. Modal scroll-lock disables outside pointer events. Radix's
|
||||
// `DismissableLayer` sets `document.body.style.pointerEvents = 'none'`
|
||||
// while a modal is open and only restores `auto` on the layer. Our
|
||||
// chrome inherits `none` from <body> and becomes unclickable.
|
||||
// 2. The dialog's outside-interaction handler (Radix's
|
||||
// `usePointerDownOutside`) listens at document level and dismisses
|
||||
// the dialog whenever a `pointerdown` lands outside the layer node.
|
||||
// Our chrome is a sibling of <body>, so Radix classifies our clicks
|
||||
// as outside and tears the dialog down mid-task.
|
||||
//
|
||||
// We can't reliably re-parent our chrome into the dialog subtree (z-index
|
||||
// stacking, scroll containers, theming all become host-page concerns), so
|
||||
// we defang both behaviors at our root:
|
||||
//
|
||||
// - `pointer-events: auto !important` overrides the inherited `none`.
|
||||
// - Stop `pointerdown` / `mousedown` propagation so the document-level
|
||||
// dismiss listener never fires for our clicks.
|
||||
// - Stop `focusin` propagation so any focus shifts inside our chrome
|
||||
// don't read as "focus moved outside the dialog" to focus traps.
|
||||
//
|
||||
// Click events still bubble normally — only the early pointer/focus
|
||||
// signals that drive outside-interaction detection are silenced.
|
||||
function defangOutsideHandlers(rootEl, { setPointerEvents = true } = {}) {
|
||||
if (!rootEl) return;
|
||||
if (setPointerEvents) {
|
||||
rootEl.style.setProperty('pointer-events', 'auto', 'important');
|
||||
}
|
||||
const stop = (e) => e.stopPropagation();
|
||||
rootEl.addEventListener('pointerdown', stop);
|
||||
rootEl.addEventListener('mousedown', stop);
|
||||
rootEl.addEventListener('focusin', stop);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Highlight overlay
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -336,6 +375,11 @@
|
||||
annotOverlayEl.addEventListener('pointerup', onAnnotUp);
|
||||
annotOverlayEl.addEventListener('pointercancel', onAnnotUp);
|
||||
document.body.appendChild(annotOverlayEl);
|
||||
// Modal-host friendliness: pointer-events is already 'auto' on this
|
||||
// overlay; we only need to silence the host's outside-interaction
|
||||
// listeners. Don't override pointer-events here (the overlay toggles
|
||||
// visibility via display:none, which is fine).
|
||||
defangOutsideHandlers(annotOverlayEl, { setPointerEvents: false });
|
||||
}
|
||||
|
||||
function updateClearChip() {
|
||||
@@ -811,6 +855,7 @@
|
||||
maxWidth: '520px', minWidth: '320px',
|
||||
});
|
||||
document.body.appendChild(barEl);
|
||||
defangOutsideHandlers(barEl);
|
||||
}
|
||||
|
||||
function positionBar() {
|
||||
@@ -1330,6 +1375,7 @@
|
||||
|
||||
pickerEl.appendChild(grid);
|
||||
document.body.appendChild(pickerEl);
|
||||
defangOutsideHandlers(pickerEl);
|
||||
|
||||
// Cache the palette on the picker so toggleActionPicker's state refresh
|
||||
// uses the same theme-aware colors when it repaints chips.
|
||||
@@ -1443,6 +1489,10 @@
|
||||
|
||||
paramsPanelEl.appendChild(paramsPanelBody);
|
||||
document.body.appendChild(paramsPanelEl);
|
||||
// Don't override pointer-events: the panel toggles between 'none' (closed,
|
||||
// click-through) and 'auto' (open) on its own. Just silence the host's
|
||||
// outside-interaction listeners while the panel is open.
|
||||
defangOutsideHandlers(paramsPanelEl, { setPointerEvents: false });
|
||||
paramsPanelInner = paramsPanelEl; // compatibility alias for the rest of the code
|
||||
}
|
||||
|
||||
@@ -3024,8 +3074,16 @@ void main() {
|
||||
|
||||
function showToast(message, duration) {
|
||||
if (toastEl) toastEl.remove();
|
||||
// Stack the toast above the global bar (which sits at bottom:14px) so
|
||||
// the two never overlap. Read the bar's actual rect — its height varies
|
||||
// with hover-expanded labels — and fall back to a sensible default
|
||||
// when the bar isn't mounted yet.
|
||||
const barRect = globalBarEl?.getBoundingClientRect();
|
||||
const barTopFromBottom = barRect && barRect.height > 0
|
||||
? Math.max(16, window.innerHeight - barRect.top + 12)
|
||||
: 16;
|
||||
toastEl = el('div', {
|
||||
position: 'fixed', bottom: '16px', left: '50%',
|
||||
position: 'fixed', bottom: barTopFromBottom + 'px', left: '50%',
|
||||
transform: 'translateX(-50%) translateY(8px)',
|
||||
background: C.ink, color: C.white,
|
||||
fontFamily: FONT, fontSize: '12px',
|
||||
@@ -3148,13 +3206,33 @@ void main() {
|
||||
// page bg. Used for screenshots and theme QA.
|
||||
const override = localStorage.getItem('impeccable-dev-theme');
|
||||
if (override === 'light' || override === 'dark') return override;
|
||||
const bg = getComputedStyle(document.body).backgroundColor
|
||||
|| getComputedStyle(document.documentElement).backgroundColor;
|
||||
const m = bg.match(/rgba?\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)/);
|
||||
if (!m) return 'light';
|
||||
const [, r, g, b] = m;
|
||||
|
||||
// Walk body → html, taking the first opaque background. The browser's
|
||||
// default body / html background is `rgba(0, 0, 0, 0)`, which a naive
|
||||
// regex would read as black and mislabel a perfectly white page as
|
||||
// dark. Honoring alpha avoids that — and falling through to <html>
|
||||
// catches the common pattern of a bg only on <html> (or only on body).
|
||||
function readOpaque(el) {
|
||||
if (!el) return null;
|
||||
const bg = getComputedStyle(el).backgroundColor;
|
||||
const m = bg.match(/rgba?\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)(?:\s*,\s*([\d.]+))?\s*\)/);
|
||||
if (!m) return null;
|
||||
const alpha = m[4] == null ? 1 : parseFloat(m[4]);
|
||||
if (alpha < 0.5) return null; // transparent / nearly transparent → skip
|
||||
return [+m[1], +m[2], +m[3]];
|
||||
}
|
||||
|
||||
const rgb = readOpaque(document.body) || readOpaque(document.documentElement);
|
||||
// Both transparent → fall back to the browser's effective canvas color.
|
||||
// White is the universal default; only one in a thousand sites swaps it
|
||||
// via `color-scheme: dark` on <html>, and `prefers-color-scheme` lets
|
||||
// us catch that case.
|
||||
if (!rgb) {
|
||||
return matchMedia?.('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
|
||||
}
|
||||
const [r, g, b] = rgb;
|
||||
// Perceptual luminance (Rec. 709)
|
||||
const L = (0.2126 * +r + 0.7152 * +g + 0.0722 * +b) / 255;
|
||||
const L = (0.2126 * r + 0.7152 * g + 0.0722 * b) / 255;
|
||||
return L > 0.55 ? 'light' : 'dark';
|
||||
} catch { return 'light'; }
|
||||
}
|
||||
@@ -3357,15 +3435,24 @@ void main() {
|
||||
});
|
||||
inner.appendChild(divider);
|
||||
|
||||
// Exit (subtle × on the right) — SVG for baseline-free centering
|
||||
// Exit × on the right — intentionally subtle (textDim at rest, text on
|
||||
// hover) so it sits behind the active toggles in visual hierarchy.
|
||||
//
|
||||
// Explicit padding + box-sizing here is load-bearing: a host page like
|
||||
// `button { padding: 0.5rem 1rem; }` (very common in resets) would
|
||||
// otherwise inflate this 24x24 button into 56x40 and push the SVG out
|
||||
// of the visible bar — the X stays invisible even though the styles in
|
||||
// DevTools look fine. Every other chrome button sets padding inline;
|
||||
// this one needed it too.
|
||||
const exitBtn = el('button', {
|
||||
display: 'inline-flex', alignItems: 'center', justifyContent: 'center',
|
||||
width: '26px', height: '26px', borderRadius: '6px',
|
||||
padding: '0', boxSizing: 'border-box',
|
||||
width: '24px', height: '24px', borderRadius: '6px',
|
||||
border: 'none', background: 'transparent',
|
||||
color: P.textDim, fontFamily: FONT, fontSize: '0', lineHeight: '0',
|
||||
cursor: 'pointer', transition: 'color 0.12s ease, background 0.12s ease',
|
||||
});
|
||||
exitBtn.innerHTML = '<svg width="12" height="12" viewBox="0 0 12 12" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round"><line x1="2.5" y1="2.5" x2="9.5" y2="9.5"/><line x1="9.5" y1="2.5" x2="2.5" y2="9.5"/></svg>';
|
||||
exitBtn.innerHTML = '<svg width="14" height="14" viewBox="0 0 14 14" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"><line x1="3" y1="3" x2="11" y2="11"/><line x1="11" y1="3" x2="3" y2="11"/></svg>';
|
||||
exitBtn.title = 'Exit live mode';
|
||||
exitBtn.addEventListener('mouseenter', () => { exitBtn.style.color = P.text; exitBtn.style.background = P.exitHover; });
|
||||
exitBtn.addEventListener('mouseleave', () => { exitBtn.style.color = P.textDim; exitBtn.style.background = 'transparent'; });
|
||||
@@ -3383,6 +3470,7 @@ void main() {
|
||||
});
|
||||
|
||||
document.body.appendChild(globalBarEl);
|
||||
defangOutsideHandlers(globalBarEl);
|
||||
|
||||
requestAnimationFrame(() => {
|
||||
globalBarEl.style.opacity = '1';
|
||||
@@ -3595,6 +3683,11 @@ void main() {
|
||||
designShadow.appendChild(root);
|
||||
|
||||
document.body.appendChild(designHost);
|
||||
// The host is pointer-events: none; the panel inside the shadow DOM
|
||||
// manages its own auto/none. Events bubble through the shadow boundary,
|
||||
// so attaching here silences host-page outside-interaction handlers
|
||||
// without touching the host's click-through behavior.
|
||||
defangOutsideHandlers(designHost, { setPointerEvents: false });
|
||||
|
||||
loadDesignPrefs();
|
||||
renderDesignChrome();
|
||||
|
||||
@@ -197,6 +197,45 @@
|
||||
|
||||
function id8() { return crypto.randomUUID().replace(/-/g, '').slice(0, 8); }
|
||||
|
||||
// Modal-aware chrome: keep our floating UI clickable inside Radix /
|
||||
// Headless UI / vaul portals.
|
||||
//
|
||||
// Two host-page behaviors break us when the picked element lives inside a
|
||||
// modal dialog:
|
||||
//
|
||||
// 1. Modal scroll-lock disables outside pointer events. Radix's
|
||||
// `DismissableLayer` sets `document.body.style.pointerEvents = 'none'`
|
||||
// while a modal is open and only restores `auto` on the layer. Our
|
||||
// chrome inherits `none` from <body> and becomes unclickable.
|
||||
// 2. The dialog's outside-interaction handler (Radix's
|
||||
// `usePointerDownOutside`) listens at document level and dismisses
|
||||
// the dialog whenever a `pointerdown` lands outside the layer node.
|
||||
// Our chrome is a sibling of <body>, so Radix classifies our clicks
|
||||
// as outside and tears the dialog down mid-task.
|
||||
//
|
||||
// We can't reliably re-parent our chrome into the dialog subtree (z-index
|
||||
// stacking, scroll containers, theming all become host-page concerns), so
|
||||
// we defang both behaviors at our root:
|
||||
//
|
||||
// - `pointer-events: auto !important` overrides the inherited `none`.
|
||||
// - Stop `pointerdown` / `mousedown` propagation so the document-level
|
||||
// dismiss listener never fires for our clicks.
|
||||
// - Stop `focusin` propagation so any focus shifts inside our chrome
|
||||
// don't read as "focus moved outside the dialog" to focus traps.
|
||||
//
|
||||
// Click events still bubble normally — only the early pointer/focus
|
||||
// signals that drive outside-interaction detection are silenced.
|
||||
function defangOutsideHandlers(rootEl, { setPointerEvents = true } = {}) {
|
||||
if (!rootEl) return;
|
||||
if (setPointerEvents) {
|
||||
rootEl.style.setProperty('pointer-events', 'auto', 'important');
|
||||
}
|
||||
const stop = (e) => e.stopPropagation();
|
||||
rootEl.addEventListener('pointerdown', stop);
|
||||
rootEl.addEventListener('mousedown', stop);
|
||||
rootEl.addEventListener('focusin', stop);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Highlight overlay
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -336,6 +375,11 @@
|
||||
annotOverlayEl.addEventListener('pointerup', onAnnotUp);
|
||||
annotOverlayEl.addEventListener('pointercancel', onAnnotUp);
|
||||
document.body.appendChild(annotOverlayEl);
|
||||
// Modal-host friendliness: pointer-events is already 'auto' on this
|
||||
// overlay; we only need to silence the host's outside-interaction
|
||||
// listeners. Don't override pointer-events here (the overlay toggles
|
||||
// visibility via display:none, which is fine).
|
||||
defangOutsideHandlers(annotOverlayEl, { setPointerEvents: false });
|
||||
}
|
||||
|
||||
function updateClearChip() {
|
||||
@@ -811,6 +855,7 @@
|
||||
maxWidth: '520px', minWidth: '320px',
|
||||
});
|
||||
document.body.appendChild(barEl);
|
||||
defangOutsideHandlers(barEl);
|
||||
}
|
||||
|
||||
function positionBar() {
|
||||
@@ -1330,6 +1375,7 @@
|
||||
|
||||
pickerEl.appendChild(grid);
|
||||
document.body.appendChild(pickerEl);
|
||||
defangOutsideHandlers(pickerEl);
|
||||
|
||||
// Cache the palette on the picker so toggleActionPicker's state refresh
|
||||
// uses the same theme-aware colors when it repaints chips.
|
||||
@@ -1443,6 +1489,10 @@
|
||||
|
||||
paramsPanelEl.appendChild(paramsPanelBody);
|
||||
document.body.appendChild(paramsPanelEl);
|
||||
// Don't override pointer-events: the panel toggles between 'none' (closed,
|
||||
// click-through) and 'auto' (open) on its own. Just silence the host's
|
||||
// outside-interaction listeners while the panel is open.
|
||||
defangOutsideHandlers(paramsPanelEl, { setPointerEvents: false });
|
||||
paramsPanelInner = paramsPanelEl; // compatibility alias for the rest of the code
|
||||
}
|
||||
|
||||
@@ -3024,8 +3074,16 @@ void main() {
|
||||
|
||||
function showToast(message, duration) {
|
||||
if (toastEl) toastEl.remove();
|
||||
// Stack the toast above the global bar (which sits at bottom:14px) so
|
||||
// the two never overlap. Read the bar's actual rect — its height varies
|
||||
// with hover-expanded labels — and fall back to a sensible default
|
||||
// when the bar isn't mounted yet.
|
||||
const barRect = globalBarEl?.getBoundingClientRect();
|
||||
const barTopFromBottom = barRect && barRect.height > 0
|
||||
? Math.max(16, window.innerHeight - barRect.top + 12)
|
||||
: 16;
|
||||
toastEl = el('div', {
|
||||
position: 'fixed', bottom: '16px', left: '50%',
|
||||
position: 'fixed', bottom: barTopFromBottom + 'px', left: '50%',
|
||||
transform: 'translateX(-50%) translateY(8px)',
|
||||
background: C.ink, color: C.white,
|
||||
fontFamily: FONT, fontSize: '12px',
|
||||
@@ -3148,13 +3206,33 @@ void main() {
|
||||
// page bg. Used for screenshots and theme QA.
|
||||
const override = localStorage.getItem('impeccable-dev-theme');
|
||||
if (override === 'light' || override === 'dark') return override;
|
||||
const bg = getComputedStyle(document.body).backgroundColor
|
||||
|| getComputedStyle(document.documentElement).backgroundColor;
|
||||
const m = bg.match(/rgba?\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)/);
|
||||
if (!m) return 'light';
|
||||
const [, r, g, b] = m;
|
||||
|
||||
// Walk body → html, taking the first opaque background. The browser's
|
||||
// default body / html background is `rgba(0, 0, 0, 0)`, which a naive
|
||||
// regex would read as black and mislabel a perfectly white page as
|
||||
// dark. Honoring alpha avoids that — and falling through to <html>
|
||||
// catches the common pattern of a bg only on <html> (or only on body).
|
||||
function readOpaque(el) {
|
||||
if (!el) return null;
|
||||
const bg = getComputedStyle(el).backgroundColor;
|
||||
const m = bg.match(/rgba?\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)(?:\s*,\s*([\d.]+))?\s*\)/);
|
||||
if (!m) return null;
|
||||
const alpha = m[4] == null ? 1 : parseFloat(m[4]);
|
||||
if (alpha < 0.5) return null; // transparent / nearly transparent → skip
|
||||
return [+m[1], +m[2], +m[3]];
|
||||
}
|
||||
|
||||
const rgb = readOpaque(document.body) || readOpaque(document.documentElement);
|
||||
// Both transparent → fall back to the browser's effective canvas color.
|
||||
// White is the universal default; only one in a thousand sites swaps it
|
||||
// via `color-scheme: dark` on <html>, and `prefers-color-scheme` lets
|
||||
// us catch that case.
|
||||
if (!rgb) {
|
||||
return matchMedia?.('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
|
||||
}
|
||||
const [r, g, b] = rgb;
|
||||
// Perceptual luminance (Rec. 709)
|
||||
const L = (0.2126 * +r + 0.7152 * +g + 0.0722 * +b) / 255;
|
||||
const L = (0.2126 * r + 0.7152 * g + 0.0722 * b) / 255;
|
||||
return L > 0.55 ? 'light' : 'dark';
|
||||
} catch { return 'light'; }
|
||||
}
|
||||
@@ -3357,15 +3435,24 @@ void main() {
|
||||
});
|
||||
inner.appendChild(divider);
|
||||
|
||||
// Exit (subtle × on the right) — SVG for baseline-free centering
|
||||
// Exit × on the right — intentionally subtle (textDim at rest, text on
|
||||
// hover) so it sits behind the active toggles in visual hierarchy.
|
||||
//
|
||||
// Explicit padding + box-sizing here is load-bearing: a host page like
|
||||
// `button { padding: 0.5rem 1rem; }` (very common in resets) would
|
||||
// otherwise inflate this 24x24 button into 56x40 and push the SVG out
|
||||
// of the visible bar — the X stays invisible even though the styles in
|
||||
// DevTools look fine. Every other chrome button sets padding inline;
|
||||
// this one needed it too.
|
||||
const exitBtn = el('button', {
|
||||
display: 'inline-flex', alignItems: 'center', justifyContent: 'center',
|
||||
width: '26px', height: '26px', borderRadius: '6px',
|
||||
padding: '0', boxSizing: 'border-box',
|
||||
width: '24px', height: '24px', borderRadius: '6px',
|
||||
border: 'none', background: 'transparent',
|
||||
color: P.textDim, fontFamily: FONT, fontSize: '0', lineHeight: '0',
|
||||
cursor: 'pointer', transition: 'color 0.12s ease, background 0.12s ease',
|
||||
});
|
||||
exitBtn.innerHTML = '<svg width="12" height="12" viewBox="0 0 12 12" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round"><line x1="2.5" y1="2.5" x2="9.5" y2="9.5"/><line x1="9.5" y1="2.5" x2="2.5" y2="9.5"/></svg>';
|
||||
exitBtn.innerHTML = '<svg width="14" height="14" viewBox="0 0 14 14" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"><line x1="3" y1="3" x2="11" y2="11"/><line x1="11" y1="3" x2="3" y2="11"/></svg>';
|
||||
exitBtn.title = 'Exit live mode';
|
||||
exitBtn.addEventListener('mouseenter', () => { exitBtn.style.color = P.text; exitBtn.style.background = P.exitHover; });
|
||||
exitBtn.addEventListener('mouseleave', () => { exitBtn.style.color = P.textDim; exitBtn.style.background = 'transparent'; });
|
||||
@@ -3383,6 +3470,7 @@ void main() {
|
||||
});
|
||||
|
||||
document.body.appendChild(globalBarEl);
|
||||
defangOutsideHandlers(globalBarEl);
|
||||
|
||||
requestAnimationFrame(() => {
|
||||
globalBarEl.style.opacity = '1';
|
||||
@@ -3595,6 +3683,11 @@ void main() {
|
||||
designShadow.appendChild(root);
|
||||
|
||||
document.body.appendChild(designHost);
|
||||
// The host is pointer-events: none; the panel inside the shadow DOM
|
||||
// manages its own auto/none. Events bubble through the shadow boundary,
|
||||
// so attaching here silences host-page outside-interaction handlers
|
||||
// without touching the host's click-through behavior.
|
||||
defangOutsideHandlers(designHost, { setPointerEvents: false });
|
||||
|
||||
loadDesignPrefs();
|
||||
renderDesignChrome();
|
||||
|
||||
@@ -197,6 +197,45 @@
|
||||
|
||||
function id8() { return crypto.randomUUID().replace(/-/g, '').slice(0, 8); }
|
||||
|
||||
// Modal-aware chrome: keep our floating UI clickable inside Radix /
|
||||
// Headless UI / vaul portals.
|
||||
//
|
||||
// Two host-page behaviors break us when the picked element lives inside a
|
||||
// modal dialog:
|
||||
//
|
||||
// 1. Modal scroll-lock disables outside pointer events. Radix's
|
||||
// `DismissableLayer` sets `document.body.style.pointerEvents = 'none'`
|
||||
// while a modal is open and only restores `auto` on the layer. Our
|
||||
// chrome inherits `none` from <body> and becomes unclickable.
|
||||
// 2. The dialog's outside-interaction handler (Radix's
|
||||
// `usePointerDownOutside`) listens at document level and dismisses
|
||||
// the dialog whenever a `pointerdown` lands outside the layer node.
|
||||
// Our chrome is a sibling of <body>, so Radix classifies our clicks
|
||||
// as outside and tears the dialog down mid-task.
|
||||
//
|
||||
// We can't reliably re-parent our chrome into the dialog subtree (z-index
|
||||
// stacking, scroll containers, theming all become host-page concerns), so
|
||||
// we defang both behaviors at our root:
|
||||
//
|
||||
// - `pointer-events: auto !important` overrides the inherited `none`.
|
||||
// - Stop `pointerdown` / `mousedown` propagation so the document-level
|
||||
// dismiss listener never fires for our clicks.
|
||||
// - Stop `focusin` propagation so any focus shifts inside our chrome
|
||||
// don't read as "focus moved outside the dialog" to focus traps.
|
||||
//
|
||||
// Click events still bubble normally — only the early pointer/focus
|
||||
// signals that drive outside-interaction detection are silenced.
|
||||
function defangOutsideHandlers(rootEl, { setPointerEvents = true } = {}) {
|
||||
if (!rootEl) return;
|
||||
if (setPointerEvents) {
|
||||
rootEl.style.setProperty('pointer-events', 'auto', 'important');
|
||||
}
|
||||
const stop = (e) => e.stopPropagation();
|
||||
rootEl.addEventListener('pointerdown', stop);
|
||||
rootEl.addEventListener('mousedown', stop);
|
||||
rootEl.addEventListener('focusin', stop);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Highlight overlay
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -336,6 +375,11 @@
|
||||
annotOverlayEl.addEventListener('pointerup', onAnnotUp);
|
||||
annotOverlayEl.addEventListener('pointercancel', onAnnotUp);
|
||||
document.body.appendChild(annotOverlayEl);
|
||||
// Modal-host friendliness: pointer-events is already 'auto' on this
|
||||
// overlay; we only need to silence the host's outside-interaction
|
||||
// listeners. Don't override pointer-events here (the overlay toggles
|
||||
// visibility via display:none, which is fine).
|
||||
defangOutsideHandlers(annotOverlayEl, { setPointerEvents: false });
|
||||
}
|
||||
|
||||
function updateClearChip() {
|
||||
@@ -811,6 +855,7 @@
|
||||
maxWidth: '520px', minWidth: '320px',
|
||||
});
|
||||
document.body.appendChild(barEl);
|
||||
defangOutsideHandlers(barEl);
|
||||
}
|
||||
|
||||
function positionBar() {
|
||||
@@ -1330,6 +1375,7 @@
|
||||
|
||||
pickerEl.appendChild(grid);
|
||||
document.body.appendChild(pickerEl);
|
||||
defangOutsideHandlers(pickerEl);
|
||||
|
||||
// Cache the palette on the picker so toggleActionPicker's state refresh
|
||||
// uses the same theme-aware colors when it repaints chips.
|
||||
@@ -1443,6 +1489,10 @@
|
||||
|
||||
paramsPanelEl.appendChild(paramsPanelBody);
|
||||
document.body.appendChild(paramsPanelEl);
|
||||
// Don't override pointer-events: the panel toggles between 'none' (closed,
|
||||
// click-through) and 'auto' (open) on its own. Just silence the host's
|
||||
// outside-interaction listeners while the panel is open.
|
||||
defangOutsideHandlers(paramsPanelEl, { setPointerEvents: false });
|
||||
paramsPanelInner = paramsPanelEl; // compatibility alias for the rest of the code
|
||||
}
|
||||
|
||||
@@ -3024,8 +3074,16 @@ void main() {
|
||||
|
||||
function showToast(message, duration) {
|
||||
if (toastEl) toastEl.remove();
|
||||
// Stack the toast above the global bar (which sits at bottom:14px) so
|
||||
// the two never overlap. Read the bar's actual rect — its height varies
|
||||
// with hover-expanded labels — and fall back to a sensible default
|
||||
// when the bar isn't mounted yet.
|
||||
const barRect = globalBarEl?.getBoundingClientRect();
|
||||
const barTopFromBottom = barRect && barRect.height > 0
|
||||
? Math.max(16, window.innerHeight - barRect.top + 12)
|
||||
: 16;
|
||||
toastEl = el('div', {
|
||||
position: 'fixed', bottom: '16px', left: '50%',
|
||||
position: 'fixed', bottom: barTopFromBottom + 'px', left: '50%',
|
||||
transform: 'translateX(-50%) translateY(8px)',
|
||||
background: C.ink, color: C.white,
|
||||
fontFamily: FONT, fontSize: '12px',
|
||||
@@ -3148,13 +3206,33 @@ void main() {
|
||||
// page bg. Used for screenshots and theme QA.
|
||||
const override = localStorage.getItem('impeccable-dev-theme');
|
||||
if (override === 'light' || override === 'dark') return override;
|
||||
const bg = getComputedStyle(document.body).backgroundColor
|
||||
|| getComputedStyle(document.documentElement).backgroundColor;
|
||||
const m = bg.match(/rgba?\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)/);
|
||||
if (!m) return 'light';
|
||||
const [, r, g, b] = m;
|
||||
|
||||
// Walk body → html, taking the first opaque background. The browser's
|
||||
// default body / html background is `rgba(0, 0, 0, 0)`, which a naive
|
||||
// regex would read as black and mislabel a perfectly white page as
|
||||
// dark. Honoring alpha avoids that — and falling through to <html>
|
||||
// catches the common pattern of a bg only on <html> (or only on body).
|
||||
function readOpaque(el) {
|
||||
if (!el) return null;
|
||||
const bg = getComputedStyle(el).backgroundColor;
|
||||
const m = bg.match(/rgba?\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)(?:\s*,\s*([\d.]+))?\s*\)/);
|
||||
if (!m) return null;
|
||||
const alpha = m[4] == null ? 1 : parseFloat(m[4]);
|
||||
if (alpha < 0.5) return null; // transparent / nearly transparent → skip
|
||||
return [+m[1], +m[2], +m[3]];
|
||||
}
|
||||
|
||||
const rgb = readOpaque(document.body) || readOpaque(document.documentElement);
|
||||
// Both transparent → fall back to the browser's effective canvas color.
|
||||
// White is the universal default; only one in a thousand sites swaps it
|
||||
// via `color-scheme: dark` on <html>, and `prefers-color-scheme` lets
|
||||
// us catch that case.
|
||||
if (!rgb) {
|
||||
return matchMedia?.('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
|
||||
}
|
||||
const [r, g, b] = rgb;
|
||||
// Perceptual luminance (Rec. 709)
|
||||
const L = (0.2126 * +r + 0.7152 * +g + 0.0722 * +b) / 255;
|
||||
const L = (0.2126 * r + 0.7152 * g + 0.0722 * b) / 255;
|
||||
return L > 0.55 ? 'light' : 'dark';
|
||||
} catch { return 'light'; }
|
||||
}
|
||||
@@ -3357,15 +3435,24 @@ void main() {
|
||||
});
|
||||
inner.appendChild(divider);
|
||||
|
||||
// Exit (subtle × on the right) — SVG for baseline-free centering
|
||||
// Exit × on the right — intentionally subtle (textDim at rest, text on
|
||||
// hover) so it sits behind the active toggles in visual hierarchy.
|
||||
//
|
||||
// Explicit padding + box-sizing here is load-bearing: a host page like
|
||||
// `button { padding: 0.5rem 1rem; }` (very common in resets) would
|
||||
// otherwise inflate this 24x24 button into 56x40 and push the SVG out
|
||||
// of the visible bar — the X stays invisible even though the styles in
|
||||
// DevTools look fine. Every other chrome button sets padding inline;
|
||||
// this one needed it too.
|
||||
const exitBtn = el('button', {
|
||||
display: 'inline-flex', alignItems: 'center', justifyContent: 'center',
|
||||
width: '26px', height: '26px', borderRadius: '6px',
|
||||
padding: '0', boxSizing: 'border-box',
|
||||
width: '24px', height: '24px', borderRadius: '6px',
|
||||
border: 'none', background: 'transparent',
|
||||
color: P.textDim, fontFamily: FONT, fontSize: '0', lineHeight: '0',
|
||||
cursor: 'pointer', transition: 'color 0.12s ease, background 0.12s ease',
|
||||
});
|
||||
exitBtn.innerHTML = '<svg width="12" height="12" viewBox="0 0 12 12" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round"><line x1="2.5" y1="2.5" x2="9.5" y2="9.5"/><line x1="9.5" y1="2.5" x2="2.5" y2="9.5"/></svg>';
|
||||
exitBtn.innerHTML = '<svg width="14" height="14" viewBox="0 0 14 14" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"><line x1="3" y1="3" x2="11" y2="11"/><line x1="11" y1="3" x2="3" y2="11"/></svg>';
|
||||
exitBtn.title = 'Exit live mode';
|
||||
exitBtn.addEventListener('mouseenter', () => { exitBtn.style.color = P.text; exitBtn.style.background = P.exitHover; });
|
||||
exitBtn.addEventListener('mouseleave', () => { exitBtn.style.color = P.textDim; exitBtn.style.background = 'transparent'; });
|
||||
@@ -3383,6 +3470,7 @@ void main() {
|
||||
});
|
||||
|
||||
document.body.appendChild(globalBarEl);
|
||||
defangOutsideHandlers(globalBarEl);
|
||||
|
||||
requestAnimationFrame(() => {
|
||||
globalBarEl.style.opacity = '1';
|
||||
@@ -3595,6 +3683,11 @@ void main() {
|
||||
designShadow.appendChild(root);
|
||||
|
||||
document.body.appendChild(designHost);
|
||||
// The host is pointer-events: none; the panel inside the shadow DOM
|
||||
// manages its own auto/none. Events bubble through the shadow boundary,
|
||||
// so attaching here silences host-page outside-interaction handlers
|
||||
// without touching the host's click-through behavior.
|
||||
defangOutsideHandlers(designHost, { setPointerEvents: false });
|
||||
|
||||
loadDesignPrefs();
|
||||
renderDesignChrome();
|
||||
|
||||
@@ -197,6 +197,45 @@
|
||||
|
||||
function id8() { return crypto.randomUUID().replace(/-/g, '').slice(0, 8); }
|
||||
|
||||
// Modal-aware chrome: keep our floating UI clickable inside Radix /
|
||||
// Headless UI / vaul portals.
|
||||
//
|
||||
// Two host-page behaviors break us when the picked element lives inside a
|
||||
// modal dialog:
|
||||
//
|
||||
// 1. Modal scroll-lock disables outside pointer events. Radix's
|
||||
// `DismissableLayer` sets `document.body.style.pointerEvents = 'none'`
|
||||
// while a modal is open and only restores `auto` on the layer. Our
|
||||
// chrome inherits `none` from <body> and becomes unclickable.
|
||||
// 2. The dialog's outside-interaction handler (Radix's
|
||||
// `usePointerDownOutside`) listens at document level and dismisses
|
||||
// the dialog whenever a `pointerdown` lands outside the layer node.
|
||||
// Our chrome is a sibling of <body>, so Radix classifies our clicks
|
||||
// as outside and tears the dialog down mid-task.
|
||||
//
|
||||
// We can't reliably re-parent our chrome into the dialog subtree (z-index
|
||||
// stacking, scroll containers, theming all become host-page concerns), so
|
||||
// we defang both behaviors at our root:
|
||||
//
|
||||
// - `pointer-events: auto !important` overrides the inherited `none`.
|
||||
// - Stop `pointerdown` / `mousedown` propagation so the document-level
|
||||
// dismiss listener never fires for our clicks.
|
||||
// - Stop `focusin` propagation so any focus shifts inside our chrome
|
||||
// don't read as "focus moved outside the dialog" to focus traps.
|
||||
//
|
||||
// Click events still bubble normally — only the early pointer/focus
|
||||
// signals that drive outside-interaction detection are silenced.
|
||||
function defangOutsideHandlers(rootEl, { setPointerEvents = true } = {}) {
|
||||
if (!rootEl) return;
|
||||
if (setPointerEvents) {
|
||||
rootEl.style.setProperty('pointer-events', 'auto', 'important');
|
||||
}
|
||||
const stop = (e) => e.stopPropagation();
|
||||
rootEl.addEventListener('pointerdown', stop);
|
||||
rootEl.addEventListener('mousedown', stop);
|
||||
rootEl.addEventListener('focusin', stop);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Highlight overlay
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -336,6 +375,11 @@
|
||||
annotOverlayEl.addEventListener('pointerup', onAnnotUp);
|
||||
annotOverlayEl.addEventListener('pointercancel', onAnnotUp);
|
||||
document.body.appendChild(annotOverlayEl);
|
||||
// Modal-host friendliness: pointer-events is already 'auto' on this
|
||||
// overlay; we only need to silence the host's outside-interaction
|
||||
// listeners. Don't override pointer-events here (the overlay toggles
|
||||
// visibility via display:none, which is fine).
|
||||
defangOutsideHandlers(annotOverlayEl, { setPointerEvents: false });
|
||||
}
|
||||
|
||||
function updateClearChip() {
|
||||
@@ -811,6 +855,7 @@
|
||||
maxWidth: '520px', minWidth: '320px',
|
||||
});
|
||||
document.body.appendChild(barEl);
|
||||
defangOutsideHandlers(barEl);
|
||||
}
|
||||
|
||||
function positionBar() {
|
||||
@@ -1330,6 +1375,7 @@
|
||||
|
||||
pickerEl.appendChild(grid);
|
||||
document.body.appendChild(pickerEl);
|
||||
defangOutsideHandlers(pickerEl);
|
||||
|
||||
// Cache the palette on the picker so toggleActionPicker's state refresh
|
||||
// uses the same theme-aware colors when it repaints chips.
|
||||
@@ -1443,6 +1489,10 @@
|
||||
|
||||
paramsPanelEl.appendChild(paramsPanelBody);
|
||||
document.body.appendChild(paramsPanelEl);
|
||||
// Don't override pointer-events: the panel toggles between 'none' (closed,
|
||||
// click-through) and 'auto' (open) on its own. Just silence the host's
|
||||
// outside-interaction listeners while the panel is open.
|
||||
defangOutsideHandlers(paramsPanelEl, { setPointerEvents: false });
|
||||
paramsPanelInner = paramsPanelEl; // compatibility alias for the rest of the code
|
||||
}
|
||||
|
||||
@@ -3024,8 +3074,16 @@ void main() {
|
||||
|
||||
function showToast(message, duration) {
|
||||
if (toastEl) toastEl.remove();
|
||||
// Stack the toast above the global bar (which sits at bottom:14px) so
|
||||
// the two never overlap. Read the bar's actual rect — its height varies
|
||||
// with hover-expanded labels — and fall back to a sensible default
|
||||
// when the bar isn't mounted yet.
|
||||
const barRect = globalBarEl?.getBoundingClientRect();
|
||||
const barTopFromBottom = barRect && barRect.height > 0
|
||||
? Math.max(16, window.innerHeight - barRect.top + 12)
|
||||
: 16;
|
||||
toastEl = el('div', {
|
||||
position: 'fixed', bottom: '16px', left: '50%',
|
||||
position: 'fixed', bottom: barTopFromBottom + 'px', left: '50%',
|
||||
transform: 'translateX(-50%) translateY(8px)',
|
||||
background: C.ink, color: C.white,
|
||||
fontFamily: FONT, fontSize: '12px',
|
||||
@@ -3148,13 +3206,33 @@ void main() {
|
||||
// page bg. Used for screenshots and theme QA.
|
||||
const override = localStorage.getItem('impeccable-dev-theme');
|
||||
if (override === 'light' || override === 'dark') return override;
|
||||
const bg = getComputedStyle(document.body).backgroundColor
|
||||
|| getComputedStyle(document.documentElement).backgroundColor;
|
||||
const m = bg.match(/rgba?\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)/);
|
||||
if (!m) return 'light';
|
||||
const [, r, g, b] = m;
|
||||
|
||||
// Walk body → html, taking the first opaque background. The browser's
|
||||
// default body / html background is `rgba(0, 0, 0, 0)`, which a naive
|
||||
// regex would read as black and mislabel a perfectly white page as
|
||||
// dark. Honoring alpha avoids that — and falling through to <html>
|
||||
// catches the common pattern of a bg only on <html> (or only on body).
|
||||
function readOpaque(el) {
|
||||
if (!el) return null;
|
||||
const bg = getComputedStyle(el).backgroundColor;
|
||||
const m = bg.match(/rgba?\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)(?:\s*,\s*([\d.]+))?\s*\)/);
|
||||
if (!m) return null;
|
||||
const alpha = m[4] == null ? 1 : parseFloat(m[4]);
|
||||
if (alpha < 0.5) return null; // transparent / nearly transparent → skip
|
||||
return [+m[1], +m[2], +m[3]];
|
||||
}
|
||||
|
||||
const rgb = readOpaque(document.body) || readOpaque(document.documentElement);
|
||||
// Both transparent → fall back to the browser's effective canvas color.
|
||||
// White is the universal default; only one in a thousand sites swaps it
|
||||
// via `color-scheme: dark` on <html>, and `prefers-color-scheme` lets
|
||||
// us catch that case.
|
||||
if (!rgb) {
|
||||
return matchMedia?.('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
|
||||
}
|
||||
const [r, g, b] = rgb;
|
||||
// Perceptual luminance (Rec. 709)
|
||||
const L = (0.2126 * +r + 0.7152 * +g + 0.0722 * +b) / 255;
|
||||
const L = (0.2126 * r + 0.7152 * g + 0.0722 * b) / 255;
|
||||
return L > 0.55 ? 'light' : 'dark';
|
||||
} catch { return 'light'; }
|
||||
}
|
||||
@@ -3357,15 +3435,24 @@ void main() {
|
||||
});
|
||||
inner.appendChild(divider);
|
||||
|
||||
// Exit (subtle × on the right) — SVG for baseline-free centering
|
||||
// Exit × on the right — intentionally subtle (textDim at rest, text on
|
||||
// hover) so it sits behind the active toggles in visual hierarchy.
|
||||
//
|
||||
// Explicit padding + box-sizing here is load-bearing: a host page like
|
||||
// `button { padding: 0.5rem 1rem; }` (very common in resets) would
|
||||
// otherwise inflate this 24x24 button into 56x40 and push the SVG out
|
||||
// of the visible bar — the X stays invisible even though the styles in
|
||||
// DevTools look fine. Every other chrome button sets padding inline;
|
||||
// this one needed it too.
|
||||
const exitBtn = el('button', {
|
||||
display: 'inline-flex', alignItems: 'center', justifyContent: 'center',
|
||||
width: '26px', height: '26px', borderRadius: '6px',
|
||||
padding: '0', boxSizing: 'border-box',
|
||||
width: '24px', height: '24px', borderRadius: '6px',
|
||||
border: 'none', background: 'transparent',
|
||||
color: P.textDim, fontFamily: FONT, fontSize: '0', lineHeight: '0',
|
||||
cursor: 'pointer', transition: 'color 0.12s ease, background 0.12s ease',
|
||||
});
|
||||
exitBtn.innerHTML = '<svg width="12" height="12" viewBox="0 0 12 12" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round"><line x1="2.5" y1="2.5" x2="9.5" y2="9.5"/><line x1="9.5" y1="2.5" x2="2.5" y2="9.5"/></svg>';
|
||||
exitBtn.innerHTML = '<svg width="14" height="14" viewBox="0 0 14 14" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"><line x1="3" y1="3" x2="11" y2="11"/><line x1="11" y1="3" x2="3" y2="11"/></svg>';
|
||||
exitBtn.title = 'Exit live mode';
|
||||
exitBtn.addEventListener('mouseenter', () => { exitBtn.style.color = P.text; exitBtn.style.background = P.exitHover; });
|
||||
exitBtn.addEventListener('mouseleave', () => { exitBtn.style.color = P.textDim; exitBtn.style.background = 'transparent'; });
|
||||
@@ -3383,6 +3470,7 @@ void main() {
|
||||
});
|
||||
|
||||
document.body.appendChild(globalBarEl);
|
||||
defangOutsideHandlers(globalBarEl);
|
||||
|
||||
requestAnimationFrame(() => {
|
||||
globalBarEl.style.opacity = '1';
|
||||
@@ -3595,6 +3683,11 @@ void main() {
|
||||
designShadow.appendChild(root);
|
||||
|
||||
document.body.appendChild(designHost);
|
||||
// The host is pointer-events: none; the panel inside the shadow DOM
|
||||
// manages its own auto/none. Events bubble through the shadow boundary,
|
||||
// so attaching here silences host-page outside-interaction handlers
|
||||
// without touching the host's click-through behavior.
|
||||
defangOutsideHandlers(designHost, { setPointerEvents: false });
|
||||
|
||||
loadDesignPrefs();
|
||||
renderDesignChrome();
|
||||
|
||||
@@ -2,9 +2,9 @@
|
||||
|
||||
Build a feature with impeccable UX and UI quality through a structured process: shape the design, load the right references, then build and iterate visually until the result is delightful.
|
||||
|
||||
## Real Example: Neon Mirai
|
||||
## Real Example: Neo Mirai
|
||||
|
||||
Neon Mirai is the full craft loop in public. A retro-futurist AI design conference started with generated brand and hi-fi reference images, then shipped as a responsive static site in `public/neon-mirai`.
|
||||
Neo Mirai is the full craft loop in public. A retro-futurist AI design conference started with generated brand and hi-fi reference images, then shipped as a responsive static site in `public/neo-mirai`.
|
||||
|
||||
Repro command:
|
||||
|
||||
|
||||
@@ -197,6 +197,45 @@
|
||||
|
||||
function id8() { return crypto.randomUUID().replace(/-/g, '').slice(0, 8); }
|
||||
|
||||
// Modal-aware chrome: keep our floating UI clickable inside Radix /
|
||||
// Headless UI / vaul portals.
|
||||
//
|
||||
// Two host-page behaviors break us when the picked element lives inside a
|
||||
// modal dialog:
|
||||
//
|
||||
// 1. Modal scroll-lock disables outside pointer events. Radix's
|
||||
// `DismissableLayer` sets `document.body.style.pointerEvents = 'none'`
|
||||
// while a modal is open and only restores `auto` on the layer. Our
|
||||
// chrome inherits `none` from <body> and becomes unclickable.
|
||||
// 2. The dialog's outside-interaction handler (Radix's
|
||||
// `usePointerDownOutside`) listens at document level and dismisses
|
||||
// the dialog whenever a `pointerdown` lands outside the layer node.
|
||||
// Our chrome is a sibling of <body>, so Radix classifies our clicks
|
||||
// as outside and tears the dialog down mid-task.
|
||||
//
|
||||
// We can't reliably re-parent our chrome into the dialog subtree (z-index
|
||||
// stacking, scroll containers, theming all become host-page concerns), so
|
||||
// we defang both behaviors at our root:
|
||||
//
|
||||
// - `pointer-events: auto !important` overrides the inherited `none`.
|
||||
// - Stop `pointerdown` / `mousedown` propagation so the document-level
|
||||
// dismiss listener never fires for our clicks.
|
||||
// - Stop `focusin` propagation so any focus shifts inside our chrome
|
||||
// don't read as "focus moved outside the dialog" to focus traps.
|
||||
//
|
||||
// Click events still bubble normally — only the early pointer/focus
|
||||
// signals that drive outside-interaction detection are silenced.
|
||||
function defangOutsideHandlers(rootEl, { setPointerEvents = true } = {}) {
|
||||
if (!rootEl) return;
|
||||
if (setPointerEvents) {
|
||||
rootEl.style.setProperty('pointer-events', 'auto', 'important');
|
||||
}
|
||||
const stop = (e) => e.stopPropagation();
|
||||
rootEl.addEventListener('pointerdown', stop);
|
||||
rootEl.addEventListener('mousedown', stop);
|
||||
rootEl.addEventListener('focusin', stop);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Highlight overlay
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -336,6 +375,11 @@
|
||||
annotOverlayEl.addEventListener('pointerup', onAnnotUp);
|
||||
annotOverlayEl.addEventListener('pointercancel', onAnnotUp);
|
||||
document.body.appendChild(annotOverlayEl);
|
||||
// Modal-host friendliness: pointer-events is already 'auto' on this
|
||||
// overlay; we only need to silence the host's outside-interaction
|
||||
// listeners. Don't override pointer-events here (the overlay toggles
|
||||
// visibility via display:none, which is fine).
|
||||
defangOutsideHandlers(annotOverlayEl, { setPointerEvents: false });
|
||||
}
|
||||
|
||||
function updateClearChip() {
|
||||
@@ -811,6 +855,7 @@
|
||||
maxWidth: '520px', minWidth: '320px',
|
||||
});
|
||||
document.body.appendChild(barEl);
|
||||
defangOutsideHandlers(barEl);
|
||||
}
|
||||
|
||||
function positionBar() {
|
||||
@@ -1330,6 +1375,7 @@
|
||||
|
||||
pickerEl.appendChild(grid);
|
||||
document.body.appendChild(pickerEl);
|
||||
defangOutsideHandlers(pickerEl);
|
||||
|
||||
// Cache the palette on the picker so toggleActionPicker's state refresh
|
||||
// uses the same theme-aware colors when it repaints chips.
|
||||
@@ -1443,6 +1489,10 @@
|
||||
|
||||
paramsPanelEl.appendChild(paramsPanelBody);
|
||||
document.body.appendChild(paramsPanelEl);
|
||||
// Don't override pointer-events: the panel toggles between 'none' (closed,
|
||||
// click-through) and 'auto' (open) on its own. Just silence the host's
|
||||
// outside-interaction listeners while the panel is open.
|
||||
defangOutsideHandlers(paramsPanelEl, { setPointerEvents: false });
|
||||
paramsPanelInner = paramsPanelEl; // compatibility alias for the rest of the code
|
||||
}
|
||||
|
||||
@@ -3024,8 +3074,16 @@ void main() {
|
||||
|
||||
function showToast(message, duration) {
|
||||
if (toastEl) toastEl.remove();
|
||||
// Stack the toast above the global bar (which sits at bottom:14px) so
|
||||
// the two never overlap. Read the bar's actual rect — its height varies
|
||||
// with hover-expanded labels — and fall back to a sensible default
|
||||
// when the bar isn't mounted yet.
|
||||
const barRect = globalBarEl?.getBoundingClientRect();
|
||||
const barTopFromBottom = barRect && barRect.height > 0
|
||||
? Math.max(16, window.innerHeight - barRect.top + 12)
|
||||
: 16;
|
||||
toastEl = el('div', {
|
||||
position: 'fixed', bottom: '16px', left: '50%',
|
||||
position: 'fixed', bottom: barTopFromBottom + 'px', left: '50%',
|
||||
transform: 'translateX(-50%) translateY(8px)',
|
||||
background: C.ink, color: C.white,
|
||||
fontFamily: FONT, fontSize: '12px',
|
||||
@@ -3148,13 +3206,33 @@ void main() {
|
||||
// page bg. Used for screenshots and theme QA.
|
||||
const override = localStorage.getItem('impeccable-dev-theme');
|
||||
if (override === 'light' || override === 'dark') return override;
|
||||
const bg = getComputedStyle(document.body).backgroundColor
|
||||
|| getComputedStyle(document.documentElement).backgroundColor;
|
||||
const m = bg.match(/rgba?\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)/);
|
||||
if (!m) return 'light';
|
||||
const [, r, g, b] = m;
|
||||
|
||||
// Walk body → html, taking the first opaque background. The browser's
|
||||
// default body / html background is `rgba(0, 0, 0, 0)`, which a naive
|
||||
// regex would read as black and mislabel a perfectly white page as
|
||||
// dark. Honoring alpha avoids that — and falling through to <html>
|
||||
// catches the common pattern of a bg only on <html> (or only on body).
|
||||
function readOpaque(el) {
|
||||
if (!el) return null;
|
||||
const bg = getComputedStyle(el).backgroundColor;
|
||||
const m = bg.match(/rgba?\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)(?:\s*,\s*([\d.]+))?\s*\)/);
|
||||
if (!m) return null;
|
||||
const alpha = m[4] == null ? 1 : parseFloat(m[4]);
|
||||
if (alpha < 0.5) return null; // transparent / nearly transparent → skip
|
||||
return [+m[1], +m[2], +m[3]];
|
||||
}
|
||||
|
||||
const rgb = readOpaque(document.body) || readOpaque(document.documentElement);
|
||||
// Both transparent → fall back to the browser's effective canvas color.
|
||||
// White is the universal default; only one in a thousand sites swaps it
|
||||
// via `color-scheme: dark` on <html>, and `prefers-color-scheme` lets
|
||||
// us catch that case.
|
||||
if (!rgb) {
|
||||
return matchMedia?.('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
|
||||
}
|
||||
const [r, g, b] = rgb;
|
||||
// Perceptual luminance (Rec. 709)
|
||||
const L = (0.2126 * +r + 0.7152 * +g + 0.0722 * +b) / 255;
|
||||
const L = (0.2126 * r + 0.7152 * g + 0.0722 * b) / 255;
|
||||
return L > 0.55 ? 'light' : 'dark';
|
||||
} catch { return 'light'; }
|
||||
}
|
||||
@@ -3357,15 +3435,24 @@ void main() {
|
||||
});
|
||||
inner.appendChild(divider);
|
||||
|
||||
// Exit (subtle × on the right) — SVG for baseline-free centering
|
||||
// Exit × on the right — intentionally subtle (textDim at rest, text on
|
||||
// hover) so it sits behind the active toggles in visual hierarchy.
|
||||
//
|
||||
// Explicit padding + box-sizing here is load-bearing: a host page like
|
||||
// `button { padding: 0.5rem 1rem; }` (very common in resets) would
|
||||
// otherwise inflate this 24x24 button into 56x40 and push the SVG out
|
||||
// of the visible bar — the X stays invisible even though the styles in
|
||||
// DevTools look fine. Every other chrome button sets padding inline;
|
||||
// this one needed it too.
|
||||
const exitBtn = el('button', {
|
||||
display: 'inline-flex', alignItems: 'center', justifyContent: 'center',
|
||||
width: '26px', height: '26px', borderRadius: '6px',
|
||||
padding: '0', boxSizing: 'border-box',
|
||||
width: '24px', height: '24px', borderRadius: '6px',
|
||||
border: 'none', background: 'transparent',
|
||||
color: P.textDim, fontFamily: FONT, fontSize: '0', lineHeight: '0',
|
||||
cursor: 'pointer', transition: 'color 0.12s ease, background 0.12s ease',
|
||||
});
|
||||
exitBtn.innerHTML = '<svg width="12" height="12" viewBox="0 0 12 12" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round"><line x1="2.5" y1="2.5" x2="9.5" y2="9.5"/><line x1="9.5" y1="2.5" x2="2.5" y2="9.5"/></svg>';
|
||||
exitBtn.innerHTML = '<svg width="14" height="14" viewBox="0 0 14 14" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"><line x1="3" y1="3" x2="11" y2="11"/><line x1="11" y1="3" x2="3" y2="11"/></svg>';
|
||||
exitBtn.title = 'Exit live mode';
|
||||
exitBtn.addEventListener('mouseenter', () => { exitBtn.style.color = P.text; exitBtn.style.background = P.exitHover; });
|
||||
exitBtn.addEventListener('mouseleave', () => { exitBtn.style.color = P.textDim; exitBtn.style.background = 'transparent'; });
|
||||
@@ -3383,6 +3470,7 @@ void main() {
|
||||
});
|
||||
|
||||
document.body.appendChild(globalBarEl);
|
||||
defangOutsideHandlers(globalBarEl);
|
||||
|
||||
requestAnimationFrame(() => {
|
||||
globalBarEl.style.opacity = '1';
|
||||
@@ -3595,6 +3683,11 @@ void main() {
|
||||
designShadow.appendChild(root);
|
||||
|
||||
document.body.appendChild(designHost);
|
||||
// The host is pointer-events: none; the panel inside the shadow DOM
|
||||
// manages its own auto/none. Events bubble through the shadow boundary,
|
||||
// so attaching here silences host-page outside-interaction handlers
|
||||
// without touching the host's click-through behavior.
|
||||
defangOutsideHandlers(designHost, { setPointerEvents: false });
|
||||
|
||||
loadDesignPrefs();
|
||||
renderDesignChrome();
|
||||
|
||||
@@ -197,6 +197,45 @@
|
||||
|
||||
function id8() { return crypto.randomUUID().replace(/-/g, '').slice(0, 8); }
|
||||
|
||||
// Modal-aware chrome: keep our floating UI clickable inside Radix /
|
||||
// Headless UI / vaul portals.
|
||||
//
|
||||
// Two host-page behaviors break us when the picked element lives inside a
|
||||
// modal dialog:
|
||||
//
|
||||
// 1. Modal scroll-lock disables outside pointer events. Radix's
|
||||
// `DismissableLayer` sets `document.body.style.pointerEvents = 'none'`
|
||||
// while a modal is open and only restores `auto` on the layer. Our
|
||||
// chrome inherits `none` from <body> and becomes unclickable.
|
||||
// 2. The dialog's outside-interaction handler (Radix's
|
||||
// `usePointerDownOutside`) listens at document level and dismisses
|
||||
// the dialog whenever a `pointerdown` lands outside the layer node.
|
||||
// Our chrome is a sibling of <body>, so Radix classifies our clicks
|
||||
// as outside and tears the dialog down mid-task.
|
||||
//
|
||||
// We can't reliably re-parent our chrome into the dialog subtree (z-index
|
||||
// stacking, scroll containers, theming all become host-page concerns), so
|
||||
// we defang both behaviors at our root:
|
||||
//
|
||||
// - `pointer-events: auto !important` overrides the inherited `none`.
|
||||
// - Stop `pointerdown` / `mousedown` propagation so the document-level
|
||||
// dismiss listener never fires for our clicks.
|
||||
// - Stop `focusin` propagation so any focus shifts inside our chrome
|
||||
// don't read as "focus moved outside the dialog" to focus traps.
|
||||
//
|
||||
// Click events still bubble normally — only the early pointer/focus
|
||||
// signals that drive outside-interaction detection are silenced.
|
||||
function defangOutsideHandlers(rootEl, { setPointerEvents = true } = {}) {
|
||||
if (!rootEl) return;
|
||||
if (setPointerEvents) {
|
||||
rootEl.style.setProperty('pointer-events', 'auto', 'important');
|
||||
}
|
||||
const stop = (e) => e.stopPropagation();
|
||||
rootEl.addEventListener('pointerdown', stop);
|
||||
rootEl.addEventListener('mousedown', stop);
|
||||
rootEl.addEventListener('focusin', stop);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Highlight overlay
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -336,6 +375,11 @@
|
||||
annotOverlayEl.addEventListener('pointerup', onAnnotUp);
|
||||
annotOverlayEl.addEventListener('pointercancel', onAnnotUp);
|
||||
document.body.appendChild(annotOverlayEl);
|
||||
// Modal-host friendliness: pointer-events is already 'auto' on this
|
||||
// overlay; we only need to silence the host's outside-interaction
|
||||
// listeners. Don't override pointer-events here (the overlay toggles
|
||||
// visibility via display:none, which is fine).
|
||||
defangOutsideHandlers(annotOverlayEl, { setPointerEvents: false });
|
||||
}
|
||||
|
||||
function updateClearChip() {
|
||||
@@ -811,6 +855,7 @@
|
||||
maxWidth: '520px', minWidth: '320px',
|
||||
});
|
||||
document.body.appendChild(barEl);
|
||||
defangOutsideHandlers(barEl);
|
||||
}
|
||||
|
||||
function positionBar() {
|
||||
@@ -1330,6 +1375,7 @@
|
||||
|
||||
pickerEl.appendChild(grid);
|
||||
document.body.appendChild(pickerEl);
|
||||
defangOutsideHandlers(pickerEl);
|
||||
|
||||
// Cache the palette on the picker so toggleActionPicker's state refresh
|
||||
// uses the same theme-aware colors when it repaints chips.
|
||||
@@ -1443,6 +1489,10 @@
|
||||
|
||||
paramsPanelEl.appendChild(paramsPanelBody);
|
||||
document.body.appendChild(paramsPanelEl);
|
||||
// Don't override pointer-events: the panel toggles between 'none' (closed,
|
||||
// click-through) and 'auto' (open) on its own. Just silence the host's
|
||||
// outside-interaction listeners while the panel is open.
|
||||
defangOutsideHandlers(paramsPanelEl, { setPointerEvents: false });
|
||||
paramsPanelInner = paramsPanelEl; // compatibility alias for the rest of the code
|
||||
}
|
||||
|
||||
@@ -3024,8 +3074,16 @@ void main() {
|
||||
|
||||
function showToast(message, duration) {
|
||||
if (toastEl) toastEl.remove();
|
||||
// Stack the toast above the global bar (which sits at bottom:14px) so
|
||||
// the two never overlap. Read the bar's actual rect — its height varies
|
||||
// with hover-expanded labels — and fall back to a sensible default
|
||||
// when the bar isn't mounted yet.
|
||||
const barRect = globalBarEl?.getBoundingClientRect();
|
||||
const barTopFromBottom = barRect && barRect.height > 0
|
||||
? Math.max(16, window.innerHeight - barRect.top + 12)
|
||||
: 16;
|
||||
toastEl = el('div', {
|
||||
position: 'fixed', bottom: '16px', left: '50%',
|
||||
position: 'fixed', bottom: barTopFromBottom + 'px', left: '50%',
|
||||
transform: 'translateX(-50%) translateY(8px)',
|
||||
background: C.ink, color: C.white,
|
||||
fontFamily: FONT, fontSize: '12px',
|
||||
@@ -3148,13 +3206,33 @@ void main() {
|
||||
// page bg. Used for screenshots and theme QA.
|
||||
const override = localStorage.getItem('impeccable-dev-theme');
|
||||
if (override === 'light' || override === 'dark') return override;
|
||||
const bg = getComputedStyle(document.body).backgroundColor
|
||||
|| getComputedStyle(document.documentElement).backgroundColor;
|
||||
const m = bg.match(/rgba?\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)/);
|
||||
if (!m) return 'light';
|
||||
const [, r, g, b] = m;
|
||||
|
||||
// Walk body → html, taking the first opaque background. The browser's
|
||||
// default body / html background is `rgba(0, 0, 0, 0)`, which a naive
|
||||
// regex would read as black and mislabel a perfectly white page as
|
||||
// dark. Honoring alpha avoids that — and falling through to <html>
|
||||
// catches the common pattern of a bg only on <html> (or only on body).
|
||||
function readOpaque(el) {
|
||||
if (!el) return null;
|
||||
const bg = getComputedStyle(el).backgroundColor;
|
||||
const m = bg.match(/rgba?\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)(?:\s*,\s*([\d.]+))?\s*\)/);
|
||||
if (!m) return null;
|
||||
const alpha = m[4] == null ? 1 : parseFloat(m[4]);
|
||||
if (alpha < 0.5) return null; // transparent / nearly transparent → skip
|
||||
return [+m[1], +m[2], +m[3]];
|
||||
}
|
||||
|
||||
const rgb = readOpaque(document.body) || readOpaque(document.documentElement);
|
||||
// Both transparent → fall back to the browser's effective canvas color.
|
||||
// White is the universal default; only one in a thousand sites swaps it
|
||||
// via `color-scheme: dark` on <html>, and `prefers-color-scheme` lets
|
||||
// us catch that case.
|
||||
if (!rgb) {
|
||||
return matchMedia?.('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
|
||||
}
|
||||
const [r, g, b] = rgb;
|
||||
// Perceptual luminance (Rec. 709)
|
||||
const L = (0.2126 * +r + 0.7152 * +g + 0.0722 * +b) / 255;
|
||||
const L = (0.2126 * r + 0.7152 * g + 0.0722 * b) / 255;
|
||||
return L > 0.55 ? 'light' : 'dark';
|
||||
} catch { return 'light'; }
|
||||
}
|
||||
@@ -3357,15 +3435,24 @@ void main() {
|
||||
});
|
||||
inner.appendChild(divider);
|
||||
|
||||
// Exit (subtle × on the right) — SVG for baseline-free centering
|
||||
// Exit × on the right — intentionally subtle (textDim at rest, text on
|
||||
// hover) so it sits behind the active toggles in visual hierarchy.
|
||||
//
|
||||
// Explicit padding + box-sizing here is load-bearing: a host page like
|
||||
// `button { padding: 0.5rem 1rem; }` (very common in resets) would
|
||||
// otherwise inflate this 24x24 button into 56x40 and push the SVG out
|
||||
// of the visible bar — the X stays invisible even though the styles in
|
||||
// DevTools look fine. Every other chrome button sets padding inline;
|
||||
// this one needed it too.
|
||||
const exitBtn = el('button', {
|
||||
display: 'inline-flex', alignItems: 'center', justifyContent: 'center',
|
||||
width: '26px', height: '26px', borderRadius: '6px',
|
||||
padding: '0', boxSizing: 'border-box',
|
||||
width: '24px', height: '24px', borderRadius: '6px',
|
||||
border: 'none', background: 'transparent',
|
||||
color: P.textDim, fontFamily: FONT, fontSize: '0', lineHeight: '0',
|
||||
cursor: 'pointer', transition: 'color 0.12s ease, background 0.12s ease',
|
||||
});
|
||||
exitBtn.innerHTML = '<svg width="12" height="12" viewBox="0 0 12 12" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round"><line x1="2.5" y1="2.5" x2="9.5" y2="9.5"/><line x1="9.5" y1="2.5" x2="2.5" y2="9.5"/></svg>';
|
||||
exitBtn.innerHTML = '<svg width="14" height="14" viewBox="0 0 14 14" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"><line x1="3" y1="3" x2="11" y2="11"/><line x1="11" y1="3" x2="3" y2="11"/></svg>';
|
||||
exitBtn.title = 'Exit live mode';
|
||||
exitBtn.addEventListener('mouseenter', () => { exitBtn.style.color = P.text; exitBtn.style.background = P.exitHover; });
|
||||
exitBtn.addEventListener('mouseleave', () => { exitBtn.style.color = P.textDim; exitBtn.style.background = 'transparent'; });
|
||||
@@ -3383,6 +3470,7 @@ void main() {
|
||||
});
|
||||
|
||||
document.body.appendChild(globalBarEl);
|
||||
defangOutsideHandlers(globalBarEl);
|
||||
|
||||
requestAnimationFrame(() => {
|
||||
globalBarEl.style.opacity = '1';
|
||||
@@ -3595,6 +3683,11 @@ void main() {
|
||||
designShadow.appendChild(root);
|
||||
|
||||
document.body.appendChild(designHost);
|
||||
// The host is pointer-events: none; the panel inside the shadow DOM
|
||||
// manages its own auto/none. Events bubble through the shadow boundary,
|
||||
// so attaching here silences host-page outside-interaction handlers
|
||||
// without touching the host's click-through behavior.
|
||||
defangOutsideHandlers(designHost, { setPointerEvents: false });
|
||||
|
||||
loadDesignPrefs();
|
||||
renderDesignChrome();
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<title>Vite 8 + Radix Dialog Fixture</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.jsx"></script>
|
||||
<!-- impeccable-live-start -->
|
||||
<script src="http://localhost:8400/live.js"></script>
|
||||
<!-- impeccable-live-end -->
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"name": "vite8-react-radix-dialog-fixture",
|
||||
"private": true,
|
||||
"version": "0.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite --host 127.0.0.1",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"@radix-ui/react-dialog": "^1.1.6",
|
||||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@vitejs/plugin-react": "^6.0.0",
|
||||
"vite": "^8.0.0"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
import { useState } from 'react';
|
||||
import * as DialogPrimitive from '@radix-ui/react-dialog';
|
||||
|
||||
export default function App() {
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
return (
|
||||
<main className="page">
|
||||
<DialogPrimitive.Root open={open} onOpenChange={setOpen}>
|
||||
<DialogPrimitive.Trigger asChild>
|
||||
<button data-testid="open-dialog" type="button">Open Dialog</button>
|
||||
</DialogPrimitive.Trigger>
|
||||
<DialogPrimitive.Portal>
|
||||
<DialogPrimitive.Overlay className="dialog-overlay" />
|
||||
<DialogPrimitive.Content className="dialog-content">
|
||||
<aside className="lightbox">
|
||||
<DialogPrimitive.Title asChild>
|
||||
<h1 className="hero-title">Modal Heading</h1>
|
||||
</DialogPrimitive.Title>
|
||||
<p className="hero-hook">Inside a Radix DialogPrimitive.Portal.</p>
|
||||
<button data-testid="close-dialog" type="button" onClick={() => setOpen(false)}>
|
||||
Close
|
||||
</button>
|
||||
</aside>
|
||||
</DialogPrimitive.Content>
|
||||
</DialogPrimitive.Portal>
|
||||
</DialogPrimitive.Root>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import { StrictMode } from 'react';
|
||||
import { createRoot } from 'react-dom/client';
|
||||
import App from './App.jsx';
|
||||
import './styles.css';
|
||||
|
||||
createRoot(document.getElementById('root')).render(
|
||||
<StrictMode>
|
||||
<App />
|
||||
</StrictMode>,
|
||||
);
|
||||
@@ -0,0 +1,36 @@
|
||||
body { margin: 0; font-family: system-ui, sans-serif; }
|
||||
.page { padding: 2rem; }
|
||||
button { padding: 0.5rem 1rem; border-radius: 0.5rem; cursor: pointer; }
|
||||
|
||||
.dialog-overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(0, 0, 0, 0.55);
|
||||
z-index: 50;
|
||||
}
|
||||
|
||||
.dialog-content {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
z-index: 51;
|
||||
}
|
||||
|
||||
.dialog-content[role="dialog"] {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.lightbox {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
background: white;
|
||||
padding: 2rem;
|
||||
border-radius: 1rem;
|
||||
min-width: 360px;
|
||||
max-width: 80vw;
|
||||
}
|
||||
|
||||
.hero-title { font-size: 2rem; margin: 0; }
|
||||
.hero-hook { color: #555; }
|
||||
@@ -0,0 +1,7 @@
|
||||
import { defineConfig } from 'vite';
|
||||
import react from '@vitejs/plugin-react';
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
server: { host: '127.0.0.1', strictPort: false },
|
||||
});
|
||||
@@ -0,0 +1,39 @@
|
||||
{
|
||||
"name": "Vite 8 + React + Radix Dialog (portal)",
|
||||
"config": {
|
||||
"files": ["index.html"],
|
||||
"insertBefore": "</body>",
|
||||
"commentSyntax": "html"
|
||||
},
|
||||
"sourceFiles": ["index.html", "src/App.jsx", "src/main.jsx", "src/styles.css", "vite.config.js"],
|
||||
"generatedFiles": [],
|
||||
"wrapCases": [
|
||||
{
|
||||
"name": "wraps lightbox heading inside Radix portal",
|
||||
"args": { "classes": "hero-title", "tag": "h1" },
|
||||
"expectedFile": "src/App.jsx"
|
||||
}
|
||||
],
|
||||
"runtime": {
|
||||
"styling": "plain-css",
|
||||
"install": ["npm", "install", "--no-audit", "--no-fund", "--loglevel=error"],
|
||||
"devCommand": ["npx", "vite", "--host", "127.0.0.1"],
|
||||
"readyPattern": "Local:\\s+https?://[^:]+:(\\d+)",
|
||||
"readyTimeoutMs": 120000,
|
||||
"preActions": [
|
||||
{ "type": "click", "selector": "[data-testid='open-dialog']" },
|
||||
{ "type": "wait", "selector": "h1.hero-title" }
|
||||
],
|
||||
"reloadProbe": {
|
||||
"preActions": [
|
||||
{ "type": "click", "selector": "[data-testid='open-dialog']" },
|
||||
{ "type": "wait", "selector": "h1.hero-title" }
|
||||
],
|
||||
"expectSelector": "h1.hero-title"
|
||||
},
|
||||
"probe": {
|
||||
"expectLiveInit": true,
|
||||
"expectConsoleClean": true
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
node_modules/
|
||||
dist/
|
||||
.vite/
|
||||
package-lock.json
|
||||
Reference in New Issue
Block a user