From 8735be37121a6e2ffd4b86806fd4f69f1729bf4f Mon Sep 17 00:00:00 2001 From: Paul Bakaus Date: Tue, 9 Jun 2026 14:10:10 -0700 Subject: [PATCH] Extract live browser DOM helpers (#239) --- scripts/test-suites.mjs | 1 + skill/scripts/live-browser-dom.js | 146 ++++++++++++++++++ skill/scripts/live-browser.js | 149 +++--------------- skill/scripts/live/browser-script-parts.mjs | 1 + tests/live-browser-dom.test.mjs | 158 ++++++++++++++++++++ tests/live-browser-script-parts.test.mjs | 15 +- tests/live-server.test.mjs | 16 +- 7 files changed, 355 insertions(+), 131 deletions(-) create mode 100644 skill/scripts/live-browser-dom.js create mode 100644 tests/live-browser-dom.test.mjs diff --git a/scripts/test-suites.mjs b/scripts/test-suites.mjs index 56d0154eb..be8f12948 100644 --- a/scripts/test-suites.mjs +++ b/scripts/test-suites.mjs @@ -106,6 +106,7 @@ export const SUITES = { files: [ 'tests/live-accept.test.mjs', 'tests/live-accept-scrub.test.mjs', + 'tests/live-browser-dom.test.mjs', 'tests/live-browser-script-parts.test.mjs', 'tests/live-browser-regression.test.mjs', 'tests/live-browser-session.test.mjs', diff --git a/skill/scripts/live-browser-dom.js b/skill/scripts/live-browser-dom.js new file mode 100644 index 000000000..ad6a794b3 --- /dev/null +++ b/skill/scripts/live-browser-dom.js @@ -0,0 +1,146 @@ +/** + * Browser-side DOM helpers for Impeccable live mode. + * + * Kept separate from live-browser.js so future browser script parts can share + * chrome mounting, lookup, focus, and picker helpers without depending on the + * full overlay UI bundle. + */ +(function (root) { + 'use strict'; + if (!root) return; + + function createLiveBrowserDomHelpers({ + prefix, + skipTags, + document: doc = root.document, + css = root.CSS, + crypto = root.crypto, + } = {}) { + if (!prefix) throw new Error('prefix required'); + if (!doc) throw new Error('document required'); + const tagsToSkip = skipTags || new Set(); + + function own(el) { + return el && (el.id?.startsWith(prefix) || el.closest?.('[id^="' + prefix + '"]')); + } + + function pickable(el) { + if (!el || el.nodeType !== 1) return false; + if (tagsToSkip.has(String(el.tagName || '').toLowerCase())) return false; + if (own(el)) return false; + const r = el.getBoundingClientRect(); + return r.width >= 20 && r.height >= 20; + } + + function desc(el) { + if (!el) return ''; + let s = el.tagName.toLowerCase(); + if (el.id) s += '#' + el.id; + else if (el.classList.length) s += '.' + [...el.classList].slice(0, 2).join('.'); + return s; + } + + function rectIsUsableAnchor(rect) { + return !!rect && rect.width > 0.5 && rect.height > 0.5; + } + + function makeFrozenAnchor(el) { + if (!el || !el.getBoundingClientRect) return null; + const r = el.getBoundingClientRect(); + if (!rectIsUsableAnchor(r)) return null; + const rect = { + x: r.x, y: r.y, + top: r.top, left: r.left, + right: r.right, bottom: r.bottom, + width: r.width, height: r.height, + }; + return { + __impeccableFrozenAnchor: true, + tagName: el.tagName || 'DIV', + id: el.id || '', + classList: el.classList ? [...el.classList] : [], + hasAttribute: () => false, + getBoundingClientRect: () => rect, + }; + } + + function id8() { + if (crypto?.randomUUID) return crypto.randomUUID().replace(/-/g, '').slice(0, 8); + return (Math.random().toString(16).slice(2) + Date.now().toString(16)).slice(0, 8); + } + + function cssId(id) { + if (css?.escape) return css.escape(id); + return String(id).replace(/([ !"#$%&'()*+,./:;<=>?@[\\\]^`{|}~])/g, '\\$1'); + } + + function liveUiRoot() { + const uiRoot = root.__IMPECCABLE_LIVE_UI_ROOT__; + if (uiRoot && typeof uiRoot.appendChild === 'function') return uiRoot; + return doc.body; + } + + function uiAppend(el) { + liveUiRoot().appendChild(el); + return el; + } + + function uiAppendStyle(styleEl) { + const uiRoot = liveUiRoot(); + if (uiRoot && uiRoot !== doc.body) uiRoot.appendChild(styleEl); + else doc.head.appendChild(styleEl); + return styleEl; + } + + function uiGetById(id) { + const uiRoot = liveUiRoot(); + if (uiRoot?.getElementById) { + const found = uiRoot.getElementById(id); + if (found) return found; + } + if (uiRoot?.querySelector) { + const found = uiRoot.querySelector('#' + cssId(id)); + if (found) return found; + } + return doc.getElementById(id); + } + + function activeElementDeep() { + let active = doc.activeElement; + while (active?.shadowRoot?.activeElement) active = active.shadowRoot.activeElement; + return active; + } + + 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); + } + + return { + own, + pickable, + desc, + rectIsUsableAnchor, + makeFrozenAnchor, + id8, + cssId, + liveUiRoot, + uiAppend, + uiAppendStyle, + uiGetById, + activeElementDeep, + defangOutsideHandlers, + }; + } + + root.__IMPECCABLE_LIVE_DOM__ = { + version: 1, + createLiveBrowserDomHelpers, + }; +})(typeof window !== 'undefined' ? window : globalThis); diff --git a/skill/scripts/live-browser.js b/skill/scripts/live-browser.js index 9c4b6c21e..9092338eb 100644 --- a/skill/scripts/live-browser.js +++ b/skill/scripts/live-browser.js @@ -195,93 +195,31 @@ // Helpers // - function own(el) { - return el && (el.id?.startsWith(PREFIX) || el.closest?.('[id^="' + PREFIX + '"]')); - } - - function pickable(el) { - if (!el || el.nodeType !== 1) return false; - if (SKIP_TAGS.has(el.tagName.toLowerCase())) return false; - if (own(el)) return false; - const r = el.getBoundingClientRect(); - return r.width >= 20 && r.height >= 20; - } - - function desc(el) { - if (!el) return ''; - let s = el.tagName.toLowerCase(); - if (el.id) s += '#' + el.id; - else if (el.classList.length) s += '.' + [...el.classList].slice(0, 2).join('.'); - return s; - } - - function rectIsUsableAnchor(rect) { - return !!rect && rect.width > 0.5 && rect.height > 0.5; - } - - function makeFrozenAnchor(el) { - if (!el || !el.getBoundingClientRect) return null; - const r = el.getBoundingClientRect(); - if (!rectIsUsableAnchor(r)) return null; - const rect = { - x: r.x, y: r.y, - top: r.top, left: r.left, - right: r.right, bottom: r.bottom, - width: r.width, height: r.height, - }; - return { - __impeccableFrozenAnchor: true, - tagName: el.tagName || 'DIV', - id: el.id || '', - classList: el.classList ? [...el.classList] : [], - hasAttribute: () => false, - getBoundingClientRect: () => rect, - }; - } - - function id8() { return crypto.randomUUID().replace(/-/g, '').slice(0, 8); } - - function cssId(id) { - if (window.CSS?.escape) return CSS.escape(id); - return String(id).replace(/([ !"#$%&'()*+,./:;<=>?@[\\\]^`{|}~])/g, '\\$1'); - } - - function liveUiRoot() { - const root = window.__IMPECCABLE_LIVE_UI_ROOT__; - if (root && typeof root.appendChild === 'function') return root; - return document.body; - } - - function uiAppend(el) { - liveUiRoot().appendChild(el); - return el; - } - - function uiAppendStyle(styleEl) { - const root = liveUiRoot(); - if (root && root !== document.body) root.appendChild(styleEl); - else document.head.appendChild(styleEl); - return styleEl; - } - - function uiGetById(id) { - const root = liveUiRoot(); - if (root?.getElementById) { - const found = root.getElementById(id); - if (found) return found; - } - if (root?.querySelector) { - const found = root.querySelector('#' + cssId(id)); - if (found) return found; - } - return document.getElementById(id); - } - - function activeElementDeep() { - let active = document.activeElement; - while (active?.shadowRoot?.activeElement) active = active.shadowRoot.activeElement; - return active; + const domHelpers = window.__IMPECCABLE_LIVE_DOM__?.createLiveBrowserDomHelpers({ + prefix: PREFIX, + skipTags: SKIP_TAGS, + document, + }); + if (!domHelpers) { + console.error('[impeccable] live-browser-dom.js was not loaded. Live mode cannot start safely.'); + window.__IMPECCABLE_LIVE_INIT__ = false; + return; } + const { + own, + pickable, + desc, + rectIsUsableAnchor, + makeFrozenAnchor, + id8, + cssId, + liveUiRoot, + uiAppend, + uiAppendStyle, + uiGetById, + activeElementDeep, + defangOutsideHandlers, + } = domHelpers; window.__IMPECCABLE_LIVE_CHROME_CORE__ = { version: 1, @@ -314,45 +252,6 @@ }), }; - // 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 // diff --git a/skill/scripts/live/browser-script-parts.mjs b/skill/scripts/live/browser-script-parts.mjs index f99704aff..9229e34f8 100644 --- a/skill/scripts/live/browser-script-parts.mjs +++ b/skill/scripts/live/browser-script-parts.mjs @@ -3,6 +3,7 @@ import path from 'node:path'; export const LIVE_BROWSER_SCRIPT_PARTS = Object.freeze([ Object.freeze({ name: 'session-state', file: 'live-browser-session.js' }), + Object.freeze({ name: 'dom-helpers', file: 'live-browser-dom.js' }), Object.freeze({ name: 'browser-ui', file: 'live-browser.js' }), ]); diff --git a/tests/live-browser-dom.test.mjs b/tests/live-browser-dom.test.mjs new file mode 100644 index 000000000..f537df2ab --- /dev/null +++ b/tests/live-browser-dom.test.mjs @@ -0,0 +1,158 @@ +import { describe, it } from 'node:test'; +import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; +import { join } from 'node:path'; +import vm from 'node:vm'; + +const REPO_ROOT = process.cwd(); +const SCRIPT = join(REPO_ROOT, 'skill/scripts/live-browser-dom.js'); + +function createAppendTarget() { + return { + children: [], + appendChild(child) { + this.children.push(child); + child.parentNode = this; + return child; + }, + }; +} + +function createElement({ + id = '', + tagName = 'DIV', + classList = [], + rect = { x: 0, y: 0, top: 0, left: 0, right: 40, bottom: 30, width: 40, height: 30 }, + closestResult = null, +} = {}) { + const listeners = {}; + const styleCalls = []; + return { + nodeType: 1, + id, + tagName, + classList, + listeners, + styleCalls, + style: { + setProperty(...args) { styleCalls.push(args); }, + }, + closest() { return closestResult; }, + getBoundingClientRect() { return rect; }, + addEventListener(type, handler) { listeners[type] = handler; }, + }; +} + +function createDocument() { + const elementsById = new Map(); + const body = createAppendTarget(); + const head = createAppendTarget(); + return { + body, + head, + activeElement: null, + elementsById, + getElementById(id) { return elementsById.get(id) || null; }, + }; +} + +function loadFactory(doc = createDocument(), extras = {}) { + const context = { + document: doc, + window: {}, + globalThis: {}, + console, + CSS: extras.CSS, + crypto: extras.crypto, + }; + context.window = context; + context.globalThis = context; + vm.createContext(context); + vm.runInContext(readFileSync(SCRIPT, 'utf-8'), context, { filename: SCRIPT }); + return { context, createHelpers: context.__IMPECCABLE_LIVE_DOM__.createLiveBrowserDomHelpers }; +} + +describe('live-browser-dom helpers', () => { + it('detects owned chrome and pickable page elements', () => { + const doc = createDocument(); + const { createHelpers } = loadFactory(doc); + const helpers = createHelpers({ prefix: 'impeccable-live', skipTags: new Set(['script']) }); + + assert.equal(helpers.own(createElement({ id: 'impeccable-live-bar' })), true); + assert.ok(helpers.own(createElement({ closestResult: createElement({ id: 'impeccable-live-root' }) }))); + assert.equal(helpers.pickable(createElement({ tagName: 'SCRIPT' })), false); + assert.equal(helpers.pickable(createElement({ rect: { width: 12, height: 30 } })), false); + assert.equal(helpers.pickable(createElement({ tagName: 'BUTTON' })), true); + }); + + it('mounts chrome in the configured live UI root and styles in head by default', () => { + const doc = createDocument(); + const { context, createHelpers } = loadFactory(doc); + const helpers = createHelpers({ prefix: 'impeccable-live', document: doc }); + const uiRoot = createAppendTarget(); + context.__IMPECCABLE_LIVE_UI_ROOT__ = uiRoot; + + const el = {}; + const styleInRoot = {}; + helpers.uiAppend(el); + helpers.uiAppendStyle(styleInRoot); + assert.deepEqual(uiRoot.children, [el, styleInRoot]); + + context.__IMPECCABLE_LIVE_UI_ROOT__ = null; + const styleInHead = {}; + helpers.uiAppendStyle(styleInHead); + assert.deepEqual(doc.head.children, [styleInHead]); + }); + + it('escapes ids while looking inside the live UI root before document fallback', () => { + const doc = createDocument(); + const rootHit = {}; + const documentHit = {}; + doc.elementsById.set('fallback', documentHit); + const { context, createHelpers } = loadFactory(doc, { CSS: { escape: (id) => 'escaped-' + id } }); + context.__IMPECCABLE_LIVE_UI_ROOT__ = { + appendChild() {}, + getElementById() { return null; }, + querySelector(selector) { + assert.equal(selector, '#escaped-a.b'); + return rootHit; + }, + }; + const helpers = createHelpers({ prefix: 'impeccable-live', document: doc, css: context.CSS }); + + assert.equal(helpers.uiGetById('a.b'), rootHit); + context.__IMPECCABLE_LIVE_UI_ROOT__ = null; + assert.equal(helpers.uiGetById('fallback'), documentHit); + }); + + it('freezes usable anchors and follows nested shadow active elements', () => { + const doc = createDocument(); + const inner = { id: 'inner' }; + doc.activeElement = { shadowRoot: { activeElement: { shadowRoot: { activeElement: inner } } } }; + const { createHelpers } = loadFactory(doc); + const helpers = createHelpers({ prefix: 'impeccable-live', document: doc }); + const anchor = createElement({ id: 'hero', tagName: 'SECTION', classList: ['hero'] }); + + const frozen = helpers.makeFrozenAnchor(anchor); + assert.equal(frozen.__impeccableFrozenAnchor, true); + assert.equal(frozen.id, 'hero'); + assert.equal(frozen.classList[0], 'hero'); + assert.equal(frozen.getBoundingClientRect().width, 40); + assert.equal(helpers.makeFrozenAnchor(createElement({ rect: { width: 0, height: 30 } })), null); + assert.equal(helpers.activeElementDeep(), inner); + }); + + it('defangs modal outside handlers on live chrome roots', () => { + const { createHelpers } = loadFactory(); + const helpers = createHelpers({ prefix: 'impeccable-live' }); + const root = createElement(); + let stopped = 0; + + helpers.defangOutsideHandlers(root); + assert.deepEqual(root.styleCalls[0], ['pointer-events', 'auto', 'important']); + root.listeners.pointerdown({ stopPropagation: () => { stopped += 1; } }); + root.listeners.mousedown({ stopPropagation: () => { stopped += 1; } }); + root.listeners.focusin({ stopPropagation: () => { stopped += 1; } }); + assert.equal(stopped, 3); + }); +}); diff --git a/tests/live-browser-script-parts.test.mjs b/tests/live-browser-script-parts.test.mjs index 023da7619..808cb30e1 100644 --- a/tests/live-browser-script-parts.test.mjs +++ b/tests/live-browser-script-parts.test.mjs @@ -12,11 +12,13 @@ describe('live browser script parts', () => { it('resolves the canonical browser script order', () => { const parts = resolveLiveBrowserScriptParts('/repo/skill/scripts'); - assert.deepEqual(parts.map((part) => part.name), ['session-state', 'browser-ui']); + assert.deepEqual(parts.map((part) => part.name), ['session-state', 'dom-helpers', 'browser-ui']); assert.equal(parts[0].file, 'live-browser-session.js'); - assert.equal(parts[1].file, 'live-browser.js'); + assert.equal(parts[1].file, 'live-browser-dom.js'); + assert.equal(parts[2].file, 'live-browser.js'); assert.equal(parts[0].path, path.join('/repo/skill/scripts', 'live-browser-session.js')); - assert.equal(parts[1].path, path.join('/repo/skill/scripts', 'live-browser.js')); + assert.equal(parts[1].path, path.join('/repo/skill/scripts', 'live-browser-dom.js')); + assert.equal(parts[2].path, path.join('/repo/skill/scripts', 'live-browser.js')); }); it('asserts missing script parts by name', () => { @@ -34,6 +36,7 @@ describe('live browser script parts', () => { assert.deepEqual(loaded.map((part) => part.source), [ 'source:live-browser-session.js', + 'source:live-browser-dom.js', 'source:live-browser.js', ]); }); @@ -45,6 +48,7 @@ describe('live browser script parts', () => { vocabulary: [{ value: 'shape', label: 'Shape' }], parts: [ { name: 'session-state', file: 'live-browser-session.js', source: 'window.__SESSION_PART__ = true;' }, + { name: 'dom-helpers', file: 'live-browser-dom.js', source: 'window.__DOM_PART__ = true;' }, { name: 'browser-ui', file: 'live-browser.js', source: 'window.__BROWSER_PART__ = true;' }, ], }); @@ -53,14 +57,17 @@ describe('live browser script parts', () => { const portIndex = script.indexOf('window.__IMPECCABLE_PORT__'); const vocabIndex = script.indexOf('window.__IMPECCABLE_VOCAB__'); const sessionIndex = script.indexOf('window.__SESSION_PART__'); + const domIndex = script.indexOf('window.__DOM_PART__'); const browserIndex = script.indexOf('window.__BROWSER_PART__'); assert.ok(tokenIndex !== -1); assert.ok(tokenIndex < portIndex); assert.ok(portIndex < vocabIndex); assert.ok(vocabIndex < sessionIndex); - assert.ok(sessionIndex < browserIndex); + assert.ok(sessionIndex < domIndex); + assert.ok(domIndex < browserIndex); assert.match(script, /impeccable live script part: session-state \(live-browser-session\.js\)/); + assert.match(script, /impeccable live script part: dom-helpers \(live-browser-dom\.js\)/); assert.match(script, /impeccable live script part: browser-ui \(live-browser\.js\)/); }); }); diff --git a/tests/live-server.test.mjs b/tests/live-server.test.mjs index 278ef2032..a310d5408 100644 --- a/tests/live-server.test.mjs +++ b/tests/live-server.test.mjs @@ -230,26 +230,38 @@ describe('live-server integration', () => { assert.ok(text.includes('__IMPECCABLE_PORT__')); const preludeIndex = text.indexOf('window.__IMPECCABLE_VOCAB__'); const sessionPartIndex = text.indexOf('impeccable live script part: session-state (live-browser-session.js)'); + const domPartIndex = text.indexOf('impeccable live script part: dom-helpers (live-browser-dom.js)'); const browserPartIndex = text.indexOf('impeccable live script part: browser-ui (live-browser.js)'); const sessionHelperIndex = text.indexOf('__IMPECCABLE_LIVE_SESSION__'); + const domHelperIndex = text.indexOf('__IMPECCABLE_LIVE_DOM__'); const browserInitIndex = text.indexOf('__IMPECCABLE_LIVE_INIT__'); assert.ok(preludeIndex !== -1); assert.ok(sessionPartIndex !== -1); + assert.ok(domPartIndex !== -1); assert.ok(browserPartIndex !== -1); assert.ok(sessionHelperIndex !== -1); + assert.ok(domHelperIndex !== -1); assert.ok(browserInitIndex !== -1); assert.ok( preludeIndex < sessionPartIndex, 'event=live_server.browser_script_order actor=browser operation=load_live_js risk=prelude_after_script_part expected=prelude before parts actual=' + preludeIndex + ':' + sessionPartIndex, ); assert.ok( - sessionPartIndex < browserPartIndex, - 'event=live_server.browser_script_order actor=browser operation=load_live_js risk=browser_part_before_session_helper expected=session part before browser part actual=' + sessionPartIndex + ':' + browserPartIndex, + sessionPartIndex < domPartIndex, + 'event=live_server.browser_script_order actor=browser operation=load_live_js risk=dom_part_before_session_helper expected=session part before dom part actual=' + sessionPartIndex + ':' + domPartIndex, + ); + assert.ok( + domPartIndex < browserPartIndex, + 'event=live_server.browser_script_order actor=browser operation=load_live_js risk=browser_part_before_dom_helpers expected=dom part before browser part actual=' + domPartIndex + ':' + browserPartIndex, ); assert.ok( sessionHelperIndex < browserInitIndex, 'event=live_server.browser_helper_order actor=browser operation=load_live_js risk=session_helper_missing_before_browser_init expected=session helper before live init actual=' + sessionHelperIndex + ':' + browserInitIndex, ); + assert.ok( + domHelperIndex < browserInitIndex, + 'event=live_server.browser_helper_order actor=browser operation=load_live_js risk=dom_helper_missing_before_browser_init expected=dom helper before live init actual=' + domHelperIndex + ':' + browserInitIndex, + ); }); it('/design-system.json reads DESIGN.md plus .impeccable/design.json', async () => {