mirror of
https://github.com/nexu-io/open-design.git
synced 2026-10-27 09:51:12 +01:00
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)
This commit is contained in:
@@ -66,6 +66,37 @@ export type DesktopRuntimeOptions = {
|
||||
discoverUrl(): Promise<string | null>;
|
||||
};
|
||||
|
||||
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(`<!doctype html>
|
||||
<html>
|
||||
@@ -119,12 +150,31 @@ function mapConsoleLevel(level: number): string {
|
||||
}
|
||||
}
|
||||
|
||||
async function applyWindowChromeCss(window: BrowserWindow): Promise<void> {
|
||||
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<DesktopRuntime> {
|
||||
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) {
|
||||
|
||||
@@ -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 <link rel="mask-icon" ...>.
|
||||
other: [{ rel: 'mask-icon', url: '/logo.svg', color: '#1F1B16' }],
|
||||
other: [{ rel: 'mask-icon', url: '/app-icon.svg', color: '#363636' }],
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
<svg width="533" height="533" viewBox="0 0 533 533" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<g clip-path="url(#clip0_107_105)">
|
||||
<mask id="mask0_107_105" style="mask-type:alpha" maskUnits="userSpaceOnUse" x="0" y="0" width="534" height="534">
|
||||
<path d="M533.006 266.503C533.006 119.317 413.688 0 266.503 0C119.318 0 0.00012207 119.317 0.00012207 266.503V494.417C0.00012207 515.729 17.2769 533.006 38.5888 533.006H266.503C413.688 533.006 533.006 413.688 533.006 266.503Z" fill="url(#paint0_linear_107_105)"/>
|
||||
</mask>
|
||||
<g mask="url(#mask0_107_105)">
|
||||
<g filter="url(#filter0_f_107_105)">
|
||||
<path d="M559.006 0H-32.4998V541.379C-32.4998 565.474 -12.9674 585.006 11.127 585.006H559.006V0Z" fill="url(#paint1_linear_107_105)"/>
|
||||
</g>
|
||||
<g filter="url(#filter1_f_107_105)">
|
||||
<path d="M241.706 161C241.706 122.422 210.431 91.1475 171.853 91.1475V91.1475C133.274 91.1475 102 122.422 102 161V161C102 199.579 133.274 230.853 171.853 230.853V230.853C210.431 230.853 241.706 199.579 241.706 161V161Z" fill="white" style="fill:white;fill-opacity:1;"/>
|
||||
</g>
|
||||
<g opacity="0.7" filter="url(#filter2_f_107_105)">
|
||||
<path d="M422.555 282.955C436.091 264.202 429.408 237.772 408.585 227.707C403.597 225.296 398.134 224.03 392.595 224.001L368.715 223.877C293.049 223.481 230.694 283.146 227.754 358.755L227.439 366.845C226.585 388.814 237.909 409.466 256.893 420.556C283.361 436.02 317.245 428.853 335.186 403.997L422.555 282.955Z" fill="white" style="fill:white;fill-opacity:1;"/>
|
||||
</g>
|
||||
</g>
|
||||
<path d="M266.503 13C406.509 13.0001 520.005 126.497 520.005 266.503C520.005 406.508 406.509 520.006 266.503 520.006C126.498 520.006 13.0005 406.508 13.0005 266.503C13.0005 126.497 126.498 13 266.503 13ZM200.144 144.795C169.919 135.316 141.442 164.107 151.526 194.357L213.957 381.65C226.127 418.163 278.22 416.846 288.531 379.766L309.793 303.293L386.266 282.03C423.346 271.72 424.663 219.628 388.151 207.457L200.858 145.025L200.144 144.795ZM176.192 186.136C172.804 175.973 182.473 166.304 192.636 169.691L379.929 232.123L380.491 232.322C392.083 236.719 391.467 253.597 379.3 256.98L298.397 279.477C292.366 281.153 287.654 285.866 285.977 291.896L263.481 372.8C260.044 385.16 242.68 385.599 238.623 373.429L176.192 186.136Z" fill="#363636" style="fill:#363636;fill:color(display-p3 0.2121 0.2121 0.2121);fill-opacity:1;"/>
|
||||
</g>
|
||||
<defs>
|
||||
<filter id="filter0_f_107_105" x="-143.001" y="-110.501" width="812.509" height="806.008" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB">
|
||||
<feFlood flood-opacity="0" result="BackgroundImageFix"/>
|
||||
<feBlend mode="normal" in="SourceGraphic" in2="BackgroundImageFix" result="shape"/>
|
||||
<feGaussianBlur stdDeviation="55.2506" result="effect1_foregroundBlur_107_105"/>
|
||||
</filter>
|
||||
<filter id="filter1_f_107_105" x="34.3993" y="23.5468" width="274.907" height="274.907" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB">
|
||||
<feFlood flood-opacity="0" result="BackgroundImageFix"/>
|
||||
<feBlend mode="normal" in="SourceGraphic" in2="BackgroundImageFix" result="shape"/>
|
||||
<feGaussianBlur stdDeviation="33.8004" result="effect1_foregroundBlur_107_105"/>
|
||||
</filter>
|
||||
<filter id="filter2_f_107_105" x="159.793" y="156.274" width="337.406" height="340.017" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB">
|
||||
<feFlood flood-opacity="0" result="BackgroundImageFix"/>
|
||||
<feBlend mode="normal" in="SourceGraphic" in2="BackgroundImageFix" result="shape"/>
|
||||
<feGaussianBlur stdDeviation="33.8004" result="effect1_foregroundBlur_107_105"/>
|
||||
</filter>
|
||||
<linearGradient id="paint0_linear_107_105" x1="13.0004" y1="260.003" x2="295.753" y2="533.006" gradientUnits="userSpaceOnUse">
|
||||
<stop stop-color="#D999F7" style="stop-color:#D999F7;stop-color:color(display-p3 0.8510 0.6000 0.9686);stop-opacity:1;"/>
|
||||
<stop offset="0.336538" stop-color="#00A365" style="stop-color:#00A365;stop-color:color(display-p3 0.0000 0.6399 0.3972);stop-opacity:1;"/>
|
||||
<stop offset="0.673077" stop-color="#F8672F" style="stop-color:#F8672F;stop-color:color(display-p3 0.9725 0.4039 0.1843);stop-opacity:1;"/>
|
||||
<stop offset="1" stop-color="#9C9C9C" style="stop-color:#9C9C9C;stop-color:color(display-p3 0.6121 0.6121 0.6121);stop-opacity:1;"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="paint1_linear_107_105" x1="6.50056" y1="533.006" x2="428.077" y2="4.44429" gradientUnits="userSpaceOnUse">
|
||||
<stop stop-color="#EEFF00" style="stop-color:#EEFF00;stop-color:color(display-p3 0.9333 1.0000 0.0000);stop-opacity:1;"/>
|
||||
<stop offset="0.307692" stop-color="#00FFAE" style="stop-color:#00FFAE;stop-color:color(display-p3 0.0000 1.0000 0.6833);stop-opacity:1;"/>
|
||||
<stop offset="0.668269" stop-color="#00EAFF" style="stop-color:#00EAFF;stop-color:color(display-p3 0.0000 0.9167 1.0000);stop-opacity:1;"/>
|
||||
<stop offset="1" stop-color="#00FF6A" style="stop-color:#00FF6A;stop-color:color(display-p3 0.0000 1.0000 0.4167);stop-opacity:1;"/>
|
||||
</linearGradient>
|
||||
<clipPath id="clip0_107_105">
|
||||
<rect width="533" height="533" fill="white" style="fill:white;fill-opacity:1;"/>
|
||||
</clipPath>
|
||||
</defs>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 4.8 KiB |
@@ -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 (
|
||||
<header className="app-chrome-header">
|
||||
<div className="app-chrome-traffic-space" aria-hidden />
|
||||
<div className="app-chrome-brand" aria-label={t('app.brand')}>
|
||||
<span className="app-chrome-mark" aria-hidden>
|
||||
{/* decorative, parent has aria-label */}
|
||||
<img src="/app-icon.svg" alt="" className="brand-mark-img" draggable={false} />
|
||||
</span>
|
||||
<span className="app-chrome-name">{t('app.brand')}</span>
|
||||
</div>
|
||||
{onBack ? (
|
||||
<button
|
||||
type="button"
|
||||
className="app-chrome-back"
|
||||
onClick={onBack}
|
||||
title={resolvedBackLabel}
|
||||
aria-label={resolvedBackLabel}
|
||||
>
|
||||
<Icon name="arrow-left" size={15} />
|
||||
</button>
|
||||
) : null}
|
||||
{children ? <div className="app-chrome-content">{children}</div> : null}
|
||||
<div className="app-chrome-drag" aria-hidden />
|
||||
{actions ? <div className="app-chrome-actions">{actions}</div> : null}
|
||||
</header>
|
||||
);
|
||||
}
|
||||
|
||||
export function SettingsIconButton({
|
||||
onClick,
|
||||
title,
|
||||
ariaLabel,
|
||||
}: {
|
||||
onClick: () => void;
|
||||
title: string;
|
||||
ariaLabel: string;
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
className="settings-icon-btn"
|
||||
onClick={onClick}
|
||||
title={title}
|
||||
aria-label={ariaLabel}
|
||||
>
|
||||
<Icon name="settings" size={17} />
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -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({
|
||||
<div className="avatar-menu" ref={wrapRef}>
|
||||
<button
|
||||
type="button"
|
||||
className="avatar-btn"
|
||||
className="settings-icon-btn"
|
||||
onClick={() => setOpen((v) => !v)}
|
||||
aria-haspopup="menu"
|
||||
aria-expanded={open}
|
||||
title={t('avatar.title')}
|
||||
aria-label={t('avatar.title')}
|
||||
>
|
||||
<img
|
||||
src="/avatar.png"
|
||||
alt=""
|
||||
aria-hidden
|
||||
draggable={false}
|
||||
className="avatar-btn-photo"
|
||||
/>
|
||||
<Icon name="settings" size={17} />
|
||||
</button>
|
||||
{open ? (
|
||||
<div className="avatar-popover" role="menu">
|
||||
|
||||
@@ -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 (
|
||||
<div
|
||||
className="entry"
|
||||
style={{ gridTemplateColumns: `${sidebarWidth}px 1fr` }}
|
||||
>
|
||||
<aside className="entry-side" style={{ width: sidebarWidth }}>
|
||||
<div className="entry-brand">
|
||||
<span className="entry-brand-mark" aria-hidden>
|
||||
<img src="/logo.svg" alt="" className="brand-mark-img" draggable={false} />
|
||||
</span>
|
||||
<div className="entry-brand-text">
|
||||
<div className="entry-brand-title-row">
|
||||
<span className="entry-brand-title">{t('app.brand')}</span>
|
||||
<span className="entry-brand-pill">{t('app.brandPill')}</span>
|
||||
</div>
|
||||
<div className="entry-brand-subtitle">{t('app.brandSubtitle')}</div>
|
||||
</div>
|
||||
</div>
|
||||
<NewProjectPanel
|
||||
skills={skills}
|
||||
designSystems={designSystems}
|
||||
defaultDesignSystemId={defaultDesignSystemId}
|
||||
templates={templates}
|
||||
onCreate={handleCreate}
|
||||
onImportClaudeDesign={onImportClaudeDesign}
|
||||
mediaProviders={config.mediaProviders}
|
||||
loading={loading}
|
||||
/>
|
||||
<div className="entry-side-foot">
|
||||
<button
|
||||
type="button"
|
||||
className="foot-pill"
|
||||
<div className="entry-shell">
|
||||
<AppChromeHeader
|
||||
actions={(
|
||||
<SettingsIconButton
|
||||
onClick={onOpenSettings}
|
||||
title={t('settings.envConfigure')}
|
||||
>
|
||||
<Icon name="settings" size={12} />
|
||||
<span>
|
||||
{config.mode === 'daemon'
|
||||
? t('settings.localCli')
|
||||
: t('settings.anthropicApi')}
|
||||
</span>
|
||||
<span style={{ color: 'var(--text-faint)' }}>·</span>
|
||||
<span style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', maxWidth: 180 }}>
|
||||
{envMetaLine}
|
||||
</span>
|
||||
</button>
|
||||
<LanguageMenu />
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
aria-label={t('entry.resizeAria')}
|
||||
className={`entry-side-resizer${resizing ? ' dragging' : ''}`}
|
||||
onMouseDown={(e) => {
|
||||
e.preventDefault();
|
||||
startWidthRef.current = sidebarWidth;
|
||||
startXRef.current = e.clientX;
|
||||
setResizing(true);
|
||||
}}
|
||||
/>
|
||||
</aside>
|
||||
<main className="entry-main">
|
||||
<div className="entry-header">
|
||||
<div className="entry-tabs" role="tablist">
|
||||
<TopTabButton current={topTab} value="designs" label={t('entry.tabDesigns')} onClick={setTopTab} />
|
||||
<TopTabButton current={topTab} value="examples" label={t('entry.tabExamples')} onClick={setTopTab} />
|
||||
<TopTabButton
|
||||
current={topTab}
|
||||
value="design-systems"
|
||||
label={t('entry.tabDesignSystems')}
|
||||
onClick={setTopTab}
|
||||
/>
|
||||
<TopTabButton
|
||||
current={topTab}
|
||||
value="image-templates"
|
||||
label={t('entry.tabImageTemplates')}
|
||||
onClick={setTopTab}
|
||||
/>
|
||||
<TopTabButton
|
||||
current={topTab}
|
||||
value="video-templates"
|
||||
label={t('entry.tabVideoTemplates')}
|
||||
onClick={setTopTab}
|
||||
/>
|
||||
</div>
|
||||
<div className="entry-header-right">
|
||||
{/* Avatar settings live next to tabs to mirror the project view. */}
|
||||
title={t('entry.openSettingsTitle')}
|
||||
ariaLabel={t('entry.openSettingsAria')}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
<div
|
||||
className="entry"
|
||||
style={{ gridTemplateColumns: `${sidebarWidth}px 1fr` }}
|
||||
>
|
||||
<aside className="entry-side" style={{ width: sidebarWidth }}>
|
||||
<NewProjectPanel
|
||||
skills={skills}
|
||||
designSystems={designSystems}
|
||||
defaultDesignSystemId={defaultDesignSystemId}
|
||||
templates={templates}
|
||||
onCreate={handleCreate}
|
||||
onImportClaudeDesign={onImportClaudeDesign}
|
||||
mediaProviders={config.mediaProviders}
|
||||
loading={loading}
|
||||
/>
|
||||
<div className="entry-side-foot">
|
||||
<button
|
||||
type="button"
|
||||
className="avatar-btn"
|
||||
className="foot-pill"
|
||||
onClick={onOpenSettings}
|
||||
title={t('entry.openSettingsTitle')}
|
||||
aria-label={t('entry.openSettingsAria')}
|
||||
title={t('settings.envConfigure')}
|
||||
>
|
||||
<img
|
||||
src="/avatar.png"
|
||||
alt=""
|
||||
aria-hidden
|
||||
draggable={false}
|
||||
className="avatar-btn-photo"
|
||||
/>
|
||||
<Icon name="settings" size={12} />
|
||||
<span>
|
||||
{config.mode === 'daemon'
|
||||
? t('settings.localCli')
|
||||
: t('settings.anthropicApi')}
|
||||
</span>
|
||||
<span style={{ color: 'var(--text-faint)' }}>·</span>
|
||||
<span style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', maxWidth: 180 }}>
|
||||
{envMetaLine}
|
||||
</span>
|
||||
</button>
|
||||
<LanguageMenu />
|
||||
</div>
|
||||
</div>
|
||||
<div className="entry-tab-content">
|
||||
{loading ? (
|
||||
<CenteredLoader label={t('entry.loadingWorkspace')} />
|
||||
) : (
|
||||
<>
|
||||
{topTab === 'designs' ? (
|
||||
<DesignsTab
|
||||
projects={projects}
|
||||
skills={skills}
|
||||
designSystems={designSystems}
|
||||
onOpen={onOpenProject}
|
||||
onDelete={onDeleteProject}
|
||||
/>
|
||||
) : null}
|
||||
{topTab === 'examples' ? (
|
||||
<ExamplesTab skills={skills} onUsePrompt={usePromptFromSkill} />
|
||||
) : null}
|
||||
{topTab === 'design-systems' ? (
|
||||
<DesignSystemsTab
|
||||
systems={designSystems}
|
||||
selectedId={defaultDesignSystemId}
|
||||
onSelect={onChangeDefaultDesignSystem}
|
||||
onPreview={previewDesignSystem}
|
||||
/>
|
||||
) : null}
|
||||
{topTab === 'image-templates' ? (
|
||||
<PromptTemplatesTab
|
||||
surface="image"
|
||||
templates={promptTemplates}
|
||||
onPreview={setPreviewPromptTemplate}
|
||||
/>
|
||||
) : null}
|
||||
{topTab === 'video-templates' ? (
|
||||
<PromptTemplatesTab
|
||||
surface="video"
|
||||
templates={promptTemplates}
|
||||
onPreview={setPreviewPromptTemplate}
|
||||
/>
|
||||
) : null}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</main>
|
||||
<button
|
||||
type="button"
|
||||
aria-label={t('entry.resizeAria')}
|
||||
className={`entry-side-resizer${resizing ? ' dragging' : ''}`}
|
||||
onMouseDown={(e) => {
|
||||
e.preventDefault();
|
||||
startWidthRef.current = sidebarWidth;
|
||||
startXRef.current = e.clientX;
|
||||
setResizing(true);
|
||||
}}
|
||||
/>
|
||||
</aside>
|
||||
<main className="entry-main">
|
||||
<div className="entry-header">
|
||||
<div className="entry-tabs" role="tablist">
|
||||
<TopTabButton current={topTab} value="designs" label={t('entry.tabDesigns')} onClick={setTopTab} />
|
||||
<TopTabButton current={topTab} value="examples" label={t('entry.tabExamples')} onClick={setTopTab} />
|
||||
<TopTabButton
|
||||
current={topTab}
|
||||
value="design-systems"
|
||||
label={t('entry.tabDesignSystems')}
|
||||
onClick={setTopTab}
|
||||
/>
|
||||
<TopTabButton
|
||||
current={topTab}
|
||||
value="image-templates"
|
||||
label={t('entry.tabImageTemplates')}
|
||||
onClick={setTopTab}
|
||||
/>
|
||||
<TopTabButton
|
||||
current={topTab}
|
||||
value="video-templates"
|
||||
label={t('entry.tabVideoTemplates')}
|
||||
onClick={setTopTab}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="entry-tab-content">
|
||||
{loading ? (
|
||||
<CenteredLoader label={t('entry.loadingWorkspace')} />
|
||||
) : (
|
||||
<>
|
||||
{topTab === 'designs' ? (
|
||||
<DesignsTab
|
||||
projects={projects}
|
||||
skills={skills}
|
||||
designSystems={designSystems}
|
||||
onOpen={onOpenProject}
|
||||
onDelete={onDeleteProject}
|
||||
/>
|
||||
) : null}
|
||||
{topTab === 'examples' ? (
|
||||
<ExamplesTab skills={skills} onUsePrompt={usePromptFromSkill} />
|
||||
) : null}
|
||||
{topTab === 'design-systems' ? (
|
||||
<DesignSystemsTab
|
||||
systems={designSystems}
|
||||
selectedId={defaultDesignSystemId}
|
||||
onSelect={onChangeDefaultDesignSystem}
|
||||
onPreview={previewDesignSystem}
|
||||
/>
|
||||
) : null}
|
||||
{topTab === 'image-templates' ? (
|
||||
<PromptTemplatesTab
|
||||
surface="image"
|
||||
templates={promptTemplates}
|
||||
onPreview={setPreviewPromptTemplate}
|
||||
/>
|
||||
) : null}
|
||||
{topTab === 'video-templates' ? (
|
||||
<PromptTemplatesTab
|
||||
surface="video"
|
||||
templates={promptTemplates}
|
||||
onPreview={setPreviewPromptTemplate}
|
||||
/>
|
||||
) : null}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
{previewSystem ? (
|
||||
<DesignSystemPreviewModal
|
||||
system={previewSystem}
|
||||
|
||||
@@ -46,10 +46,10 @@ import type {
|
||||
ProjectTemplate,
|
||||
SkillSummary,
|
||||
} from '../types';
|
||||
import { AppChromeHeader } from './AppChromeHeader';
|
||||
import { AvatarMenu } from './AvatarMenu';
|
||||
import { ChatPane } from './ChatPane';
|
||||
import { FileWorkspace } from './FileWorkspace';
|
||||
import { Icon } from './Icon';
|
||||
|
||||
interface Props {
|
||||
project: Project;
|
||||
@@ -1044,20 +1044,24 @@ export function ProjectView({
|
||||
|
||||
return (
|
||||
<div className="app">
|
||||
<div className="topbar">
|
||||
<div className="topbar-left">
|
||||
<button
|
||||
className="ghost back-btn"
|
||||
onClick={onBack}
|
||||
title={t('project.backToProjects')}
|
||||
aria-label={t('project.backToProjects')}
|
||||
>
|
||||
<Icon name="arrow-left" size={14} />
|
||||
</button>
|
||||
<span className="brand-mark" aria-hidden>
|
||||
<img src="/logo.svg" alt="" className="brand-mark-img" draggable={false} />
|
||||
</span>
|
||||
<div className="topbar-title">
|
||||
<AppChromeHeader
|
||||
onBack={onBack}
|
||||
backLabel={t('project.backToProjects')}
|
||||
actions={(
|
||||
<AvatarMenu
|
||||
config={config}
|
||||
agents={agents}
|
||||
daemonLive={daemonLive}
|
||||
onModeChange={onModeChange}
|
||||
onAgentChange={onAgentChange}
|
||||
onAgentModelChange={onAgentModelChange}
|
||||
onOpenSettings={onOpenSettings}
|
||||
onRefreshAgents={onRefreshAgents}
|
||||
onBack={onBack}
|
||||
/>
|
||||
)}
|
||||
>
|
||||
<div className="app-project-title">
|
||||
<span
|
||||
className="title editable"
|
||||
data-testid="project-title"
|
||||
@@ -1076,22 +1080,8 @@ export function ProjectView({
|
||||
{project.name}
|
||||
</span>
|
||||
<span className="meta" data-testid="project-meta">{projectMeta}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="topbar-right">
|
||||
<AvatarMenu
|
||||
config={config}
|
||||
agents={agents}
|
||||
daemonLive={daemonLive}
|
||||
onModeChange={onModeChange}
|
||||
onAgentChange={onAgentChange}
|
||||
onAgentModelChange={onAgentModelChange}
|
||||
onOpenSettings={onOpenSettings}
|
||||
onRefreshAgents={onRefreshAgents}
|
||||
onBack={onBack}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</AppChromeHeader>
|
||||
<div className="split">
|
||||
<ChatPane
|
||||
// The conversation id is part of the key so switching conversations
|
||||
|
||||
+124
-4
@@ -170,6 +170,114 @@ code {
|
||||
background: var(--bg-app);
|
||||
}
|
||||
|
||||
.app-chrome-header {
|
||||
--app-chrome-traffic-space: 0px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
min-height: 40px;
|
||||
padding: 4px 14px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
background: var(--bg);
|
||||
gap: 12px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.app-chrome-traffic-space {
|
||||
width: var(--app-chrome-traffic-space);
|
||||
flex: 0 0 var(--app-chrome-traffic-space);
|
||||
}
|
||||
.app-chrome-brand {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
min-width: 0;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.app-chrome-mark {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border-radius: 0;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: transparent;
|
||||
overflow: visible;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.app-chrome-mark .brand-mark-img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: contain;
|
||||
display: block;
|
||||
padding: 0;
|
||||
user-select: none;
|
||||
-webkit-user-drag: none;
|
||||
}
|
||||
.app-chrome-name {
|
||||
color: var(--text-strong);
|
||||
font-size: 18px;
|
||||
font-weight: 650;
|
||||
letter-spacing: -0.01em;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.app-chrome-back,
|
||||
.settings-icon-btn {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
padding: 0;
|
||||
border-color: transparent;
|
||||
background: transparent;
|
||||
color: var(--text-muted);
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.app-chrome-back:hover:not(:disabled),
|
||||
.settings-icon-btn:hover:not(:disabled) {
|
||||
background: var(--bg-subtle);
|
||||
border-color: var(--border);
|
||||
color: var(--text);
|
||||
}
|
||||
.app-chrome-content {
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
.app-chrome-drag {
|
||||
min-width: 24px;
|
||||
flex: 1 1 auto;
|
||||
align-self: stretch;
|
||||
}
|
||||
.app-chrome-actions {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.app-project-title {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1px;
|
||||
min-width: 0;
|
||||
}
|
||||
.app-project-title .title {
|
||||
color: var(--text-strong);
|
||||
font-size: 13.5px;
|
||||
font-weight: 600;
|
||||
letter-spacing: -0.01em;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
.app-project-title .meta {
|
||||
color: var(--text-muted);
|
||||
font-size: 11.5px;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.topbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -1286,10 +1394,19 @@ code {
|
||||
/* ============================================================
|
||||
Entry view — left sidebar + right tabs
|
||||
============================================================ */
|
||||
.entry-shell {
|
||||
display: grid;
|
||||
grid-template-rows: auto 1fr;
|
||||
height: 100vh;
|
||||
min-height: 0;
|
||||
background: var(--bg);
|
||||
}
|
||||
|
||||
.entry {
|
||||
display: grid;
|
||||
grid-template-columns: 380px 1fr;
|
||||
height: 100vh;
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
background: var(--bg);
|
||||
}
|
||||
|
||||
@@ -2284,9 +2401,10 @@ code {
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
padding: 14px 28px 0;
|
||||
padding: 0 28px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
min-width: 0;
|
||||
min-height: 52px;
|
||||
}
|
||||
.entry-header-tabs-row {
|
||||
display: flex;
|
||||
@@ -4414,13 +4532,15 @@ code {
|
||||
============================================================ */
|
||||
|
||||
/* Editable project title (inline rename in topbar) */
|
||||
.topbar-title .title.editable {
|
||||
.topbar-title .title.editable,
|
||||
.app-project-title .title.editable {
|
||||
outline: none;
|
||||
border-radius: 4px;
|
||||
padding: 1px 4px;
|
||||
margin: -1px -4px;
|
||||
}
|
||||
.topbar-title .title.editable:focus {
|
||||
.topbar-title .title.editable:focus,
|
||||
.app-project-title .title.editable:focus {
|
||||
background: var(--bg-subtle);
|
||||
box-shadow: 0 0 0 1px var(--accent);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user