From 82a79c8b235fa3f95de6db382bf932b856c19d4b Mon Sep 17 00:00:00 2001 From: Siri-Ray <109605599+Siri-Ray@users.noreply.github.com> Date: Fri, 1 May 2026 19:09:02 +0800 Subject: [PATCH] Add desktop chrome header (#205) * feat: add desktop chrome header * fix: address app chrome review feedback Generated-By: looper 0.2.7 (runner=fixer, agent=codex) * fix: address app chrome review feedback Generated-By: looper 0.2.7 (runner=fixer, agent=codex) --- apps/desktop/src/main/runtime.ts | 57 +++++ apps/web/app/layout.tsx | 4 +- apps/web/public/app-icon.svg | 51 ++++ apps/web/src/components/AppChromeHeader.tsx | 64 +++++ apps/web/src/components/AvatarMenu.tsx | 13 +- apps/web/src/components/EntryView.tsx | 258 +++++++++----------- apps/web/src/components/ProjectView.tsx | 50 ++-- apps/web/src/index.css | 128 +++++++++- 8 files changed, 442 insertions(+), 183 deletions(-) create mode 100644 apps/web/public/app-icon.svg create mode 100644 apps/web/src/components/AppChromeHeader.tsx diff --git a/apps/desktop/src/main/runtime.ts b/apps/desktop/src/main/runtime.ts index e894ea3b9a..b3447950d6 100644 --- a/apps/desktop/src/main/runtime.ts +++ b/apps/desktop/src/main/runtime.ts @@ -66,6 +66,37 @@ export type DesktopRuntimeOptions = { discoverUrl(): Promise; }; +const MAC_WINDOW_CHROME = + process.platform === "darwin" + ? ({ + titleBarStyle: "hiddenInset" as const, + trafficLightPosition: { x: 14, y: 12 }, + }) + : {}; + +const MAC_WINDOW_CHROME_CSS = ` + .app-chrome-header { + --app-chrome-traffic-space: 56px !important; + -webkit-app-region: drag; + } + .app-chrome-traffic-space { + flex: 0 0 56px !important; + width: 56px !important; + } + .app-chrome-header button, + .app-chrome-header [role="button"], + .app-chrome-header [contenteditable], + .app-chrome-actions, + .app-chrome-actions *, + .avatar-popover, + .avatar-popover * { + -webkit-app-region: no-drag; + } + .app-chrome-drag { + -webkit-app-region: drag; + } +`; + function createPendingHtml(): string { return `data:text/html;charset=utf-8,${encodeURIComponent(` @@ -119,12 +150,31 @@ function mapConsoleLevel(level: number): string { } } +async function applyWindowChromeCss(window: BrowserWindow): Promise { + if (process.platform !== "darwin" || window.isDestroyed()) return; + await window.webContents.insertCSS(MAC_WINDOW_CHROME_CSS, { cssOrigin: "user" }); +} + +function installWindowChromeCssHook(window: BrowserWindow): void { + window.webContents.on("did-finish-load", () => { + void applyWindowChromeCss(window).catch((error: unknown) => { + console.error("desktop window chrome CSS injection failed", error); + }); + }); +} + +function showWindowButtons(window: BrowserWindow): void { + if (process.platform !== "darwin" || window.isDestroyed()) return; + window.setWindowButtonVisibility(true); +} + export async function createDesktopRuntime(options: DesktopRuntimeOptions): Promise { const consoleEntries: DesktopConsoleEntry[] = []; const window = new BrowserWindow({ height: 900, show: true, title: "Open Design", + ...MAC_WINDOW_CHROME, webPreferences: { contextIsolation: true, nodeIntegration: false, @@ -132,10 +182,15 @@ export async function createDesktopRuntime(options: DesktopRuntimeOptions): Prom }, width: 1280, }); + installWindowChromeCssHook(window); + showWindowButtons(window); let currentUrl: string | null = null; let stopped = false; let timer: NodeJS.Timeout | null = null; + window.on("focus", () => showWindowButtons(window)); + window.on("blur", () => showWindowButtons(window)); + (window.webContents as any).on("console-message", (event: { level?: number | string; message?: string }) => { const level = typeof event.level === "number" ? mapConsoleLevel(event.level) : (event.level ?? "log"); consoleEntries.push({ @@ -149,6 +204,7 @@ export async function createDesktopRuntime(options: DesktopRuntimeOptions): Prom }); await window.loadURL(createPendingHtml()); + showWindowButtons(window); const schedule = (delayMs: number) => { if (stopped) return; @@ -165,6 +221,7 @@ export async function createDesktopRuntime(options: DesktopRuntimeOptions): Prom if (url != null && url !== currentUrl) { currentUrl = url; await window.loadURL(url); + showWindowButtons(window); } schedule(url == null ? PENDING_POLL_MS : RUNNING_POLL_MS); } catch (error) { diff --git a/apps/web/app/layout.tsx b/apps/web/app/layout.tsx index d92bfce4c9..c25e0c1d9e 100644 --- a/apps/web/app/layout.tsx +++ b/apps/web/app/layout.tsx @@ -6,11 +6,11 @@ import '../src/index.css'; export const metadata: Metadata = { title: 'Open Design', icons: { - icon: '/logo.svg', + icon: '/app-icon.svg', // Safari pinned-tab mask icon — Next.js's Metadata API doesn't have a // dedicated `mask` field, so we surface it via the generic `other` // bucket which renders as a raw . - other: [{ rel: 'mask-icon', url: '/logo.svg', color: '#1F1B16' }], + other: [{ rel: 'mask-icon', url: '/app-icon.svg', color: '#363636' }], }, }; diff --git a/apps/web/public/app-icon.svg b/apps/web/public/app-icon.svg new file mode 100644 index 0000000000..b2a63e255b --- /dev/null +++ b/apps/web/public/app-icon.svg @@ -0,0 +1,51 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/apps/web/src/components/AppChromeHeader.tsx b/apps/web/src/components/AppChromeHeader.tsx new file mode 100644 index 0000000000..6f2502a137 --- /dev/null +++ b/apps/web/src/components/AppChromeHeader.tsx @@ -0,0 +1,64 @@ +import type { ReactNode } from 'react'; +import { useT } from '../i18n'; +import { Icon } from './Icon'; + +interface Props { + actions?: ReactNode; + children?: ReactNode; + onBack?: () => void; + backLabel?: string; +} + +export function AppChromeHeader({ actions, children, onBack, backLabel }: Props) { + const t = useT(); + const resolvedBackLabel = backLabel ?? t('project.backToProjects'); + + return ( +
+
+
+ + {/* decorative, parent has aria-label */} + + + {t('app.brand')} +
+ {onBack ? ( + + ) : null} + {children ?
{children}
: null} +
+ {actions ?
{actions}
: null} +
+ ); +} + +export function SettingsIconButton({ + onClick, + title, + ariaLabel, +}: { + onClick: () => void; + title: string; + ariaLabel: string; +}) { + return ( + + ); +} diff --git a/apps/web/src/components/AvatarMenu.tsx b/apps/web/src/components/AvatarMenu.tsx index 3cc2c8db75..6e1561b248 100644 --- a/apps/web/src/components/AvatarMenu.tsx +++ b/apps/web/src/components/AvatarMenu.tsx @@ -21,7 +21,7 @@ interface Props { } /** - * Compact avatar at the right of the project topbar. Click opens a dropdown + * Compact settings control at the right of the project header. Click opens a dropdown * with current execution mode, the agent picker (when in daemon mode), and * a Settings entry — replaces the wide AgentPicker + env-pill row. */ @@ -81,19 +81,14 @@ export function AvatarMenu({
{open ? (
diff --git a/apps/web/src/components/EntryView.tsx b/apps/web/src/components/EntryView.tsx index 88bf1ce484..b580f0679f 100644 --- a/apps/web/src/components/EntryView.tsx +++ b/apps/web/src/components/EntryView.tsx @@ -20,6 +20,7 @@ import { DesignsTab } from './DesignsTab'; import { DesignSystemPreviewModal } from './DesignSystemPreviewModal'; import { DesignSystemsTab } from './DesignSystemsTab'; import { ExamplesTab } from './ExamplesTab'; +import { AppChromeHeader, SettingsIconButton } from './AppChromeHeader'; import { Icon } from './Icon'; import { LanguageMenu } from './LanguageMenu'; import { CenteredLoader } from './Loading'; @@ -168,151 +169,132 @@ export function EntryView({ }, [sidebarWidth]); return ( -
- -
-
-
- - - - - -
-
- {/* Avatar settings live next to tabs to mirror the project view. */} + title={t('entry.openSettingsTitle')} + ariaLabel={t('entry.openSettingsAria')} + /> + )} + /> +
+
-
- {loading ? ( - - ) : ( - <> - {topTab === 'designs' ? ( - - ) : null} - {topTab === 'examples' ? ( - - ) : null} - {topTab === 'design-systems' ? ( - - ) : null} - {topTab === 'image-templates' ? ( - - ) : null} - {topTab === 'video-templates' ? ( - - ) : null} - - )} -
-
+
{previewSystem ? ( -
-
- - - - -
+ + )} + > +
{projectMeta} -
-
- -
-
+