feat(web): implement embedded browser module in Design Files workspace

- Added a `+` icon to the Design Files tab for opening a new Browser module.
- The Browser module supports navigation features including back, forward, refresh, and address input.
- Integrated a curated list of design reference URLs for user convenience.
- Implemented browser data clearing functionality via IPC.
- Enhanced desktop runtime to support embedded browser with appropriate security measures.
- Added tests for browser functionality and URL handling.

This commit establishes a new workspace for browsing and referencing design resources directly within the application, improving user experience and accessibility to design tools.
This commit is contained in:
pftom 2026-05-31 16:03:50 +08:00
parent cfde84b038
commit 6a5975d508
17 changed files with 2275 additions and 8 deletions

View file

@ -47,7 +47,12 @@ import {
// runtime. They are part of the security boundary for child-window
// navigation (see `setWindowOpenHandler` in `runtime.ts`), so
// pinning them is worth the small extra surface.
export { isAllowedChildWindowUrl, isHttpUrl, resolveDesktopStatusUrl } from "./runtime.js";
export {
isAllowedChildWindowUrl,
isAllowedEmbeddedBrowserUrl,
isHttpUrl,
resolveDesktopStatusUrl,
} from "./runtime.js";
// Re-export the path-validation helpers for the same reason (#974).
// shell.openPath is privileged main-process behaviour; pinning the

View file

@ -3,6 +3,7 @@ const { contextBridge, ipcRenderer } = require('electron');
import type {
OpenDesignHostBridge,
OpenDesignHostActionResult,
OpenDesignHostBrowserClearDataOptions,
OpenDesignHostFailure,
OpenDesignHostProjectImportResult,
OpenDesignHostProjectReplaceWorkingDirResult,
@ -188,6 +189,16 @@ const shell = {
},
};
const browser = {
clearData: async (options?: OpenDesignHostBrowserClearDataOptions): Promise<OpenDesignHostActionResult> => {
try {
return await ipcRenderer.invoke('browser:clear-data', options ?? null);
} catch (error) {
return actionFailure(reasonFromError(error));
}
},
};
function invokeUpdater(
action: 'check' | 'download' | 'install' | 'status',
options?: OpenDesignHostUpdaterActionOptions,
@ -232,6 +243,7 @@ const hostBridge = {
...(osLocale !== undefined ? { osLocale } : {}),
},
shell,
browser,
project,
pdf: {
print: async (html: string, nonce?: string, options?: PrintPdfOptions): Promise<OpenDesignHostActionResult> => {

View file

@ -4,7 +4,7 @@ import { appendFile, mkdir, realpath, stat, writeFile } from "node:fs/promises";
import { dirname, isAbsolute, join, resolve } from "node:path";
import { fileURLToPath } from "node:url";
import { BrowserWindow, app, dialog, ipcMain, nativeImage, screen, shell } from "electron";
import { BrowserWindow, app, dialog, ipcMain, nativeImage, screen, session, shell } from "electron";
import {
DESKTOP_UPDATE_CHANNELS,
DESKTOP_UPDATE_MODES,
@ -219,6 +219,7 @@ const DESKTOP_PET_WINDOW_WIDTH = 360;
const DESKTOP_PET_WINDOW_HEIGHT = 300;
const DESKTOP_PET_WINDOW_MARGIN = 24;
const UPDATER_STATUS_EVENT = "od:update:status-changed";
const DESIGN_BROWSER_PARTITION = "persist:open-design-design-browser";
const UPDATER_IPC_CHANNELS = [
"od:update:status",
"od:update:check",
@ -255,6 +256,16 @@ export type DesktopConsoleResult = {
entries: DesktopConsoleEntry[];
};
type DesktopBrowserStorageType =
| "cachestorage"
| "cookies"
| "filesystem"
| "indexdb"
| "localstorage"
| "serviceworkers"
| "shadercache"
| "websql";
export type DesktopClickInput = {
selector: string;
};
@ -772,6 +783,20 @@ export function isAllowedChildWindowUrl(url: string): boolean {
}
}
export function isAllowedEmbeddedBrowserUrl(url: string): boolean {
try {
const parsed = new URL(url);
return (
parsed.protocol === "http:" ||
parsed.protocol === "https:" ||
parsed.protocol === "file:" ||
(parsed.protocol === "about:" && parsed.pathname === "blank")
);
} catch {
return false;
}
}
export function resolveDesktopStatusUrl(currentUrl: string | null, pendingUrl: string | null): string | null {
return pendingUrl ?? currentUrl;
}
@ -1053,6 +1078,7 @@ export async function createDesktopRuntime(options: DesktopRuntimeOptions): Prom
ipcMain.removeHandler("dialog:pick-and-replace-working-dir");
ipcMain.removeHandler("shell:open-external");
ipcMain.removeHandler("shell:open-path");
ipcMain.removeHandler("browser:clear-data");
for (const channel of UPDATER_IPC_CHANNELS) {
ipcMain.removeHandler(channel);
}
@ -1230,6 +1256,7 @@ export async function createDesktopRuntime(options: DesktopRuntimeOptions): Prom
nodeIntegration: false,
preload: preloadPath,
sandbox: true,
webviewTag: true,
},
width: 1280,
});
@ -1258,9 +1285,53 @@ export async function createDesktopRuntime(options: DesktopRuntimeOptions): Prom
const unsubscribeUpdater = options.updater?.subscribe(() => sendUpdaterStatus()) ?? (() => undefined);
const requireMainWindowSender = (event: Electron.IpcMainInvokeEvent): void => {
if (event.sender !== window.webContents) {
throw new Error("updater IPC is only available to the main Open Design window");
throw new Error("host IPC is only available to the main Open Design window");
}
};
window.webContents.on("will-attach-webview", (event, webPreferences, params) => {
const src = typeof params.src === "string" ? params.src : "";
const partition = typeof params.partition === "string" ? params.partition : "";
if (!isAllowedEmbeddedBrowserUrl(src) || partition !== DESIGN_BROWSER_PARTITION) {
event.preventDefault();
return;
}
delete webPreferences.preload;
webPreferences.contextIsolation = true;
webPreferences.nodeIntegration = false;
webPreferences.sandbox = true;
});
ipcMain.handle("browser:clear-data", async (event, rawOptions: unknown): Promise<OpenDesignHostActionResult> => {
requireMainWindowSender(event);
const optionsRecord = rawOptions != null && typeof rawOptions === "object"
? rawOptions as { cookies?: unknown; storage?: unknown }
: {};
const clearCookies = optionsRecord.cookies !== false;
const clearStorage = optionsRecord.storage !== false;
const storages: DesktopBrowserStorageType[] = [];
if (clearCookies) storages.push("cookies");
if (clearStorage) {
storages.push(
"cachestorage",
"filesystem",
"indexdb",
"localstorage",
"shadercache",
"websql",
"serviceworkers",
);
}
try {
if (storages.length > 0) {
await session.fromPartition(DESIGN_BROWSER_PARTITION).clearStorageData({ storages });
}
return { ok: true };
} catch (error) {
return {
ok: false,
reason: error instanceof Error ? error.message : String(error),
};
}
});
ipcMain.handle("od:update:status", async (event) => {
requireMainWindowSender(event);
const status = await (options.updater?.status() ?? unavailableUpdaterStatus());
@ -1500,6 +1571,7 @@ export async function createDesktopRuntime(options: DesktopRuntimeOptions): Prom
for (const channel of UPDATER_IPC_CHANNELS) {
ipcMain.removeHandler(channel);
}
ipcMain.removeHandler("browser:clear-data");
if (!petWindow.isDestroyed()) petWindow.close();
if (!window.isDestroyed()) window.close();
},

View file

@ -18,6 +18,8 @@ describe("desktop preload host boundary", () => {
expect(source).toContain("OPEN_DESIGN_HOST_GLOBAL");
expect(source).toContain("exportDiagnostics");
expect(source).toContain("satisfies OpenDesignHostBridge");
expect(source).toContain("browser");
expect(source).toContain("browser:clear-data");
expect(source).toContain("updater");
// OS locale forwarded from main via webPreferences.additionalArguments
// is mirrored onto __od__.client.osLocale. Pin the literal prefix

View file

@ -20,6 +20,7 @@ vi.mock('electron', () => ({
BrowserWindow: class {},
dialog: { showOpenDialog: vi.fn() },
ipcMain: { handle: vi.fn(), removeHandler: vi.fn() },
session: { fromPartition: vi.fn() },
shell: { openExternal: vi.fn() },
app: { whenReady: vi.fn() },
}));
@ -28,6 +29,7 @@ import { describe, expect, it } from 'vitest';
import {
isAllowedChildWindowUrl,
isAllowedEmbeddedBrowserUrl,
isHttpUrl,
resolveDesktopStatusUrl,
} from '@open-design/desktop/main';
@ -96,6 +98,22 @@ describe('isAllowedChildWindowUrl (issue #911)', () => {
});
});
describe('isAllowedEmbeddedBrowserUrl', () => {
it('allows browser-tab page URLs and local files', () => {
expect(isAllowedEmbeddedBrowserUrl('https://example.com')).toBe(true);
expect(isAllowedEmbeddedBrowserUrl('http://127.0.0.1:17579/index.html')).toBe(true);
expect(isAllowedEmbeddedBrowserUrl('file:///Users/pftom/example.html')).toBe(true);
expect(isAllowedEmbeddedBrowserUrl('about:blank')).toBe(true);
});
it('rejects executable or privileged schemes for embedded browser startup', () => {
expect(isAllowedEmbeddedBrowserUrl('javascript:alert(1)')).toBe(false);
expect(isAllowedEmbeddedBrowserUrl('data:text/html,<script>alert(1)</script>')).toBe(false);
expect(isAllowedEmbeddedBrowserUrl('od://app/')).toBe(false);
expect(isAllowedEmbeddedBrowserUrl('not a url')).toBe(false);
});
});
describe('resolveDesktopStatusUrl', () => {
it('reports the pending URL while navigation is in flight', () => {
expect(resolveDesktopStatusUrl(null, 'od://app/')).toBe('od://app/');

View file

@ -0,0 +1,908 @@
import {
useCallback,
useEffect,
useMemo,
useRef,
useState,
type FormEvent,
} from 'react';
import {
clearHostBrowserData,
isOpenDesignHostAvailable,
} from '@open-design/host';
import {
openExternalUrl,
writeProjectBase64File,
writeProjectTextFile,
} from '../providers/registry';
import { Icon } from './Icon';
type BrowserHistoryEntry = {
title: string;
url: string;
lastVisitedAt: number;
visitCount: number;
};
type ReferenceSite = {
label: string;
url: string;
detail: string;
};
type ReferenceGroup = {
title: string;
sites: ReferenceSite[];
};
type PageBrief = {
title?: string;
url?: string;
description?: string;
headings?: string[];
images?: string[];
links?: { text: string; url: string }[];
colors?: { value: string; count: number }[];
};
type WebviewElement = HTMLElement & {
canGoBack(): boolean;
canGoForward(): boolean;
capturePage(): Promise<{ toDataURL(): string }>;
executeJavaScript<T = unknown>(code: string, userGesture?: boolean): Promise<T>;
getTitle(): string;
getURL(): string;
goBack(): void;
goForward(): void;
isLoading(): boolean;
reload(): void;
reloadIgnoringCache(): void;
};
type WebviewNavigationEvent = Event & {
isMainFrame?: boolean;
url?: string;
};
type WebviewTitleEvent = Event & {
explicitSet?: boolean;
title?: string;
};
interface DesignBrowserPanelProps {
projectId: string;
onOpenFile: (name: string) => void;
onRefreshFiles: () => Promise<void> | void;
}
const EMPTY_URL = 'about:blank';
const DESIGN_BROWSER_PARTITION = 'persist:open-design-design-browser';
const HISTORY_LIMIT = 80;
const REFERENCE_GROUPS: ReferenceGroup[] = [
{
title: 'Motion',
sites: [
{ label: 'GSAP', url: 'https://gsap.com/', detail: 'Production animation engine and examples.' },
{ label: 'Transitions', url: 'https://transitions.dev/', detail: 'Transition patterns for modern interfaces.' },
{ label: 'Motion Sites', url: 'https://motionsites.ai/', detail: 'High-end motion and interaction references.' },
{ label: 'Motion.page Showcase', url: 'https://motion.page/showcase/', detail: 'Scroll and timeline animation inspiration.' },
{ label: 'Animography', url: 'https://animography.net/', detail: 'Animated type and kinetic lettering.' },
],
},
{
title: 'Assets',
sites: [
{ label: 'The SVG', url: 'https://thesvg.org/', detail: 'SVG assets and vector references.' },
{ label: 'Unsplash', url: 'https://unsplash.com/', detail: 'Photography for visual direction.' },
{ label: 'Google Fonts', url: 'https://fonts.google.com/', detail: 'Typography exploration and pairing.' },
{ label: 'Whirrls', url: 'https://www.whirrls.com/', detail: 'Hand-drawn image references.' },
{ label: 'World in Dots', url: 'https://www.worldindots.com/', detail: 'Dot-map and data visualization references.' },
],
},
{
title: 'Systems',
sites: [
{ label: 'Styles Refero', url: 'https://styles.refero.design/', detail: 'Design style references and visual systems.' },
{ label: 'Brandfetch', url: 'https://brandfetch.com/', detail: 'Brand assets, logos, and company identity.' },
{ label: 'Toolfolio', url: 'https://toolfolio.io/', detail: 'Design tools, resources, and collections.' },
{ label: 'GetDesign', url: 'https://getdesign.md/', detail: 'Design resources and curated references.' },
{ label: 'Startups Gallery', url: 'https://startups.gallery/', detail: 'Top startup product and brand references.' },
],
},
];
const PAGE_BRIEF_SCRIPT = `(() => {
const clean = (value) => String(value || '').replace(/\\s+/g, ' ').trim();
const attr = (selector, name) => document.querySelector(selector)?.getAttribute(name) || '';
const headings = Array.from(document.querySelectorAll('h1, h2, h3'))
.map((node) => clean(node.textContent))
.filter(Boolean)
.slice(0, 18);
const links = Array.from(document.querySelectorAll('a[href]'))
.map((node) => ({ text: clean(node.textContent), url: node.href }))
.filter((item) => item.url && item.text)
.slice(0, 28);
const images = Array.from(document.images)
.map((image) => image.currentSrc || image.src)
.filter(Boolean)
.slice(0, 24);
const colorCounts = new Map();
const transparent = new Set(['rgba(0, 0, 0, 0)', 'transparent']);
for (const element of Array.from(document.querySelectorAll('body, body *')).slice(0, 700)) {
const style = getComputedStyle(element);
for (const prop of ['color', 'backgroundColor', 'borderColor']) {
const value = style[prop];
if (!value || transparent.has(value)) continue;
colorCounts.set(value, (colorCounts.get(value) || 0) + 1);
}
}
return {
title: clean(document.title),
url: location.href,
description: clean(attr('meta[name="description"]', 'content') || attr('meta[property="og:description"]', 'content')),
headings,
images,
links,
colors: Array.from(colorCounts.entries())
.sort((left, right) => right[1] - left[1])
.slice(0, 16)
.map(([value, count]) => ({ value, count })),
};
})()`;
export function DesignBrowserPanel({
projectId,
onOpenFile,
onRefreshFiles,
}: DesignBrowserPanelProps) {
const desktopHostAvailable = isOpenDesignHostAvailable();
// `loadUrl` is the navigation target bound to the <webview>/<iframe> `src`.
// It changes ONLY on user-initiated navigation. `currentUrl` is the committed
// location shown in the address bar and recorded in history, synced from the
// webview's own navigation events. They are deliberately separate: if `src`
// tracked every committed URL, a server redirect (e.g. adding a trailing
// slash) would mutate `src` mid-load and Electron would abort the in-flight
// navigation (ERR_ABORTED -3), leaving the page blank.
const [loadUrl, setLoadUrl] = useState(EMPTY_URL);
const [currentUrl, setCurrentUrl] = useState(EMPTY_URL);
const [addressValue, setAddressValue] = useState('');
const [history, setHistory] = useState<BrowserHistoryEntry[]>(() => loadHistory(projectId));
const [suggestionsOpen, setSuggestionsOpen] = useState(false);
const [menuOpen, setMenuOpen] = useState(false);
const [isLoading, setIsLoading] = useState(false);
const [canGoBack, setCanGoBack] = useState(false);
const [canGoForward, setCanGoForward] = useState(false);
const [webviewNode, setWebviewNode] = useState<WebviewElement | null>(null);
const [statusMessage, setStatusMessage] = useState<string | null>(null);
const [savingAction, setSavingAction] = useState<'brief' | 'screenshot' | 'task' | null>(null);
const chromeRef = useRef<HTMLDivElement | null>(null);
const assignWebviewNode = useCallback((node: HTMLWebViewElement | null) => {
// Set `allowpopups` imperatively rather than as a JSX prop. React's DOM
// renderer does not treat `allowpopups` as a known boolean attribute, so
// passing it through JSX logs "Received `true` for a non-boolean
// attribute" at runtime (only reproducible once the webview branch mounts
// in the desktop host). The attribute must still reach Electron's <webview>
// as a present string so the guest page may open popups.
if (node) node.setAttribute('allowpopups', 'true');
setWebviewNode(node as WebviewElement | null);
}, []);
useEffect(() => {
setHistory(loadHistory(projectId));
setLoadUrl(EMPTY_URL);
setCurrentUrl(EMPTY_URL);
setAddressValue('');
setCanGoBack(false);
setCanGoForward(false);
}, [projectId]);
useEffect(() => {
saveHistory(projectId, history);
}, [history, projectId]);
useEffect(() => {
if (!statusMessage) return;
const timer = window.setTimeout(() => setStatusMessage(null), 2600);
return () => window.clearTimeout(timer);
}, [statusMessage]);
useEffect(() => {
if (!menuOpen && !suggestionsOpen) return;
const onPointerDown = (event: PointerEvent) => {
const chrome = chromeRef.current;
if (chrome && event.target instanceof Node && chrome.contains(event.target)) return;
setMenuOpen(false);
setSuggestionsOpen(false);
};
document.addEventListener('pointerdown', onPointerDown);
return () => document.removeEventListener('pointerdown', onPointerDown);
}, [menuOpen, suggestionsOpen]);
const commitHistory = useCallback((url: string, title?: string) => {
if (!isHistoryUrl(url)) return;
setHistory((current) => {
const now = Date.now();
const existing = current.find((entry) => sameUrl(entry.url, url));
const nextTitle = title && title.trim() ? title.trim() : labelFromUrl(url);
const entry = existing
? { ...existing, title: nextTitle, lastVisitedAt: now, visitCount: existing.visitCount + 1 }
: { title: nextTitle, url, lastVisitedAt: now, visitCount: 1 };
return [entry, ...current.filter((item) => !sameUrl(item.url, url))]
.slice(0, HISTORY_LIMIT);
});
}, []);
const navigateTo = useCallback((rawAddress: string) => {
const nextUrl = normalizeBrowserAddress(rawAddress);
setLoadUrl(nextUrl);
setCurrentUrl(nextUrl);
setAddressValue(nextUrl === EMPTY_URL ? '' : nextUrl);
setSuggestionsOpen(false);
setMenuOpen(false);
if (isHistoryUrl(nextUrl)) commitHistory(nextUrl);
}, [commitHistory]);
const updateNavigationState = useCallback((node: WebviewElement | null = webviewNode) => {
if (!node) {
setCanGoBack(false);
setCanGoForward(false);
setIsLoading(false);
return;
}
// Electron's <webview> throws ("The WebView must be attached to the DOM and
// the dom-ready event emitted before this method can be called") when
// canGoBack/canGoForward/isLoading run before the guest attaches. The mount
// effect calls this immediately, so guard like safeGetWebviewUrl/Title do.
try {
setCanGoBack(Boolean(node.canGoBack()));
setCanGoForward(Boolean(node.canGoForward()));
setIsLoading(Boolean(node.isLoading()));
} catch {
// Pre-dom-ready: keep the existing (default) navigation state.
}
}, [webviewNode]);
useEffect(() => {
const node = webviewNode;
if (!node) return;
const syncFromWebview = (url?: string, title?: string) => {
const nextUrl = url || safeGetWebviewUrl(node);
if (nextUrl) {
setCurrentUrl(nextUrl);
setAddressValue(nextUrl === EMPTY_URL ? '' : nextUrl);
}
const nextTitle = title || safeGetWebviewTitle(node);
if (nextUrl) commitHistory(nextUrl, nextTitle);
updateNavigationState(node);
};
const onStart = () => {
setIsLoading(true);
updateNavigationState(node);
};
const onStop = () => {
setIsLoading(false);
syncFromWebview();
};
const onNavigate = (event: Event) => {
const navigationEvent = event as WebviewNavigationEvent;
if (navigationEvent.isMainFrame === false) return;
syncFromWebview(navigationEvent.url);
};
const onTitle = (event: Event) => {
const titleEvent = event as WebviewTitleEvent;
syncFromWebview(undefined, titleEvent.title);
};
const onFail = (event: Event) => {
const navigationEvent = event as WebviewNavigationEvent;
if (navigationEvent.isMainFrame === false) return;
setIsLoading(false);
updateNavigationState(node);
};
node.addEventListener('did-start-loading', onStart);
node.addEventListener('did-stop-loading', onStop);
node.addEventListener('did-navigate', onNavigate);
node.addEventListener('did-navigate-in-page', onNavigate);
node.addEventListener('page-title-updated', onTitle);
node.addEventListener('did-fail-load', onFail);
node.addEventListener('dom-ready', onStop);
updateNavigationState(node);
return () => {
node.removeEventListener('did-start-loading', onStart);
node.removeEventListener('did-stop-loading', onStop);
node.removeEventListener('did-navigate', onNavigate);
node.removeEventListener('did-navigate-in-page', onNavigate);
node.removeEventListener('page-title-updated', onTitle);
node.removeEventListener('did-fail-load', onFail);
node.removeEventListener('dom-ready', onStop);
};
}, [commitHistory, updateNavigationState, webviewNode]);
const suggestions = useMemo(() => {
const query = addressValue.trim().toLocaleLowerCase();
const referenceSuggestions = REFERENCE_GROUPS.flatMap((group) =>
group.sites.map((site) => ({
detail: `${group.title} - ${site.detail}`,
id: `site:${site.url}`,
label: site.label,
type: 'Reference' as const,
url: site.url,
})),
);
const historySuggestions = history.map((entry) => ({
detail: entry.url,
id: `history:${entry.url}`,
label: entry.title || labelFromUrl(entry.url),
type: 'History' as const,
url: entry.url,
}));
const all = [...historySuggestions, ...referenceSuggestions];
if (!query) return all.slice(0, 12);
return all
.filter((item) =>
`${item.label} ${item.url} ${item.detail}`.toLocaleLowerCase().includes(query),
)
.slice(0, 12);
}, [addressValue, history]);
const pageTitle = history.find((entry) => sameUrl(entry.url, currentUrl))?.title || labelFromUrl(currentUrl);
// Drive the start-page/webview branch off the load target, not the committed
// URL, so a transient about:blank navigation event can't unmount the webview.
const isBlank = loadUrl === EMPTY_URL;
async function handleAddressSubmit(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
navigateTo(addressValue);
}
async function copyCurrentUrl() {
const text = isBlank ? '' : currentUrl;
if (!text) {
setStatusMessage('No URL to copy');
return;
}
await copyText(text);
setStatusMessage('URL copied');
setMenuOpen(false);
}
async function openCurrentExternally() {
if (isBlank || !isHttpLikeUrl(currentUrl)) {
setStatusMessage('Open an http URL first');
return;
}
await openExternalUrl(currentUrl);
setMenuOpen(false);
}
async function takeScreenshot() {
if (!webviewNode || isBlank) {
setStatusMessage('Open a page before taking a screenshot');
return;
}
setSavingAction('screenshot');
try {
const image = await webviewNode.capturePage();
const base64 = image.toDataURL().split(',', 2)[1] ?? '';
const file = await writeProjectBase64File(
projectId,
browserFileName('browser-capture', currentUrl, 'png'),
base64,
);
if (!file) throw new Error('screenshot save failed');
await onRefreshFiles();
onOpenFile(file.name);
} catch (error) {
setStatusMessage(error instanceof Error ? error.message : 'Screenshot failed');
} finally {
setSavingAction(null);
setMenuOpen(false);
}
}
async function savePageBrief() {
if (!webviewNode || isBlank) {
setStatusMessage('Open a page before saving a brief');
return;
}
setSavingAction('brief');
try {
const brief = await webviewNode.executeJavaScript<PageBrief>(PAGE_BRIEF_SCRIPT, true);
const file = await writeProjectTextFile(
projectId,
browserFileName('browser-brief', currentUrl, 'md'),
pageBriefMarkdown(brief, currentUrl),
);
if (!file) throw new Error('brief save failed');
await onRefreshFiles();
onOpenFile(file.name);
} catch (error) {
setStatusMessage(error instanceof Error ? error.message : 'Brief save failed');
} finally {
setSavingAction(null);
setMenuOpen(false);
}
}
async function saveHarnessTask(url = currentUrl) {
if (url === EMPTY_URL) {
setStatusMessage('Open a page before creating a task');
return;
}
setSavingAction('task');
try {
const content = browserHarnessTaskMarkdown(projectId, url);
await copyText(content);
const file = await writeProjectTextFile(
projectId,
browserFileName('browser-harness-task', url, 'md'),
content,
);
if (!file) throw new Error('task save failed');
await onRefreshFiles();
onOpenFile(file.name);
} catch (error) {
setStatusMessage(error instanceof Error ? error.message : 'Task save failed');
} finally {
setSavingAction(null);
setMenuOpen(false);
}
}
async function clearCookies(storage: boolean) {
if (!desktopHostAvailable) {
setStatusMessage('Desktop browser data is unavailable here');
return;
}
const result = await clearHostBrowserData({ cookies: true, storage });
setStatusMessage(result.ok ? 'Browser data cleared' : result.reason);
if (storage) {
setHistory([]);
setLoadUrl(EMPTY_URL);
setCurrentUrl(EMPTY_URL);
setAddressValue('');
}
setMenuOpen(false);
}
function clearHistoryOnly() {
setHistory([]);
setStatusMessage('History cleared');
setMenuOpen(false);
}
function reload(hard = false) {
if (isBlank) return;
if (webviewNode) {
// Reload is enabled as soon as a URL is set, which can be before the
// <webview> emits dom-ready; reload()/reloadIgnoringCache() throw in that
// window. Guard so an early click can't crash the panel.
try {
if (hard) webviewNode.reloadIgnoringCache();
else webviewNode.reload();
} catch {
setLoadUrl((url) => `${url}${url.includes('?') ? '&' : '?'}odReload=${Date.now()}`);
}
} else {
setLoadUrl((url) => `${url}${url.includes('?') ? '&' : '?'}odReload=${Date.now()}`);
}
setMenuOpen(false);
}
return (
<section className="design-browser" aria-label="Design Browser">
<div className="db-chrome" ref={chromeRef}>
<div className="db-nav">
<button
type="button"
className="db-icon-btn"
aria-label="Back"
title="Back"
disabled={!canGoBack}
onClick={() => webviewNode?.goBack()}
>
<Icon name="chevron-left" size={16} />
</button>
<button
type="button"
className="db-icon-btn"
aria-label="Forward"
title="Forward"
disabled={!canGoForward}
onClick={() => webviewNode?.goForward()}
>
<Icon name="chevron-right" size={16} />
</button>
<button
type="button"
className={`db-icon-btn ${isLoading ? 'is-spinning' : ''}`}
aria-label="Reload"
title="Reload"
disabled={isBlank}
onClick={() => reload(false)}
>
<Icon name="reload" size={15} />
</button>
</div>
<form className="db-address-form" onSubmit={handleAddressSubmit}>
<Icon name="globe" size={15} />
<input
value={addressValue}
onChange={(event) => {
setAddressValue(event.target.value);
setSuggestionsOpen(true);
}}
onFocus={() => setSuggestionsOpen(true)}
placeholder="Enter URL or search..."
aria-label="Browser address"
autoComplete="off"
spellCheck={false}
/>
{suggestionsOpen && suggestions.length > 0 ? (
<div className="db-suggestions" role="listbox">
{suggestions.map((item) => (
<button
key={item.id}
type="button"
role="option"
onClick={() => navigateTo(item.url)}
>
<span className="db-suggestion-icon">
<Icon name={item.type === 'History' ? 'history' : 'sparkles'} size={14} />
</span>
<span className="db-suggestion-copy">
<span>{item.label}</span>
<small>{item.detail}</small>
</span>
<span className="db-suggestion-type">{item.type}</span>
</button>
))}
</div>
) : null}
</form>
<div className="db-actions">
<button
type="button"
className="db-icon-btn"
aria-label="Save page brief"
title="Save page brief"
disabled={isBlank || savingAction != null}
onClick={savePageBrief}
>
<Icon name="file-code" size={15} />
</button>
<button
type="button"
className="db-icon-btn"
aria-label="Browser menu"
title="Browser menu"
onClick={() => setMenuOpen((open) => !open)}
>
<Icon name="more-horizontal" size={16} />
</button>
{menuOpen ? (
<div className="db-menu" role="menu">
<button type="button" role="menuitem" onClick={takeScreenshot} disabled={isBlank || savingAction != null}>
<Icon name="image" size={14} />
Take Screenshot
</button>
<button type="button" role="menuitem" onClick={() => reload(true)} disabled={isBlank}>
<Icon name="reload" size={14} />
Hard Reload
</button>
<button type="button" role="menuitem" onClick={copyCurrentUrl} disabled={isBlank}>
<Icon name="copy" size={14} />
Copy URL
</button>
<button type="button" role="menuitem" onClick={openCurrentExternally} disabled={isBlank || !isHttpLikeUrl(currentUrl)}>
<Icon name="external-link" size={14} />
Open in Browser
</button>
<span className="db-menu-separator" />
<button type="button" role="menuitem" onClick={savePageBrief} disabled={isBlank || savingAction != null}>
<Icon name="file" size={14} />
Save Page Brief
</button>
<button type="button" role="menuitem" onClick={() => saveHarnessTask()} disabled={isBlank || savingAction != null}>
<Icon name="sparkles" size={14} />
Browser Harness Task
</button>
<span className="db-menu-separator" />
<button type="button" role="menuitem" onClick={clearHistoryOnly}>
<Icon name="history" size={14} />
Clear Browsing History
</button>
<button type="button" role="menuitem" onClick={() => void clearCookies(false)}>
<Icon name="trash" size={14} />
Clear Cookies
</button>
<button type="button" role="menuitem" onClick={() => void clearCookies(true)}>
<Icon name="trash" size={14} />
Clear All Data
</button>
</div>
) : null}
</div>
</div>
{statusMessage ? <div className="db-status">{statusMessage}</div> : null}
<div className="db-content">
{isBlank ? (
<DesignBrowserStart
onNavigate={navigateTo}
onSaveHarnessTask={saveHarnessTask}
savingTask={savingAction === 'task'}
/>
) : desktopHostAvailable ? (
<webview
ref={assignWebviewNode}
className="db-webview"
src={loadUrl}
partition={DESIGN_BROWSER_PARTITION}
title={pageTitle}
/>
) : (
<div className="db-fallback">
<iframe title={pageTitle} src={loadUrl} />
<div className="db-fallback-bar">
<span>Embedded browser controls are available in the desktop app.</span>
<button type="button" onClick={openCurrentExternally}>
<Icon name="external-link" size={14} />
Open
</button>
</div>
</div>
)}
</div>
</section>
);
}
function DesignBrowserStart({
onNavigate,
onSaveHarnessTask,
savingTask,
}: {
onNavigate: (url: string) => void;
onSaveHarnessTask: (url: string) => Promise<void>;
savingTask: boolean;
}) {
return (
<div className="db-start">
<div className="db-start-hero">
<div>
<div className="db-kicker">Open Design browser</div>
<h2>Reference, extract, apply.</h2>
</div>
<div className="db-agent-card">
<div className="db-agent-card-title">
<Icon name="sparkles" size={15} />
Browser Harness
</div>
<p>Turn any opened reference into a saved extraction task for browser-use and artifacts.</p>
</div>
</div>
<div className="db-reference-grid">
{REFERENCE_GROUPS.map((group) => (
<section key={group.title} className="db-reference-group">
<h3>{group.title}</h3>
<div className="db-reference-list">
{group.sites.map((site) => (
<article key={site.url} className="db-reference-card">
<button type="button" onClick={() => onNavigate(site.url)}>
<span>{site.label}</span>
<small>{new URL(site.url).hostname.replace(/^www\./, '')}</small>
</button>
<p>{site.detail}</p>
<div className="db-reference-actions">
<button type="button" onClick={() => onNavigate(site.url)}>
<Icon name="globe" size={13} />
Open
</button>
<button
type="button"
onClick={() => void onSaveHarnessTask(site.url)}
disabled={savingTask}
>
<Icon name="sparkles" size={13} />
Task
</button>
</div>
</article>
))}
</div>
</section>
))}
</div>
</div>
);
}
export function loadHistory(projectId: string): BrowserHistoryEntry[] {
if (typeof window === 'undefined') return [];
try {
const raw = window.localStorage.getItem(historyStorageKey(projectId));
const parsed = raw ? JSON.parse(raw) : [];
if (!Array.isArray(parsed)) return [];
return parsed
.filter(isHistoryEntry)
.sort((left, right) => right.lastVisitedAt - left.lastVisitedAt)
.slice(0, HISTORY_LIMIT);
} catch {
return [];
}
}
export function saveHistory(projectId: string, history: BrowserHistoryEntry[]) {
if (typeof window === 'undefined') return;
try {
window.localStorage.setItem(historyStorageKey(projectId), JSON.stringify(history.slice(0, HISTORY_LIMIT)));
} catch {
// Ignore storage quota and private-mode failures.
}
}
function historyStorageKey(projectId: string): string {
return `od:design-browser:${projectId}:history:v1`;
}
export function isHistoryEntry(value: unknown): value is BrowserHistoryEntry {
if (typeof value !== 'object' || value == null || Array.isArray(value)) return false;
const record = value as Record<string, unknown>;
return (
typeof record.url === 'string' &&
typeof record.title === 'string' &&
typeof record.lastVisitedAt === 'number' &&
typeof record.visitCount === 'number'
);
}
export function normalizeBrowserAddress(rawAddress: string): string {
const value = rawAddress.trim();
if (!value) return EMPTY_URL;
if (value === EMPTY_URL) return EMPTY_URL;
if (/^(https?|file):\/\//i.test(value)) return value;
if (/^localhost(:\d+)?(\/.*)?$/i.test(value)) return `http://${value}`;
if (/^(127\.0\.0\.1|0\.0\.0\.0)(:\d+)?(\/.*)?$/i.test(value)) return `http://${value}`;
if (value.startsWith('/')) {
if (/^\/(api|artifacts|frames)(\/|$)/.test(value) && typeof window !== 'undefined') {
return new URL(value, window.location.origin).toString();
}
return `file://${encodeURI(value)}`;
}
if (/^[\w.-]+\.[a-z]{2,}(:\d+)?(\/.*)?$/i.test(value)) return `https://${value}`;
return `https://www.google.com/search?q=${encodeURIComponent(value)}`;
}
export function labelFromUrl(url: string): string {
if (url === EMPTY_URL) return 'New Tab';
try {
const parsed = new URL(url);
return parsed.hostname.replace(/^www\./, '') || url;
} catch {
return url;
}
}
export function isHistoryUrl(url: string): boolean {
return url !== EMPTY_URL && (isHttpLikeUrl(url) || /^file:\/\//i.test(url));
}
function isHttpLikeUrl(url: string): boolean {
return /^https?:\/\//i.test(url);
}
export function sameUrl(left: string, right: string): boolean {
return left.replace(/\/+$/, '') === right.replace(/\/+$/, '');
}
function safeGetWebviewUrl(node: WebviewElement): string {
try {
return node.getURL();
} catch {
return '';
}
}
function safeGetWebviewTitle(node: WebviewElement): string {
try {
return node.getTitle();
} catch {
return '';
}
}
export function browserFileName(prefix: string, url: string, extension: 'md' | 'png'): string {
const host = labelFromUrl(url).replace(/[^a-z0-9._-]+/gi, '-').replace(/^-+|-+$/g, '') || 'page';
const stamp = new Date().toISOString().replace(/[:.]/g, '-');
return `browser/${prefix}-${host}-${stamp}.${extension}`;
}
export function pageBriefMarkdown(brief: PageBrief, fallbackUrl: string): string {
const title = brief.title || labelFromUrl(fallbackUrl);
const url = brief.url || fallbackUrl;
const lines = [
`# ${title}`,
'',
`Source: ${url}`,
'',
];
if (brief.description) {
lines.push('## Description', '', brief.description, '');
}
appendList(lines, 'Headings', brief.headings);
appendList(lines, 'Images', brief.images);
appendList(lines, 'Links', brief.links?.map((link) => `${link.text} - ${link.url}`));
appendList(lines, 'Colors', brief.colors?.map((color) => `${color.value} (${color.count})`));
lines.push('## Browser Harness follow-up', '', browserHarnessTaskMarkdown('', url).trim(), '');
return `${lines.join('\n').trim()}\n`;
}
function appendList(lines: string[], title: string, values?: string[]) {
const filtered = (values ?? []).map((value) => value.trim()).filter(Boolean);
if (filtered.length === 0) return;
lines.push(`## ${title}`, '');
for (const value of filtered) lines.push(`- ${value}`);
lines.push('');
}
export function browserHarnessTaskMarkdown(projectId: string, url: string): string {
const projectLine = projectId ? `Open Design project: ${projectId}` : 'Open Design project: current project';
return `# Browser Harness Design Extraction
Target URL: ${url}
${projectLine}
## Goal
Use browser-use/browser-harness to inspect the target page, extract the design language, and produce reusable Open Design artifacts.
## Capture
- Key screenshots for desktop and mobile.
- Typography, color palette, spacing rhythm, interaction and motion notes.
- Useful public assets, links, and implementation references.
- A concise design brief that can guide the next artifact iteration.
## Suggested command
\`\`\`bash
browser-harness <<'PY'
new_tab("${url}")
wait_for_load()
capture_screenshot(path="reference.png", full_page=True)
print(page_info())
print(js("""(() => ({
title: document.title,
headings: Array.from(document.querySelectorAll('h1,h2,h3')).map((node) => node.textContent.trim()).filter(Boolean).slice(0, 20),
colors: Array.from(new Set(Array.from(document.querySelectorAll('body,body *')).slice(0, 500).flatMap((node) => {
const style = getComputedStyle(node);
return [style.color, style.backgroundColor, style.borderColor].filter((value) => value && value !== 'rgba(0, 0, 0, 0)');
}))).slice(0, 20)
}))()"""))
PY
\`\`\`
`;
}
async function copyText(text: string): Promise<void> {
try {
await navigator.clipboard.writeText(text);
return;
} catch {
// Fall back for desktop/web contexts where clipboard permission is blocked.
}
const textarea = document.createElement('textarea');
textarea.value = text;
textarea.style.position = 'fixed';
textarea.style.left = '-9999px';
document.body.appendChild(textarea);
textarea.focus();
textarea.select();
try {
document.execCommand('copy');
} finally {
textarea.remove();
}
}

View file

@ -5,6 +5,7 @@ import {
useState,
type DragEvent as ReactDragEvent,
} from 'react';
import { createPortal } from 'react-dom';
import type { TrackingProjectKind } from '@open-design/contracts/analytics';
import { useAnalytics } from '../analytics/provider';
import {
@ -43,6 +44,7 @@ import {
type ProjectFile,
} from '../types';
import { DesignFilesPanel } from './DesignFilesPanel';
import { DesignBrowserPanel } from './DesignBrowserPanel';
import type { PluginFolderAgentAction } from './design-files/pluginFolderActions';
import { designSystemGithubEvidenceState, repoConnectCopy } from './design-system-github-evidence';
import { FileViewer, LiveArtifactViewer } from './FileViewer';
@ -123,6 +125,7 @@ interface SketchState {
const DESIGN_FILES_TAB = '__design_files__';
const DESIGN_SYSTEM_TAB = '__design_system__';
const BROWSER_TAB = '__browser__';
type TabDropEdge = 'before' | 'after';
type DesignSystemReviewDecision =
NonNullable<ProjectMetadata['designSystemReview']>[string]['decision'];
@ -251,12 +254,17 @@ export function FileWorkspace({
const [uploadError, setUploadError] = useState<string | null>(null);
const [sketches, setSketches] = useState<Record<string, SketchState>>({});
const [quickSwitcherOpen, setQuickSwitcherOpen] = useState(false);
const [browserTabOpen, setBrowserTabOpen] = useState(false);
const [addMenuOpen, setAddMenuOpen] = useState(false);
const [addMenuPos, setAddMenuPos] = useState<{ top: number; left: number } | null>(null);
const [draggedTabName, setDraggedTabName] = useState<string | null>(null);
const [dragOverTab, setDragOverTab] = useState<{
name: string;
edge: TabDropEdge;
} | null>(null);
const fileInputRef = useRef<HTMLInputElement | null>(null);
const addButtonRef = useRef<HTMLButtonElement | null>(null);
const addMenuRef = useRef<HTMLDivElement | null>(null);
const tabsBarRef = useRef<HTMLDivElement | null>(null);
const draggedTabNameRef = useRef<string | null>(null);
@ -277,11 +285,63 @@ export function FileWorkspace({
setActiveTab(tabsState.active ?? defaultRootTab);
}, [tabsState.active, defaultRootTab]);
useEffect(() => {
setBrowserTabOpen(false);
setAddMenuOpen(false);
}, [projectId]);
// The add-module menu is portaled to <body> so the tab strip's overflow
// clipping (overflow-x: auto turns .ws-tabs-bar into a scroll container that
// also clips vertically) cannot hide it — without this the menu opened but
// was clipped out of view, making the + button look dead. Because the menu
// lives outside .ws-add-tab now, position it manually from the button and
// treat both the button and the portaled menu as "inside" for dismissal.
useEffect(() => {
if (!addMenuOpen) {
setAddMenuPos(null);
return;
}
const updatePosition = () => {
const button = addButtonRef.current;
if (!button) return;
const rect = button.getBoundingClientRect();
setAddMenuPos({ top: rect.bottom + 8, left: rect.left });
};
updatePosition();
const onPointerDown = (event: PointerEvent) => {
const target = event.target;
if (!(target instanceof Node)) return;
if (addButtonRef.current?.contains(target)) return;
if (addMenuRef.current?.contains(target)) return;
setAddMenuOpen(false);
};
document.addEventListener('pointerdown', onPointerDown);
window.addEventListener('scroll', updatePosition, true);
window.addEventListener('resize', updatePosition);
return () => {
document.removeEventListener('pointerdown', onPointerDown);
window.removeEventListener('scroll', updatePosition, true);
window.removeEventListener('resize', updatePosition);
};
}, [addMenuOpen]);
function setPersistedActive(name: string | null) {
setActiveTab(name ?? defaultRootTab);
onTabsStateChange({ tabs: persistedTabs, active: name });
}
function openBrowserTab() {
setUploadError(null);
setBrowserTabOpen(true);
setActiveTab(BROWSER_TAB);
setAddMenuOpen(false);
}
function closeBrowserTab() {
setBrowserTabOpen(false);
if (activeTab === BROWSER_TAB) setActiveTab(DESIGN_FILES_TAB);
}
function activatePending(name: string) {
// Pending sketches are not in tabsState.tabs — flip the local
// activeTab without round-tripping through the parent.
@ -292,7 +352,7 @@ export function FileWorkspace({
// back to the last remaining tab. Skip transient activeTab values
// (DESIGN_FILES_TAB, pending sketches) since those aren't in persistedTabs.
useEffect(() => {
if (activeTab === DESIGN_FILES_TAB || activeTab === DESIGN_SYSTEM_TAB) return;
if (activeTab === DESIGN_FILES_TAB || activeTab === DESIGN_SYSTEM_TAB || activeTab === BROWSER_TAB) return;
if (sketches[activeTab] && !sketches[activeTab]!.persisted) return;
if (!persistedTabs.includes(activeTab)) {
setPersistedActive(persistedTabs[persistedTabs.length - 1] ?? null);
@ -499,7 +559,7 @@ export function FileWorkspace({
// The Design Files entry is already sticky-pinned, so we only scroll
// for real workspace tabs. Issue #775.
useEffect(() => {
if (activeTab === DESIGN_FILES_TAB || activeTab === DESIGN_SYSTEM_TAB) return;
if (activeTab === DESIGN_FILES_TAB || activeTab === DESIGN_SYSTEM_TAB || activeTab === BROWSER_TAB) return;
const tabBar = tabsBarRef.current;
if (!tabBar) return;
const el = tabBar.querySelector<HTMLElement>('.ws-tab.active');
@ -767,7 +827,7 @@ export function FileWorkspace({
}
const activeFile = useMemo<ProjectFile | null>(() => {
if (activeTab === DESIGN_FILES_TAB || activeTab === DESIGN_SYSTEM_TAB) return null;
if (activeTab === DESIGN_FILES_TAB || activeTab === DESIGN_SYSTEM_TAB || activeTab === BROWSER_TAB) return null;
const onDisk = visibleFiles.find((f) => f.name === activeTab);
if (onDisk) return onDisk;
if (isSketchName(activeTab) && sketches[activeTab]) {
@ -783,7 +843,7 @@ export function FileWorkspace({
}, [activeTab, visibleFiles, sketches]);
const activeLiveArtifact = useMemo<LiveArtifactWorkspaceEntry | null>(() => {
if (activeTab === DESIGN_FILES_TAB || activeTab === DESIGN_SYSTEM_TAB) return null;
if (activeTab === DESIGN_FILES_TAB || activeTab === DESIGN_SYSTEM_TAB || activeTab === BROWSER_TAB) return null;
return liveArtifactEntries.find((entry) => entry.tabId === activeTab) ?? null;
}, [activeTab, liveArtifactEntries]);
@ -871,6 +931,38 @@ export function FileWorkspace({
</span>
<span className="ws-tab-label">{t('workspace.designFiles')}</span>
</button>
<div className="ws-add-tab" ref={addMenuRef}>
<button
type="button"
className={`ws-tab-add ${addMenuOpen ? 'active' : ''}`}
aria-label="Add workspace module"
title="Add"
onClick={() => setAddMenuOpen((open) => !open)}
>
<Icon name="plus" size={15} />
</button>
{addMenuOpen ? (
<div className="ws-add-menu" role="menu">
<button type="button" role="menuitem" onClick={openBrowserTab}>
<Icon name="globe" size={15} />
<span>
<strong>Browser</strong>
<small>Reference sites and browser-harness tasks</small>
</span>
</button>
</div>
) : null}
</div>
{browserTabOpen ? (
<Tab
key={BROWSER_TAB}
label="Browser"
active={activeTab === BROWSER_TAB}
onActivate={() => setActiveTab(BROWSER_TAB)}
onClose={closeBrowserTab}
kind="browser"
/>
) : null}
{tabNames.map((name) => {
const sketchEntry = sketches[name];
const dirtyMark =
@ -1029,6 +1121,12 @@ export function FileWorkspace({
activePluginActionPaths={activePluginActionPaths}
hiddenPluginActionPaths={hiddenPluginActionPaths}
/>
) : activeTab === BROWSER_TAB && browserTabOpen ? (
<DesignBrowserPanel
projectId={projectId}
onRefreshFiles={onRefreshFiles}
onOpenFile={openFile}
/>
) : isActiveSketch && activeSketch && activeFile ? (
activeSketch.loaded ? (
<SketchEditor
@ -2486,7 +2584,7 @@ function Tab({
onActivate: () => void;
onClose?: () => void;
closable?: boolean;
kind?: ProjectFile['kind'] | 'live-artifact';
kind?: ProjectFile['kind'] | 'live-artifact' | 'browser';
liveArtifact?: LiveArtifactWorkspaceEntry;
draggable?: boolean;
dragging?: boolean;
@ -2595,10 +2693,12 @@ function kindIconName(
kind?: string,
):
| 'file-code'
| 'globe'
| 'image'
| 'pencil'
| 'file'
| null {
if (kind === 'browser') return 'globe';
if (kind === 'live-artifact') return 'file-code';
if (kind === 'html') return 'file-code';
if (kind === 'image') return 'image';

View file

@ -27,6 +27,7 @@ export type IconName =
| 'github'
| 'github-filled'
| 'grid'
| 'globe'
| 'hammer'
| 'help-circle'
| 'history'
@ -288,6 +289,14 @@ export function Icon({ name, size = 14, strokeWidth = 1.6, ...rest }: Props) {
<rect x="14" y="14" width="7" height="7" rx="1" />
</svg>
);
case 'globe':
return (
<svg {...common}>
<circle cx="12" cy="12" r="10" />
<path d="M2 12h20" />
<path d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10Z" />
</svg>
);
case 'puzzle':
return (
<svg {...common} fill="currentColor" stroke="none">

View file

@ -235,6 +235,431 @@
display: flex;
flex-direction: column;
}
/* Design browser workspace module. */
.design-browser {
flex: 1 1 auto;
min-height: 0;
display: flex;
flex-direction: column;
background: var(--bg);
color: var(--text);
}
.db-chrome {
position: relative;
display: grid;
grid-template-columns: auto minmax(220px, 1fr) auto;
align-items: center;
gap: 8px;
padding: 8px 12px;
border-bottom: 1px solid var(--border);
background: var(--bg-panel);
z-index: 3;
}
.db-nav,
.db-actions {
display: inline-flex;
align-items: center;
gap: 4px;
min-width: 0;
}
.db-actions {
position: relative;
justify-content: flex-end;
}
.db-icon-btn {
width: 30px;
height: 30px;
padding: 0;
border: 1px solid transparent;
border-radius: var(--radius-sm);
background: transparent;
color: var(--text-muted);
display: inline-flex;
align-items: center;
justify-content: center;
cursor: pointer;
transition: background 120ms cubic-bezier(0.23, 1, 0.32, 1), color 120ms cubic-bezier(0.23, 1, 0.32, 1);
}
.db-icon-btn:hover:not(:disabled) {
background: var(--bg-subtle);
color: var(--text);
}
.db-icon-btn:disabled {
cursor: default;
opacity: 0.38;
}
.db-icon-btn:focus-visible {
outline: 2px solid var(--accent);
outline-offset: 2px;
}
.db-icon-btn.is-spinning svg {
animation: db-spin 900ms linear infinite;
}
@keyframes db-spin {
to { transform: rotate(360deg); }
}
.db-address-form {
position: relative;
height: 34px;
min-width: 0;
border: 1px solid var(--border);
border-radius: var(--radius-sm);
background: var(--bg);
color: var(--text-muted);
display: flex;
align-items: center;
gap: 8px;
padding: 0 10px;
}
.db-address-form:focus-within {
border-color: var(--accent);
box-shadow: 0 0 0 2px color-mix(in srgb, var(--accent) 14%, transparent);
}
.db-address-form input {
flex: 1 1 auto;
min-width: 0;
height: 100%;
border: 0;
outline: 0;
background: transparent;
color: var(--text);
font: inherit;
font-size: 13px;
}
.db-address-form input::placeholder {
color: var(--text-faint);
}
.db-suggestions {
position: absolute;
top: calc(100% + 8px);
left: 0;
right: 0;
max-height: min(420px, calc(100vh - 160px));
padding: 6px;
border: 1px solid var(--border);
border-radius: var(--radius-sm);
background: var(--bg-panel);
box-shadow: var(--shadow-lg);
overflow-y: auto;
z-index: 220;
}
.db-suggestions button {
width: 100%;
min-height: 42px;
display: grid;
grid-template-columns: 24px minmax(0, 1fr) auto;
align-items: center;
gap: 9px;
border: 0;
border-radius: var(--radius-sm);
background: transparent;
color: var(--text);
padding: 7px 8px;
cursor: pointer;
text-align: left;
}
.db-suggestions button:hover {
background: var(--bg-subtle);
}
.db-suggestion-icon {
width: 24px;
height: 24px;
border-radius: var(--radius-sm);
background: var(--bg-subtle);
color: var(--text-muted);
display: inline-flex;
align-items: center;
justify-content: center;
}
.db-suggestion-copy {
min-width: 0;
display: flex;
flex-direction: column;
gap: 2px;
}
.db-suggestion-copy span,
.db-suggestion-copy small {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.db-suggestion-copy span {
font-size: 12.5px;
font-weight: 600;
}
.db-suggestion-copy small {
color: var(--text-muted);
font-size: 11px;
}
.db-suggestion-type {
color: var(--text-faint);
font-size: 10.5px;
}
.db-menu {
position: absolute;
top: calc(100% + 8px);
right: 0;
width: 248px;
padding: 6px;
border: 1px solid var(--border);
border-radius: var(--radius-sm);
background: var(--bg-panel);
box-shadow: var(--shadow-lg);
z-index: 230;
}
.db-menu button {
width: 100%;
min-height: 34px;
display: grid;
grid-template-columns: 20px minmax(0, 1fr);
align-items: center;
gap: 8px;
border: 0;
border-radius: var(--radius-sm);
background: transparent;
color: var(--text);
padding: 7px 8px;
text-align: left;
cursor: pointer;
font-size: 12.5px;
}
.db-menu button:hover:not(:disabled) {
background: var(--bg-subtle);
}
.db-menu button:disabled {
color: var(--text-faint);
cursor: default;
}
.db-menu-separator {
display: block;
height: 1px;
margin: 6px 2px;
background: var(--border);
}
.db-status {
min-height: 28px;
padding: 6px 14px;
border-bottom: 1px solid var(--border);
background: color-mix(in srgb, var(--bg-subtle) 72%, var(--bg-panel));
color: var(--text-muted);
font-size: 12px;
}
.db-content {
flex: 1 1 auto;
min-height: 0;
position: relative;
display: flex;
background: var(--bg);
}
.db-webview,
.db-fallback,
.db-fallback iframe {
flex: 1 1 auto;
min-width: 0;
min-height: 0;
width: 100%;
height: 100%;
border: 0;
}
.db-fallback {
position: relative;
display: flex;
background: var(--bg);
}
.db-fallback iframe {
background: #fff;
}
.db-fallback-bar {
position: absolute;
left: 16px;
right: 16px;
bottom: 16px;
min-height: 40px;
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 8px 10px 8px 14px;
border: 1px solid var(--border);
border-radius: var(--radius-sm);
background: color-mix(in srgb, var(--bg-panel) 92%, transparent);
color: var(--text-muted);
font-size: 12px;
box-shadow: var(--shadow-lg);
}
.db-fallback-bar button,
.db-reference-actions button {
min-height: 28px;
display: inline-flex;
align-items: center;
gap: 6px;
border: 1px solid var(--border);
border-radius: var(--radius-sm);
background: var(--bg-panel);
color: var(--text);
padding: 4px 9px;
font-size: 12px;
cursor: pointer;
}
.db-fallback-bar button:hover,
.db-reference-actions button:hover:not(:disabled) {
background: var(--bg-subtle);
}
.db-start {
flex: 1 1 auto;
min-width: 0;
min-height: 0;
overflow-y: auto;
padding: 28px;
background: var(--bg);
}
.db-start-hero {
display: grid;
grid-template-columns: minmax(0, 1fr) minmax(260px, 360px);
gap: 24px;
align-items: end;
padding: 10px 0 24px;
border-bottom: 1px solid var(--border);
}
.db-kicker {
color: var(--text-faint);
font-size: 11px;
letter-spacing: 0.1em;
text-transform: uppercase;
}
.db-start h2 {
max-width: 720px;
margin: 8px 0 0;
color: var(--text);
font-size: 46px;
line-height: 0.96;
letter-spacing: 0;
}
.db-agent-card {
border: 1px solid var(--border);
border-radius: 8px;
background: var(--bg-panel);
padding: 14px;
}
.db-agent-card-title {
display: flex;
align-items: center;
gap: 8px;
color: var(--text);
font-size: 13px;
font-weight: 700;
}
.db-agent-card p {
margin: 8px 0 0;
color: var(--text-muted);
font-size: 12.5px;
line-height: 1.45;
}
.db-reference-grid {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 18px;
padding-top: 22px;
}
.db-reference-group {
min-width: 0;
}
.db-reference-group h3 {
margin: 0 0 10px;
color: var(--text-muted);
font-size: 12px;
font-weight: 700;
letter-spacing: 0.08em;
text-transform: uppercase;
}
.db-reference-list {
display: grid;
grid-template-columns: minmax(0, 1fr);
gap: 10px;
}
.db-reference-card {
min-width: 0;
border: 1px solid var(--border);
border-radius: 8px;
background: var(--bg-panel);
padding: 12px;
}
.db-reference-card > button {
width: 100%;
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 10px;
border: 0;
background: transparent;
color: var(--text);
padding: 0;
cursor: pointer;
text-align: left;
}
.db-reference-card > button span,
.db-reference-card > button small {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.db-reference-card > button span {
font-size: 14px;
font-weight: 700;
}
.db-reference-card > button small {
color: var(--text-faint);
font-size: 11px;
}
.db-reference-card p {
margin: 8px 0 12px;
min-height: 36px;
color: var(--text-muted);
font-size: 12.5px;
line-height: 1.45;
}
.db-reference-actions {
display: flex;
align-items: center;
gap: 6px;
}
.db-reference-actions button:disabled {
opacity: 0.46;
cursor: default;
}
@media (max-width: 1020px) {
.db-reference-grid {
grid-template-columns: minmax(0, 1fr);
}
.db-reference-list {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
}
@media (max-width: 720px) {
.db-chrome {
grid-template-columns: minmax(0, 1fr);
}
.db-nav,
.db-actions {
justify-content: flex-start;
}
.db-start {
padding: 18px;
}
.db-start-hero {
grid-template-columns: minmax(0, 1fr);
}
.db-start h2 {
font-size: 34px;
}
.db-reference-list {
grid-template-columns: minmax(0, 1fr);
}
}
.df-kind-filter-list li + li {
border-top: 1px solid color-mix(in srgb, var(--border) 60%, transparent);
}

View file

@ -1497,6 +1497,83 @@
background: var(--bg-subtle);
}
.ws-add-tab {
position: relative;
flex: 0 0 auto;
display: inline-flex;
align-items: center;
z-index: 2;
}
.ws-tab-add {
width: 28px;
height: 28px;
padding: 0;
border: 1px solid transparent;
border-radius: var(--radius-sm);
background: transparent;
color: var(--text-muted);
display: inline-flex;
align-items: center;
justify-content: center;
cursor: pointer;
}
.ws-tab-add:hover,
.ws-tab-add.active {
background: var(--bg-subtle);
color: var(--text);
}
.ws-tab-add:focus-visible {
outline: 2px solid var(--accent);
outline-offset: 2px;
}
.ws-add-menu {
position: absolute;
top: calc(100% + 8px);
left: 0;
width: 260px;
padding: 6px;
border: 1px solid var(--border);
border-radius: var(--radius-sm);
background: var(--bg-panel);
box-shadow: var(--shadow-lg);
z-index: 180;
}
.ws-add-menu button {
width: 100%;
min-height: 46px;
border: 0;
border-radius: var(--radius-sm);
background: transparent;
color: var(--text);
display: grid;
grid-template-columns: 22px minmax(0, 1fr);
gap: 9px;
align-items: center;
padding: 8px;
text-align: left;
cursor: pointer;
}
.ws-add-menu button:hover {
background: var(--bg-subtle);
}
.ws-add-menu strong,
.ws-add-menu small {
display: block;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.ws-add-menu strong {
font-size: 12.5px;
font-weight: 600;
}
.ws-add-menu small {
margin-top: 2px;
color: var(--text-muted);
font-size: 11px;
}
.ws-tabs-actions { display: inline-flex; gap: 4px; align-items: center; }
.ws-focus-toggle {
display: inline-flex;

View file

@ -0,0 +1,265 @@
// @vitest-environment jsdom
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import {
browserFileName,
browserHarnessTaskMarkdown,
isHistoryEntry,
isHistoryUrl,
labelFromUrl,
loadHistory,
normalizeBrowserAddress,
pageBriefMarkdown,
sameUrl,
saveHistory,
} from '../../src/components/DesignBrowserPanel';
describe('normalizeBrowserAddress', () => {
it('passes through absolute http URLs unchanged', () => {
expect(normalizeBrowserAddress('http://example.com/page')).toBe('http://example.com/page');
});
it('passes through absolute https URLs unchanged', () => {
expect(normalizeBrowserAddress('https://example.com/page')).toBe('https://example.com/page');
});
it('passes through file URLs unchanged', () => {
expect(normalizeBrowserAddress('file:///Users/me/page.html')).toBe('file:///Users/me/page.html');
});
it('trims surrounding whitespace before matching', () => {
expect(normalizeBrowserAddress(' https://example.com ')).toBe('https://example.com');
});
it('promotes a bare domain to https', () => {
expect(normalizeBrowserAddress('example.com')).toBe('https://example.com');
});
it('promotes a bare domain with a path and port to https', () => {
expect(normalizeBrowserAddress('example.com:8080/path')).toBe('https://example.com:8080/path');
});
it('maps localhost to http', () => {
expect(normalizeBrowserAddress('localhost')).toBe('http://localhost');
expect(normalizeBrowserAddress('localhost:3000/dash')).toBe('http://localhost:3000/dash');
});
it('maps loopback IPs to http', () => {
expect(normalizeBrowserAddress('127.0.0.1')).toBe('http://127.0.0.1');
expect(normalizeBrowserAddress('127.0.0.1:5173')).toBe('http://127.0.0.1:5173');
expect(normalizeBrowserAddress('0.0.0.0:8000')).toBe('http://0.0.0.0:8000');
});
it('resolves /api, /artifacts, /frames paths against the page origin', () => {
const origin = window.location.origin;
expect(normalizeBrowserAddress('/api/runs')).toBe(`${origin}/api/runs`);
expect(normalizeBrowserAddress('/artifacts/x.png')).toBe(`${origin}/artifacts/x.png`);
expect(normalizeBrowserAddress('/frames/1')).toBe(`${origin}/frames/1`);
});
it('maps other absolute paths to file URLs', () => {
expect(normalizeBrowserAddress('/Users/me/page.html')).toBe('file:///Users/me/page.html');
expect(normalizeBrowserAddress('/some path/with space')).toBe(`file://${encodeURI('/some path/with space')}`);
});
it('treats free text as a Google search', () => {
expect(normalizeBrowserAddress('design inspiration')).toBe(
'https://www.google.com/search?q=design%20inspiration',
);
});
it('maps an empty string to about:blank', () => {
expect(normalizeBrowserAddress('')).toBe('about:blank');
expect(normalizeBrowserAddress(' ')).toBe('about:blank');
});
it('passes through an explicit about:blank', () => {
expect(normalizeBrowserAddress('about:blank')).toBe('about:blank');
});
});
describe('sameUrl', () => {
it('treats trailing slashes as equivalent', () => {
expect(sameUrl('https://example.com', 'https://example.com/')).toBe(true);
expect(sameUrl('https://example.com///', 'https://example.com')).toBe(true);
});
it('distinguishes different paths', () => {
expect(sameUrl('https://example.com/a', 'https://example.com/b')).toBe(false);
});
});
describe('labelFromUrl', () => {
it('returns New Tab for the blank URL', () => {
expect(labelFromUrl('about:blank')).toBe('New Tab');
});
it('strips the www. prefix from the host', () => {
expect(labelFromUrl('https://www.example.com/page')).toBe('example.com');
expect(labelFromUrl('https://sub.example.com/')).toBe('sub.example.com');
});
it('falls back to the raw value when the URL cannot be parsed', () => {
expect(labelFromUrl('not a url')).toBe('not a url');
});
});
describe('isHistoryUrl', () => {
it('accepts http(s) and file URLs', () => {
expect(isHistoryUrl('https://example.com')).toBe(true);
expect(isHistoryUrl('http://localhost:3000')).toBe(true);
expect(isHistoryUrl('file:///Users/me/x.html')).toBe(true);
});
it('rejects the blank URL', () => {
expect(isHistoryUrl('about:blank')).toBe(false);
});
it('rejects non http/file schemes', () => {
expect(isHistoryUrl('data:text/html,hi')).toBe(false);
expect(isHistoryUrl('mailto:hi@example.com')).toBe(false);
});
});
describe('browserHarnessTaskMarkdown', () => {
it('embeds the target URL and the browser-harness command', () => {
const md = browserHarnessTaskMarkdown('proj-123', 'https://example.com/ref');
expect(md).toContain('Target URL: https://example.com/ref');
expect(md).toContain('Open Design project: proj-123');
expect(md).toContain('browser-harness');
expect(md).toContain('new_tab("https://example.com/ref")');
});
it('uses the current-project fallback line when projectId is empty', () => {
const md = browserHarnessTaskMarkdown('', 'https://example.com/ref');
expect(md).toContain('Open Design project: current project');
expect(md).not.toContain('Open Design project: \n');
});
});
describe('pageBriefMarkdown', () => {
it('renders title, source, and populated sections while skipping empty ones', () => {
const md = pageBriefMarkdown(
{
title: 'Example',
url: 'https://example.com',
description: 'A description',
headings: ['Hero', ' ', 'Features'],
images: [],
links: [{ text: 'Docs', url: 'https://example.com/docs' }],
colors: [{ value: 'rgb(0, 0, 0)', count: 4 }],
},
'https://fallback.example.com',
);
expect(md).toContain('# Example');
expect(md).toContain('Source: https://example.com');
expect(md).toContain('## Description');
expect(md).toContain('## Headings');
expect(md).toContain('- Hero');
expect(md).toContain('- Features');
expect(md).not.toContain('## Images');
expect(md).toContain('## Links');
expect(md).toContain('- Docs - https://example.com/docs');
expect(md).toContain('## Colors');
expect(md).toContain('- rgb(0, 0, 0) (4)');
expect(md).toContain('## Browser Harness follow-up');
});
it('falls back to label and url when the brief omits them', () => {
const md = pageBriefMarkdown({}, 'https://www.fallback.example.com/path');
expect(md).toContain('# fallback.example.com');
expect(md).toContain('Source: https://www.fallback.example.com/path');
});
});
describe('browserFileName', () => {
it('sanitizes the host and includes the prefix and extension', () => {
const name = browserFileName('browser-capture', 'https://www.example.com/page', 'png');
expect(name).toMatch(/^browser\/browser-capture-example\.com-[\dTZ-]+\.png$/);
});
it('uses a page fallback when the host sanitizes to empty', () => {
const name = browserFileName('browser-brief', 'about:blank', 'md');
// about:blank -> labelFromUrl 'New Tab' -> 'New-Tab'
expect(name).toMatch(/^browser\/browser-brief-New-Tab-[\dTZ-]+\.md$/);
});
});
describe('isHistoryEntry', () => {
it('accepts a well-formed entry', () => {
expect(
isHistoryEntry({ url: 'https://x', title: 'X', lastVisitedAt: 1, visitCount: 1 }),
).toBe(true);
});
it('rejects malformed values', () => {
expect(isHistoryEntry(null)).toBe(false);
expect(isHistoryEntry([])).toBe(false);
expect(isHistoryEntry('x')).toBe(false);
expect(isHistoryEntry({ url: 1, title: 'X', lastVisitedAt: 1, visitCount: 1 })).toBe(false);
expect(isHistoryEntry({ url: 'x', title: 'X', lastVisitedAt: 1 })).toBe(false);
});
});
describe('loadHistory / saveHistory round-trip', () => {
const projectId = 'proj-history';
beforeEach(() => {
window.localStorage.clear();
});
afterEach(() => {
window.localStorage.clear();
});
it('returns an empty array when nothing is stored', () => {
expect(loadHistory(projectId)).toEqual([]);
});
it('round-trips entries and sorts by lastVisitedAt descending', () => {
saveHistory(projectId, [
{ url: 'https://a.com', title: 'A', lastVisitedAt: 100, visitCount: 1 },
{ url: 'https://b.com', title: 'B', lastVisitedAt: 300, visitCount: 2 },
{ url: 'https://c.com', title: 'C', lastVisitedAt: 200, visitCount: 1 },
]);
const loaded = loadHistory(projectId);
expect(loaded.map((entry) => entry.url)).toEqual([
'https://b.com',
'https://c.com',
'https://a.com',
]);
});
it('drops malformed entries on load', () => {
window.localStorage.setItem(
`od:design-browser:${projectId}:history:v1`,
JSON.stringify([
{ url: 'https://ok.com', title: 'OK', lastVisitedAt: 1, visitCount: 1 },
{ url: 123, title: 'bad', lastVisitedAt: 1, visitCount: 1 },
]),
);
const loaded = loadHistory(projectId);
expect(loaded).toHaveLength(1);
expect(loaded[0]?.url).toBe('https://ok.com');
});
it('returns an empty array for corrupt or non-array JSON', () => {
const key = `od:design-browser:${projectId}:history:v1`;
window.localStorage.setItem(key, 'not json');
expect(loadHistory(projectId)).toEqual([]);
window.localStorage.setItem(key, JSON.stringify({ not: 'an array' }));
expect(loadHistory(projectId)).toEqual([]);
});
it('caps stored history at the HISTORY_LIMIT on save and load', () => {
const many = Array.from({ length: 120 }, (_, index) => ({
url: `https://site-${index}.com`,
title: `Site ${index}`,
lastVisitedAt: index,
visitCount: 1,
}));
saveHistory(projectId, many);
expect(loadHistory(projectId)).toHaveLength(80);
});
});

View file

@ -0,0 +1,103 @@
// @vitest-environment jsdom
import { cleanup, fireEvent, render, screen } from '@testing-library/react';
import { act } from 'react';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { installMockOpenDesignHost } from '@open-design/host/testing';
import { DesignBrowserPanel } from '../../src/components/DesignBrowserPanel';
// The panel imports these writers from the registry at module load; stub them so
// rendering never reaches the network.
vi.mock('../../src/providers/registry', async () => {
const actual = await vi.importActual<typeof import('../../src/providers/registry')>(
'../../src/providers/registry',
);
return {
...actual,
openExternalUrl: vi.fn(async () => true),
writeProjectTextFile: vi.fn(async () => null),
writeProjectBase64File: vi.fn(async () => null),
};
});
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
let restoreHost: (() => void) | null = null;
beforeEach(() => {
window.localStorage.clear();
// Makes isOpenDesignHostAvailable() true so the panel renders the desktop
// <webview> branch (rather than the iframe fallback).
restoreHost = installMockOpenDesignHost();
});
afterEach(() => {
cleanup();
restoreHost?.();
restoreHost = null;
window.localStorage.clear();
});
function dispatchWebviewNavigate(webview: HTMLElement, url: string) {
act(() => {
const event = new Event('did-navigate') as Event & { url?: string; isMainFrame?: boolean };
event.url = url;
event.isMainFrame = true;
webview.dispatchEvent(event);
});
}
describe('DesignBrowserPanel <webview> navigation', () => {
it('pins the webview src to the load target when the guest commits a redirected URL', () => {
// Regression guard for the blank-page bug: the embedded <webview> rendered
// but never painted because did-navigate fed the committed (trailing-slash)
// URL straight back into the src prop, so Electron re-navigated and aborted
// the in-flight load (ERR_ABORTED -3). The load target (src) must stay put
// while only the address bar follows the committed URL.
const { container } = render(
<DesignBrowserPanel projectId="proj-webview" onOpenFile={() => {}} onRefreshFiles={() => {}} />,
);
const input = screen.getByLabelText('Browser address') as HTMLInputElement;
fireEvent.change(input, { target: { value: 'example.com' } });
fireEvent.submit(input.closest('form')!);
const webview = container.querySelector('webview.db-webview') as HTMLElement | null;
expect(webview).not.toBeNull();
// The bare domain is normalized to https and becomes the load target.
expect(webview!.getAttribute('src')).toBe('https://example.com');
expect(input.value).toBe('https://example.com');
// The guest commits a redirect that appends a trailing slash.
dispatchWebviewNavigate(webview!, 'https://example.com/');
// The address bar follows the committed URL...
expect(input.value).toBe('https://example.com/');
// ...but the src remains the original target, so no abort/reload loop.
expect(webview!.getAttribute('src')).toBe('https://example.com');
});
it('changes the src only when the user navigates to a new target', () => {
const { container } = render(
<DesignBrowserPanel projectId="proj-webview-2" onOpenFile={() => {}} onRefreshFiles={() => {}} />,
);
const input = screen.getByLabelText('Browser address') as HTMLInputElement;
fireEvent.change(input, { target: { value: 'https://gsap.com' } });
fireEvent.submit(input.closest('form')!);
const webview = container.querySelector('webview.db-webview') as HTMLElement;
expect(webview.getAttribute('src')).toBe('https://gsap.com');
// An in-page navigation event must not move the load target.
dispatchWebviewNavigate(webview, 'https://gsap.com/docs/');
expect(webview.getAttribute('src')).toBe('https://gsap.com');
expect(input.value).toBe('https://gsap.com/docs/');
// A fresh user navigation does move it.
fireEvent.change(input, { target: { value: 'unsplash.com' } });
fireEvent.submit(input.closest('form')!);
expect(webview.getAttribute('src')).toBe('https://unsplash.com');
});
});

View file

@ -830,3 +830,40 @@ describe('FileWorkspace sketch save', () => {
expect(btn.querySelector('svg')).not.toBeNull();
});
});
describe('FileWorkspace add-module menu', () => {
it('opens the add-module menu so the + button reveals the Browser option', () => {
render(
<FileWorkspace
projectId="project-1"
projectKind="prototype"
files={[]}
liveArtifacts={[]}
onRefreshFiles={vi.fn()}
isDeck={false}
tabsState={{ tabs: [], active: null }}
onTabsStateChange={vi.fn()}
/>,
);
const addButton = screen.getByRole('button', { name: 'Add workspace module' });
expect(addButton.getAttribute('aria-expanded')).toBe('false');
act(() => {
fireEvent.click(addButton);
});
expect(addButton.getAttribute('aria-expanded')).toBe('true');
const browserItem = screen.getByRole('menuitem', { name: /Browser/ });
const menu = browserItem.closest('.ws-add-menu');
expect(menu).not.toBeNull();
// The tab strip is a scroll container (overflow-x: auto turns it into one
// that also clips vertically), so a menu nested inside it would be clipped
// out of view — the bug that made the + button look dead. The menu must be
// portaled out of the clipping bar to stay visible.
const tabsBar = document.querySelector('.ws-tabs-bar');
expect(tabsBar).not.toBeNull();
expect(tabsBar!.contains(menu)).toBe(false);
});
});

View file

@ -0,0 +1,196 @@
# Design Browser Task Handoff
Generated from this chat session on 2026-05-30.
## Repository
- Worktree: `/Users/pftom/.superset/worktrees/d3aab1a3-c696-403f-9692-7e5bc2dfa1f3/accidental-bolt`
- Product area: Open Design `Design Files` workspace, embedded browser module, desktop host bridge, browser-harness task entry.
## User Queries, In Order
1. Initial feature request:
> `[Image #1]` 在 design files 那一排支持一个 plus icon, 增加一个类似 `[Image #2]` 的 browser 模块,然后可以打开浏览器, 支持 `[Image #3]` 如图的能力, 包括上一页/下一页/刷新/输入地址栏/支持展示/搜索和选择历史地址打开各种能在浏览器打开的文件如本地文件/各种服务文件/网站 `[Image #4]` 如图也支持一些 clear/open in browser /等截图的能力
>
> 参考 https://github.com/superset-sh/superset 代码, 里面已经实现了完整的能力了,直接搬过来,确保在 open-design 里面完美适配和使用, 就像内嵌了一个真实的浏览器, 然后那个地址栏 + 浏览器空白页可以给大量的推荐网址, 方便做设计的参考,做的好看/酷/世界级设计
>
> 比如 svg 的 https://thesvg.org/
> https://unsplash.com/ 图片的
> https://motionsites.ai/ 各种高级网站参考 https://motion.page/showcase/
> https://styles.refero.design/ 借鉴设计风格
2. Add blank-page recommendation:
> 浏览器空白页网址继续添加:
>
> - https://brandfetch.com/
3. Agent/browser-harness integration direction:
> 然后理论上应该结合 agent 的能力 和 https://github.com/browser-use/browser-harness , 以及结合对 browser 的控制和能力, 增加一些快捷的入口或者操作, 能够让用户轻松通过 browser usebrowser harness能够把这些网站的内容搞下来然后应用在用户自己的设计 artifacts 里面, 这样能够极大的增长用户的设计水准
4. User supplied repo instructions:
> `# AGENTS.md instructions for /Users/pftom/.superset/worktrees/d3aab1a3-c696-403f-9692-7e5bc2dfa1f3/accidental-bolt`
>
> The full root `AGENTS.md` content was pasted into the chat. Important for next agent: follow the real repo `AGENTS.md`, plus `apps/AGENTS.md` and `packages/AGENTS.md` before touching those directories.
5. Add motion site:
> 网址增加 https://gsap.com/ 做动效的
6. Add transition and font references:
> 网址添加 https://transitions.dev/ 过渡动画/ 以及字体:https://fonts.google.com/
7. Add text animation reference:
> 文字动画:https://animography.net/
8. Add resource collection:
> 各种集合站和资源:https://toolfolio.io/
9. Add hand-drawn image reference:
> 手绘图像: https://www.whirrls.com/
10. Add startup inspiration reference:
> 各种顶尖的创业公司: https://startups.gallery/
11. Add dot-map visualization reference:
> 世界点状图:https://www.worldindots.com/
12. Add design resource URL:
> 添加 网址: getdesign.md
13. Verification request:
> check 一下整体能力真的实现和跑通了吗
14. Handoff request:
> 吧我这个 task/会话所有的历史的 query 都帮我提取出来放到一个文件里, 我要 handoff 任务给下一个 agent
## Derived Acceptance Requirements
- Add a `+` icon in the Design Files tab row.
- `+` opens a module menu with a Browser module.
- Browser module opens as a workspace tab.
- Browser supports back, forward, refresh, hard reload, address/search input, history suggestions, and reference-site suggestions.
- Browser can open websites and local/service URLs where supported by the embedded runtime.
- Browser menu supports:
- Take Screenshot
- Hard Reload
- Copy URL
- Open in Browser
- Clear Browsing History
- Clear Cookies
- Clear All Data
- Blank page includes curated design-reference recommendations:
- https://thesvg.org/
- https://unsplash.com/
- https://motionsites.ai/
- https://motion.page/showcase/
- https://styles.refero.design/
- https://brandfetch.com/
- https://gsap.com/
- https://transitions.dev/
- https://fonts.google.com/
- https://animography.net/
- https://toolfolio.io/
- https://www.whirrls.com/
- https://startups.gallery/
- https://www.worldindots.com/
- https://getdesign.md/
- Add browser-use/browser-harness oriented shortcut/task entry so a user can extract page screenshots/design language/assets and apply them to Open Design artifacts.
## Current Implementation Status
Files changed so far:
- `apps/web/src/components/DesignBrowserPanel.tsx`
- `apps/web/src/components/FileWorkspace.tsx`
- `apps/web/src/components/Icon.tsx`
- `apps/web/src/styles/workspace/design-files.css`
- `apps/web/src/styles/workspace/drawer.css`
- `apps/desktop/src/main/index.ts`
- `apps/desktop/src/main/preload.cts`
- `apps/desktop/src/main/runtime.ts`
- `apps/desktop/tests/main/preload-host-boundary.test.ts`
- `apps/packaged/tests/desktop-url-allowlist.test.ts`
- `packages/host/src/index.ts`
- `packages/host/src/testing.ts`
- `packages/host/tests/index.test.ts`
Implemented behavior:
- Design Files tab strip now has a `+` button.
- `+` menu can open a local `Browser` tab.
- Browser panel has address input, suggestions, history persistence, back/forward/reload controls, menu actions, reference cards, screenshot saving, page brief saving, and browser-harness task saving.
- Desktop runtime enables Electron `webviewTag` only for the main window and validates embedded browser startup URLs.
- Desktop host bridge exposes browser data clearing for the dedicated browser partition.
- Host package has helper `clearHostBrowserData`.
## Verification Status
Passed:
- `pnpm --filter @open-design/web typecheck`
- `pnpm --filter @open-design/desktop typecheck`
- `pnpm --filter @open-design/host typecheck`
- `pnpm --filter @open-design/host test`
- `pnpm --filter @open-design/packaged test -- desktop-url-allowlist`
- Direct targeted web tests from `apps/web`:
- `pnpm exec vitest run -c vitest.config.ts tests/components/FileWorkspace.test.tsx tests/components/FileWorkspace.design-system.test.tsx`
- Result: 2 files passed, 38 tests passed.
- Root `pnpm typecheck` completed successfully. It emitted existing landing-page warnings/hints but no errors.
Not fully verified:
- `pnpm guard` did not run in this sandbox because `tsx` failed to create its IPC pipe with `EPERM`.
- `pnpm tools-dev run web --daemon-port 17456 --web-port 17573` did not start in this sandbox for the same `tsx` IPC pipe `EPERM`.
- No final visual/browser verification was completed because local runtime startup was blocked by the sandbox.
Observed unrelated test friction:
- Running `pnpm --filter @open-design/web test -- FileWorkspace` or passing paths through the package script unexpectedly exercised the full web test set. It hit unrelated failures/timeouts in `SettingsDialog.execution.test.tsx` or `ExamplesTab.test.tsx`.
- Direct `pnpm exec vitest ...` from `apps/web` correctly scoped to FileWorkspace and passed.
## Next Agent Suggested Checks
- Run `pnpm guard` outside this sandbox or in an environment where `tsx` can create its local IPC pipe.
- Start `pnpm tools-dev run web --daemon-port <free> --web-port <free>` and visually check:
- Design Files row shows the `+` icon.
- `+` opens Browser.
- Blank browser page shows all requested URLs.
- Address input navigates to at least one external site and one local/service URL.
- History suggestions appear after navigation.
- Browser menu actions render and enabled/disabled states make sense.
- In desktop runtime, webview loads and screenshot/page brief/task save into Design Files.
## Completion Pass — Workflow + Runtime Verification (2026-05-30, follow-up agent)
### Static audit (multi-agent workflow)
Ran a 5-phase audit/verify/implement workflow over all acceptance requirements. Result: **42/45 requirements met, 0 defects** in static analysis; remaining 3 are partial/judgment (i18n debt, CLI dual-track N/A for an interactive render surface, CSS global-vs-module precedent). The workflow added `apps/web/tests/components/DesignBrowserPanel.test.tsx` (34 cases over the pure helpers — `normalizeBrowserAddress` every branch, history round-trip, harness/brief markdown, etc.) and made those helpers named exports.
### Real runtime verification (computer-use against the live Electron desktop app)
Drove the running desktop runtime via `pnpm tools-dev inspect desktop eval/screenshot`. Confirmed in the real app: `+` → add-menu → **Browser** tab → `DesignBrowserPanel` renders; all **15** reference URLs across MOTION/ASSETS/SYSTEMS; `webviewTag` enabled (`WebViewElement`); host bridge `__od__` exposes `browser.clearData`; navigation commits; the embedded `<webview>` loads and **paints** a real page (`example.com` → `<h1>Example Domain</h1>`, 487×117 layout); and `webview.capturePage()` returns a real 51 KB PNG (the "Take Screenshot" action works).
### Three runtime-only bugs found and fixed (invisible to static/JSDOM checks)
All in `apps/web/src/components/DesignBrowserPanel.tsx`:
1. **`allowpopups` React warning** — bare boolean JSX attr tripped "Received `true` for a non-boolean attribute" when the webview branch mounts. Fixed by setting `allowpopups` imperatively in the ref callback.
2. **`dom-ready` crash** — `updateNavigationState` (and the reload button) called `canGoBack()`/`canGoForward()`/`isLoading()`/`reload()` before the webview attached, throwing "The WebView must be attached…". Guarded with try/catch (matching the existing `safeGetWebviewUrl/Title` pattern).
3. **`ERR_ABORTED (-3)` blank page (critical / made the feature unusable)** — `src={currentUrl}` + a `did-navigate` handler that wrote the committed (trailing-slash) URL back into `currentUrl` caused Electron to re-navigate mid-load and abort it, leaving a blank pane. Fixed by splitting state into `loadUrl` (drives `src`, changes only on user navigation) and `currentUrl` (address bar / history, synced from webview events). Covered by a red→green regression test: `apps/web/tests/components/DesignBrowserPanel.webview.test.tsx`.
### Final verification (all green after fixes)
`pnpm guard` 33 · web `typecheck` clean · desktop `typecheck` clean · web tests **74** (34 pure + 2 webview regression + FileWorkspace + design-system) · desktop **73** · host **14** · packaged **111**. Runtime re-drive on the fixed code: page paints, no error overlays.

View file

@ -67,6 +67,11 @@ export type OpenDesignHostPdfPrintOptions = {
deck?: boolean;
};
export type OpenDesignHostBrowserClearDataOptions = {
cookies?: boolean;
storage?: boolean;
};
export const OPEN_DESIGN_HOST_UPDATER_ACTIONS = Object.freeze({
CHECK: "check",
DOWNLOAD: "download",
@ -203,6 +208,9 @@ export type OpenDesignHostUpdaterResult =
export type OpenDesignHostUpdaterStatusListener = (status: OpenDesignHostUpdaterStatusSnapshot) => void;
export type OpenDesignHostBridge = {
browser: {
clearData(options?: OpenDesignHostBrowserClearDataOptions): Promise<OpenDesignHostActionResult>;
};
client: OpenDesignHostClient;
pdf: {
print(html: string, nonce?: string, options?: OpenDesignHostPdfPrintOptions): Promise<OpenDesignHostActionResult>;
@ -260,6 +268,9 @@ export function isOpenDesignHostBridge(value: unknown): value is OpenDesignHostB
const shell = value.shell;
if (!isRecord(shell) || !hasFunction(shell, "openExternal") || !hasFunction(shell, "openPath")) return false;
const browser = value.browser;
if (!isRecord(browser) || !hasFunction(browser, "clearData")) return false;
const project = value.project;
if (
!isRecord(project) ||
@ -405,6 +416,19 @@ export async function openHostProjectPath(projectId: string, scope: OpenDesignHo
}
}
export async function clearHostBrowserData(
options?: OpenDesignHostBrowserClearDataOptions,
scope: OpenDesignHostGlobalScope = globalThis,
): Promise<OpenDesignHostActionResult> {
const host = getOpenDesignHost(scope);
if (host == null) return unavailable("Open Design host is not available");
try {
return await host.browser.clearData(options);
} catch (error) {
return unavailable(error instanceof Error ? error.message : String(error));
}
}
export async function pickAndImportHostProject(
init?: OpenDesignHostProjectImportInit,
scope: OpenDesignHostGlobalScope = globalThis,

View file

@ -7,6 +7,7 @@ import {
} from "./index.js";
export type MockOpenDesignHost = Partial<Omit<OpenDesignHostBridge, "client" | "pdf" | "pet" | "project" | "shell" | "updater">> & {
browser?: Partial<OpenDesignHostBridge["browser"]>;
client?: Partial<OpenDesignHostBridge["client"]>;
pdf?: Partial<OpenDesignHostBridge["pdf"]>;
pet?: Partial<OpenDesignHostBridge["pet"]>;
@ -39,6 +40,9 @@ function defaultHost(): OpenDesignHostBridge {
};
return {
version: OPEN_DESIGN_HOST_VERSION,
browser: {
clearData: async () => ({ ok: true }),
},
client: {
type: "desktop",
platform: "test",
@ -82,6 +86,7 @@ export function createMockOpenDesignHost(overrides: MockOpenDesignHost = {}): Op
return {
...base,
...overrides,
browser: { ...base.browser, ...overrides.browser },
client: { ...base.client, ...overrides.client },
shell: { ...base.shell, ...overrides.shell },
project: { ...base.project, ...overrides.project },

View file

@ -7,6 +7,7 @@ import { describe, expect, it, vi } from "vitest";
import {
OPEN_DESIGN_HOST_GLOBAL,
OPEN_DESIGN_HOST_VERSION,
clearHostBrowserData,
checkHostUpdater,
detectOpenDesignHostClientType,
getHostUpdaterStatus,
@ -66,6 +67,10 @@ describe("open-design host contract", () => {
it("rejects legacy or incomplete bridge shapes", () => {
expect(isOpenDesignHostBridge({ version: OPEN_DESIGN_HOST_VERSION })).toBe(false);
expect(isOpenDesignHostBridge({ ...createMockOpenDesignHost(), version: 2 })).toBe(false);
expect(isOpenDesignHostBridge({
...createMockOpenDesignHost(),
browser: {},
})).toBe(false);
expect(isOpenDesignHostBridge({
...createMockOpenDesignHost(),
shell: { openExternal: async () => ({ ok: true }) },
@ -188,6 +193,7 @@ describe("open-design host contract", () => {
it("routes all host actions through package-owned helpers", async () => {
const openExternal = vi.fn(async () => ({ ok: true as const }));
const openPath = vi.fn(async () => ({ ok: true as const }));
const clearData = vi.fn(async () => ({ ok: true as const }));
const pickAndImport = vi.fn(async () => ({
ok: true as const,
projectId: "project-2",
@ -198,6 +204,7 @@ describe("open-design host contract", () => {
const setVisible = vi.fn();
const scope: Record<string, unknown> = {};
scope[OPEN_DESIGN_HOST_GLOBAL] = createMockOpenDesignHost({
browser: { clearData },
shell: { openExternal, openPath },
project: { pickAndImport },
pdf: { print },
@ -206,6 +213,7 @@ describe("open-design host contract", () => {
await expect(openHostExternalUrl("https://example.com", scope)).resolves.toEqual({ ok: true });
await expect(openHostProjectPath("project-2", scope)).resolves.toEqual({ ok: true });
await expect(clearHostBrowserData({ cookies: true }, scope)).resolves.toEqual({ ok: true });
await expect(pickAndImportHostProject({ skillId: "skill-1" }, scope)).resolves.toMatchObject({
ok: true,
projectId: "project-2",
@ -215,6 +223,7 @@ describe("open-design host contract", () => {
expect(openExternal).toHaveBeenCalledWith("https://example.com");
expect(openPath).toHaveBeenCalledWith("project-2");
expect(clearData).toHaveBeenCalledWith({ cookies: true });
expect(pickAndImport).toHaveBeenCalledWith({ skillId: "skill-1" });
expect(print).toHaveBeenCalledWith("<html></html>", "nonce", { deck: true });
expect(setVisible).toHaveBeenCalledWith(true);