Skip to content

Commit 4611915

Browse files
authored
Split message/code fonts and add app zoom persistence (#493)
- Separate message and code font settings with runtime font loading - Add persisted zoom controls and wire desktop/menu shortcuts - Raise sidebar project row height for the new typography scale
1 parent 13c3943 commit 4611915

21 files changed

Lines changed: 1155 additions & 105 deletions

KEYBINDINGS.md

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,11 @@ See the full schema for more details: [`packages/contracts/src/keybindings.ts`](
2727
{ "key": "mod+n", "command": "chat.new", "when": "!terminalFocus" },
2828
{ "key": "mod+shift+o", "command": "chat.new", "when": "!terminalFocus" },
2929
{ "key": "mod+shift+n", "command": "chat.newLocal", "when": "!terminalFocus" },
30-
{ "key": "mod+o", "command": "editor.openFavorite" }
30+
{ "key": "mod+o", "command": "editor.openFavorite" },
31+
{ "key": "mod+=", "command": "view.zoomIn" },
32+
{ "key": "mod+plus", "command": "view.zoomIn" },
33+
{ "key": "mod+-", "command": "view.zoomOut" },
34+
{ "key": "mod+0", "command": "view.zoomReset" }
3135
]
3236
```
3337

@@ -54,6 +58,9 @@ Invalid rules are ignored. Invalid config files are ignored. Warnings are logged
5458
- `chat.new`: create a new chat thread preserving the active thread's branch/worktree state
5559
- `chat.newLocal`: create a new chat thread for the active project in a new environment (local/worktree determined by app settings (default `local`))
5660
- `editor.openFavorite`: open current project/worktree in the last-used editor
61+
- `view.zoomIn`: scale the whole UI up one step (persisted to localStorage)
62+
- `view.zoomOut`: scale the whole UI down one step (persisted to localStorage)
63+
- `view.zoomReset`: reset the UI zoom back to 100 %
5764
- `script.{id}.run`: run a project script by id (for example `script.test.run`)
5865

5966
### Key Syntax

apps/desktop/src/main.ts

Lines changed: 40 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,9 @@ const CONFIRM_CHANNEL = "desktop:confirm";
6060
const SET_THEME_CHANNEL = "desktop:set-theme";
6161
const SET_SIDEBAR_OPACITY_CHANNEL = "desktop:set-sidebar-opacity";
6262
const SET_WINDOW_BUTTON_VISIBILITY_CHANNEL = "desktop:set-window-button-visibility";
63+
const SET_ZOOM_FACTOR_CHANNEL = "desktop:set-zoom-factor";
64+
const ZOOM_MIN = 0.75;
65+
const ZOOM_MAX = 1.75;
6366
const CONTEXT_MENU_CHANNEL = "desktop:context-menu";
6467
const OPEN_EXTERNAL_CHANNEL = "desktop:open-external";
6568
const MENU_ACTION_CHANNEL = "desktop:menu-action";
@@ -648,10 +651,30 @@ function configureApplicationMenu(): void {
648651
{ role: "forceReload" },
649652
{ role: "toggleDevTools" },
650653
{ type: "separator" },
651-
{ role: "resetZoom" },
652-
{ role: "zoomIn", accelerator: "CmdOrCtrl+=" },
653-
{ role: "zoomIn", accelerator: "CmdOrCtrl+Plus", visible: false },
654-
{ role: "zoomOut" },
654+
// Dispatch to the renderer instead of using Electron's role-based zoom.
655+
// The renderer owns the persisted zoom factor; native roles would
656+
// bypass our storage and the two would drift.
657+
{
658+
label: "Actual Size",
659+
accelerator: "CmdOrCtrl+0",
660+
click: () => dispatchMenuAction("view-zoom-reset"),
661+
},
662+
{
663+
label: "Zoom In",
664+
accelerator: "CmdOrCtrl+=",
665+
click: () => dispatchMenuAction("view-zoom-in"),
666+
},
667+
{
668+
label: "Zoom In",
669+
accelerator: "CmdOrCtrl+Plus",
670+
visible: false,
671+
click: () => dispatchMenuAction("view-zoom-in"),
672+
},
673+
{
674+
label: "Zoom Out",
675+
accelerator: "CmdOrCtrl+-",
676+
click: () => dispatchMenuAction("view-zoom-out"),
677+
},
655678
{ type: "separator" },
656679
{ role: "togglefullscreen" },
657680
],
@@ -1195,6 +1218,19 @@ function registerIpcHandlers(): void {
11951218
// applies the value through a CSS custom-property.
11961219
});
11971220

1221+
ipcMain.removeHandler(SET_ZOOM_FACTOR_CHANNEL);
1222+
ipcMain.handle(SET_ZOOM_FACTOR_CHANNEL, async (event, rawFactor: unknown) => {
1223+
// Scale the requesting webContents. We clamp to the same [0.75, 1.75]
1224+
// range the renderer enforces so a malicious or buggy call can't drive
1225+
// the UI into an unreadable state.
1226+
const contents = event.sender;
1227+
if (!contents || typeof contents.setZoomFactor !== "function") return;
1228+
const numeric = typeof rawFactor === "number" ? rawFactor : Number(rawFactor);
1229+
const factor = Number.isFinite(numeric) ? numeric : 1;
1230+
const clamped = Math.max(ZOOM_MIN, Math.min(ZOOM_MAX, factor));
1231+
contents.setZoomFactor(clamped);
1232+
});
1233+
11981234
ipcMain.removeHandler(SET_WINDOW_BUTTON_VISIBILITY_CHANNEL);
11991235
ipcMain.handle(SET_WINDOW_BUTTON_VISIBILITY_CHANNEL, async (event, rawVisible: unknown) => {
12001236
if (process.platform !== "darwin" || typeof rawVisible !== "boolean") {

apps/desktop/src/preload.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ const CONFIRM_CHANNEL = "desktop:confirm";
77
const SET_THEME_CHANNEL = "desktop:set-theme";
88
const SET_SIDEBAR_OPACITY_CHANNEL = "desktop:set-sidebar-opacity";
99
const SET_WINDOW_BUTTON_VISIBILITY_CHANNEL = "desktop:set-window-button-visibility";
10+
const SET_ZOOM_FACTOR_CHANNEL = "desktop:set-zoom-factor";
1011
const CONTEXT_MENU_CHANNEL = "desktop:context-menu";
1112
const OPEN_EXTERNAL_CHANNEL = "desktop:open-external";
1213
const MENU_ACTION_CHANNEL = "desktop:menu-action";
@@ -42,6 +43,7 @@ contextBridge.exposeInMainWorld("desktopBridge", {
4243
setSidebarOpacity: (opacity) => ipcRenderer.invoke(SET_SIDEBAR_OPACITY_CHANNEL, opacity),
4344
setWindowButtonVisibility: (visible) =>
4445
ipcRenderer.invoke(SET_WINDOW_BUTTON_VISIBILITY_CHANNEL, visible),
46+
setZoomFactor: (factor) => ipcRenderer.invoke(SET_ZOOM_FACTOR_CHANNEL, factor),
4547
showContextMenu: (items, position) => ipcRenderer.invoke(CONTEXT_MENU_CHANNEL, items, position),
4648
openExternal: (url: string) => ipcRenderer.invoke(OPEN_EXTERNAL_CHANNEL, url),
4749
onMenuAction: (listener) => {

apps/web/src/appSettings.test.ts

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import { describe, expect, it } from "vitest";
33

44
import {
55
AppSettingsSchema,
6+
clampSidebarProjectRowHeight,
67
DEFAULT_BROWSER_PREVIEW_START_PAGE_URL,
78
DEFAULT_PR_REVIEW_REQUEST_CHANGES_TONE,
89
DEFAULT_SIDEBAR_FONT_SIZE,
@@ -11,6 +12,8 @@ import {
1112
DEFAULT_SIDEBAR_THREAD_ROW_HEIGHT,
1213
getProviderStartOptions,
1314
resolveBrowserPreviewStartPageUrl,
15+
SIDEBAR_PROJECT_ROW_HEIGHT_MAX,
16+
SIDEBAR_PROJECT_ROW_HEIGHT_MIN,
1417
} from "./appSettings";
1518

1619
describe("AppSettingsSchema", () => {
@@ -62,6 +65,34 @@ describe("AppSettingsSchema", () => {
6265
});
6366
});
6467

68+
describe("clampSidebarProjectRowHeight", () => {
69+
it("exposes the expected accessibility-minded bounds", () => {
70+
expect(SIDEBAR_PROJECT_ROW_HEIGHT_MIN).toBe(32);
71+
expect(SIDEBAR_PROJECT_ROW_HEIGHT_MAX).toBe(72);
72+
expect(DEFAULT_SIDEBAR_PROJECT_ROW_HEIGHT).toBe(32);
73+
});
74+
75+
it("clamps below-floor values up to the new floor of 32", () => {
76+
expect(clampSidebarProjectRowHeight(0)).toBe(32);
77+
expect(clampSidebarProjectRowHeight(24)).toBe(32); // legacy floor
78+
expect(clampSidebarProjectRowHeight(28)).toBe(32); // legacy default
79+
expect(clampSidebarProjectRowHeight(31)).toBe(32);
80+
});
81+
82+
it("accepts in-range values and rounds fractional input", () => {
83+
expect(clampSidebarProjectRowHeight(32)).toBe(32);
84+
expect(clampSidebarProjectRowHeight(48)).toBe(48);
85+
expect(clampSidebarProjectRowHeight(71.4)).toBe(71);
86+
expect(clampSidebarProjectRowHeight(72)).toBe(72);
87+
});
88+
89+
it("clamps above-ceiling values down to the new max of 72", () => {
90+
expect(clampSidebarProjectRowHeight(73)).toBe(72);
91+
expect(clampSidebarProjectRowHeight(120)).toBe(72);
92+
expect(clampSidebarProjectRowHeight(Number.POSITIVE_INFINITY)).toBe(72);
93+
});
94+
});
95+
6596
describe("getProviderStartOptions", () => {
6697
it("includes the Claude binary path when configured", () => {
6798
expect(

apps/web/src/appSettings.ts

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -21,9 +21,9 @@ const MAX_CUSTOM_MODEL_COUNT = 32;
2121
export const MAX_CUSTOM_MODEL_LENGTH = 256;
2222
const BACKGROUND_IMAGE_KEY = "okcode:background-image";
2323
const BACKGROUND_OPACITY_KEY = "okcode:background-opacity";
24-
export const SIDEBAR_PROJECT_ROW_HEIGHT_MIN = 24;
25-
export const SIDEBAR_PROJECT_ROW_HEIGHT_MAX = 44;
26-
export const DEFAULT_SIDEBAR_PROJECT_ROW_HEIGHT = 28;
24+
export const SIDEBAR_PROJECT_ROW_HEIGHT_MIN = 32;
25+
export const SIDEBAR_PROJECT_ROW_HEIGHT_MAX = 72;
26+
export const DEFAULT_SIDEBAR_PROJECT_ROW_HEIGHT = 32;
2727
export const SIDEBAR_THREAD_ROW_HEIGHT_MIN = 24;
2828
export const SIDEBAR_THREAD_ROW_HEIGHT_MAX = 44;
2929
export const DEFAULT_SIDEBAR_THREAD_ROW_HEIGHT = 28;
@@ -237,7 +237,7 @@ function clampBackgroundOpacity(value: number): number {
237237
return Math.max(0.05, Math.min(1, value));
238238
}
239239

240-
function clampSidebarProjectRowHeight(value: number): number {
240+
export function clampSidebarProjectRowHeight(value: number): number {
241241
return Math.round(
242242
Math.max(SIDEBAR_PROJECT_ROW_HEIGHT_MIN, Math.min(SIDEBAR_PROJECT_ROW_HEIGHT_MAX, value)),
243243
);

apps/web/src/components/CodeMirrorViewer.tsx

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -40,7 +40,8 @@ const baseExtensions: Extension[] = [
4040
backgroundColor: "var(--background)",
4141
},
4242
".cm-scroller": {
43-
fontFamily: "ui-monospace, SFMono-Regular, 'SF Mono', Menlo, Consolas, monospace",
43+
fontFamily:
44+
"var(--font-code, var(--font-mono, ui-monospace, SFMono-Regular, 'SF Mono', Menlo, Consolas, monospace))",
4445
overflow: "auto",
4546
},
4647
".cm-gutters": {

apps/web/src/components/ThreadTerminalDrawer.tsx

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -332,12 +332,19 @@ function TerminalViewport({
332332
let disposed = false;
333333

334334
const fitAddon = new FitAddon();
335+
// Resolve `--font-code` at mount time so the terminal picks up the user's
336+
// Code font selection. xterm.js renders via canvas and does not accept
337+
// CSS vars in `fontFamily`, so we have to snapshot the value here.
338+
const resolvedCodeFont =
339+
(typeof window !== "undefined" &&
340+
getComputedStyle(document.documentElement).getPropertyValue("--font-code").trim()) ||
341+
'"JetBrains Mono", "SF Mono", "SFMono-Regular", Consolas, "Liberation Mono", Menlo, monospace';
335342
const terminal = new Terminal({
336343
cursorBlink: true,
337344
lineHeight: 1.2,
338345
fontSize: getStoredFontSizeOverride() ?? 12,
339346
scrollback: 5_000,
340-
fontFamily: '"SF Mono", "SFMono-Regular", Consolas, "Liberation Mono", Menlo, monospace',
347+
fontFamily: resolvedCodeFont,
341348
theme: terminalThemeFromApp(),
342349
});
343350
terminal.loadAddon(fitAddon);

0 commit comments

Comments
 (0)