Extract live browser DOM helpers (#239)

This commit is contained in:
Paul Bakaus
2026-06-09 23:10:10 +02:00
committed by GitHub
parent e3e22007a9
commit 8735be3712
7 changed files with 355 additions and 131 deletions
+1
View File
@@ -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',
+146
View File
@@ -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);
+24 -125
View File
@@ -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 <body> and becomes unclickable.
// 2. The dialog's outside-interaction handler (Radix's
// `usePointerDownOutside`) listens at document level and dismisses
// the dialog whenever a `pointerdown` lands outside the layer node.
// Our chrome is a sibling of <body>, so Radix classifies our clicks
// as outside and tears the dialog down mid-task.
//
// We can't reliably re-parent our chrome into the dialog subtree (z-index
// stacking, scroll containers, theming all become host-page concerns), so
// we defang both behaviors at our root:
//
// - `pointer-events: auto !important` overrides the inherited `none`.
// - Stop `pointerdown` / `mousedown` propagation so the document-level
// dismiss listener never fires for our clicks.
// - Stop `focusin` propagation so any focus shifts inside our chrome
// don't read as "focus moved outside the dialog" to focus traps.
//
// Click events still bubble normally - only the early pointer/focus
// signals that drive outside-interaction detection are silenced.
function defangOutsideHandlers(rootEl, { setPointerEvents = true } = {}) {
if (!rootEl) return;
if (setPointerEvents) {
rootEl.style.setProperty('pointer-events', 'auto', 'important');
}
const stop = (e) => e.stopPropagation();
rootEl.addEventListener('pointerdown', stop);
rootEl.addEventListener('mousedown', stop);
rootEl.addEventListener('focusin', stop);
}
//
// Highlight overlay
//
@@ -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' }),
]);
+158
View File
@@ -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);
});
});
+11 -4
View File
@@ -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\)/);
});
});
+14 -2
View File
@@ -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 () => {