Thanks to visit codestin.com
Credit goes to github.com

Skip to content

Commit 82a79c8

Browse files
author
Siri-Ray
authored
Add desktop chrome header (nexu-io#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)
1 parent 6789dd4 commit 82a79c8

8 files changed

Lines changed: 442 additions & 183 deletions

File tree

apps/desktop/src/main/runtime.ts

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,37 @@ export type DesktopRuntimeOptions = {
6666
discoverUrl(): Promise<string | null>;
6767
};
6868

69+
const MAC_WINDOW_CHROME =
70+
process.platform === "darwin"
71+
? ({
72+
titleBarStyle: "hiddenInset" as const,
73+
trafficLightPosition: { x: 14, y: 12 },
74+
})
75+
: {};
76+
77+
const MAC_WINDOW_CHROME_CSS = `
78+
.app-chrome-header {
79+
--app-chrome-traffic-space: 56px !important;
80+
-webkit-app-region: drag;
81+
}
82+
.app-chrome-traffic-space {
83+
flex: 0 0 56px !important;
84+
width: 56px !important;
85+
}
86+
.app-chrome-header button,
87+
.app-chrome-header [role="button"],
88+
.app-chrome-header [contenteditable],
89+
.app-chrome-actions,
90+
.app-chrome-actions *,
91+
.avatar-popover,
92+
.avatar-popover * {
93+
-webkit-app-region: no-drag;
94+
}
95+
.app-chrome-drag {
96+
-webkit-app-region: drag;
97+
}
98+
`;
99+
69100
function createPendingHtml(): string {
70101
return `data:text/html;charset=utf-8,${encodeURIComponent(`<!doctype html>
71102
<html>
@@ -119,23 +150,47 @@ function mapConsoleLevel(level: number): string {
119150
}
120151
}
121152

153+
async function applyWindowChromeCss(window: BrowserWindow): Promise<void> {
154+
if (process.platform !== "darwin" || window.isDestroyed()) return;
155+
await window.webContents.insertCSS(MAC_WINDOW_CHROME_CSS, { cssOrigin: "user" });
156+
}
157+
158+
function installWindowChromeCssHook(window: BrowserWindow): void {
159+
window.webContents.on("did-finish-load", () => {
160+
void applyWindowChromeCss(window).catch((error: unknown) => {
161+
console.error("desktop window chrome CSS injection failed", error);
162+
});
163+
});
164+
}
165+
166+
function showWindowButtons(window: BrowserWindow): void {
167+
if (process.platform !== "darwin" || window.isDestroyed()) return;
168+
window.setWindowButtonVisibility(true);
169+
}
170+
122171
export async function createDesktopRuntime(options: DesktopRuntimeOptions): Promise<DesktopRuntime> {
123172
const consoleEntries: DesktopConsoleEntry[] = [];
124173
const window = new BrowserWindow({
125174
height: 900,
126175
show: true,
127176
title: "Open Design",
177+
...MAC_WINDOW_CHROME,
128178
webPreferences: {
129179
contextIsolation: true,
130180
nodeIntegration: false,
131181
sandbox: true,
132182
},
133183
width: 1280,
134184
});
185+
installWindowChromeCssHook(window);
186+
showWindowButtons(window);
135187
let currentUrl: string | null = null;
136188
let stopped = false;
137189
let timer: NodeJS.Timeout | null = null;
138190

191+
window.on("focus", () => showWindowButtons(window));
192+
window.on("blur", () => showWindowButtons(window));
193+
139194
(window.webContents as any).on("console-message", (event: { level?: number | string; message?: string }) => {
140195
const level = typeof event.level === "number" ? mapConsoleLevel(event.level) : (event.level ?? "log");
141196
consoleEntries.push({
@@ -149,6 +204,7 @@ export async function createDesktopRuntime(options: DesktopRuntimeOptions): Prom
149204
});
150205

151206
await window.loadURL(createPendingHtml());
207+
showWindowButtons(window);
152208

153209
const schedule = (delayMs: number) => {
154210
if (stopped) return;
@@ -165,6 +221,7 @@ export async function createDesktopRuntime(options: DesktopRuntimeOptions): Prom
165221
if (url != null && url !== currentUrl) {
166222
currentUrl = url;
167223
await window.loadURL(url);
224+
showWindowButtons(window);
168225
}
169226
schedule(url == null ? PENDING_POLL_MS : RUNNING_POLL_MS);
170227
} catch (error) {

apps/web/app/layout.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,11 +6,11 @@ import '../src/index.css';
66
export const metadata: Metadata = {
77
title: 'Open Design',
88
icons: {
9-
icon: '/logo.svg',
9+
icon: '/app-icon.svg',
1010
// Safari pinned-tab mask icon — Next.js's Metadata API doesn't have a
1111
// dedicated `mask` field, so we surface it via the generic `other`
1212
// bucket which renders as a raw <link rel="mask-icon" ...>.
13-
other: [{ rel: 'mask-icon', url: '/logo.svg', color: '#1F1B16' }],
13+
other: [{ rel: 'mask-icon', url: '/app-icon.svg', color: '#363636' }],
1414
},
1515
};
1616

apps/web/public/app-icon.svg

Lines changed: 51 additions & 0 deletions
Loading
Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
import type { ReactNode } from 'react';
2+
import { useT } from '../i18n';
3+
import { Icon } from './Icon';
4+
5+
interface Props {
6+
actions?: ReactNode;
7+
children?: ReactNode;
8+
onBack?: () => void;
9+
backLabel?: string;
10+
}
11+
12+
export function AppChromeHeader({ actions, children, onBack, backLabel }: Props) {
13+
const t = useT();
14+
const resolvedBackLabel = backLabel ?? t('project.backToProjects');
15+
16+
return (
17+
<header className="app-chrome-header">
18+
<div className="app-chrome-traffic-space" aria-hidden />
19+
<div className="app-chrome-brand" aria-label={t('app.brand')}>
20+
<span className="app-chrome-mark" aria-hidden>
21+
{/* decorative, parent has aria-label */}
22+
<img src="/app-icon.svg" alt="" className="brand-mark-img" draggable={false} />
23+
</span>
24+
<span className="app-chrome-name">{t('app.brand')}</span>
25+
</div>
26+
{onBack ? (
27+
<button
28+
type="button"
29+
className="app-chrome-back"
30+
onClick={onBack}
31+
title={resolvedBackLabel}
32+
aria-label={resolvedBackLabel}
33+
>
34+
<Icon name="arrow-left" size={15} />
35+
</button>
36+
) : null}
37+
{children ? <div className="app-chrome-content">{children}</div> : null}
38+
<div className="app-chrome-drag" aria-hidden />
39+
{actions ? <div className="app-chrome-actions">{actions}</div> : null}
40+
</header>
41+
);
42+
}
43+
44+
export function SettingsIconButton({
45+
onClick,
46+
title,
47+
ariaLabel,
48+
}: {
49+
onClick: () => void;
50+
title: string;
51+
ariaLabel: string;
52+
}) {
53+
return (
54+
<button
55+
type="button"
56+
className="settings-icon-btn"
57+
onClick={onClick}
58+
title={title}
59+
aria-label={ariaLabel}
60+
>
61+
<Icon name="settings" size={17} />
62+
</button>
63+
);
64+
}

apps/web/src/components/AvatarMenu.tsx

Lines changed: 4 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@ interface Props {
2121
}
2222

2323
/**
24-
* Compact avatar at the right of the project topbar. Click opens a dropdown
24+
* Compact settings control at the right of the project header. Click opens a dropdown
2525
* with current execution mode, the agent picker (when in daemon mode), and
2626
* a Settings entry — replaces the wide AgentPicker + env-pill row.
2727
*/
@@ -81,19 +81,14 @@ export function AvatarMenu({
8181
<div className="avatar-menu" ref={wrapRef}>
8282
<button
8383
type="button"
84-
className="avatar-btn"
84+
className="settings-icon-btn"
8585
onClick={() => setOpen((v) => !v)}
8686
aria-haspopup="menu"
8787
aria-expanded={open}
8888
title={t('avatar.title')}
89+
aria-label={t('avatar.title')}
8990
>
90-
<img
91-
src="/avatar.png"
92-
alt=""
93-
aria-hidden
94-
draggable={false}
95-
className="avatar-btn-photo"
96-
/>
91+
<Icon name="settings" size={17} />
9792
</button>
9893
{open ? (
9994
<div className="avatar-popover" role="menu">

0 commit comments

Comments
 (0)