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
4 changes: 4 additions & 0 deletions apps/electron/src/renderer/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import { OnboardingWizard, ReauthScreen } from '@/components/onboarding'
import { WorkspacePicker } from '@/components/workspace'
import { ResetConfirmationDialog } from '@/components/ResetConfirmationDialog'
import { CommandPalette } from '@/components/CommandPalette'
import { ZoomHotkeys } from '@/components/ZoomHotkeys'
import { SplashScreen } from '@/components/SplashScreen'
import { TooltipProvider } from '@craft-agent/ui'
import { FocusProvider } from '@/context/FocusContext'
Expand Down Expand Up @@ -2501,6 +2502,9 @@ export default function App() {
{/* Global command palette (⌘K / Ctrl+K) — search and run any action */}
<CommandPalette />

{/* Interface zoom shortcuts (⌘+ / ⌘- / ⌘0) wired to the action registry */}
<ZoomHotkeys />

{/* Splash screen overlay - fades out when fully ready */}
{showSplash && (
<SplashScreen
Expand Down
3 changes: 3 additions & 0 deletions apps/electron/src/renderer/actions/action-i18n.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,9 @@ export const ACTION_LABEL_KEYS: Partial<Record<ActionId, string>> = {
'nav.goForwardAlt': 'shortcuts.action.goForward',
'view.toggleSidebar': 'shortcuts.action.toggleSidebar',
'view.toggleFocusMode': 'shortcuts.action.toggleFocusMode',
'view.zoomIn': 'shortcuts.action.zoomIn',
'view.zoomOut': 'shortcuts.action.zoomOut',
'view.zoomReset': 'shortcuts.action.zoomReset',
'navigator.selectAll': 'shortcuts.action.selectAll',
'navigator.clearSelection': 'shortcuts.action.clearSelection',
'panel.focusNext': 'shortcuts.action.focusNextPanel',
Expand Down
21 changes: 21 additions & 0 deletions apps/electron/src/renderer/actions/definitions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -143,6 +143,27 @@ export const actions = {
defaultHotkey: 'mod+.',
category: 'View',
},
'view.zoomIn': {
id: 'view.zoomIn',
label: 'Zoom In',
description: 'Scale the whole interface up',
defaultHotkey: 'mod+=',
category: 'View',
},
'view.zoomOut': {
id: 'view.zoomOut',
label: 'Zoom Out',
description: 'Scale the whole interface down',
defaultHotkey: 'mod+-',
category: 'View',
},
'view.zoomReset': {
id: 'view.zoomReset',
label: 'Reset Zoom',
description: 'Reset the interface zoom to 100%',
defaultHotkey: 'mod+0',
category: 'View',
},

// ═══════════════════════════════════════════
// Navigator (scoped — active entity list in middle panel)
Expand Down
23 changes: 23 additions & 0 deletions apps/electron/src/renderer/components/ZoomHotkeys.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
/**
* ZoomHotkeys
*
* Bridges the interface-zoom preference to the action registry so the standard
* desktop zoom shortcuts (⌘+ / ⌘- / ⌘0) and their Command Palette entries drive
* `ZoomProvider`. Renders nothing — it only registers handlers.
*
* Must be mounted inside both `ZoomProvider` (for `useZoom`) and
* `ActionRegistryProvider` (for `useAction`).
*/

import { useZoom } from '@/context/ZoomContext'
import { useAction } from '@/actions'

export function ZoomHotkeys() {
const { zoomIn, zoomOut, resetZoom } = useZoom()

useAction('view.zoomIn', zoomIn)
useAction('view.zoomOut', zoomOut)
useAction('view.zoomReset', resetZoom)

return null
}
140 changes: 140 additions & 0 deletions apps/electron/src/renderer/context/ZoomContext.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,140 @@
/**
* ZoomContext
*
* App-wide "interface zoom" preference — scales the whole renderer (sidebar,
* navigator, chat, settings, dialogs) up or down, like the ⌘+ / ⌘- / ⌘0 zoom
* in Claude Desktop, VS Code, and other desktop apps.
*
* The zoom factor is one of a fixed set of Chrome-like steps and is applied by
* setting `document.documentElement.style.zoom` — a Chromium-native property
* that scales the entire tree, including portalled dialogs rendered into
* `<body>`. At 100% the inline style is cleared so the DOM stays clean.
*
* The preference is persisted in `localStorage` (renderer-only, no backend),
* mirroring the other lightweight UI prefs in `lib/local-storage.ts` and the
* merged `ReduceMotionProvider`.
*/

import React, {
createContext,
useContext,
useState,
useEffect,
useCallback,
useMemo,
type ReactNode,
} from 'react'
import * as storage from '@/lib/local-storage'

/** Discrete zoom steps (factors), mirroring a browser's zoom ladder. */
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]

/** The neutral / "actual size" factor. */
export const DEFAULT_ZOOM = 1

interface ZoomContextType {
/** Current zoom factor (e.g. 1 = 100%, 1.1 = 110%). */
zoom: number
/** Whole-number percentage for display (e.g. 110). */
zoomPercent: number
/** Step up to the next larger factor (no-op at the max). */
zoomIn: () => void
/** Step down to the next smaller factor (no-op at the min). */
zoomOut: () => void
/** Return to 100%. */
resetZoom: () => void
/** Whether a larger step is available. */
canZoomIn: boolean
/** Whether a smaller step is available. */
canZoomOut: boolean
}

const ZoomContext = createContext<ZoomContextType | null>(null)

/** Clamp an arbitrary stored value onto the nearest valid step. */
function normalizeZoom(value: number): number {
if (!Number.isFinite(value)) return DEFAULT_ZOOM
let nearest = ZOOM_STEPS[0]
let bestDelta = Math.abs(value - nearest)
for (const step of ZOOM_STEPS) {
const delta = Math.abs(value - step)
if (delta < bestDelta) {
nearest = step
bestDelta = delta
}
}
return nearest
}

/** Reflect the factor onto <html> so the whole renderer scales. */
function applyZoom(factor: number): void {
const root = document.documentElement
if (factor === DEFAULT_ZOOM) {
root.style.removeProperty('zoom')
} else {
root.style.zoom = String(factor)
}
}

export function ZoomProvider({ children }: { children: ReactNode }) {
const [zoom, setZoomState] = useState<number>(() =>
normalizeZoom(storage.get<number>(storage.KEYS.zoomLevel, DEFAULT_ZOOM)),
)

// Keep the DOM in sync (also covers the initial value on mount).
useEffect(() => {
applyZoom(zoom)
}, [zoom])

const setZoom = useCallback((factor: number) => {
const next = normalizeZoom(factor)
setZoomState(next)
storage.set(storage.KEYS.zoomLevel, next)
applyZoom(next)
}, [])

const zoomIn = useCallback(() => {
setZoomState((current) => {
const idx = ZOOM_STEPS.indexOf(normalizeZoom(current))
const next = ZOOM_STEPS[Math.min(idx + 1, ZOOM_STEPS.length - 1)]
storage.set(storage.KEYS.zoomLevel, next)
applyZoom(next)
return next
})
}, [])

const zoomOut = useCallback(() => {
setZoomState((current) => {
const idx = ZOOM_STEPS.indexOf(normalizeZoom(current))
const next = ZOOM_STEPS[Math.max(idx - 1, 0)]
storage.set(storage.KEYS.zoomLevel, next)
applyZoom(next)
return next
})
}, [])

const resetZoom = useCallback(() => setZoom(DEFAULT_ZOOM), [setZoom])

const value = useMemo<ZoomContextType>(() => {
const idx = ZOOM_STEPS.indexOf(zoom)
return {
zoom,
zoomPercent: Math.round(zoom * 100),
zoomIn,
zoomOut,
resetZoom,
canZoomIn: idx < ZOOM_STEPS.length - 1,
canZoomOut: idx > 0,
}
}, [zoom, zoomIn, zoomOut, resetZoom])

return <ZoomContext.Provider value={value}>{children}</ZoomContext.Provider>
}

export function useZoom(): ZoomContextType {
const ctx = useContext(ZoomContext)
if (!ctx) {
throw new Error('useZoom must be used within a ZoomProvider')
}
return ctx
}
1 change: 1 addition & 0 deletions apps/electron/src/renderer/lib/local-storage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,7 @@ export const KEYS = {
conversationWidth: 'conversation-width', // Chat reading-column width: comfortable | wide | full
chatTextSize: 'chat-text-size', // Scale conversation text without resizing the app chrome
highContrast: 'high-contrast', // Raise contrast of borders, dividers, and muted text app-wide
zoomLevel: 'zoom-level', // Interface zoom factor (⌘+/⌘-/⌘0), scales the whole renderer

// What's New
whatsNewLastSeenVersion: 'whats-new-last-seen-version',
Expand Down
9 changes: 6 additions & 3 deletions apps/electron/src/renderer/main.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import { ReduceMotionProvider } from './context/ReduceMotionContext'
import { ConversationWidthProvider } from './context/ConversationWidthContext'
import { ChatTextSizeProvider } from './context/ChatTextSizeContext'
import { HighContrastProvider } from './context/HighContrastContext'
import { ZoomProvider } from './context/ZoomContext'
import { windowWorkspaceIdAtom } from './atoms/sessions'
import { Toaster } from '@/components/ui/sonner'
import { PetWindowController } from '@/components/pet/PetWindowController'
Expand Down Expand Up @@ -114,9 +115,11 @@ function Root() {
<ConversationWidthProvider>
<ChatTextSizeProvider>
<HighContrastProvider>
<App />
<Toaster />
<PetWindowController />
<ZoomProvider>
<App />
<Toaster />
<PetWindowController />
</ZoomProvider>
</HighContrastProvider>
</ChatTextSizeProvider>
</ConversationWidthProvider>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,9 +18,11 @@ import { useReduceMotion } from '@/context/ReduceMotionContext'
import { useConversationWidth } from '@/context/ConversationWidthContext'
import { useChatTextSize, type ChatTextSize } from '@/context/ChatTextSizeContext'
import { useHighContrast } from '@/context/HighContrastContext'
import { useZoom } from '@/context/ZoomContext'
import { Button } from '@/components/ui/button'
import { useAppShellContext } from '@/context/AppShellContext'
import { routes } from '@/lib/navigate'
import { FolderOpen, Monitor, RefreshCw, Sun, Moon } from 'lucide-react'
import { FolderOpen, Monitor, RefreshCw, Sun, Moon, Minus, Plus, RotateCcw } from 'lucide-react'
import type { DetailsPageMeta } from '@/lib/navigation-registry'
import type { ToolIconMapping } from '../../../shared/types'

Expand Down Expand Up @@ -145,6 +147,7 @@ export default function AppearanceSettingsPage() {

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

// Conversation width (renderer-only preference, persisted in localStorage)
const { conversationWidth, setConversationWidth } = useConversationWidth()
Expand Down Expand Up @@ -434,6 +437,58 @@ export default function AppearanceSettingsPage() {
onCheckedChange={setHighContrast}
testId="high-contrast-toggle"
/>
<SettingsRow
label={t("settings.appearance.zoom")}
description={t("settings.appearance.zoomDesc")}
>
<div
className="flex items-center gap-1"
data-testid="zoom-control"
>
<Button
variant="ghost"
size="icon"
className="size-7"
aria-label={t("shortcuts.action.zoomOut")}
title={t("shortcuts.action.zoomOut")}
disabled={!canZoomOut}
onClick={zoomOut}
data-testid="zoom-out"
>
<Minus className="size-4" />
</Button>
<span
className="min-w-[3.25rem] text-center text-[13px] tabular-nums text-foreground/80 select-none"
data-testid="zoom-value"
>
{zoomPercent}%
</span>
<Button
variant="ghost"
size="icon"
className="size-7"
aria-label={t("shortcuts.action.zoomIn")}
title={t("shortcuts.action.zoomIn")}
disabled={!canZoomIn}
onClick={zoomIn}
data-testid="zoom-in"
>
<Plus className="size-4" />
</Button>
<Button
variant="ghost"
size="icon"
className="size-7 ml-1"
aria-label={t("shortcuts.action.zoomReset")}
title={t("shortcuts.action.zoomReset")}
disabled={zoomPercent === 100}
onClick={resetZoom}
data-testid="zoom-reset"
>
<RotateCcw className="size-3.5" />
</Button>
</div>
</SettingsRow>
</SettingsCard>
</SettingsSection>

Expand Down
1 change: 1 addition & 0 deletions docs/loop/feature-ledger.md
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ log, not the system of record.

| slug | title | source | feasibility | status | issue | pr | branch | updated | notes |
| --- | --- | --- | --- | --- | --- | --- | --- | --- | --- |
| 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. |
| 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). |
| 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). |
| 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). |
Expand Down
Loading