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:
Paul Bakaus
2026-04-27 16:22:00 -07:00
co-authored by Claude Opus 4.7
parent 39bec7c08c
commit 630e586b01
22 changed files with 1501 additions and 132 deletions
+103 -10
View File
@@ -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();
+103 -10
View File
@@ -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();
+103 -10
View File
@@ -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();
+103 -10
View File
@@ -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();
+103 -10
View File
@@ -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();
+103 -10
View File
@@ -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();
+103 -10
View File
@@ -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();
+103 -10
View File
@@ -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 -2
View File
@@ -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:
+103 -10
View File
@@ -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();
+103 -10
View File
@@ -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