From 630e586b0106eb5c95c0cbfed4e5761892362718 Mon Sep 17 00:00:00 2001 From: Paul Bakaus Date: Mon, 27 Apr 2026 16:22:00 -0700 Subject: [PATCH] feat(live): make picker chrome modal-host friendly (Radix, Headless UI, vaul) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- .../skills/impeccable/scripts/live-browser.js | 113 ++++++++++++++++-- .../skills/impeccable/scripts/live-browser.js | 113 ++++++++++++++++-- .../skills/impeccable/scripts/live-browser.js | 113 ++++++++++++++++-- .../skills/impeccable/scripts/live-browser.js | 113 ++++++++++++++++-- .../skills/impeccable/scripts/live-browser.js | 113 ++++++++++++++++-- .../skills/impeccable/scripts/live-browser.js | 113 ++++++++++++++++-- .../skills/impeccable/scripts/live-browser.js | 113 ++++++++++++++++-- .pi/skills/impeccable/scripts/live-browser.js | 113 ++++++++++++++++-- .../skills/impeccable/scripts/live-browser.js | 113 ++++++++++++++++-- .../skills/impeccable/scripts/live-browser.js | 113 ++++++++++++++++-- .../skills/impeccable/scripts/live-browser.js | 113 ++++++++++++++++-- plugin/skills/impeccable/reference/craft.md | 4 +- .../skills/impeccable/scripts/live-browser.js | 113 ++++++++++++++++-- .../skills/impeccable/scripts/live-browser.js | 113 ++++++++++++++++-- .../vite8-react-radix-dialog/files/index.html | 14 +++ .../files/package.json | 20 ++++ .../files/src/App.jsx | 30 +++++ .../files/src/main.jsx | 10 ++ .../files/src/styles.css | 36 ++++++ .../files/vite.config.js | 7 ++ .../vite8-react-radix-dialog/fixture.json | 39 ++++++ .../vite8-react-radix-dialog/gitignore.txt | 4 + 22 files changed, 1501 insertions(+), 132 deletions(-) create mode 100644 tests/framework-fixtures/vite8-react-radix-dialog/files/index.html create mode 100644 tests/framework-fixtures/vite8-react-radix-dialog/files/package.json create mode 100644 tests/framework-fixtures/vite8-react-radix-dialog/files/src/App.jsx create mode 100644 tests/framework-fixtures/vite8-react-radix-dialog/files/src/main.jsx create mode 100644 tests/framework-fixtures/vite8-react-radix-dialog/files/src/styles.css create mode 100644 tests/framework-fixtures/vite8-react-radix-dialog/files/vite.config.js create mode 100644 tests/framework-fixtures/vite8-react-radix-dialog/fixture.json create mode 100644 tests/framework-fixtures/vite8-react-radix-dialog/gitignore.txt diff --git a/.agents/skills/impeccable/scripts/live-browser.js b/.agents/skills/impeccable/scripts/live-browser.js index 66a54435a..ac13e80fa 100644 --- a/.agents/skills/impeccable/scripts/live-browser.js +++ b/.agents/skills/impeccable/scripts/live-browser.js @@ -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 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 , 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 + // catches the common pattern of a bg only on (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 , 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 = ''; + exitBtn.innerHTML = ''; 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(); diff --git a/.claude/skills/impeccable/scripts/live-browser.js b/.claude/skills/impeccable/scripts/live-browser.js index 66a54435a..ac13e80fa 100644 --- a/.claude/skills/impeccable/scripts/live-browser.js +++ b/.claude/skills/impeccable/scripts/live-browser.js @@ -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 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 , 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 + // catches the common pattern of a bg only on (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 , 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 = ''; + exitBtn.innerHTML = ''; 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(); diff --git a/.cursor/skills/impeccable/scripts/live-browser.js b/.cursor/skills/impeccable/scripts/live-browser.js index 66a54435a..ac13e80fa 100644 --- a/.cursor/skills/impeccable/scripts/live-browser.js +++ b/.cursor/skills/impeccable/scripts/live-browser.js @@ -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 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 , 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 + // catches the common pattern of a bg only on (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 , 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 = ''; + exitBtn.innerHTML = ''; 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(); diff --git a/.gemini/skills/impeccable/scripts/live-browser.js b/.gemini/skills/impeccable/scripts/live-browser.js index 66a54435a..ac13e80fa 100644 --- a/.gemini/skills/impeccable/scripts/live-browser.js +++ b/.gemini/skills/impeccable/scripts/live-browser.js @@ -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 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 , 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 + // catches the common pattern of a bg only on (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 , 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 = ''; + exitBtn.innerHTML = ''; 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(); diff --git a/.github/skills/impeccable/scripts/live-browser.js b/.github/skills/impeccable/scripts/live-browser.js index 66a54435a..ac13e80fa 100644 --- a/.github/skills/impeccable/scripts/live-browser.js +++ b/.github/skills/impeccable/scripts/live-browser.js @@ -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 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 , 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 + // catches the common pattern of a bg only on (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 , 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 = ''; + exitBtn.innerHTML = ''; 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(); diff --git a/.kiro/skills/impeccable/scripts/live-browser.js b/.kiro/skills/impeccable/scripts/live-browser.js index 66a54435a..ac13e80fa 100644 --- a/.kiro/skills/impeccable/scripts/live-browser.js +++ b/.kiro/skills/impeccable/scripts/live-browser.js @@ -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 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 , 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 + // catches the common pattern of a bg only on (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 , 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 = ''; + exitBtn.innerHTML = ''; 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(); diff --git a/.opencode/skills/impeccable/scripts/live-browser.js b/.opencode/skills/impeccable/scripts/live-browser.js index 66a54435a..ac13e80fa 100644 --- a/.opencode/skills/impeccable/scripts/live-browser.js +++ b/.opencode/skills/impeccable/scripts/live-browser.js @@ -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 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 , 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 + // catches the common pattern of a bg only on (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 , 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 = ''; + exitBtn.innerHTML = ''; 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(); diff --git a/.pi/skills/impeccable/scripts/live-browser.js b/.pi/skills/impeccable/scripts/live-browser.js index 66a54435a..ac13e80fa 100644 --- a/.pi/skills/impeccable/scripts/live-browser.js +++ b/.pi/skills/impeccable/scripts/live-browser.js @@ -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 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 , 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 + // catches the common pattern of a bg only on (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 , 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 = ''; + exitBtn.innerHTML = ''; 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(); diff --git a/.rovodev/skills/impeccable/scripts/live-browser.js b/.rovodev/skills/impeccable/scripts/live-browser.js index 66a54435a..ac13e80fa 100644 --- a/.rovodev/skills/impeccable/scripts/live-browser.js +++ b/.rovodev/skills/impeccable/scripts/live-browser.js @@ -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 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 , 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 + // catches the common pattern of a bg only on (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 , 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 = ''; + exitBtn.innerHTML = ''; 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(); diff --git a/.trae-cn/skills/impeccable/scripts/live-browser.js b/.trae-cn/skills/impeccable/scripts/live-browser.js index 66a54435a..ac13e80fa 100644 --- a/.trae-cn/skills/impeccable/scripts/live-browser.js +++ b/.trae-cn/skills/impeccable/scripts/live-browser.js @@ -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 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 , 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 + // catches the common pattern of a bg only on (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 , 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 = ''; + exitBtn.innerHTML = ''; 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(); diff --git a/.trae/skills/impeccable/scripts/live-browser.js b/.trae/skills/impeccable/scripts/live-browser.js index 66a54435a..ac13e80fa 100644 --- a/.trae/skills/impeccable/scripts/live-browser.js +++ b/.trae/skills/impeccable/scripts/live-browser.js @@ -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 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 , 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 + // catches the common pattern of a bg only on (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 , 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 = ''; + exitBtn.innerHTML = ''; 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(); diff --git a/plugin/skills/impeccable/reference/craft.md b/plugin/skills/impeccable/reference/craft.md index 84b9e1c81..b144589d7 100644 --- a/plugin/skills/impeccable/reference/craft.md +++ b/plugin/skills/impeccable/reference/craft.md @@ -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: diff --git a/plugin/skills/impeccable/scripts/live-browser.js b/plugin/skills/impeccable/scripts/live-browser.js index 66a54435a..ac13e80fa 100644 --- a/plugin/skills/impeccable/scripts/live-browser.js +++ b/plugin/skills/impeccable/scripts/live-browser.js @@ -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 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 , 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 + // catches the common pattern of a bg only on (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 , 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 = ''; + exitBtn.innerHTML = ''; 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(); diff --git a/source/skills/impeccable/scripts/live-browser.js b/source/skills/impeccable/scripts/live-browser.js index 66a54435a..ac13e80fa 100644 --- a/source/skills/impeccable/scripts/live-browser.js +++ b/source/skills/impeccable/scripts/live-browser.js @@ -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 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 , 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 + // catches the common pattern of a bg only on (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 , 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 = ''; + exitBtn.innerHTML = ''; 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(); diff --git a/tests/framework-fixtures/vite8-react-radix-dialog/files/index.html b/tests/framework-fixtures/vite8-react-radix-dialog/files/index.html new file mode 100644 index 000000000..baadf2830 --- /dev/null +++ b/tests/framework-fixtures/vite8-react-radix-dialog/files/index.html @@ -0,0 +1,14 @@ + + + + + Vite 8 + Radix Dialog Fixture + + +
+ + + + + + diff --git a/tests/framework-fixtures/vite8-react-radix-dialog/files/package.json b/tests/framework-fixtures/vite8-react-radix-dialog/files/package.json new file mode 100644 index 000000000..caa9bd050 --- /dev/null +++ b/tests/framework-fixtures/vite8-react-radix-dialog/files/package.json @@ -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" + } +} diff --git a/tests/framework-fixtures/vite8-react-radix-dialog/files/src/App.jsx b/tests/framework-fixtures/vite8-react-radix-dialog/files/src/App.jsx new file mode 100644 index 000000000..5641ca32d --- /dev/null +++ b/tests/framework-fixtures/vite8-react-radix-dialog/files/src/App.jsx @@ -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 ( +
+ + + + + + + + + + + +
+ ); +} diff --git a/tests/framework-fixtures/vite8-react-radix-dialog/files/src/main.jsx b/tests/framework-fixtures/vite8-react-radix-dialog/files/src/main.jsx new file mode 100644 index 000000000..f2baba283 --- /dev/null +++ b/tests/framework-fixtures/vite8-react-radix-dialog/files/src/main.jsx @@ -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( + + + , +); diff --git a/tests/framework-fixtures/vite8-react-radix-dialog/files/src/styles.css b/tests/framework-fixtures/vite8-react-radix-dialog/files/src/styles.css new file mode 100644 index 000000000..ad34ced7b --- /dev/null +++ b/tests/framework-fixtures/vite8-react-radix-dialog/files/src/styles.css @@ -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; } diff --git a/tests/framework-fixtures/vite8-react-radix-dialog/files/vite.config.js b/tests/framework-fixtures/vite8-react-radix-dialog/files/vite.config.js new file mode 100644 index 000000000..f7ad4b565 --- /dev/null +++ b/tests/framework-fixtures/vite8-react-radix-dialog/files/vite.config.js @@ -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 }, +}); diff --git a/tests/framework-fixtures/vite8-react-radix-dialog/fixture.json b/tests/framework-fixtures/vite8-react-radix-dialog/fixture.json new file mode 100644 index 000000000..2f78e9716 --- /dev/null +++ b/tests/framework-fixtures/vite8-react-radix-dialog/fixture.json @@ -0,0 +1,39 @@ +{ + "name": "Vite 8 + React + Radix Dialog (portal)", + "config": { + "files": ["index.html"], + "insertBefore": "", + "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 + } + } +} diff --git a/tests/framework-fixtures/vite8-react-radix-dialog/gitignore.txt b/tests/framework-fixtures/vite8-react-radix-dialog/gitignore.txt new file mode 100644 index 000000000..8cda9ad20 --- /dev/null +++ b/tests/framework-fixtures/vite8-react-radix-dialog/gitignore.txt @@ -0,0 +1,4 @@ +node_modules/ +dist/ +.vite/ +package-lock.json