Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion docs-site/src/content/docs/guides/desktop-app.md
Original file line number Diff line number Diff line change
Expand Up @@ -87,7 +87,8 @@ patch needs to be preserved across an update.

On macOS and Linux, Cmd (macOS) or Ctrl (Linux) with `+`, `-` and `0`, or Ctrl with the mouse wheel,
zooms the window between 50% and 300% in 10% steps, and `0` returns to 100%. The sidebar shows the
same level with `-`, `+` and a reset button beside the theme and language rows. The level is
same level next to the theme switch, as `-`, the current percentage and `+`; clicking the
percentage returns to 100%. The level is
remembered and applied again the next time the app starts. The dashboard comes from the proxy the
app is attached to, so an app attached to an older proxy keeps the earlier behaviour (20% steps, not
remembered, no sidebar control) until that proxy is updated. Windows uses the browser engine's own
Expand Down
2 changes: 1 addition & 1 deletion gui/design-system/components.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@
- `.nav-item`은 아이콘 17px, control text, 4px 세로 간격을 사용한다.
- hover와 active는 같은 surface family를 쓰되 active는 semibold로 구분한다.
- 메뉴마다 margin을 직접 추가하지 않고 `.sidebar nav`의 `gap`을 사용한다.
- 데스크톱 레이아웃에서 `.sidebar nav`는 스크롤 영역이고 footer(언어, 테마, 줌, 프록시)는 항상 보인다. 창이 낮거나 페이지 줌으로 CSS 뷰포트가 줄어도 footer 컨트롤에 닿을 수 있어야 한다. 데스크톱 셸의 줌 컨트롤(`.zoom-control`)은 테마 행과 같은 행 높이를 쓴다.
- 데스크톱 레이아웃에서 `.sidebar nav`는 스크롤 영역이고 footer(언어, 테마, 줌, 프록시)는 항상 보인다. 창이 낮거나 페이지 줌으로 CSS 뷰포트가 줄어도 footer 컨트롤에 닿을 수 있어야 한다. 테마는 해·달·모니터 세 아이콘을 담은 알약(`.theme-switch`)으로 고르고, 현재 모드는 채운 칸으로 표시한다. macOS·Linux 데스크톱 셸에서는 이 알약과 줌 스테퍼(`.zoom-control`)가 한 행(`.sidebar-display-row`)을 나눠 쓴다. 첫 테마 아이콘은 위 언어 아이콘과 같은 세로선에, 오른쪽 끝은 프록시 행 orb와 같은 10px 선에 맞춘다. 줌이 없는 Windows와 브라우저에서는 프록시 행과 같은 형태로 `테마` 라벨 뒤에 알약을 둔다.

## Buttons

Expand Down
28 changes: 15 additions & 13 deletions gui/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ import ErrorBoundary from "./components/ErrorBoundary";
import QuotaSummaryBar from "./components/quota-summary-bar/QuotaSummaryBar";
import { SidebarGithubRow } from "./components/sidebar-github-row";
import { DesktopStarOnboarding } from "./components/desktop-star-onboarding";
import { IconGrid, IconServer, IconBoxes, IconBot, IconList, IconActivity, IconHardDrive, IconCodex, IconClaude, IconMenu, IconSun, IconMoon, IconMonitor, IconGlobe, IconPower, IconX, IconRefresh} from "./icons";
import { IconGrid, IconServer, IconBoxes, IconBot, IconList, IconActivity, IconHardDrive, IconCodex, IconClaude, IconMenu, IconMonitor, IconGlobe, IconPower, IconX, IconRefresh} from "./icons";
import { useI18n, useT, LOCALES, localeDisplayName, type Locale, type TKey } from "./i18n/shared";
import { Notice, Select, ToastNotice, type NoticeTone } from "./ui";
import { configureApiTargets, hasApiSession, installApiAuthFetch, installApiSessionFromHtml, logoutApiSession, SESSION_UNAVAILABLE_EVENT } from "./api";
Expand All @@ -34,10 +34,11 @@ import { zoomManagedOn } from "./lib/desktop-zoom";
import { useSidebarCollapse } from "./use-sidebar-collapse";
import { useDesktopZoom } from "./use-desktop-zoom";
import { DesktopZoomControl } from "./components/desktop-zoom-control";
import { ThemeSwitch, type ThemeMode } from "./components/theme-switch";
import { MainTopStrip, SidebarTopStrip } from "./components/app-titlebar";
import { watchMacTitlebarMetrics, windowChromeHandlers } from "./lib/window-chrome";

type Theme = "light" | "dark" | "system";
type Theme = ThemeMode;

const PAGE_TKEY: Record<Page, TKey> = {
dashboard: "nav.dashboard",
Expand Down Expand Up @@ -83,8 +84,6 @@ const NAV: NavEntry[] = [
{ id: "integrations", tkey: "nav.integrations", Icon: IconGlobe },
];

const THEME_ICON = { light: IconSun, dark: IconMoon, system: IconMonitor } as const;
const THEME_TKEY: Record<Theme, TKey> = { light: "theme.light", dark: "theme.dark", system: "theme.system" };

export interface RemoteWorkspaceRouteProps {
available: boolean;
Expand Down Expand Up @@ -278,8 +277,7 @@ export default function App() {
{ pollMs: 30_000, enabled: targetsSettled },
);

const cycleTheme = () => setTheme(t => (t === "light" ? "dark" : t === "dark" ? "system" : "light"));
const ThemeIcon = THEME_ICON[theme];
const themeSwitch = <ThemeSwitch theme={theme} onChange={setTheme} />;
const displayedVersion: string = healthPoll.data ?? __APP_VERSION__;

const [stopping, setStopping] = useState(false);
Expand Down Expand Up @@ -485,13 +483,17 @@ export default function App() {
style={{ flex: 1, minWidth: 0, width: "100%" }}
/>
</div>
<button type="button" className="theme-toggle" onClick={cycleTheme}
aria-label={`${t("theme.label")}: ${t(THEME_TKEY[theme])}`} title={`${t("theme.label")}: ${t(THEME_TKEY[theme])}`}>
<ThemeIcon /> <span className="mode">{t(THEME_TKEY[theme])}</span>
</button>
{zoomManaged && (
<DesktopZoomControl percent={desktopZoom.percent} canZoomIn={desktopZoom.canZoomIn}
canZoomOut={desktopZoom.canZoomOut} onStep={desktopZoom.step} />
{zoomManaged ? (
<div className="sidebar-display-row">
{themeSwitch}
<DesktopZoomControl percent={desktopZoom.percent} canZoomIn={desktopZoom.canZoomIn}
canZoomOut={desktopZoom.canZoomOut} onStep={desktopZoom.step} />
</div>
) : (
<div className="sidebar-action-row sidebar-action-row--theme">
<span className="sidebar-action-label">{t("theme.label")}</span>
{themeSwitch}
</div>
)}
<div className="sidebar-action-row">
<span className="sidebar-action-label">{t("dash.actions")}</span>
Expand Down
9 changes: 6 additions & 3 deletions gui/src/components/desktop-zoom-control.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,12 +9,15 @@ interface DesktopZoomControlProps {
onStep: (action: ZoomAction) => void;
}

/** Sidebar row for the desktop window's page zoom; the same steps as Ctrl/Cmd + plus, minus and zero. */
/**
* Icon-only page-zoom stepper for the desktop window; the same steps as Ctrl/Cmd + plus, minus
* and zero. It shares the theme row in the sidebar foot, so it carries no visible label of its own:
* the group name and each button's tooltip say what it does.
*/
export function DesktopZoomControl({ percent, canZoomIn, canZoomOut, onStep }: DesktopZoomControlProps) {
const t = useT();
return (
<div className="zoom-control" role="group" aria-label={t("zoom.label")}>
<span className="zoom-control__label">{t("zoom.label")}</span>
<div className="zoom-control" role="group" aria-label={t("zoom.label")} title={t("zoom.label")}>
<button type="button" className="zoom-control__btn" disabled={!canZoomOut}
onClick={() => onStep("out")} aria-label={t("zoom.out")} title={t("zoom.out")}>
<IconMinus aria-hidden />
Expand Down
34 changes: 34 additions & 0 deletions gui/src/components/theme-switch.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
import { IconMonitor, IconMoon, IconSun } from "../icons";
import { useT, type TKey } from "../i18n/shared";

export type ThemeMode = "light" | "dark" | "system";

const MODES: ReadonlyArray<{ mode: ThemeMode; tkey: TKey; Icon: typeof IconSun }> = [
{ mode: "light", tkey: "theme.light", Icon: IconSun },
{ mode: "dark", tkey: "theme.dark", Icon: IconMoon },
{ mode: "system", tkey: "theme.system", Icon: IconMonitor },
];

interface ThemeSwitchProps {
theme: ThemeMode;
onChange: (mode: ThemeMode) => void;
}

/**
* Icon-only theme switch for the sidebar foot. All three modes stay visible and the current one
* is filled, so the user picks a mode in one click instead of cycling through a single button
* whose next state is hidden. Each button carries its mode name as tooltip and accessible name.
*/
export function ThemeSwitch({ theme, onChange }: ThemeSwitchProps) {
const t = useT();
return (
<div className="theme-switch" role="group" aria-label={t("theme.label")}>
{MODES.map(({ mode, tkey, Icon }) => (
<button key={mode} type="button" className="theme-switch__btn" aria-pressed={theme === mode}
aria-label={t(tkey)} title={t(tkey)} onClick={() => onChange(mode)}>
<Icon aria-hidden />
</button>
))}
</div>
);
}
4 changes: 0 additions & 4 deletions gui/src/styles.css
Original file line number Diff line number Diff line change
Expand Up @@ -397,10 +397,6 @@ input[type="checkbox"], input[type="radio"] { accent-color: var(--accent); }
/* Clear the sidebar's 14px padding + 1px border so the menu's left edge sits in the
main pane instead of straddling the divider (trigger ends at the content edge). */
.lang-toggle .select-dropdown-beside { top: auto; bottom: 0; left: calc(100% + 20px); right: auto; min-width: 10rem; max-height: min(60vh, 20rem); overflow-y: auto; }
.theme-toggle { display: flex; align-items: center; gap: 9px; padding: 8px 10px; width: 100%; text-align: left; background: none; border: none; cursor: pointer; color: var(--muted); font: inherit; font-size: var(--text-control); border-radius: var(--radius-sm); transition: background var(--motion-fast), color var(--motion-fast); }
.theme-toggle:hover { background: var(--accent-soft); color: var(--text); }
.theme-toggle svg { width: 16px; height: 16px; flex-shrink: 0; }
.theme-toggle .mode { text-transform: capitalize; }
.stop-toggle { color: var(--red); }
.stop-toggle:hover { background: var(--red-soft); color: var(--red); }
.stop-toggle:disabled { opacity: 0.5; cursor: default; }
Expand Down
45 changes: 33 additions & 12 deletions gui/src/styles/sidebar-zoom.css
Original file line number Diff line number Diff line change
@@ -1,22 +1,43 @@
/* ── Desktop shell: sidebar page-zoom control and a footer that stays reachable ──
/* ── Desktop shell and sidebar foot: theme switch, page-zoom stepper, and a footer that stays reachable ──
Kept out of styles.css, which is under a file-size cap that only moves down. Loaded after it
(see main.tsx), so these rules follow the base .sidebar and .sidebar nav rules in the cascade. */

/* Same row metrics as .theme-toggle so the language, theme and zoom rows read as one group. */
.zoom-control { display: flex; align-items: center; gap: 4px; padding: 4px 10px; width: 100%; color: var(--muted); font-size: var(--text-control); }
.zoom-control__label { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.zoom-control__btn, .zoom-control__value {
/* Theme and zoom share one row on macOS and Linux, where the desktop shell manages zoom. The row
keeps the foot's row height (3px + 28px pill + 3px) and its right edge sits on the same 10px
inset as the proxy orbs below. The 4px left inset puts the first theme icon on the same vertical
line as the language globe above it. Elsewhere the switch sits in a .sidebar-action-row behind a
"Theme" label, like the proxy row. */
.sidebar-display-row { display: flex; align-items: center; justify-content: space-between; gap: 4px; min-width: 0; padding: 3px 10px 3px 4px; }

/* Both controls are one bordered pill in the .sidebar-orb material. Children carry no borders of
their own; the current theme is shown by a fill. */
.theme-switch, .zoom-control {
display: inline-flex; align-items: center; flex: 0 0 auto; height: 28px; padding: 0 1px;
color: var(--muted); font-size: var(--text-control);
border: 1px solid var(--border); border-radius: var(--radius-pill); background: var(--raised);
}
.theme-switch__btn, .zoom-control__btn, .zoom-control__value {
display: inline-flex; align-items: center; justify-content: center;
height: 26px; padding: 0; border: none; border-radius: var(--radius-sm);
height: 24px; padding: 0; border: none; border-radius: var(--radius-pill);
Comment thread
lidge-jun marked this conversation as resolved.
background: none; color: inherit; font: inherit; cursor: pointer;
transition: background var(--motion-fast), color var(--motion-fast);
}
.zoom-control__btn { width: 26px; }
.zoom-control__btn svg { width: 14px; height: 14px; }
.zoom-control__value { min-width: 3.25rem; padding: 0 6px; font-variant-numeric: tabular-nums; }
.zoom-control__btn:hover:not(:disabled), .zoom-control__value:hover { background: var(--accent-soft); color: var(--text); }
.zoom-control__btn:focus-visible, .zoom-control__value:focus-visible { outline: 2px solid var(--accent-ring); outline-offset: 2px; }
.zoom-control__btn:disabled { opacity: 0.5; cursor: default; }
.theme-switch__btn { width: 26px; }
.theme-switch__btn svg { width: 14px; height: 14px; }
.theme-switch__btn[aria-pressed="true"] { background: var(--accent-soft); color: var(--text); box-shadow: inset 0 0 0 1px var(--accent-ring); }
.zoom-control__btn { width: 24px; }
.zoom-control__btn svg { width: 12px; height: 12px; }
.zoom-control__value { min-width: 2.75rem; padding: 0 4px; color: var(--text); font-size: 12px; font-variant-numeric: tabular-nums; }
.theme-switch__btn:hover:not([aria-pressed="true"]), .zoom-control__btn:hover:not(:disabled), .zoom-control__value:hover { background: var(--accent-soft); color: var(--text); }
.theme-switch__btn:focus-visible, .zoom-control__btn:focus-visible, .zoom-control__value:focus-visible { outline: 2px solid var(--accent-ring); outline-offset: 1px; }
.zoom-control__btn:disabled { opacity: 0.4; cursor: default; }
/* The drawer layout follows the 44px mobile touch height (design-system/components.md). The
drawer is up to 280px wide, so three 40px theme buttons still share the row with the zoom
stepper; the pill grows with its buttons instead of clipping a choice. */
@media (max-width: 760px) {
.theme-switch { height: auto; }
.theme-switch__btn { width: 40px; height: 44px; }
}

/* Page zoom shrinks the CSS viewport, so a zoomed or short window is shorter than the menu plus
the footer. On the desktop layout the menu scrolls and the footer (language, theme, zoom, proxy)
Expand Down
2 changes: 1 addition & 1 deletion gui/tests/sidebar-rows.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -67,7 +67,7 @@ test("the foot's four rows share one text column and one trailing inset", async
};

// The column every label sits in, owned by the rows that carry an icon.
for (const selector of [".lang-toggle", ".theme-toggle", ".sidebar-link"]) {
for (const selector of [".lang-toggle", ".sidebar-link"]) {
expect(rule(selector)).toContain("padding: 8px 10px");
expect(rule(selector)).toContain("gap: 9px");
}
Expand Down
86 changes: 86 additions & 0 deletions gui/tests/theme-switch-dom.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
import { afterEach, expect, test } from "bun:test";
import { Window } from "happy-dom";
import { act } from "react";
import { createRoot, type Root } from "react-dom/client";
import { ThemeSwitch, type ThemeMode } from "../src/components/theme-switch";
import { en } from "../src/i18n/en";
import { ko } from "../src/i18n/ko";
import { LanguageProvider } from "../src/i18n/provider";
import { getActiveLocale, setActiveLocale, type Locale } from "../src/i18n/shared";

const globals = ["document", "window", "navigator", "localStorage", "IS_REACT_ACT_ENVIRONMENT"] as const;
const modes: ThemeMode[] = ["light", "dark", "system"];
let previous: Record<(typeof globals)[number], unknown>;
let previousLocale: Locale;
let root: Root | null = null;

function mount(theme: ThemeMode, onChange: (mode: ThemeMode) => void, locale: "en" | "ko" = "en") {
previousLocale = getActiveLocale();
const win = new Window({ url: "http://127.0.0.1:10100/" });
previous = Object.fromEntries(globals.map(k => [k, Reflect.get(globalThis, k)])) as typeof previous;
win.localStorage.setItem("ocx-lang", locale);
// Earlier DOM suites may leave non-writable globals; keep ours writable for later suites.
Object.defineProperties(globalThis, {
document: { configurable: true, writable: true, value: win.document },
window: { configurable: true, writable: true, value: win },
navigator: { configurable: true, writable: true, value: win.navigator },
localStorage: { configurable: true, writable: true, value: win.localStorage },
IS_REACT_ACT_ENVIRONMENT: { configurable: true, writable: true, value: true },
});
const host = win.document.createElement("div") as never as HTMLElement;
win.document.body.appendChild(host as never);
root = createRoot(host);
const render = (mode: ThemeMode) => act(() => {
root!.render(<LanguageProvider><ThemeSwitch theme={mode} onChange={onChange} /></LanguageProvider>);
});
render(theme);
return { host, render };
}

afterEach(() => {
if (root) act(() => root!.unmount());
root = null;
for (const k of globals) Object.defineProperty(globalThis, k, { configurable: true, writable: true, value: previous?.[k] });
setActiveLocale(previousLocale);
});

for (const [locale, dict] of [["en", en], ["ko", ko]] as const) {
test(`theme choices have ordered ${locale} accessible names and decorative icons`, () => {
const { host } = mount("system", () => {}, locale);
const group = host.querySelector('[role="group"]');
expect(group?.classList.contains("theme-switch")).toBe(true);
expect(group?.getAttribute("aria-label")).toBe(dict["theme.label"]);
const buttons = Array.from(group!.querySelectorAll("button"));
expect(buttons).toHaveLength(3);
expect(buttons.map(button => button.getAttribute("aria-label"))).toEqual(modes.map(mode => dict[`theme.${mode}`]));
buttons.forEach((button, index) => {
expect(button.type).toBe("button");
expect(button.classList.contains("theme-switch__btn")).toBe(true);
expect(button.title).toBe(dict[`theme.${modes[index]!}`]);
expect(button.textContent?.trim()).toBe("");
expect(button.querySelectorAll("svg")).toHaveLength(1);
expect(button.querySelector("svg")?.getAttribute("aria-hidden")).toBe("true");
});
});
}

test("exactly the controlled theme is pressed, including after re-render", () => {
const { host, render } = mount("light", () => {});
for (const mode of modes) {
render(mode);
const buttons = Array.from(host.querySelectorAll("button"));
expect(buttons.map(button => button.getAttribute("aria-pressed"))).toEqual(modes.map(choice => String(choice === mode)));
expect(host.querySelectorAll('[aria-pressed="true"]')).toHaveLength(1);
}
});

test("dark, system and light clicks report their own mode", () => {
const calls: ThemeMode[] = [];
const { host } = mount("light", mode => calls.push(mode));
const [light, dark, system] = Array.from(host.querySelectorAll("button"));
for (const button of [dark, system, light]) act(() => { button!.click(); });
expect(calls).toEqual(["dark", "system", "light"]);
// Selection stays controlled by the prop until the parent re-renders.
expect(light!.getAttribute("aria-pressed")).toBe("true");
expect(host.querySelectorAll('[aria-pressed="true"]')).toHaveLength(1);
});
Loading