Skip to content

Commit 8abfafe

Browse files
DragonnZhangclaude
andauthored
Add interface zoom (⌘+ / ⌘- / ⌘0) to scale the whole app (#69)
* Add interface zoom (⌘+/⌘-/⌘0) to scale the whole app Adds an app-wide interface-zoom preference that scales the entire renderer (sidebar, navigator, chat, settings, dialogs) up or down, matching the standard desktop zoom in Claude Desktop, VS Code, and other apps. - ZoomProvider (renderer context, mirrors ReduceMotionProvider): owns a discrete zoom factor from a Chrome-like step ladder (50%-200%), applies it via document.documentElement.style.zoom, and persists it in localStorage (craft-zoom-level). Frontend-only; no IPC, no backend, no config.json. - Three action-registry entries under View - view.zoomIn (mod+=), view.zoomOut (mod+-), view.zoomReset (mod+0) - so the shortcuts appear in the Command Palette and Keyboard Shortcuts reference for free. Wired to the provider via a ZoomHotkeys bridge component. - A Zoom stepper (-/percentage/+ with reset) in Settings > Appearance > Interface for discoverability and mouse-only users. - i18n keys added to all 7 locales. - CDP e2e assertion (zoom.assert.ts) drives the stepper and asserts the percentage, the inline zoom on <html>, and the persisted value. Closes #68 * docs(loop): record interface-zoom (#69) and reconcile ledger from GitHub --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent dc5c919 commit 8abfafe

17 files changed

Lines changed: 394 additions & 4 deletions

File tree

apps/electron/src/renderer/App.tsx

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ import { OnboardingWizard, ReauthScreen } from '@/components/onboarding'
1818
import { WorkspacePicker } from '@/components/workspace'
1919
import { ResetConfirmationDialog } from '@/components/ResetConfirmationDialog'
2020
import { CommandPalette } from '@/components/CommandPalette'
21+
import { ZoomHotkeys } from '@/components/ZoomHotkeys'
2122
import { SplashScreen } from '@/components/SplashScreen'
2223
import { TooltipProvider } from '@craft-agent/ui'
2324
import { FocusProvider } from '@/context/FocusContext'
@@ -2501,6 +2502,9 @@ export default function App() {
25012502
{/* Global command palette (⌘K / Ctrl+K) — search and run any action */}
25022503
<CommandPalette />
25032504

2505+
{/* Interface zoom shortcuts (⌘+ / ⌘- / ⌘0) wired to the action registry */}
2506+
<ZoomHotkeys />
2507+
25042508
{/* Splash screen overlay - fades out when fully ready */}
25052509
{showSplash && (
25062510
<SplashScreen

apps/electron/src/renderer/actions/action-i18n.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,9 @@ export const ACTION_LABEL_KEYS: Partial<Record<ActionId, string>> = {
3030
'nav.goForwardAlt': 'shortcuts.action.goForward',
3131
'view.toggleSidebar': 'shortcuts.action.toggleSidebar',
3232
'view.toggleFocusMode': 'shortcuts.action.toggleFocusMode',
33+
'view.zoomIn': 'shortcuts.action.zoomIn',
34+
'view.zoomOut': 'shortcuts.action.zoomOut',
35+
'view.zoomReset': 'shortcuts.action.zoomReset',
3336
'navigator.selectAll': 'shortcuts.action.selectAll',
3437
'navigator.clearSelection': 'shortcuts.action.clearSelection',
3538
'panel.focusNext': 'shortcuts.action.focusNextPanel',

apps/electron/src/renderer/actions/definitions.ts

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -143,6 +143,27 @@ export const actions = {
143143
defaultHotkey: 'mod+.',
144144
category: 'View',
145145
},
146+
'view.zoomIn': {
147+
id: 'view.zoomIn',
148+
label: 'Zoom In',
149+
description: 'Scale the whole interface up',
150+
defaultHotkey: 'mod+=',
151+
category: 'View',
152+
},
153+
'view.zoomOut': {
154+
id: 'view.zoomOut',
155+
label: 'Zoom Out',
156+
description: 'Scale the whole interface down',
157+
defaultHotkey: 'mod+-',
158+
category: 'View',
159+
},
160+
'view.zoomReset': {
161+
id: 'view.zoomReset',
162+
label: 'Reset Zoom',
163+
description: 'Reset the interface zoom to 100%',
164+
defaultHotkey: 'mod+0',
165+
category: 'View',
166+
},
146167

147168
// ═══════════════════════════════════════════
148169
// Navigator (scoped — active entity list in middle panel)
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
/**
2+
* ZoomHotkeys
3+
*
4+
* Bridges the interface-zoom preference to the action registry so the standard
5+
* desktop zoom shortcuts (⌘+ / ⌘- / ⌘0) and their Command Palette entries drive
6+
* `ZoomProvider`. Renders nothing — it only registers handlers.
7+
*
8+
* Must be mounted inside both `ZoomProvider` (for `useZoom`) and
9+
* `ActionRegistryProvider` (for `useAction`).
10+
*/
11+
12+
import { useZoom } from '@/context/ZoomContext'
13+
import { useAction } from '@/actions'
14+
15+
export function ZoomHotkeys() {
16+
const { zoomIn, zoomOut, resetZoom } = useZoom()
17+
18+
useAction('view.zoomIn', zoomIn)
19+
useAction('view.zoomOut', zoomOut)
20+
useAction('view.zoomReset', resetZoom)
21+
22+
return null
23+
}
Lines changed: 140 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,140 @@
1+
/**
2+
* ZoomContext
3+
*
4+
* App-wide "interface zoom" preference — scales the whole renderer (sidebar,
5+
* navigator, chat, settings, dialogs) up or down, like the ⌘+ / ⌘- / ⌘0 zoom
6+
* in Claude Desktop, VS Code, and other desktop apps.
7+
*
8+
* The zoom factor is one of a fixed set of Chrome-like steps and is applied by
9+
* setting `document.documentElement.style.zoom` — a Chromium-native property
10+
* that scales the entire tree, including portalled dialogs rendered into
11+
* `<body>`. At 100% the inline style is cleared so the DOM stays clean.
12+
*
13+
* The preference is persisted in `localStorage` (renderer-only, no backend),
14+
* mirroring the other lightweight UI prefs in `lib/local-storage.ts` and the
15+
* merged `ReduceMotionProvider`.
16+
*/
17+
18+
import React, {
19+
createContext,
20+
useContext,
21+
useState,
22+
useEffect,
23+
useCallback,
24+
useMemo,
25+
type ReactNode,
26+
} from 'react'
27+
import * as storage from '@/lib/local-storage'
28+
29+
/** Discrete zoom steps (factors), mirroring a browser's zoom ladder. */
30+
export const ZOOM_STEPS: readonly number[] = [0.5, 0.67, 0.75, 0.8, 0.9, 1, 1.1, 1.25, 1.5, 1.75, 2]
31+
32+
/** The neutral / "actual size" factor. */
33+
export const DEFAULT_ZOOM = 1
34+
35+
interface ZoomContextType {
36+
/** Current zoom factor (e.g. 1 = 100%, 1.1 = 110%). */
37+
zoom: number
38+
/** Whole-number percentage for display (e.g. 110). */
39+
zoomPercent: number
40+
/** Step up to the next larger factor (no-op at the max). */
41+
zoomIn: () => void
42+
/** Step down to the next smaller factor (no-op at the min). */
43+
zoomOut: () => void
44+
/** Return to 100%. */
45+
resetZoom: () => void
46+
/** Whether a larger step is available. */
47+
canZoomIn: boolean
48+
/** Whether a smaller step is available. */
49+
canZoomOut: boolean
50+
}
51+
52+
const ZoomContext = createContext<ZoomContextType | null>(null)
53+
54+
/** Clamp an arbitrary stored value onto the nearest valid step. */
55+
function normalizeZoom(value: number): number {
56+
if (!Number.isFinite(value)) return DEFAULT_ZOOM
57+
let nearest = ZOOM_STEPS[0]
58+
let bestDelta = Math.abs(value - nearest)
59+
for (const step of ZOOM_STEPS) {
60+
const delta = Math.abs(value - step)
61+
if (delta < bestDelta) {
62+
nearest = step
63+
bestDelta = delta
64+
}
65+
}
66+
return nearest
67+
}
68+
69+
/** Reflect the factor onto <html> so the whole renderer scales. */
70+
function applyZoom(factor: number): void {
71+
const root = document.documentElement
72+
if (factor === DEFAULT_ZOOM) {
73+
root.style.removeProperty('zoom')
74+
} else {
75+
root.style.zoom = String(factor)
76+
}
77+
}
78+
79+
export function ZoomProvider({ children }: { children: ReactNode }) {
80+
const [zoom, setZoomState] = useState<number>(() =>
81+
normalizeZoom(storage.get<number>(storage.KEYS.zoomLevel, DEFAULT_ZOOM)),
82+
)
83+
84+
// Keep the DOM in sync (also covers the initial value on mount).
85+
useEffect(() => {
86+
applyZoom(zoom)
87+
}, [zoom])
88+
89+
const setZoom = useCallback((factor: number) => {
90+
const next = normalizeZoom(factor)
91+
setZoomState(next)
92+
storage.set(storage.KEYS.zoomLevel, next)
93+
applyZoom(next)
94+
}, [])
95+
96+
const zoomIn = useCallback(() => {
97+
setZoomState((current) => {
98+
const idx = ZOOM_STEPS.indexOf(normalizeZoom(current))
99+
const next = ZOOM_STEPS[Math.min(idx + 1, ZOOM_STEPS.length - 1)]
100+
storage.set(storage.KEYS.zoomLevel, next)
101+
applyZoom(next)
102+
return next
103+
})
104+
}, [])
105+
106+
const zoomOut = useCallback(() => {
107+
setZoomState((current) => {
108+
const idx = ZOOM_STEPS.indexOf(normalizeZoom(current))
109+
const next = ZOOM_STEPS[Math.max(idx - 1, 0)]
110+
storage.set(storage.KEYS.zoomLevel, next)
111+
applyZoom(next)
112+
return next
113+
})
114+
}, [])
115+
116+
const resetZoom = useCallback(() => setZoom(DEFAULT_ZOOM), [setZoom])
117+
118+
const value = useMemo<ZoomContextType>(() => {
119+
const idx = ZOOM_STEPS.indexOf(zoom)
120+
return {
121+
zoom,
122+
zoomPercent: Math.round(zoom * 100),
123+
zoomIn,
124+
zoomOut,
125+
resetZoom,
126+
canZoomIn: idx < ZOOM_STEPS.length - 1,
127+
canZoomOut: idx > 0,
128+
}
129+
}, [zoom, zoomIn, zoomOut, resetZoom])
130+
131+
return <ZoomContext.Provider value={value}>{children}</ZoomContext.Provider>
132+
}
133+
134+
export function useZoom(): ZoomContextType {
135+
const ctx = useContext(ZoomContext)
136+
if (!ctx) {
137+
throw new Error('useZoom must be used within a ZoomProvider')
138+
}
139+
return ctx
140+
}

apps/electron/src/renderer/lib/local-storage.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,7 @@ export const KEYS = {
6565
conversationWidth: 'conversation-width', // Chat reading-column width: comfortable | wide | full
6666
chatTextSize: 'chat-text-size', // Scale conversation text without resizing the app chrome
6767
highContrast: 'high-contrast', // Raise contrast of borders, dividers, and muted text app-wide
68+
zoomLevel: 'zoom-level', // Interface zoom factor (⌘+/⌘-/⌘0), scales the whole renderer
6869

6970
// What's New
7071
whatsNewLastSeenVersion: 'whats-new-last-seen-version',

apps/electron/src/renderer/main.tsx

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import { ReduceMotionProvider } from './context/ReduceMotionContext'
1010
import { ConversationWidthProvider } from './context/ConversationWidthContext'
1111
import { ChatTextSizeProvider } from './context/ChatTextSizeContext'
1212
import { HighContrastProvider } from './context/HighContrastContext'
13+
import { ZoomProvider } from './context/ZoomContext'
1314
import { windowWorkspaceIdAtom } from './atoms/sessions'
1415
import { Toaster } from '@/components/ui/sonner'
1516
import { PetWindowController } from '@/components/pet/PetWindowController'
@@ -114,9 +115,11 @@ function Root() {
114115
<ConversationWidthProvider>
115116
<ChatTextSizeProvider>
116117
<HighContrastProvider>
117-
<App />
118-
<Toaster />
119-
<PetWindowController />
118+
<ZoomProvider>
119+
<App />
120+
<Toaster />
121+
<PetWindowController />
122+
</ZoomProvider>
120123
</HighContrastProvider>
121124
</ChatTextSizeProvider>
122125
</ConversationWidthProvider>

apps/electron/src/renderer/pages/settings/AppearanceSettingsPage.tsx

Lines changed: 56 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,9 +18,11 @@ import { useReduceMotion } from '@/context/ReduceMotionContext'
1818
import { useConversationWidth } from '@/context/ConversationWidthContext'
1919
import { useChatTextSize, type ChatTextSize } from '@/context/ChatTextSizeContext'
2020
import { useHighContrast } from '@/context/HighContrastContext'
21+
import { useZoom } from '@/context/ZoomContext'
22+
import { Button } from '@/components/ui/button'
2123
import { useAppShellContext } from '@/context/AppShellContext'
2224
import { routes } from '@/lib/navigate'
23-
import { FolderOpen, Monitor, RefreshCw, Sun, Moon } from 'lucide-react'
25+
import { FolderOpen, Monitor, RefreshCw, Sun, Moon, Minus, Plus, RotateCcw } from 'lucide-react'
2426
import type { DetailsPageMeta } from '@/lib/navigation-registry'
2527
import type { ToolIconMapping } from '../../../shared/types'
2628

@@ -145,6 +147,7 @@ export default function AppearanceSettingsPage() {
145147

146148
// Reduce motion toggle (renderer-only preference, persisted in localStorage)
147149
const { reduceMotion, setReduceMotion } = useReduceMotion()
150+
const { zoomPercent, zoomIn, zoomOut, resetZoom, canZoomIn, canZoomOut } = useZoom()
148151

149152
// Conversation width (renderer-only preference, persisted in localStorage)
150153
const { conversationWidth, setConversationWidth } = useConversationWidth()
@@ -434,6 +437,58 @@ export default function AppearanceSettingsPage() {
434437
onCheckedChange={setHighContrast}
435438
testId="high-contrast-toggle"
436439
/>
440+
<SettingsRow
441+
label={t("settings.appearance.zoom")}
442+
description={t("settings.appearance.zoomDesc")}
443+
>
444+
<div
445+
className="flex items-center gap-1"
446+
data-testid="zoom-control"
447+
>
448+
<Button
449+
variant="ghost"
450+
size="icon"
451+
className="size-7"
452+
aria-label={t("shortcuts.action.zoomOut")}
453+
title={t("shortcuts.action.zoomOut")}
454+
disabled={!canZoomOut}
455+
onClick={zoomOut}
456+
data-testid="zoom-out"
457+
>
458+
<Minus className="size-4" />
459+
</Button>
460+
<span
461+
className="min-w-[3.25rem] text-center text-[13px] tabular-nums text-foreground/80 select-none"
462+
data-testid="zoom-value"
463+
>
464+
{zoomPercent}%
465+
</span>
466+
<Button
467+
variant="ghost"
468+
size="icon"
469+
className="size-7"
470+
aria-label={t("shortcuts.action.zoomIn")}
471+
title={t("shortcuts.action.zoomIn")}
472+
disabled={!canZoomIn}
473+
onClick={zoomIn}
474+
data-testid="zoom-in"
475+
>
476+
<Plus className="size-4" />
477+
</Button>
478+
<Button
479+
variant="ghost"
480+
size="icon"
481+
className="size-7 ml-1"
482+
aria-label={t("shortcuts.action.zoomReset")}
483+
title={t("shortcuts.action.zoomReset")}
484+
disabled={zoomPercent === 100}
485+
onClick={resetZoom}
486+
data-testid="zoom-reset"
487+
>
488+
<RotateCcw className="size-3.5" />
489+
</Button>
490+
</div>
491+
</SettingsRow>
437492
</SettingsCard>
438493
</SettingsSection>
439494

docs/loop/feature-ledger.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@ log, not the system of record.
3333

3434
| slug | title | source | feasibility | status | issue | pr | branch | updated | notes |
3535
| --- | --- | --- | --- | --- | --- | --- | --- | --- | --- |
36+
| interface-zoom | Interface zoom (⌘+/⌘-/⌘0) to scale the whole app | Claude Desktop / VS Code / Codex desktop View→Zoom In/Out/Actual Size | frontend-only | pr-open | [#68](https://github.com/modelstudioai/openwork/issues/68) | [#69](https://github.com/modelstudioai/openwork/pull/69) | loop/interface-zoom | 2026-07-08 | New `ZoomProvider` (mirrors `ReduceMotionProvider`) scales whole renderer via `document.documentElement.style.zoom`, discrete 50–200% steps, persisted in localStorage (`craft-zoom-level`). 3 View actions `view.zoomIn/Out/Reset` (`mod+=`/`mod+-`/`mod+0`) → auto in Command Palette + Shortcuts ref, wired via `ZoomHotkeys` bridge. Stepper in Appearance→Interface. 5 i18n keys ×7 locales. typecheck/`bun test` zero-delta vs main (56-failure set byte-identical); renderer build ✅; lint 0 errors; i18n parity ✅ (1549 keys). CDP assertion included; **could not run locally** (org egress 403 on Electron binary download — same block as #51). Distinct from chat-text-size (#64)/conversation-width (#62): scales entire UI, not just chat text. |
3637
| high-contrast | "Increase contrast" accessibility setting in Appearance | macOS Accessibility "Increase contrast" / Windows contrast themes / VS Code High Contrast | frontend-only | pr-open | [#66](https://github.com/modelstudioai/openwork/issues/66) | [#67](https://github.com/modelstudioai/openwork/pull/67) | loop/high-contrast | 2026-07-07 | Companion to merged reduce-motion. Renderer-only pref (`craft-high-contrast` localStorage) → `HighContrastProvider` context → `data-high-contrast` on `<html>` → CSS overrides of theme tokens (`--border`/`--input`/`--muted-foreground`/`--ring`) gated on `:root[data-high-contrast='true']` (higher specificity beats theme `:root` injection; works light+dark since tokens derive from `--foreground`). Toggle in Appearance→Interface below Reduce motion; 2 i18n keys ×7 locales. typecheck:all/`bun test` zero-delta vs main (11 electron / 56 test pre-existing failures byte-identical); i18n parity OK; renderer build ✅ (verified rule in bundled CSS). CDP assertion written (`high-contrast.assert.ts`); **not run locally** — egress 403 blocks Electron binary download (same env block as #51). |
3738
| chat-text-size | "Chat text size" setting (Small / Default / Large) in Appearance | Claude Desktop "Chat font" + anthropics/claude-code #50543/#48887; ChatGPT desktop font-size requests | frontend-only | pr-open | [#64](https://github.com/modelstudioai/openwork/issues/64) | [#65](https://github.com/modelstudioai/openwork/pull/65) | loop/chat-text-size | 2026-07-07 | Renderer-only pref (localStorage `craft-chat-text-size`) reflected onto `<html>` as `data-chat-text-size` + `--chat-font-scale` CSS var (0.9/1/1.15). Transcript container gets `.chat-text-scope` with `font-size: calc(1em * var(--chat-font-scale))` — em-relative ⇒ neutral at Default, scales only conversation text (chrome untouched). New `ChatTextSizeProvider`; segmented control in Appearance→Interface; `SettingsSegmentedControl` gained optional `testId`/`data-value`; 5 new i18n keys ×7 locales. typecheck/`bun test` **zero-delta vs main** (11 pre-existing tsc errors + 56 pre-existing fail-lines byte-identical); renderer build ✅; i18n parity ✅. CDP assertion authored (drives control, asserts attr/CSS-var/localStorage + probe computed font-size ratio ~1.15/0.9); **could not execute** — Electron runtime binary download is org-egress-policy 403 (same block as #51). |
3839
| conversation-width | "Conversation width" (Comfortable / Wide / Full) setting in Appearance | Claude Desktop wider-chat / ChatGPT desktop width / Codex & VS Code content width | frontend-only | pr-open | [#62](https://github.com/modelstudioai/openwork/issues/62) | [#63](https://github.com/modelstudioai/openwork/pull/63) | loop/conversation-width | 2026-07-06 | Renderer-only pref (localStorage `craft-conversation-width`). New `ConversationWidthProvider` in `main.tsx` sets `data-conversation-width` on `<html>` + drives CSS var `--chat-content-max-width` (840px/1100px/none). Transcript container (`ChatDisplay`) + composer (`ChatInputZone`) read it via `max-width: var(--chat-content-max-width, 840px)`; fallback keeps shared web viewer unchanged. Segmented control in Appearance→Interface; added `testId` to `SettingsSegmentedControl`; 5 new i18n keys ×7 locales. typecheck/`bun test` zero-delta vs main (56-failure set byte-identical); renderer build ✅; i18n parity ✅. CDP assertion `e2e/assertions/conversation-width.assert.ts` included; **could not run locally** (egress 403 blocks Electron binary + `libsignal-node`/`eslint-config` git-tarball deps — same env blocker as #51). |

0 commit comments

Comments
 (0)