diff --git a/KEYBINDINGS.md b/KEYBINDINGS.md index ce6723dd..81f845e0 100644 --- a/KEYBINDINGS.md +++ b/KEYBINDINGS.md @@ -27,7 +27,11 @@ See the full schema for more details: [`packages/contracts/src/keybindings.ts`]( { "key": "mod+n", "command": "chat.new", "when": "!terminalFocus" }, { "key": "mod+shift+o", "command": "chat.new", "when": "!terminalFocus" }, { "key": "mod+shift+n", "command": "chat.newLocal", "when": "!terminalFocus" }, - { "key": "mod+o", "command": "editor.openFavorite" } + { "key": "mod+o", "command": "editor.openFavorite" }, + { "key": "mod+=", "command": "view.zoomIn" }, + { "key": "mod+plus", "command": "view.zoomIn" }, + { "key": "mod+-", "command": "view.zoomOut" }, + { "key": "mod+0", "command": "view.zoomReset" } ] ``` @@ -54,6 +58,9 @@ Invalid rules are ignored. Invalid config files are ignored. Warnings are logged - `chat.new`: create a new chat thread preserving the active thread's branch/worktree state - `chat.newLocal`: create a new chat thread for the active project in a new environment (local/worktree determined by app settings (default `local`)) - `editor.openFavorite`: open current project/worktree in the last-used editor +- `view.zoomIn`: scale the whole UI up one step (persisted to localStorage) +- `view.zoomOut`: scale the whole UI down one step (persisted to localStorage) +- `view.zoomReset`: reset the UI zoom back to 100 % - `script.{id}.run`: run a project script by id (for example `script.test.run`) ### Key Syntax diff --git a/apps/desktop/src/main.ts b/apps/desktop/src/main.ts index 6f3e8abd..b134f71b 100644 --- a/apps/desktop/src/main.ts +++ b/apps/desktop/src/main.ts @@ -60,6 +60,9 @@ const CONFIRM_CHANNEL = "desktop:confirm"; const SET_THEME_CHANNEL = "desktop:set-theme"; const SET_SIDEBAR_OPACITY_CHANNEL = "desktop:set-sidebar-opacity"; const SET_WINDOW_BUTTON_VISIBILITY_CHANNEL = "desktop:set-window-button-visibility"; +const SET_ZOOM_FACTOR_CHANNEL = "desktop:set-zoom-factor"; +const ZOOM_MIN = 0.75; +const ZOOM_MAX = 1.75; const CONTEXT_MENU_CHANNEL = "desktop:context-menu"; const OPEN_EXTERNAL_CHANNEL = "desktop:open-external"; const MENU_ACTION_CHANNEL = "desktop:menu-action"; @@ -648,10 +651,30 @@ function configureApplicationMenu(): void { { role: "forceReload" }, { role: "toggleDevTools" }, { type: "separator" }, - { role: "resetZoom" }, - { role: "zoomIn", accelerator: "CmdOrCtrl+=" }, - { role: "zoomIn", accelerator: "CmdOrCtrl+Plus", visible: false }, - { role: "zoomOut" }, + // Dispatch to the renderer instead of using Electron's role-based zoom. + // The renderer owns the persisted zoom factor; native roles would + // bypass our storage and the two would drift. + { + label: "Actual Size", + accelerator: "CmdOrCtrl+0", + click: () => dispatchMenuAction("view-zoom-reset"), + }, + { + label: "Zoom In", + accelerator: "CmdOrCtrl+=", + click: () => dispatchMenuAction("view-zoom-in"), + }, + { + label: "Zoom In", + accelerator: "CmdOrCtrl+Plus", + visible: false, + click: () => dispatchMenuAction("view-zoom-in"), + }, + { + label: "Zoom Out", + accelerator: "CmdOrCtrl+-", + click: () => dispatchMenuAction("view-zoom-out"), + }, { type: "separator" }, { role: "togglefullscreen" }, ], @@ -1195,6 +1218,19 @@ function registerIpcHandlers(): void { // applies the value through a CSS custom-property. }); + ipcMain.removeHandler(SET_ZOOM_FACTOR_CHANNEL); + ipcMain.handle(SET_ZOOM_FACTOR_CHANNEL, async (event, rawFactor: unknown) => { + // Scale the requesting webContents. We clamp to the same [0.75, 1.75] + // range the renderer enforces so a malicious or buggy call can't drive + // the UI into an unreadable state. + const contents = event.sender; + if (!contents || typeof contents.setZoomFactor !== "function") return; + const numeric = typeof rawFactor === "number" ? rawFactor : Number(rawFactor); + const factor = Number.isFinite(numeric) ? numeric : 1; + const clamped = Math.max(ZOOM_MIN, Math.min(ZOOM_MAX, factor)); + contents.setZoomFactor(clamped); + }); + ipcMain.removeHandler(SET_WINDOW_BUTTON_VISIBILITY_CHANNEL); ipcMain.handle(SET_WINDOW_BUTTON_VISIBILITY_CHANNEL, async (event, rawVisible: unknown) => { if (process.platform !== "darwin" || typeof rawVisible !== "boolean") { diff --git a/apps/desktop/src/preload.ts b/apps/desktop/src/preload.ts index 15a34615..1c2b64a9 100644 --- a/apps/desktop/src/preload.ts +++ b/apps/desktop/src/preload.ts @@ -7,6 +7,7 @@ const CONFIRM_CHANNEL = "desktop:confirm"; const SET_THEME_CHANNEL = "desktop:set-theme"; const SET_SIDEBAR_OPACITY_CHANNEL = "desktop:set-sidebar-opacity"; const SET_WINDOW_BUTTON_VISIBILITY_CHANNEL = "desktop:set-window-button-visibility"; +const SET_ZOOM_FACTOR_CHANNEL = "desktop:set-zoom-factor"; const CONTEXT_MENU_CHANNEL = "desktop:context-menu"; const OPEN_EXTERNAL_CHANNEL = "desktop:open-external"; const MENU_ACTION_CHANNEL = "desktop:menu-action"; @@ -42,6 +43,7 @@ contextBridge.exposeInMainWorld("desktopBridge", { setSidebarOpacity: (opacity) => ipcRenderer.invoke(SET_SIDEBAR_OPACITY_CHANNEL, opacity), setWindowButtonVisibility: (visible) => ipcRenderer.invoke(SET_WINDOW_BUTTON_VISIBILITY_CHANNEL, visible), + setZoomFactor: (factor) => ipcRenderer.invoke(SET_ZOOM_FACTOR_CHANNEL, factor), showContextMenu: (items, position) => ipcRenderer.invoke(CONTEXT_MENU_CHANNEL, items, position), openExternal: (url: string) => ipcRenderer.invoke(OPEN_EXTERNAL_CHANNEL, url), onMenuAction: (listener) => { diff --git a/apps/web/src/appSettings.test.ts b/apps/web/src/appSettings.test.ts index 20ef20e1..19622be8 100644 --- a/apps/web/src/appSettings.test.ts +++ b/apps/web/src/appSettings.test.ts @@ -3,6 +3,7 @@ import { describe, expect, it } from "vitest"; import { AppSettingsSchema, + clampSidebarProjectRowHeight, DEFAULT_BROWSER_PREVIEW_START_PAGE_URL, DEFAULT_PR_REVIEW_REQUEST_CHANGES_TONE, DEFAULT_SIDEBAR_FONT_SIZE, @@ -11,6 +12,8 @@ import { DEFAULT_SIDEBAR_THREAD_ROW_HEIGHT, getProviderStartOptions, resolveBrowserPreviewStartPageUrl, + SIDEBAR_PROJECT_ROW_HEIGHT_MAX, + SIDEBAR_PROJECT_ROW_HEIGHT_MIN, } from "./appSettings"; describe("AppSettingsSchema", () => { @@ -62,6 +65,34 @@ describe("AppSettingsSchema", () => { }); }); +describe("clampSidebarProjectRowHeight", () => { + it("exposes the expected accessibility-minded bounds", () => { + expect(SIDEBAR_PROJECT_ROW_HEIGHT_MIN).toBe(32); + expect(SIDEBAR_PROJECT_ROW_HEIGHT_MAX).toBe(72); + expect(DEFAULT_SIDEBAR_PROJECT_ROW_HEIGHT).toBe(32); + }); + + it("clamps below-floor values up to the new floor of 32", () => { + expect(clampSidebarProjectRowHeight(0)).toBe(32); + expect(clampSidebarProjectRowHeight(24)).toBe(32); // legacy floor + expect(clampSidebarProjectRowHeight(28)).toBe(32); // legacy default + expect(clampSidebarProjectRowHeight(31)).toBe(32); + }); + + it("accepts in-range values and rounds fractional input", () => { + expect(clampSidebarProjectRowHeight(32)).toBe(32); + expect(clampSidebarProjectRowHeight(48)).toBe(48); + expect(clampSidebarProjectRowHeight(71.4)).toBe(71); + expect(clampSidebarProjectRowHeight(72)).toBe(72); + }); + + it("clamps above-ceiling values down to the new max of 72", () => { + expect(clampSidebarProjectRowHeight(73)).toBe(72); + expect(clampSidebarProjectRowHeight(120)).toBe(72); + expect(clampSidebarProjectRowHeight(Number.POSITIVE_INFINITY)).toBe(72); + }); +}); + describe("getProviderStartOptions", () => { it("includes the Claude binary path when configured", () => { expect( diff --git a/apps/web/src/appSettings.ts b/apps/web/src/appSettings.ts index e63e9186..d2b5c4a7 100644 --- a/apps/web/src/appSettings.ts +++ b/apps/web/src/appSettings.ts @@ -21,9 +21,9 @@ const MAX_CUSTOM_MODEL_COUNT = 32; export const MAX_CUSTOM_MODEL_LENGTH = 256; const BACKGROUND_IMAGE_KEY = "okcode:background-image"; const BACKGROUND_OPACITY_KEY = "okcode:background-opacity"; -export const SIDEBAR_PROJECT_ROW_HEIGHT_MIN = 24; -export const SIDEBAR_PROJECT_ROW_HEIGHT_MAX = 44; -export const DEFAULT_SIDEBAR_PROJECT_ROW_HEIGHT = 28; +export const SIDEBAR_PROJECT_ROW_HEIGHT_MIN = 32; +export const SIDEBAR_PROJECT_ROW_HEIGHT_MAX = 72; +export const DEFAULT_SIDEBAR_PROJECT_ROW_HEIGHT = 32; export const SIDEBAR_THREAD_ROW_HEIGHT_MIN = 24; export const SIDEBAR_THREAD_ROW_HEIGHT_MAX = 44; export const DEFAULT_SIDEBAR_THREAD_ROW_HEIGHT = 28; @@ -237,7 +237,7 @@ function clampBackgroundOpacity(value: number): number { return Math.max(0.05, Math.min(1, value)); } -function clampSidebarProjectRowHeight(value: number): number { +export function clampSidebarProjectRowHeight(value: number): number { return Math.round( Math.max(SIDEBAR_PROJECT_ROW_HEIGHT_MIN, Math.min(SIDEBAR_PROJECT_ROW_HEIGHT_MAX, value)), ); diff --git a/apps/web/src/components/CodeMirrorViewer.tsx b/apps/web/src/components/CodeMirrorViewer.tsx index 7aa92c6b..7570422a 100644 --- a/apps/web/src/components/CodeMirrorViewer.tsx +++ b/apps/web/src/components/CodeMirrorViewer.tsx @@ -40,7 +40,8 @@ const baseExtensions: Extension[] = [ backgroundColor: "var(--background)", }, ".cm-scroller": { - fontFamily: "ui-monospace, SFMono-Regular, 'SF Mono', Menlo, Consolas, monospace", + fontFamily: + "var(--font-code, var(--font-mono, ui-monospace, SFMono-Regular, 'SF Mono', Menlo, Consolas, monospace))", overflow: "auto", }, ".cm-gutters": { diff --git a/apps/web/src/components/ThreadTerminalDrawer.tsx b/apps/web/src/components/ThreadTerminalDrawer.tsx index e1761814..e3b61c1b 100644 --- a/apps/web/src/components/ThreadTerminalDrawer.tsx +++ b/apps/web/src/components/ThreadTerminalDrawer.tsx @@ -332,12 +332,19 @@ function TerminalViewport({ let disposed = false; const fitAddon = new FitAddon(); + // Resolve `--font-code` at mount time so the terminal picks up the user's + // Code font selection. xterm.js renders via canvas and does not accept + // CSS vars in `fontFamily`, so we have to snapshot the value here. + const resolvedCodeFont = + (typeof window !== "undefined" && + getComputedStyle(document.documentElement).getPropertyValue("--font-code").trim()) || + '"JetBrains Mono", "SF Mono", "SFMono-Regular", Consolas, "Liberation Mono", Menlo, monospace'; const terminal = new Terminal({ cursorBlink: true, lineHeight: 1.2, fontSize: getStoredFontSizeOverride() ?? 12, scrollback: 5_000, - fontFamily: '"SF Mono", "SFMono-Regular", Consolas, "Liberation Mono", Menlo, monospace', + fontFamily: resolvedCodeFont, theme: terminalThemeFromApp(), }); terminal.loadAddon(fitAddon); diff --git a/apps/web/src/components/settings/SettingsRouteContext.tsx b/apps/web/src/components/settings/SettingsRouteContext.tsx index 66fc9332..3be5f26a 100644 --- a/apps/web/src/components/settings/SettingsRouteContext.tsx +++ b/apps/web/src/components/settings/SettingsRouteContext.tsx @@ -1,22 +1,40 @@ -import { type ReactNode, createContext, useCallback, useContext, useMemo, useState } from "react"; +import { + type ReactNode, + createContext, + useCallback, + useContext, + useEffect, + useMemo, + useState, +} from "react"; import { DEFAULT_GIT_TEXT_GENERATION_MODEL } from "@okcode/contracts"; import { DEFAULT_PR_REVIEW_REQUEST_CHANGES_TONE, useAppSettings } from "../../appSettings"; -import { DEFAULT_COLOR_THEME, useTheme } from "../../hooks/useTheme"; +import { + DEFAULT_CODE_FONT, + DEFAULT_COLOR_THEME, + DEFAULT_MESSAGE_FONT, + useTheme, +} from "../../hooks/useTheme"; import { readNativeApi, ensureNativeApi } from "../../nativeApi"; import { + ZOOM_CHANGE_EVENT, + ZOOM_DEFAULT, clearFontOverride, clearFontSizeOverride, clearRadiusOverride, clearStoredCustomTheme, + clearZoom, getStoredFontOverride, getStoredFontSizeOverride, getStoredRadiusOverride, + getStoredZoom, removeCustomTheme, setStoredFontOverride, setStoredFontSizeOverride, setStoredRadiusOverride, + setStoredZoom, } from "../../lib/customTheme"; type ThemeState = ReturnType; @@ -26,8 +44,10 @@ interface SettingsRouteContextValue { setTheme: ThemeState["setTheme"]; colorTheme: ThemeState["colorTheme"]; setColorTheme: ThemeState["setColorTheme"]; - fontFamily: ThemeState["fontFamily"]; - setFontFamily: ThemeState["setFontFamily"]; + messageFont: ThemeState["messageFont"]; + setMessageFont: ThemeState["setMessageFont"]; + codeFont: ThemeState["codeFont"]; + setCodeFont: ThemeState["setCodeFont"]; settingsState: ReturnType; radiusOverride: number | null; setRadiusOverride: (value: number | null) => void; @@ -35,6 +55,8 @@ interface SettingsRouteContextValue { setFontOverride: (value: string) => void; fontSizeOverride: number | null; setFontSizeOverride: (value: number | null) => void; + zoom: number; + setZoom: (value: number) => void; changedSettingLabels: readonly string[]; restoreDefaults: () => Promise; } @@ -42,7 +64,16 @@ interface SettingsRouteContextValue { const SettingsRouteContext = createContext(null); export function SettingsRouteContextProvider({ children }: { children: ReactNode }) { - const { theme, setTheme, colorTheme, setColorTheme, fontFamily, setFontFamily } = useTheme(); + const { + theme, + setTheme, + colorTheme, + setColorTheme, + messageFont, + setMessageFont, + codeFont, + setCodeFont, + } = useTheme(); const settingsState = useAppSettings(); const { settings, defaults, resetSettings } = settingsState; const [radiusOverrideState, setRadiusOverrideState] = useState(() => @@ -54,6 +85,7 @@ export function SettingsRouteContextProvider({ children }: { children: ReactNode const [fontSizeOverrideState, setFontSizeOverrideState] = useState(() => getStoredFontSizeOverride(), ); + const [zoomState, setZoomState] = useState(() => getStoredZoom()); const setRadiusOverride = useCallback((value: number | null) => { setRadiusOverrideState(value); @@ -82,6 +114,32 @@ export function SettingsRouteContextProvider({ children }: { children: ReactNode setStoredFontSizeOverride(value); }, []); + // Keep local React state in sync with storage. `setStoredZoom` also + // clamps — we read back via `getStoredZoom` so the slider shows the clamped + // value rather than the raw input when the user drags past the bounds. + const setZoom = useCallback((value: number) => { + setStoredZoom(value); + setZoomState(getStoredZoom()); + }, []); + + // The keybinding handler in `ChatRouteGlobalShortcuts` writes zoom directly + // to storage via `setStoredZoom`. Listen for the in-window `zoom-change` + // event so the slider reflects keyboard-driven changes live; also listen to + // `storage` for multi-window consistency. + useEffect(() => { + const refresh = () => setZoomState(getStoredZoom()); + const handleStorage = (event: StorageEvent) => { + if (event.key !== "okcode:app-zoom") return; + refresh(); + }; + window.addEventListener("storage", handleStorage); + window.addEventListener(ZOOM_CHANGE_EVENT, refresh as EventListener); + return () => { + window.removeEventListener("storage", handleStorage); + window.removeEventListener(ZOOM_CHANGE_EVENT, refresh as EventListener); + }; + }, []); + const currentGitTextGenerationModel = settings.textGenerationModel ?? DEFAULT_GIT_TEXT_GENERATION_MODEL; const defaultGitTextGenerationModel = @@ -103,7 +161,8 @@ export function SettingsRouteContextProvider({ children }: { children: ReactNode [ ...(theme !== "system" ? ["Theme"] : []), ...(colorTheme !== DEFAULT_COLOR_THEME ? ["Color theme"] : []), - ...(fontFamily !== "inter" ? ["Font"] : []), + ...(messageFont !== DEFAULT_MESSAGE_FONT ? ["Message font"] : []), + ...(codeFont !== DEFAULT_CODE_FONT ? ["Code font"] : []), ...(settings.prReviewRequestChangesTone !== DEFAULT_PR_REVIEW_REQUEST_CHANGES_TONE ? ["PR request changes button"] : []), @@ -177,19 +236,22 @@ export function SettingsRouteContextProvider({ children }: { children: ReactNode ...(radiusOverrideState !== null ? ["Border radius"] : []), ...(fontOverrideState ? ["Font family"] : []), ...(fontSizeOverrideState !== null ? ["Code font size"] : []), + ...(zoomState !== ZOOM_DEFAULT ? ["App zoom"] : []), ] as const, [ + codeFont, colorTheme, defaults, - fontFamily, fontOverrideState, fontSizeOverrideState, isGitTextGenerationModelDirty, isInstallSettingsDirty, isOpenClawSettingsDirty, + messageFont, radiusOverrideState, settings, theme, + zoomState, ], ); @@ -206,7 +268,8 @@ export function SettingsRouteContextProvider({ children }: { children: ReactNode setTheme("system"); setColorTheme(DEFAULT_COLOR_THEME); - setFontFamily("inter"); + setMessageFont(DEFAULT_MESSAGE_FONT); + setCodeFont(DEFAULT_CODE_FONT); resetSettings(); clearStoredCustomTheme(); @@ -217,15 +280,19 @@ export function SettingsRouteContextProvider({ children }: { children: ReactNode setFontOverrideState(""); clearFontSizeOverride(); setFontSizeOverrideState(null); + clearZoom(); + setZoomState(ZOOM_DEFAULT); }, [ changedSettingLabels, resetSettings, + setCodeFont, setColorTheme, - setFontFamily, + setMessageFont, setTheme, setFontOverrideState, setFontSizeOverrideState, setRadiusOverrideState, + setZoomState, ]); const value = useMemo( @@ -234,8 +301,10 @@ export function SettingsRouteContextProvider({ children }: { children: ReactNode setTheme, colorTheme, setColorTheme, - fontFamily, - setFontFamily, + messageFont, + setMessageFont, + codeFont, + setCodeFont, settingsState, radiusOverride: radiusOverrideState, setRadiusOverride, @@ -243,25 +312,31 @@ export function SettingsRouteContextProvider({ children }: { children: ReactNode setFontOverride, fontSizeOverride: fontSizeOverrideState, setFontSizeOverride, + zoom: zoomState, + setZoom, changedSettingLabels, restoreDefaults, }), [ changedSettingLabels, + codeFont, colorTheme, - fontFamily, fontOverrideState, fontSizeOverrideState, + messageFont, radiusOverrideState, restoreDefaults, + setCodeFont, setColorTheme, - setFontFamily, setFontOverride, setFontSizeOverride, + setMessageFont, setRadiusOverride, setTheme, + setZoom, settingsState, theme, + zoomState, ], ); diff --git a/apps/web/src/hooks/themeConfig.ts b/apps/web/src/hooks/themeConfig.ts index f735c3a1..440c3d11 100644 --- a/apps/web/src/hooks/themeConfig.ts +++ b/apps/web/src/hooks/themeConfig.ts @@ -6,7 +6,158 @@ export type ColorTheme = | "purple-stuff" | "hot-tamale" | "custom"; -export type FontFamily = "dm-sans" | "inter" | "plus-jakarta-sans"; + +/** + * Font used for chat messages and most UI text (prose). Applied via the + * `--font-ui` CSS variable on `:root`. Selection is persisted separately from + * {@link CodeFont}. + */ +export type MessageFont = + | "inter" + | "dm-sans" + | "plus-jakarta-sans" + | "playfair-display" + | "cormorant-garamond" + | "dm-serif-display" + | "italiana" + | "cinzel" + | "bodoni-moda"; + +/** + * Font used for code blocks, diff viewer, terminal, and CodeMirror editors. + * Applied via the `--font-code` CSS variable on `:root`. + */ +export type CodeFont = + | "jetbrains-mono" + | "fira-code" + | "victor-mono" + | "cascadia-code" + | "monaspace-radon" + | "recursive-mono" + | "ibm-plex-mono" + | "source-code-pro"; + +export interface FontOption { + readonly id: Id; + readonly label: string; + /** Full CSS `font-family` stack (already quote-escaped). */ + readonly stack: string; + /** Google Fonts family name to request, or `null` if self-/system-hosted. */ + readonly googleFont: string | null; +} + +const SANS_FALLBACK = '-apple-system, BlinkMacSystemFont, "Segoe UI", system-ui, sans-serif'; +const SERIF_FALLBACK = 'Georgia, "Times New Roman", Didot, serif'; +const MONO_FALLBACK = + 'ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, "Liberation Mono", monospace'; + +export const MESSAGE_FONTS: ReadonlyArray> = [ + { + id: "inter", + label: "Inter", + stack: `"Inter", ${SANS_FALLBACK}`, + googleFont: "Inter", + }, + { + id: "dm-sans", + label: "DM Sans", + stack: `"DM Sans", ${SANS_FALLBACK}`, + googleFont: "DM Sans", + }, + { + id: "plus-jakarta-sans", + label: "Plus Jakarta Sans", + stack: `"Plus Jakarta Sans", ${SANS_FALLBACK}`, + googleFont: "Plus Jakarta Sans", + }, + { + id: "playfair-display", + label: "Playfair Display", + stack: `"Playfair Display", ${SERIF_FALLBACK}`, + googleFont: "Playfair Display", + }, + { + id: "cormorant-garamond", + label: "Cormorant Garamond", + stack: `"Cormorant Garamond", Garamond, ${SERIF_FALLBACK}`, + googleFont: "Cormorant Garamond", + }, + { + id: "dm-serif-display", + label: "DM Serif Display", + stack: `"DM Serif Display", "Playfair Display", ${SERIF_FALLBACK}`, + googleFont: "DM Serif Display", + }, + { + id: "italiana", + label: "Italiana", + stack: `"Italiana", Didot, ${SERIF_FALLBACK}`, + googleFont: "Italiana", + }, + { + id: "cinzel", + label: "Cinzel", + stack: `"Cinzel", "Trajan Pro", ${SERIF_FALLBACK}`, + googleFont: "Cinzel", + }, + { + id: "bodoni-moda", + label: "Bodoni Moda", + stack: `"Bodoni Moda", Didot, ${SERIF_FALLBACK}`, + googleFont: "Bodoni Moda", + }, +]; + +export const CODE_FONTS: ReadonlyArray> = [ + { + id: "jetbrains-mono", + label: "JetBrains Mono", + stack: `"JetBrains Mono", ${MONO_FALLBACK}`, + googleFont: "JetBrains Mono", + }, + { + id: "fira-code", + label: "Fira Code", + stack: `"Fira Code", ${MONO_FALLBACK}`, + googleFont: "Fira Code", + }, + { + id: "victor-mono", + label: "Victor Mono", + stack: `"Victor Mono", ${MONO_FALLBACK}`, + googleFont: "Victor Mono", + }, + { + id: "cascadia-code", + label: "Cascadia Code", + stack: `"Cascadia Code", "Cascadia Mono", ${MONO_FALLBACK}`, + googleFont: null, + }, + { + id: "monaspace-radon", + label: "Monaspace Radon", + stack: `"Monaspace Radon", "Monaspace Neon", ${MONO_FALLBACK}`, + googleFont: null, + }, + { + id: "recursive-mono", + label: "Recursive Mono", + stack: `"Recursive Mono Casual Static", "Recursive Mono", ${MONO_FALLBACK}`, + googleFont: "Recursive", + }, + { + id: "ibm-plex-mono", + label: "IBM Plex Mono", + stack: `"IBM Plex Mono", ${MONO_FALLBACK}`, + googleFont: "IBM Plex Mono", + }, + { + id: "source-code-pro", + label: "Source Code Pro", + stack: `"Source Code Pro", ${MONO_FALLBACK}`, + googleFont: "Source Code Pro", + }, +]; export const COLOR_THEMES: { id: ColorTheme; label: string }[] = [ { id: "default", label: "Default" }, @@ -17,10 +168,33 @@ export const COLOR_THEMES: { id: ColorTheme; label: string }[] = [ { id: "custom", label: "Custom" }, ]; -export const FONT_FAMILIES: { id: FontFamily; label: string }[] = [ - { id: "inter", label: "Inter" }, - { id: "dm-sans", label: "DM Sans" }, - { id: "plus-jakarta-sans", label: "Plus Jakarta Sans" }, -]; - export const DEFAULT_COLOR_THEME: ColorTheme = "carbon"; +export const DEFAULT_MESSAGE_FONT: MessageFont = "inter"; +export const DEFAULT_CODE_FONT: CodeFont = "jetbrains-mono"; + +const MESSAGE_FONT_IDS: ReadonlySet = new Set(MESSAGE_FONTS.map((f) => f.id)); +const CODE_FONT_IDS: ReadonlySet = new Set(CODE_FONTS.map((f) => f.id)); + +export function isMessageFont(value: unknown): value is MessageFont { + return typeof value === "string" && MESSAGE_FONT_IDS.has(value); +} + +export function isCodeFont(value: unknown): value is CodeFont { + return typeof value === "string" && CODE_FONT_IDS.has(value); +} + +// TypeScript under `noUncheckedIndexedAccess` types MESSAGE_FONTS[0] as +// possibly undefined even though the array literal guarantees otherwise. +// Pull the fallback out into a named constant whose type we enforce so the +// resolver can return `string` without a non-null assertion. +const MESSAGE_FONT_FALLBACK_STACK: string = MESSAGE_FONTS[0]?.stack ?? "system-ui, sans-serif"; +const CODE_FONT_FALLBACK_STACK: string = + CODE_FONTS[0]?.stack ?? "ui-monospace, Menlo, Consolas, monospace"; + +export function getMessageFontStack(id: MessageFont): string { + return MESSAGE_FONTS.find((f) => f.id === id)?.stack ?? MESSAGE_FONT_FALLBACK_STACK; +} + +export function getCodeFontStack(id: CodeFont): string { + return CODE_FONTS.find((f) => f.id === id)?.stack ?? CODE_FONT_FALLBACK_STACK; +} diff --git a/apps/web/src/hooks/useTheme.test.ts b/apps/web/src/hooks/useTheme.test.ts index d68b23ce..3d62d68d 100644 --- a/apps/web/src/hooks/useTheme.test.ts +++ b/apps/web/src/hooks/useTheme.test.ts @@ -1,9 +1,108 @@ import { describe, expect, it } from "vitest"; -import { COLOR_THEMES } from "./themeConfig"; +import { + CODE_FONTS, + COLOR_THEMES, + DEFAULT_CODE_FONT, + DEFAULT_MESSAGE_FONT, + MESSAGE_FONTS, + getCodeFontStack, + getMessageFontStack, + isCodeFont, + isMessageFont, +} from "./themeConfig"; describe("COLOR_THEMES", () => { it("includes the Hot Tamale preset", () => { expect(COLOR_THEMES.some((theme) => theme.id === "hot-tamale")).toBe(true); }); }); + +describe("MESSAGE_FONTS catalogue", () => { + it("exposes 8+ options covering both neutral sans and femme-fatale serifs", () => { + expect(MESSAGE_FONTS.length).toBeGreaterThanOrEqual(8); + const ids = MESSAGE_FONTS.map((f) => f.id); + // Preserved legacy picks so existing users aren't orphaned. + expect(ids).toContain("inter"); + expect(ids).toContain("dm-sans"); + // Femme-fatale lineup. + expect(ids).toContain("playfair-display"); + expect(ids).toContain("italiana"); + expect(ids).toContain("cinzel"); + }); + + it("ships a non-empty CSS stack for every option", () => { + for (const option of MESSAGE_FONTS) { + expect(option.stack.trim().length).toBeGreaterThan(0); + // Every stack falls through to a generic family so there is always a + // renderable font even if Google Fonts failed to load. + expect(option.stack).toMatch(/sans-serif|serif|monospace/); + } + }); + + it("points DEFAULT_MESSAGE_FONT at a known option", () => { + expect(MESSAGE_FONTS.some((f) => f.id === DEFAULT_MESSAGE_FONT)).toBe(true); + }); +}); + +describe("CODE_FONTS catalogue", () => { + it("exposes 8+ options and includes the femme-fatale monos", () => { + expect(CODE_FONTS.length).toBeGreaterThanOrEqual(8); + const ids = CODE_FONTS.map((f) => f.id); + expect(ids).toContain("jetbrains-mono"); + // Victor Mono is the signature cursive-italic code font. + expect(ids).toContain("victor-mono"); + expect(ids).toContain("cascadia-code"); + }); + + it("terminates every stack with a monospace fallback", () => { + for (const option of CODE_FONTS) { + expect(option.stack).toMatch(/monospace\s*$/); + } + }); + + it("points DEFAULT_CODE_FONT at a known option", () => { + expect(CODE_FONTS.some((f) => f.id === DEFAULT_CODE_FONT)).toBe(true); + }); +}); + +describe("font type guards", () => { + it("isMessageFont returns true for every catalogue id and false otherwise", () => { + for (const option of MESSAGE_FONTS) { + expect(isMessageFont(option.id)).toBe(true); + } + expect(isMessageFont("jetbrains-mono")).toBe(false); // code id, not message + expect(isMessageFont("comic-sans")).toBe(false); + expect(isMessageFont(null)).toBe(false); + expect(isMessageFont(undefined)).toBe(false); + expect(isMessageFont(42)).toBe(false); + }); + + it("isCodeFont returns true for every catalogue id and false otherwise", () => { + for (const option of CODE_FONTS) { + expect(isCodeFont(option.id)).toBe(true); + } + expect(isCodeFont("inter")).toBe(false); // message id, not code + expect(isCodeFont("")).toBe(false); + expect(isCodeFont(null)).toBe(false); + }); +}); + +describe("font stack resolvers", () => { + it("returns the matching stack for known ids", () => { + expect(getMessageFontStack("playfair-display")).toContain("Playfair Display"); + expect(getCodeFontStack("victor-mono")).toContain("Victor Mono"); + }); + + it("falls back to the first catalogue entry for an unknown id (edge case)", () => { + // `any` cast simulates a corrupt storage value reaching the resolver. + const messageFallback = getMessageFontStack("totally-fake" as never); + const codeFallback = getCodeFontStack("also-fake" as never); + const firstMessage = MESSAGE_FONTS[0]; + const firstCode = CODE_FONTS[0]; + expect(firstMessage).toBeDefined(); + expect(firstCode).toBeDefined(); + expect(messageFallback).toBe(firstMessage?.stack); + expect(codeFallback).toBe(firstCode?.stack); + }); +}); diff --git a/apps/web/src/hooks/useTheme.ts b/apps/web/src/hooks/useTheme.ts index 80b783b8..b153d592 100644 --- a/apps/web/src/hooks/useTheme.ts +++ b/apps/web/src/hooks/useTheme.ts @@ -1,19 +1,43 @@ import { useCallback, useEffect, useSyncExternalStore } from "react"; import { initCustomTheme } from "../lib/customTheme"; -import { DEFAULT_COLOR_THEME } from "./themeConfig"; -export { COLOR_THEMES, DEFAULT_COLOR_THEME, FONT_FAMILIES } from "./themeConfig"; -import type { ColorTheme, FontFamily, Theme } from "./themeConfig"; +import { + CODE_FONTS, + DEFAULT_CODE_FONT, + DEFAULT_COLOR_THEME, + DEFAULT_MESSAGE_FONT, + MESSAGE_FONTS, + getCodeFontStack, + getMessageFontStack, + isCodeFont, + isMessageFont, +} from "./themeConfig"; +export { + CODE_FONTS, + COLOR_THEMES, + DEFAULT_CODE_FONT, + DEFAULT_COLOR_THEME, + DEFAULT_MESSAGE_FONT, + MESSAGE_FONTS, + getCodeFontStack, + getMessageFontStack, +} from "./themeConfig"; +import type { CodeFont, ColorTheme, MessageFont, Theme } from "./themeConfig"; type ThemeSnapshot = { theme: Theme; systemDark: boolean; colorTheme: ColorTheme; - fontFamily: FontFamily; + messageFont: MessageFont; + codeFont: CodeFont; }; const STORAGE_KEY = "okcode:theme"; const COLOR_THEME_STORAGE_KEY = "okcode:color-theme"; -const FONT_FAMILY_STORAGE_KEY = "okcode:font-family"; +/** Legacy (pre-split) — kept for one-shot migration to {@link MESSAGE_FONT_STORAGE_KEY}. */ +const LEGACY_FONT_FAMILY_STORAGE_KEY = "okcode:font-family"; +const MESSAGE_FONT_STORAGE_KEY = "okcode:message-font"; +const CODE_FONT_STORAGE_KEY = "okcode:code-font"; +const RUNTIME_FONTS_LINK_ID = "okcode-runtime-fonts"; const MEDIA_QUERY = "(prefers-color-scheme: dark)"; const canUseDOM = typeof window !== "undefined" && typeof document !== "undefined"; @@ -21,7 +45,8 @@ const SERVER_SNAPSHOT: ThemeSnapshot = { theme: "system", systemDark: false, colorTheme: DEFAULT_COLOR_THEME, - fontFamily: "inter", + messageFont: DEFAULT_MESSAGE_FONT, + codeFont: DEFAULT_CODE_FONT, }; let listeners: Array<() => void> = []; @@ -90,20 +115,23 @@ function getStoredColorTheme(): ColorTheme { return DEFAULT_COLOR_THEME; } -function getStoredFontFamily(): FontFamily { - const raw = safeLocalStorageGet(FONT_FAMILY_STORAGE_KEY); - if (raw === "dm-sans" || raw === "inter" || raw === "plus-jakarta-sans") { - return raw; +function getStoredMessageFont(): MessageFont { + const raw = safeLocalStorageGet(MESSAGE_FONT_STORAGE_KEY); + if (isMessageFont(raw)) return raw; + // One-shot migration from the legacy `okcode:font-family` key. + const legacy = safeLocalStorageGet(LEGACY_FONT_FAMILY_STORAGE_KEY); + if (isMessageFont(legacy)) { + safeLocalStorageSet(MESSAGE_FONT_STORAGE_KEY, legacy); + return legacy; } - return "inter"; + return DEFAULT_MESSAGE_FONT; } -const FONT_FAMILY_MAP: Record = { - inter: '"Inter", -apple-system, BlinkMacSystemFont, "Segoe UI", system-ui, sans-serif', - "dm-sans": '"DM Sans", -apple-system, BlinkMacSystemFont, "Segoe UI", system-ui, sans-serif', - "plus-jakarta-sans": - '"Plus Jakarta Sans", -apple-system, BlinkMacSystemFont, "Segoe UI", system-ui, sans-serif', -}; +function getStoredCodeFont(): CodeFont { + const raw = safeLocalStorageGet(CODE_FONT_STORAGE_KEY); + if (isCodeFont(raw)) return raw; + return DEFAULT_CODE_FONT; +} function getRootElement(): HTMLElement | null { if (typeof document === "undefined") { @@ -134,13 +162,61 @@ function hasClassListTarget( ); } -function applyFont(fontFamily?: FontFamily) { - const font = fontFamily ?? getStoredFontFamily(); +function applyMessageFont(font?: MessageFont) { + const next = font ?? getStoredMessageFont(); + const root = getRootElement(); + if (!hasStyleTarget(root)) return; + root.style.setProperty("--font-ui", getMessageFontStack(next)); +} + +function applyCodeFont(font?: CodeFont) { + const next = font ?? getStoredCodeFont(); const root = getRootElement(); - if (!hasStyleTarget(root)) { + if (!hasStyleTarget(root)) return; + root.style.setProperty("--font-code", getCodeFontStack(next)); +} + +/** + * Ensures any Google-Fonts-backed fonts currently selected are available in + * the page. Replaces (never duplicates) a single `` tag keyed by + * {@link RUNTIME_FONTS_LINK_ID}. + * + * Node/vitest runs with a minimal HTMLElement shim but no document factory + * methods (`getElementById`, `createElement`, `document.head`). Guard every + * call we make so this helper no-ops cleanly under tests. + */ +function loadRequiredGoogleFonts(messageFont: MessageFont, codeFont: CodeFont) { + if (typeof document === "undefined") return; + if (typeof document.getElementById !== "function") return; + + const families: string[] = []; + const messageOption = MESSAGE_FONTS.find((f) => f.id === messageFont); + const codeOption = CODE_FONTS.find((f) => f.id === codeFont); + if (messageOption?.googleFont) families.push(messageOption.googleFont); + if (codeOption?.googleFont && codeOption.googleFont !== messageOption?.googleFont) { + families.push(codeOption.googleFont); + } + + const existing = document.getElementById(RUNTIME_FONTS_LINK_ID) as HTMLLinkElement | null; + + if (families.length === 0) { + existing?.remove(); return; } - root.style.setProperty("--font-ui", FONT_FAMILY_MAP[font]); + + const familyParams = families + .map((name) => `family=${name.replace(/\s+/g, "+")}:ital,wght@0,300..800;1,300..800`) + .join("&"); + const href = `https://fonts.googleapis.com/css2?${familyParams}&display=swap`; + + if (existing && existing.href === href) return; + + if (typeof document.createElement !== "function" || !document.head) return; + const link = existing ?? document.createElement("link"); + link.id = RUNTIME_FONTS_LINK_ID; + link.rel = "stylesheet"; + link.href = href; + if (!existing) document.head.appendChild(link); } function applyTheme(theme: Theme, suppressTransitions = false) { @@ -167,8 +243,12 @@ function applyTheme(theme: Theme, suppressTransitions = false) { root.classList.add(`theme-${colorTheme}`); } - // Apply font family - applyFont(); + // Apply message + code fonts (both read from storage, both independent). + const messageFont = getStoredMessageFont(); + const codeFont = getStoredCodeFont(); + applyMessageFont(messageFont); + applyCodeFont(codeFont); + loadRequiredGoogleFonts(messageFont, codeFont); syncDesktopTheme(theme); if (suppressTransitions) { @@ -211,19 +291,21 @@ function getSnapshot(): ThemeSnapshot { const theme = getStored(); const systemDark = theme === "system" ? getSystemDark() : false; const colorTheme = getStoredColorTheme(); - const fontFamily = getStoredFontFamily(); + const messageFont = getStoredMessageFont(); + const codeFont = getStoredCodeFont(); if ( lastSnapshot && lastSnapshot.theme === theme && lastSnapshot.systemDark === systemDark && lastSnapshot.colorTheme === colorTheme && - lastSnapshot.fontFamily === fontFamily + lastSnapshot.messageFont === messageFont && + lastSnapshot.codeFont === codeFont ) { return lastSnapshot; } - lastSnapshot = { theme, systemDark, colorTheme, fontFamily }; + lastSnapshot = { theme, systemDark, colorTheme, messageFont, codeFont }; return lastSnapshot; } @@ -251,7 +333,9 @@ function subscribe(listener: () => void): () => void { if ( e.key === STORAGE_KEY || e.key === COLOR_THEME_STORAGE_KEY || - e.key === FONT_FAMILY_STORAGE_KEY + e.key === MESSAGE_FONT_STORAGE_KEY || + e.key === CODE_FONT_STORAGE_KEY || + e.key === LEGACY_FONT_FAMILY_STORAGE_KEY ) { applyTheme(getStored(), true); emitChange(); @@ -270,7 +354,8 @@ export function useTheme() { const snapshot = useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot); const theme = snapshot.theme; const colorTheme = snapshot.colorTheme; - const fontFamily = snapshot.fontFamily; + const messageFont = snapshot.messageFont; + const codeFont = snapshot.codeFont; const resolvedTheme: "light" | "dark" = theme === "system" ? (snapshot.systemDark ? "dark" : "light") : theme; @@ -287,9 +372,17 @@ export function useTheme() { emitChange(); }, []); - const setFontFamily = useCallback((next: FontFamily) => { - safeLocalStorageSet(FONT_FAMILY_STORAGE_KEY, next); - applyFont(next); + const setMessageFont = useCallback((next: MessageFont) => { + safeLocalStorageSet(MESSAGE_FONT_STORAGE_KEY, next); + applyMessageFont(next); + loadRequiredGoogleFonts(next, getStoredCodeFont()); + emitChange(); + }, []); + + const setCodeFont = useCallback((next: CodeFont) => { + safeLocalStorageSet(CODE_FONT_STORAGE_KEY, next); + applyCodeFont(next); + loadRequiredGoogleFonts(getStoredMessageFont(), next); emitChange(); }, []); @@ -304,9 +397,11 @@ export function useTheme() { resolvedTheme, colorTheme, setColorTheme, - fontFamily, - setFontFamily, + messageFont, + setMessageFont, + codeFont, + setCodeFont, } as const; } -export type { Theme, ColorTheme }; +export type { CodeFont, ColorTheme, MessageFont, Theme }; diff --git a/apps/web/src/index.css b/apps/web/src/index.css index ba496787..3ffc2686 100644 --- a/apps/web/src/index.css +++ b/apps/web/src/index.css @@ -127,7 +127,10 @@ --sidebar-ring: var(--ring); --font-sans: Oxanium, sans-serif; --font-serif: "Space Grotesk", serif; - --font-mono: Oxanium, sans-serif; + --font-mono: + "JetBrains Mono", ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, "Liberation Mono", + monospace; + --font-code: var(--font-mono); --spacing: 0.25rem; --tracking-normal: 0em; @@ -209,9 +212,11 @@ body::after { pre, code, -textarea, -input { - font-family: var(--font-mono, Oxanium, sans-serif); +.cm-scroller { + font-family: var( + --font-code, + var(--font-mono, ui-monospace, SFMono-Regular, Menlo, Consolas, monospace) + ); } /* Window drag region (frameless titlebar) */ diff --git a/apps/web/src/keybindings.test.ts b/apps/web/src/keybindings.test.ts index ced7f5ef..bddb66d5 100644 --- a/apps/web/src/keybindings.test.ts +++ b/apps/web/src/keybindings.test.ts @@ -417,6 +417,43 @@ describe("resolveShortcutCommand", () => { "git.pullRequest", ); }); + + it("resolves Cmd+= / Cmd++ / Cmd+- / Cmd+0 to the zoom commands", () => { + const zoomBindings = compile([ + { shortcut: modShortcut("="), command: "view.zoomIn" }, + { shortcut: modShortcut("+"), command: "view.zoomIn" }, + { shortcut: modShortcut("-"), command: "view.zoomOut" }, + { shortcut: modShortcut("0"), command: "view.zoomReset" }, + ]); + + assert.strictEqual( + resolveShortcutCommand(event({ key: "=", metaKey: true }), zoomBindings, { + platform: "MacIntel", + }), + "view.zoomIn", + ); + // The `+` binding catches layouts / numeric keypads that emit `+` + // without shift; strict shift-equality means Cmd+Shift+= stays on the + // `=` binding via the browser's reported key. + assert.strictEqual( + resolveShortcutCommand(event({ key: "+", metaKey: true }), zoomBindings, { + platform: "MacIntel", + }), + "view.zoomIn", + ); + assert.strictEqual( + resolveShortcutCommand(event({ key: "-", ctrlKey: true }), zoomBindings, { + platform: "Linux", + }), + "view.zoomOut", + ); + assert.strictEqual( + resolveShortcutCommand(event({ key: "0", metaKey: true }), zoomBindings, { + platform: "MacIntel", + }), + "view.zoomReset", + ); + }); }); describe("formatShortcutLabel", () => { diff --git a/apps/web/src/lib/customTheme.ts b/apps/web/src/lib/customTheme.ts index d9180201..375399eb 100644 --- a/apps/web/src/lib/customTheme.ts +++ b/apps/web/src/lib/customTheme.ts @@ -72,6 +72,7 @@ const CUSTOM_THEME_FONT_LINK_ID = "okcode-custom-theme-fonts"; const RADIUS_OVERRIDE_KEY = "okcode:radius-override"; const FONT_OVERRIDE_KEY = "okcode:font-override"; const FONT_SIZE_OVERRIDE_KEY = "okcode:font-size-override"; +const ZOOM_KEY = "okcode:app-zoom"; const LEGACY_BACKGROUND_STYLE_ID = "okcode-background-image-style"; /** System-bundled fonts that don't need to be loaded from Google Fonts. */ @@ -549,7 +550,11 @@ export function applyFontOverride(): void { styleEl.id = "okcode-font-override-style"; document.head.appendChild(styleEl); } - styleEl.textContent = `body { font-family: ${font} !important; }`; + // Scope the override to prose/message text only — never override the + // `--font-code` stack selected separately in the Code font picker. Keeping + // `code, pre, .cm-scroller` on the native stack prevents the escape-hatch + // input from accidentally changing how source text renders. + styleEl.textContent = `body, .chat-markdown { font-family: ${font} !important; } code, pre, .cm-scroller { font-family: var(--font-code) !important; }`; // Load from Google Fonts if it's not a system font const families = font.split(",").map((f) => f.trim().replace(/^["']|["']$/g, "")); @@ -602,6 +607,102 @@ export function applyFontSizeOverride(): void { } } +// --------------------------------------------------------------------------- +// App Zoom (accessibility — scale the entire UI) +// --------------------------------------------------------------------------- + +/** Lowest allowed zoom factor. Below this, fixed-width panels become unreadable. */ +export const ZOOM_MIN = 0.75; +/** Highest allowed zoom factor. Above this, fixed-width panels clip. */ +export const ZOOM_MAX = 1.75; +/** Slider + keyboard increment. 5 % steps feel responsive without overshoot. */ +export const ZOOM_STEP = 0.05; +/** Default 100 % — matches the baseline designers ship against. */ +export const ZOOM_DEFAULT = 1.0; + +/** + * Normalize a numeric factor into the allowed zoom range. + * + * Non-finite inputs fall back to the default so a corrupt storage value or + * unbounded `x / 0` calculation can't propagate into the UI. We round to two + * decimals so that successive additions of `ZOOM_STEP` (0.05) don't drift due + * to float imprecision. + */ +export function clampZoom(value: number): number { + if (!Number.isFinite(value)) return ZOOM_DEFAULT; + const rounded = Math.round(value * 100) / 100; + return Math.max(ZOOM_MIN, Math.min(ZOOM_MAX, rounded)); +} + +/** Read the persisted zoom factor; falls back to the default when missing/invalid. */ +export function getStoredZoom(): number { + if (!hasDom() || typeof localStorage === "undefined") return ZOOM_DEFAULT; + const raw = localStorage.getItem(ZOOM_KEY); + if (raw === null) return ZOOM_DEFAULT; + const parsed = Number.parseFloat(raw); + return Number.isFinite(parsed) ? clampZoom(parsed) : ZOOM_DEFAULT; +} + +/** Persist and apply a zoom factor. Value is clamped before storage. */ +export function setStoredZoom(value: number): void { + const clamped = clampZoom(value); + if (hasDom() && typeof localStorage !== "undefined") { + localStorage.setItem(ZOOM_KEY, String(clamped)); + } + applyZoom(clamped); +} + +/** Reset zoom back to 100 % and drop the stored override. */ +export function clearZoom(): void { + if (hasDom() && typeof localStorage !== "undefined") { + localStorage.removeItem(ZOOM_KEY); + } + applyZoom(ZOOM_DEFAULT); +} + +interface ZoomBridgeShape { + setZoomFactor?: (factor: number) => Promise; +} + +/** + * Apply the given factor both natively (Electron) and in-DOM. + * + * On Electron we call `webContents.setZoomFactor` via the preload bridge — this + * gives crisp, text-rendered scaling that respects DPI. We also set + * `document.documentElement.style.zoom` so: + * 1. The web-only shell (no Electron bridge) still scales. + * 2. The two scaling paths stay in sync for CSS that reads `zoom`. + * Both paths accept the same numeric factor, so there's one source of truth. + */ +/** + * Custom event dispatched whenever app zoom changes (storage event only fires + * cross-window). Settings UI listens to this so the slider stays in sync when + * the user triggers zoom via keybindings in the same window. + */ +export const ZOOM_CHANGE_EVENT = "okcode:zoom-change"; + +function applyZoom(factor: number): void { + if (!hasDom()) return; + const bridge = (window as unknown as { desktopBridge?: ZoomBridgeShape }).desktopBridge; + if (bridge?.setZoomFactor) { + void bridge.setZoomFactor(factor).catch(() => { + // Bridge rejection is non-fatal — the DOM `zoom` fallback still applies. + }); + } + document.documentElement.style.setProperty("zoom", String(factor)); + try { + window.dispatchEvent(new CustomEvent(ZOOM_CHANGE_EVENT, { detail: factor })); + } catch { + // `CustomEvent` is unavailable in some SSR/test stubs — ignore. + } +} + +/** Read-and-apply the stored zoom. Called synchronously on boot to avoid FOUC. */ +export function applyStoredZoom(): void { + if (!hasDom()) return; + applyZoom(getStoredZoom()); +} + // --------------------------------------------------------------------------- // Initialization (called on module load) // --------------------------------------------------------------------------- @@ -627,4 +728,8 @@ export function initCustomTheme(): void { // Always apply font size override if set applyFontSizeOverride(); + + // Always apply stored zoom (runs before first paint when imported from + // useTheme, so users don't see a 100 % → N % flash on reload). + applyStoredZoom(); } diff --git a/apps/web/src/lib/customTheme.zoom.test.ts b/apps/web/src/lib/customTheme.zoom.test.ts new file mode 100644 index 00000000..8dd9f373 --- /dev/null +++ b/apps/web/src/lib/customTheme.zoom.test.ts @@ -0,0 +1,194 @@ +import { afterEach, beforeEach, describe, expect, it } from "vitest"; + +import { + ZOOM_CHANGE_EVENT, + ZOOM_DEFAULT, + ZOOM_MAX, + ZOOM_MIN, + ZOOM_STEP, + clampZoom, + clearZoom, + getStoredZoom, + setStoredZoom, +} from "./customTheme"; + +// --------------------------------------------------------------------------- +// Pure `clampZoom` — runs without any DOM fixtures. +// --------------------------------------------------------------------------- + +describe("clampZoom", () => { + it("accepts a value inside the allowed range", () => { + expect(clampZoom(1.25)).toBe(1.25); + expect(clampZoom(ZOOM_MIN)).toBe(ZOOM_MIN); + expect(clampZoom(ZOOM_MAX)).toBe(ZOOM_MAX); + }); + + it("clamps below-floor inputs to ZOOM_MIN", () => { + expect(clampZoom(0.1)).toBe(ZOOM_MIN); + expect(clampZoom(-5)).toBe(ZOOM_MIN); + }); + + it("clamps above-ceiling inputs to ZOOM_MAX", () => { + expect(clampZoom(5)).toBe(ZOOM_MAX); + expect(clampZoom(999)).toBe(ZOOM_MAX); + }); + + it("falls back to default on non-finite input (edge — corrupt storage)", () => { + expect(clampZoom(Number.NaN)).toBe(ZOOM_DEFAULT); + expect(clampZoom(Number.POSITIVE_INFINITY)).toBe(ZOOM_DEFAULT); + expect(clampZoom(Number.NEGATIVE_INFINITY)).toBe(ZOOM_DEFAULT); + }); + + it("rounds to two decimals so successive ZOOM_STEP additions don't drift", () => { + // 1.0 + 0.05*3 yields 1.1500000000000001 in float — verify rounding. + const drift = 1.0 + ZOOM_STEP * 3; + expect(clampZoom(drift)).toBe(1.15); + }); +}); + +// --------------------------------------------------------------------------- +// DOM-interacting helpers — exercised against a minimal fake window/document. +// The zoom code only touches `document.documentElement.style`, +// `localStorage`, and `window.dispatchEvent`, so a small shim is enough. +// --------------------------------------------------------------------------- + +type StyleBag = Record; + +function createFakeDom() { + const style: StyleBag = {}; + const store = new Map(); + const listeners: Array<(event: Event) => void> = []; + + const documentElement = { + style: { + setProperty(name: string, value: string) { + style[name] = value; + }, + getPropertyValue(name: string) { + return style[name] ?? ""; + }, + removeProperty(name: string) { + delete style[name]; + }, + }, + }; + + const localStorage = { + getItem: (key: string) => store.get(key) ?? null, + setItem: (key: string, value: string) => { + store.set(key, value); + }, + removeItem: (key: string) => { + store.delete(key); + }, + clear: () => store.clear(), + }; + + const win = { + dispatchEvent: (event: Event) => { + for (const listener of listeners) listener(event); + return true; + }, + addEventListener: (_type: string, listener: (event: Event) => void) => { + listeners.push(listener); + }, + removeEventListener: (_type: string, listener: (event: Event) => void) => { + const idx = listeners.indexOf(listener); + if (idx >= 0) listeners.splice(idx, 1); + }, + }; + + return { + style, + store, + listeners, + document: { + documentElement, + // The `hasDom()` guard also checks for getElementById + createElement. + getElementById: () => null, + createElement: () => ({}), + }, + localStorage, + window: win, + }; +} + +const ZOOM_STORAGE_KEY = "okcode:app-zoom"; + +describe("zoom storage + apply (with DOM shim)", () => { + let fake: ReturnType; + const originals: Record = {}; + + beforeEach(() => { + fake = createFakeDom(); + // Stash originals so the shim doesn't leak into neighboring tests. + originals.document = Object.getOwnPropertyDescriptor(globalThis, "document"); + originals.localStorage = Object.getOwnPropertyDescriptor(globalThis, "localStorage"); + originals.window = Object.getOwnPropertyDescriptor(globalThis, "window"); + + Object.defineProperty(globalThis, "document", { configurable: true, value: fake.document }); + Object.defineProperty(globalThis, "localStorage", { + configurable: true, + value: fake.localStorage, + }); + Object.defineProperty(globalThis, "window", { configurable: true, value: fake.window }); + }); + + afterEach(() => { + const restore = (key: string) => { + const descriptor = originals[key]; + if (descriptor) { + Object.defineProperty(globalThis, key, descriptor); + } else { + // biome-ignore lint/performance/noDelete: restoring original globals + delete (globalThis as Record)[key]; + } + }; + restore("document"); + restore("localStorage"); + restore("window"); + }); + + it("getStoredZoom returns the default when storage is empty", () => { + expect(getStoredZoom()).toBe(ZOOM_DEFAULT); + }); + + it("getStoredZoom falls back to default when storage value is garbage", () => { + fake.localStorage.setItem(ZOOM_STORAGE_KEY, "not-a-number"); + expect(getStoredZoom()).toBe(ZOOM_DEFAULT); + }); + + it("setStoredZoom persists a clamped value and applies it to the root style", () => { + setStoredZoom(1.25); + expect(fake.localStorage.getItem(ZOOM_STORAGE_KEY)).toBe("1.25"); + expect(fake.style["zoom"]).toBe("1.25"); + }); + + it("setStoredZoom clamps above-ceiling input at storage time (edge)", () => { + setStoredZoom(9); + // Storage must not hold an out-of-range value even if the caller sends one. + expect(fake.localStorage.getItem(ZOOM_STORAGE_KEY)).toBe(String(ZOOM_MAX)); + expect(fake.style["zoom"]).toBe(String(ZOOM_MAX)); + }); + + it("clearZoom removes the stored value and resets applied zoom to 1", () => { + setStoredZoom(1.5); + clearZoom(); + expect(fake.localStorage.getItem(ZOOM_STORAGE_KEY)).toBe(null); + expect(fake.style["zoom"]).toBe(String(ZOOM_DEFAULT)); + }); + + it("applyZoom (via setStoredZoom) dispatches a same-window change event", () => { + const received: number[] = []; + fake.window.addEventListener(ZOOM_CHANGE_EVENT, (event: Event) => { + const detail = (event as CustomEvent).detail; + received.push(detail); + }); + + setStoredZoom(1.1); + setStoredZoom(1.2); + + // Both writes fire the event so UI subscribed via the event can re-read. + expect(received).toEqual([1.1, 1.2]); + }); +}); diff --git a/apps/web/src/lib/markdownHtml.ts b/apps/web/src/lib/markdownHtml.ts index dac10ad2..f26e6928 100644 --- a/apps/web/src/lib/markdownHtml.ts +++ b/apps/web/src/lib/markdownHtml.ts @@ -21,7 +21,8 @@ export const MARKDOWN_PREVIEW_CONTAINER_STYLE: CSSProperties = { "--cm-radius": "12px", "--cm-font": 'var(--font-ui, "Inter", -apple-system, BlinkMacSystemFont, "Segoe UI", system-ui, sans-serif)', - "--cm-mono": '"SF Mono", "SFMono-Regular", Consolas, "Liberation Mono", Menlo, monospace', + "--cm-mono": + 'var(--font-code, var(--font-mono, ui-monospace, "SF Mono", "SFMono-Regular", Consolas, "Liberation Mono", Menlo, monospace))', } as CSSProperties; function getThemeCss(theme: MarkdownPreviewTheme): string { diff --git a/apps/web/src/routes/_chat.settings.style.tsx b/apps/web/src/routes/_chat.settings.style.tsx index 3391b3ad..ee38aac5 100644 --- a/apps/web/src/routes/_chat.settings.style.tsx +++ b/apps/web/src/routes/_chat.settings.style.tsx @@ -26,9 +26,22 @@ import { } from "../components/settings/SettingsUi"; import { Button } from "../components/ui/button"; import { Tooltip, TooltipPopup, TooltipTrigger } from "../components/ui/tooltip"; -import { COLOR_THEMES, FONT_FAMILIES, DEFAULT_COLOR_THEME } from "../hooks/useTheme"; +import { + CODE_FONTS, + COLOR_THEMES, + DEFAULT_CODE_FONT, + DEFAULT_COLOR_THEME, + DEFAULT_MESSAGE_FONT, + MESSAGE_FONTS, + getCodeFontStack, + getMessageFontStack, +} from "../hooks/useTheme"; import { useSettingsRouteContext } from "../components/settings/SettingsRouteContext"; import { + ZOOM_DEFAULT, + ZOOM_MAX, + ZOOM_MIN, + ZOOM_STEP, applyCustomTheme, clearStoredCustomTheme, getStoredCustomTheme, @@ -76,8 +89,10 @@ function SettingsStyleRouteView() { setTheme, colorTheme, setColorTheme, - fontFamily, - setFontFamily, + messageFont, + setMessageFont, + codeFont, + setCodeFont, settingsState: { settings, defaults, updateSettings }, radiusOverride, setRadiusOverride, @@ -85,6 +100,8 @@ function SettingsStyleRouteView() { setFontOverride, fontSizeOverride, setFontSizeOverride, + zoom, + setZoom, changedSettingLabels, restoreDefaults, } = useSettingsRouteContext(); @@ -237,35 +254,91 @@ function SettingsStyleRouteView() { /> setFontFamily("inter")} /> + messageFont !== DEFAULT_MESSAGE_FONT ? ( + setMessageFont(DEFAULT_MESSAGE_FONT)} + /> ) : null } control={ - +
+ + + The quick brown fox jumps over the lazy dog. + +
+ } + /> + + setCodeFont(DEFAULT_CODE_FONT)} + /> + ) : null + } + control={ +
+ + + const greet = (name) => `hi, ${"{"}name{"}"}`; + +
} /> @@ -330,6 +403,35 @@ function SettingsStyleRouteView() { } /> + setZoom(ZOOM_DEFAULT)} /> + ) : null + } + control={ +
+ { + setZoom(Number.parseFloat(e.target.value)); + }} + className="h-1.5 w-24 cursor-pointer appearance-none rounded-full bg-muted accent-foreground sm:w-28" + aria-label="App zoom" + /> + + {Math.round(zoom * 100)}% + +
+ } + /> + { - if (action !== "open-settings") return; - void navigate({ to: "/settings" }); + if (action === "open-settings") { + void navigate({ to: "/settings" }); + return; + } + // Native View-menu entries dispatch through the same renderer path as + // the Cmd+= / Cmd+- / Cmd+0 keybindings, so stored zoom stays in sync. + if (action === "view-zoom-in") { + setStoredZoom(getStoredZoom() + ZOOM_STEP); + return; + } + if (action === "view-zoom-out") { + setStoredZoom(getStoredZoom() - ZOOM_STEP); + return; + } + if (action === "view-zoom-reset") { + clearZoom(); + return; + } }); return () => { diff --git a/packages/contracts/src/ipc.ts b/packages/contracts/src/ipc.ts index 8b9c011e..0433e2f6 100644 --- a/packages/contracts/src/ipc.ts +++ b/packages/contracts/src/ipc.ts @@ -281,6 +281,12 @@ export interface DesktopBridge { setTheme: (theme: DesktopTheme) => Promise; setSidebarOpacity: (opacity: number) => Promise; setWindowButtonVisibility: (visible: boolean) => Promise; + /** + * Scale the entire Electron webContents by `factor` (e.g. 1.25 → 125 %). + * Values are clamped to [0.75, 1.75] server-side to match the UI slider's + * range. Falls back to a CSS `zoom` style on non-Electron shells. + */ + setZoomFactor: (factor: number) => Promise; showContextMenu: ( items: readonly ContextMenuItem[], position?: { x: number; y: number }, diff --git a/packages/contracts/src/keybindings.ts b/packages/contracts/src/keybindings.ts index 02cf1c45..56ac75e6 100644 --- a/packages/contracts/src/keybindings.ts +++ b/packages/contracts/src/keybindings.ts @@ -16,6 +16,9 @@ const STATIC_KEYBINDING_COMMANDS = [ "chat.newLocal", "git.pullRequest", "editor.openFavorite", + "view.zoomIn", + "view.zoomOut", + "view.zoomReset", ] as const; export const SCRIPT_RUN_COMMAND_PATTERN = Schema.TemplateLiteral([ diff --git a/packages/shared/src/keybindings.ts b/packages/shared/src/keybindings.ts index 0e8a79ab..d5ecc649 100644 --- a/packages/shared/src/keybindings.ts +++ b/packages/shared/src/keybindings.ts @@ -26,6 +26,13 @@ export const DEFAULT_KEYBINDINGS: ReadonlyArray = [ { key: "mod+down", command: "git.pullRequest", when: "!terminalFocus" }, { key: "mod+shift+p", command: "git.pullRequest", when: "!terminalFocus" }, { key: "mod+o", command: "editor.openFavorite" }, + // App zoom — available anywhere for accessibility. `=` is the unshifted + // character on US layouts; `+` is the shifted variant, registered so + // Cmd+Shift+= (which the OS often reports as Cmd++) still fires zoom-in. + { key: "mod+=", command: "view.zoomIn" }, + { key: "mod+plus", command: "view.zoomIn" }, + { key: "mod+-", command: "view.zoomOut" }, + { key: "mod+0", command: "view.zoomReset" }, ] as const; export const HOTKEY_COMMAND_DEFINITIONS = [ @@ -60,6 +67,27 @@ export const HOTKEY_COMMAND_DEFINITIONS = [ group: "Workspace", contextLabel: "Available anywhere", }, + { + command: "view.zoomIn", + title: "Zoom in", + description: "Scale the entire interface up by one step.", + group: "Workspace", + contextLabel: "Available anywhere", + }, + { + command: "view.zoomOut", + title: "Zoom out", + description: "Scale the entire interface down by one step.", + group: "Workspace", + contextLabel: "Available anywhere", + }, + { + command: "view.zoomReset", + title: "Reset zoom", + description: "Return the interface to 100 %.", + group: "Workspace", + contextLabel: "Available anywhere", + }, { command: "terminal.toggle", title: "Toggle terminal", @@ -108,6 +136,9 @@ function isMacLikePlatform(platform: string): boolean { function normalizeKeyToken(token: string): string { if (token === "space") return " "; if (token === "esc") return "escape"; + // `mod+plus` authored in config gets parsed to the literal `+` character so + // it matches a KeyboardEvent with `event.key === "+"` at runtime. + if (token === "plus") return "+"; return token; }