Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 4 additions & 2 deletions PRODUCT_BRIEF.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,8 +19,10 @@ settings, and local or remote execution easier to operate without reimplementing
availability and behavior do not diverge.
- Quick Open searches through bounded authorized operations. Flutter never receives or chooses an
absolute path it does not already own.
- A visible text preview may be staged as reviewed context for a prompt. It does not become a second
runtime authority.
- The Universal Working Set lets a user deliberately stage exact material from a file preview,
transcript message, review diff, selected terminal text, or browser accessibility snapshot. The
user can inspect and remove each item before it joins one ordinary OMP prompt; it does not become
a second runtime authority.
- Light and dark themes use neutral surfaces with minimal semantic accent.
- OMP identity uses the existing pi/connector mark and Pi Pink `#e83174` accent.

Expand Down
49 changes: 41 additions & 8 deletions apps/desktop/src/browser-dom-automation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ interface SnapshotElement {
readonly ref: string;
readonly role: string;
readonly name: string;
readonly visible: true;
readonly text?: string;
readonly value?: string;
readonly bounds?: { x: number; y: number; width: number; height: number };
Expand Down Expand Up @@ -97,7 +98,15 @@ function accessibleName(element: Element): string {
if (placeholder) return bound(placeholder);
}
if (element instanceof HTMLImageElement && element.alt) return bound(element.alt);
return bound((element.textContent ?? "").replace(/\s+/gu, " ").trim(), 8_192);
const tag = element.tagName.toLowerCase();
const textNamedElement =
tag === "a" ||
tag === "button" ||
/^h[1-6]$/u.test(tag) ||
element.children.length === 0;
return textNamedElement
? bound((element.textContent ?? "").replace(/\s+/gu, " ").trim(), 8_192)
: "";
}
function elementRef(element: Element): string {
const old = elements.get(element);
Expand Down Expand Up @@ -139,21 +148,43 @@ function boundsOf(element: Element): { x: number; y: number; width: number; heig
}
function isVisible(element: Element): boolean {
if (!element.isConnected) return false;
const style = (element.ownerDocument.defaultView ?? window).getComputedStyle(element);
const view = element.ownerDocument.defaultView ?? window;
let current: Element | null = element;
while (current !== null) {
const style = view.getComputedStyle(current);
if (
current.hasAttribute("hidden") ||
current.getAttribute("aria-hidden") === "true" ||
style.display === "none" ||
style.visibility === "hidden" ||
style.visibility === "collapse" ||
Number(style.opacity) === 0
) {
return false;
}
current = current.parentElement;
}
const rect = element.getBoundingClientRect();
return style.display !== "none" && style.visibility !== "hidden" && style.visibility !== "collapse" && Number(style.opacity) !== 0 && rect.width > 0 && rect.height > 0;
return rect.width > 0 && rect.height > 0;
}
function isDisabled(element: Element): boolean {
return (element as HTMLButtonElement | HTMLInputElement | HTMLSelectElement | HTMLTextAreaElement).disabled === true || element.getAttribute("aria-disabled") === "true" || element.closest("fieldset[disabled]") !== null;
}
function snapshotNode(element: Element, depth: number, budget: { count: number }): SnapshotElement {
if (budget.count >= MAX_SNAPSHOT_ELEMENTS) return { ref: elementRef(element), role: roleFor(element), name: accessibleName(element) };
if (budget.count >= MAX_SNAPSHOT_ELEMENTS) {
return {
ref: elementRef(element),
role: roleFor(element),
name: accessibleName(element),
visible: true,
};
}
budget.count += 1;
const result: SnapshotElement = {
ref: elementRef(element), role: roleFor(element), name: accessibleName(element),
ref: elementRef(element), role: roleFor(element), name: accessibleName(element), visible: true,
...(element instanceof HTMLInputElement || element instanceof HTMLTextAreaElement || element instanceof HTMLSelectElement ? { value: bound(element.value) } : {}),
...(element.children.length === 0 && element.textContent?.trim() ? { text: bound(element.textContent.trim(), 8_192) } : {}),
...(isVisible(element) ? { bounds: boundsOf(element) } : {}),
bounds: boundsOf(element),
...(isDisabled(element) ? { disabled: true } : {}),
...(element instanceof HTMLInputElement && (element.type === "checkbox" || element.type === "radio") ? { checked: element.checked } : {}),
...(element.getAttribute("aria-expanded") !== null ? { expanded: element.getAttribute("aria-expanded") === "true" } : {}),
Expand All @@ -162,6 +193,7 @@ function snapshotNode(element: Element, depth: number, budget: { count: number }
const children: SnapshotElement[] = [];
for (const child of Array.from(element.children).slice(0, MAX_SNAPSHOT_ELEMENTS)) {
if (budget.count >= MAX_SNAPSHOT_ELEMENTS) break;
if (!isVisible(child)) continue;
children.push(snapshotNode(child, depth + 1, budget));
}
return children.length ? { ...result, children } : result;
Expand All @@ -172,14 +204,15 @@ function snapshot(): JsonValue {
const flat: SnapshotElement[] = [];
const visit = (element: Element): void => {
if (flat.length >= MAX_SNAPSHOT_ELEMENTS) return;
flat.push(snapshotNode(element, MAX_DEPTH, { count: 0 }));
if (isVisible(element)) flat.push(snapshotNode(element, MAX_DEPTH, { count: 0 }));
for (const child of Array.from(element.children)) visit(child);
};
visit(body);
const tree = isVisible(body) ? snapshotNode(body, 0, { count: 0 }) : null;
return json({
url: bound(doc.URL), title: bound(doc.title), readyState: doc.readyState,
viewport: { x: 0, y: 0, width: Math.max(0, window.innerWidth), height: Math.max(0, window.innerHeight) },
tree: snapshotNode(body, 0, { count: 0 }), elements: flat, capturedAt: Date.now(), truncated: flat.length >= MAX_SNAPSHOT_ELEMENTS,
tree, elements: flat, capturedAt: Date.now(), truncated: flat.length >= MAX_SNAPSHOT_ELEMENTS,
});
}
function postAction(params: Record<string, unknown>): Record<string, JsonValue> {
Expand Down
190 changes: 183 additions & 7 deletions apps/desktop/test/browser-dom-automation.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,19 +5,139 @@ import {
resetBrowserDomAutomation,
} from "../src/browser-dom-automation.ts";

const originalDocument = Object.getOwnPropertyDescriptor(globalThis, "document");
const originalWindow = Object.getOwnPropertyDescriptor(globalThis, "window");
const DOM_GLOBALS = [
"document",
"window",
"Element",
"HTMLElement",
"HTMLButtonElement",
"HTMLFrameElement",
"HTMLIFrameElement",
"HTMLImageElement",
"HTMLInputElement",
"HTMLSelectElement",
"HTMLTextAreaElement",
] as const;
const originalGlobals = new Map(
DOM_GLOBALS.map((name) => [name, Object.getOwnPropertyDescriptor(globalThis, name)]),
);

function exposeGlobal(name: "document" | "window", value: unknown): void {
function exposeGlobal(name: (typeof DOM_GLOBALS)[number], value: unknown): void {
Object.defineProperty(globalThis, name, { configurable: true, value, writable: true });
}

function restoreGlobals(): void {
resetBrowserDomAutomation();
if (originalDocument === undefined) Reflect.deleteProperty(globalThis, "document");
else Object.defineProperty(globalThis, "document", originalDocument);
if (originalWindow === undefined) Reflect.deleteProperty(globalThis, "window");
else Object.defineProperty(globalThis, "window", originalWindow);
for (const name of DOM_GLOBALS) {
const descriptor = originalGlobals.get(name);
if (descriptor === undefined) Reflect.deleteProperty(globalThis, name);
else Object.defineProperty(globalThis, name, descriptor);
}
}

interface FakeStyle {
readonly display: string;
readonly visibility: string;
readonly opacity: string;
}

class FakeElement {
readonly attributes = new Map<string, string>();
readonly children: FakeElement[] = [];
readonly isConnected = true;
readonly ownerDocument: FakeDocument;
readonly style: FakeStyle;
readonly tagName: string;
parentElement: FakeElement | null = null;
textContent: string;

constructor(
tagName: string,
ownerDocument: FakeDocument,
textContent = "",
style: FakeStyle = { display: "block", visibility: "visible", opacity: "1" },
) {
this.tagName = tagName;
this.ownerDocument = ownerDocument;
this.textContent = textContent;
this.style = style;
}

append(child: FakeElement): void {
child.parentElement = this;
this.children.push(child);
}

getAttribute(name: string): string | null {
return this.attributes.get(name) ?? null;
}

hasAttribute(name: string): boolean {
return this.attributes.has(name);
}

closest(): null {
return null;
}

getBoundingClientRect(): { x: number; y: number; width: number; height: number } {
return { x: 0, y: 0, width: 640, height: 24 };
}
}

class FakeHTMLElement extends FakeElement {}
class FakeHTMLButtonElement extends FakeHTMLElement {}
class FakeHTMLFrameElement extends FakeHTMLElement { readonly contentDocument = null; }
class FakeHTMLIFrameElement extends FakeHTMLElement { readonly contentDocument = null; }
class FakeHTMLImageElement extends FakeHTMLElement { readonly alt = ""; }
class FakeHTMLInputElement extends FakeHTMLElement {
readonly checked = false;
readonly disabled = false;
readonly id = "";
readonly type = "text";
readonly value: string;
constructor(tagName: string, ownerDocument: FakeDocument, value: string) {
super(tagName, ownerDocument);
this.value = value;
}
}
class FakeHTMLSelectElement extends FakeHTMLElement { readonly disabled = false; readonly value = ""; }
class FakeHTMLTextAreaElement extends FakeHTMLElement { readonly disabled = false; readonly value = ""; }

class FakeDocument {
readonly URL = "https://example.test/dashboard";
readonly defaultView: FakeWindow;
readonly readyState = "complete";
readonly title = "Dashboard";
body!: FakeHTMLElement;

constructor() {
this.defaultView = new FakeWindow();
}
}

class FakeWindow {
alert = () => undefined;
confirm = () => false;
prompt = () => null;
readonly innerHeight = 720;
readonly innerWidth = 1_280;
getComputedStyle(element: FakeElement): FakeStyle { return element.style; }
getSelection(): null { return null; }
}

function exposeSnapshotDom(document: FakeDocument): void {
exposeGlobal("document", document);
exposeGlobal("window", document.defaultView);
exposeGlobal("Element", FakeElement);
exposeGlobal("HTMLElement", FakeHTMLElement);
exposeGlobal("HTMLButtonElement", FakeHTMLButtonElement);
exposeGlobal("HTMLFrameElement", FakeHTMLFrameElement);
exposeGlobal("HTMLIFrameElement", FakeHTMLIFrameElement);
exposeGlobal("HTMLImageElement", FakeHTMLImageElement);
exposeGlobal("HTMLInputElement", FakeHTMLInputElement);
exposeGlobal("HTMLSelectElement", FakeHTMLSelectElement);
exposeGlobal("HTMLTextAreaElement", FakeHTMLTextAreaElement);
}

describe("browser DOM Design Mode", () => {
Expand Down Expand Up @@ -54,3 +174,59 @@ describe("browser DOM Design Mode", () => {
}
});
});

describe("browser DOM accessibility snapshot", () => {
it("keeps visible content, excludes hidden DOM, and avoids container text duplication", async () => {
const document = new FakeDocument();
const body = new FakeHTMLElement("BODY", document, "Visible account Hidden secret Password");
const main = new FakeHTMLElement("MAIN", document, "Visible account Hidden secret Password");
const heading = new FakeHTMLElement("H1", document, "Visible account");
const hidden = new FakeHTMLElement("DIV", document, "Hidden secret", {
display: "none",
visibility: "visible",
opacity: "1",
});
const input = new FakeHTMLInputElement("INPUT", document, "never-stage-this");
input.attributes.set("placeholder", "Password");
body.append(main);
main.append(heading);
main.append(hidden);
main.append(input);
document.body = body;

try {
exposeSnapshotDom(document);
const result = await executeBrowserDomAutomation("browser.snapshot", {});
const snapshot = (result as { readonly snapshot: { readonly elements: readonly Record<string, unknown>[] } }).snapshot;

expect(
snapshot.elements.some(
(element) =>
element.role === "heading" &&
element.name === "Visible account" &&
element.visible === true,
),
).toBe(true);
expect(
snapshot.elements.some(
(element) =>
element.role === "textbox" &&
element.name === "Password" &&
element.value === "never-stage-this" &&
element.visible === true,
),
).toBe(true);
expect(snapshot.elements.some((element) => element.name === "Hidden secret")).toBe(false);
expect(
snapshot.elements.some(
(element) =>
(element.role === "document" || element.role === "main") &&
typeof element.name === "string" &&
element.name.includes("Visible account"),
),
).toBe(false);
} finally {
restoreGlobals();
}
});
});
28 changes: 26 additions & 2 deletions apps/desktop/test/browser-runtime.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -141,7 +141,7 @@ async function settleBackgroundWork(): Promise<void> {
}

describe("BrowserRuntime native view lifecycle", () => {
it("routes design mode through the exact owner-scoped browser surface", async () => {
it("routes accessibility snapshots and design mode through the exact owner-scoped surface", async () => {
electron.reset();
const calls: unknown[] = [];
const runtime = new BrowserRuntime({
Expand All @@ -166,6 +166,15 @@ describe("BrowserRuntime native view lifecycle", () => {
automationCoordinator: {
call: async (call) => {
calls.push(call);
if (call.method === "browser.snapshot") {
return {
snapshot: {
url: "https://example.test/",
title: "Example",
elements: [{ role: "heading", name: "Visible heading", visible: true }],
},
};
}
return { enabled: true, selection: "Heading" };
},
dispose: () => {},
Expand All @@ -180,9 +189,19 @@ describe("BrowserRuntime native view lifecycle", () => {
surfaceId: created.surface.surfaceId,
enabled: true,
}));
const snapshot = await runtime.call(browserCall("browser.snapshot", {
surfaceId: created.surface.surfaceId,
}));

expect(result).toEqual({ enabled: true, selection: "Heading" });
expect(calls).toHaveLength(1);
expect(snapshot).toEqual({
snapshot: {
url: "https://example.test/",
title: "Example",
elements: [{ role: "heading", name: "Visible heading", visible: true }],
},
});
expect(calls).toHaveLength(2);
expect(calls[0]).toMatchObject({
method: "browser.design_mode.set",
ownerSessionId: OWNER_A,
Expand All @@ -191,6 +210,11 @@ describe("BrowserRuntime native view lifecycle", () => {
enabled: true,
},
});
expect(calls[1]).toMatchObject({
method: "browser.snapshot",
ownerSessionId: OWNER_A,
request: { surfaceId: created.surface.surfaceId },
});
await runtime.dispose();
});

Expand Down
Loading
Loading