mirror of
https://github.com/nexu-io/open-design.git
synced 2026-05-31 19:04:39 +07:00
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:
parent
cfde84b038
commit
6a5975d508
17 changed files with 2275 additions and 8 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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> => {
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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/');
|
||||
|
|
|
|||
908
apps/web/src/components/DesignBrowserPanel.tsx
Normal file
908
apps/web/src/components/DesignBrowserPanel.tsx
Normal 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();
|
||||
}
|
||||
}
|
||||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
265
apps/web/tests/components/DesignBrowserPanel.test.tsx
Normal file
265
apps/web/tests/components/DesignBrowserPanel.test.tsx
Normal 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);
|
||||
});
|
||||
});
|
||||
103
apps/web/tests/components/DesignBrowserPanel.webview.test.tsx
Normal file
103
apps/web/tests/components/DesignBrowserPanel.webview.test.tsx
Normal 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');
|
||||
});
|
||||
});
|
||||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
196
design-browser-task-handoff.md
Normal file
196
design-browser-task-handoff.md
Normal 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 use(browser 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.
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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 },
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
Loading…
Reference in a new issue