Skip to content

Commit ec71864

Browse files
claudeDragonnZhang
authored andcommitted
Add a "Chat text size" setting (Small / Default / Large) to Appearance
Introduces a renderer-only preference that scales the conversation transcript's typography without resizing the rest of the app (sidebar, top bar, composer, icons stay at their native size). This differs from the OS-level window zoom wired to the View menu, which scales the whole UI and is not a persisted appearance preference. Mechanism mirrors the existing ReduceMotionContext idiom: - ChatTextSizeProvider persists the choice in localStorage (craft-chat-text-size) and reflects it onto <html> as data-chat-text-size="small|medium|large". - Global CSS maps that attribute to a --chat-font-scale custom property (0.9 / 1 / 1.15). The transcript container in ChatDisplay carries a .chat-text-scope class whose font-size: calc(1em * var(--chat-font-scale)) is em-relative, so Default is exactly neutral and only the conversation body text scales. - Segmented control added to Settings -> Appearance -> Interface. - SettingsSegmentedControl gained an optional testId (adds data-testid on the group and data-value on each option) for stable, label-independent targeting. - 5 new i18n keys across all 7 locales. Adds a CDP assertion (e2e/assertions/chat-text-size.assert.ts) that drives the real control and verifies the html attribute, the resolved CSS variable, the persisted value, and the computed font-size of a probe using the real .chat-text-scope class (ratio ~1.15 at Large, ~0.9 at Small), covering apply / change / revert. Closes #64
1 parent 2bb5bc0 commit ec71864

16 files changed

Lines changed: 377 additions & 19 deletions

File tree

apps/electron/src/renderer/components/app-shell/ChatDisplay.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1717,7 +1717,7 @@ export const ChatDisplay = React.forwardRef<ChatDisplayHandle, ChatDisplayProps>
17171717
style={compactMode ? undefined : { maxWidth: 'var(--chat-content-max-width, 840px)' }}
17181718
className={cn(
17191719
compactMode && CHAT_LAYOUT.maxWidth,
1720-
"mx-auto min-w-0",
1720+
"chat-text-scope mx-auto min-w-0",
17211721
compactMode ? "px-3 py-4 space-y-2" : [CHAT_LAYOUT.containerPadding, CHAT_LAYOUT.messageSpacing]
17221722
)}>
17231723
{/* Session-level AnimatePresence: Prevents layout jump when switching sessions */}

apps/electron/src/renderer/components/settings/SettingsSegmentedControl.tsx

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,12 @@ export interface SettingsSegmentedControlProps<T extends string = string> {
2828
size?: 'sm' | 'md'
2929
/** Additional className */
3030
className?: string
31-
/** Optional test id — applied to the group and, suffixed with `-<value>`, to each option */
31+
/**
32+
* Optional test id. When set, `data-testid` is applied to the group and,
33+
* suffixed with `-<value>`, to each option. Every option button also
34+
* carries a `data-value` attribute, so tests can address a specific
35+
* segment without depending on its (localized) label.
36+
*/
3237
testId?: string
3338
}
3439

Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,90 @@
1+
/**
2+
* ChatTextSizeContext
3+
*
4+
* App-wide "Chat text size" preference — scales the typography of the
5+
* conversation transcript without resizing the rest of the app.
6+
*
7+
* Unlike the OS-level window zoom wired to the View menu
8+
* (`webContents.setZoomFactor`), which scales the entire UI (sidebar, toolbar,
9+
* composer, icons, spacing), this preference only affects the reading text in
10+
* chat. It works by reflecting the choice onto `<html>` as
11+
* `data-chat-text-size="small|medium|large"`; the global CSS in `index.css`
12+
* maps that to a `--chat-font-scale` custom property, which the chat transcript
13+
* container (`.chat-text-scope` in `ChatDisplay`) consumes via
14+
* `font-size: calc(1em * var(--chat-font-scale))`. Because the scale is
15+
* `em`-relative it is exactly neutral at `medium` (1×) and scales the inherited
16+
* message text up/down at `small` (0.9×) / `large` (1.15×).
17+
*
18+
* The preference is persisted in `localStorage` (renderer-only, no backend),
19+
* mirroring the other lightweight UI prefs in `lib/local-storage.ts` and the
20+
* sibling `ReduceMotionContext`.
21+
*/
22+
23+
import React, {
24+
createContext,
25+
useContext,
26+
useState,
27+
useEffect,
28+
useCallback,
29+
type ReactNode,
30+
} from 'react'
31+
import * as storage from '@/lib/local-storage'
32+
33+
export type ChatTextSize = 'small' | 'medium' | 'large'
34+
35+
const CHAT_TEXT_SIZES: readonly ChatTextSize[] = ['small', 'medium', 'large']
36+
37+
const DEFAULT_CHAT_TEXT_SIZE: ChatTextSize = 'medium'
38+
39+
interface ChatTextSizeContextType {
40+
chatTextSize: ChatTextSize
41+
setChatTextSize: (value: ChatTextSize) => void
42+
}
43+
44+
const ChatTextSizeContext = createContext<ChatTextSizeContextType | null>(null)
45+
46+
const CHAT_TEXT_SIZE_ATTR = 'data-chat-text-size'
47+
48+
/** Guard against malformed persisted values. */
49+
function normalize(value: unknown): ChatTextSize {
50+
return CHAT_TEXT_SIZES.includes(value as ChatTextSize)
51+
? (value as ChatTextSize)
52+
: DEFAULT_CHAT_TEXT_SIZE
53+
}
54+
55+
/** Reflect the preference onto <html> so the global CSS var can react. */
56+
function applyChatTextSizeAttribute(size: ChatTextSize): void {
57+
document.documentElement.setAttribute(CHAT_TEXT_SIZE_ATTR, size)
58+
}
59+
60+
export function ChatTextSizeProvider({ children }: { children: ReactNode }) {
61+
const [chatTextSize, setChatTextSizeState] = useState<ChatTextSize>(() =>
62+
normalize(storage.get<ChatTextSize>(storage.KEYS.chatTextSize, DEFAULT_CHAT_TEXT_SIZE)),
63+
)
64+
65+
// Keep the DOM attribute in sync (also covers the initial value on mount).
66+
useEffect(() => {
67+
applyChatTextSizeAttribute(chatTextSize)
68+
}, [chatTextSize])
69+
70+
const setChatTextSize = useCallback((value: ChatTextSize) => {
71+
const next = normalize(value)
72+
setChatTextSizeState(next)
73+
storage.set(storage.KEYS.chatTextSize, next)
74+
applyChatTextSizeAttribute(next)
75+
}, [])
76+
77+
return (
78+
<ChatTextSizeContext.Provider value={{ chatTextSize, setChatTextSize }}>
79+
{children}
80+
</ChatTextSizeContext.Provider>
81+
)
82+
}
83+
84+
export function useChatTextSize(): ChatTextSizeContextType {
85+
const ctx = useContext(ChatTextSizeContext)
86+
if (!ctx) {
87+
throw new Error('useChatTextSize must be used within a ChatTextSizeProvider')
88+
}
89+
return ctx
90+
}

apps/electron/src/renderer/index.css

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1386,3 +1386,23 @@ html.dark[data-scenic] .fullscreen-overlay-background {
13861386
transition-delay: 0ms !important;
13871387
scroll-behavior: auto !important;
13881388
}
1389+
1390+
/* Chat text size: the "Chat text size" control in Appearance settings reflects
1391+
its choice onto <html> as data-chat-text-size. That maps to the
1392+
--chat-font-scale custom property, which the chat transcript container
1393+
(.chat-text-scope) consumes as `font-size: calc(1em * var(--chat-font-scale))`.
1394+
Being em-relative keeps the default (medium) exactly neutral and scales only
1395+
the conversation's reading text — the sidebar, top bar, composer, and icons
1396+
keep their native size. */
1397+
:root {
1398+
--chat-font-scale: 1;
1399+
}
1400+
[data-chat-text-size='small'] {
1401+
--chat-font-scale: 0.9;
1402+
}
1403+
[data-chat-text-size='large'] {
1404+
--chat-font-scale: 1.15;
1405+
}
1406+
.chat-text-scope {
1407+
font-size: calc(1em * var(--chat-font-scale));
1408+
}

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

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,7 @@ export const KEYS = {
6363
showConnectionIcons: 'show-connection-icons',
6464
reduceMotion: 'reduce-motion', // Minimize animations/transitions app-wide
6565
conversationWidth: 'conversation-width', // Chat reading-column width: comfortable | wide | full
66+
chatTextSize: 'chat-text-size', // Scale conversation text without resizing the app chrome
6667

6768
// What's New
6869
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
@@ -8,6 +8,7 @@ import App from './App'
88
import { ThemeProvider } from './context/ThemeContext'
99
import { ReduceMotionProvider } from './context/ReduceMotionContext'
1010
import { ConversationWidthProvider } from './context/ConversationWidthContext'
11+
import { ChatTextSizeProvider } from './context/ChatTextSizeContext'
1112
import { windowWorkspaceIdAtom } from './atoms/sessions'
1213
import { Toaster } from '@/components/ui/sonner'
1314
import { PetWindowController } from '@/components/pet/PetWindowController'
@@ -110,9 +111,11 @@ function Root() {
110111
<ThemeProvider activeWorkspaceId={workspaceId}>
111112
<ReduceMotionProvider>
112113
<ConversationWidthProvider>
113-
<App />
114-
<Toaster />
115-
<PetWindowController />
114+
<ChatTextSizeProvider>
115+
<App />
116+
<Toaster />
117+
<PetWindowController />
118+
</ChatTextSizeProvider>
116119
</ConversationWidthProvider>
117120
</ReduceMotionProvider>
118121
</ThemeProvider>

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

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ import { EditPopover, EditButton, getEditConfig } from '@/components/ui/EditPopo
1616
import { useTheme } from '@/context/ThemeContext'
1717
import { useReduceMotion } from '@/context/ReduceMotionContext'
1818
import { useConversationWidth } from '@/context/ConversationWidthContext'
19+
import { useChatTextSize, type ChatTextSize } from '@/context/ChatTextSizeContext'
1920
import { useAppShellContext } from '@/context/AppShellContext'
2021
import { routes } from '@/lib/navigate'
2122
import { FolderOpen, Monitor, RefreshCw, Sun, Moon } from 'lucide-react'
@@ -146,6 +147,8 @@ export default function AppearanceSettingsPage() {
146147

147148
// Conversation width (renderer-only preference, persisted in localStorage)
148149
const { conversationWidth, setConversationWidth } = useConversationWidth()
150+
// Chat text size (renderer-only preference, persisted in localStorage)
151+
const { chatTextSize, setChatTextSize } = useChatTextSize()
149152

150153
// Pet companion settings + custom pets (synced via shared Jotai atoms)
151154
const {
@@ -406,6 +409,21 @@ export default function AppearanceSettingsPage() {
406409
]}
407410
/>
408411
</SettingsRow>
412+
<SettingsRow
413+
label={t("settings.appearance.chatTextSize")}
414+
description={t("settings.appearance.chatTextSizeDesc")}
415+
>
416+
<SettingsSegmentedControl
417+
value={chatTextSize}
418+
onValueChange={(value) => setChatTextSize(value as ChatTextSize)}
419+
testId="chat-text-size-control"
420+
options={[
421+
{ value: 'small', label: t("settings.appearance.chatTextSizeSmall") },
422+
{ value: 'medium', label: t("settings.appearance.chatTextSizeDefault") },
423+
{ value: 'large', label: t("settings.appearance.chatTextSizeLarge") },
424+
]}
425+
/>
426+
</SettingsRow>
409427
</SettingsCard>
410428
</SettingsSection>
411429

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+
| 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). |
3637
| 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). |
3738
| composer-word-count | Live word/character/line count indicator in the chat composer | Codex/Claude desktop composer counters + editor status bars | frontend-only | pr-open | [#60](https://github.com/modelstudioai/openwork/issues/60) | [#61](https://github.com/modelstudioai/openwork/pull/61) | loop/composer-word-count | 2026-07-06 | Pure `computeComposerCounts` module (+unit tests) → `ComposerCountIndicator` in the composer toolbar, derived from the existing `input` string; hidden when empty / in compact mode. 3 new plural-neutral i18n keys (`chat.composer{Words,Characters,Lines}`) in all 7 locales. typecheck:all zero-delta (11 pre-existing baseline errors, none in touched files); unit tests 10/10; i18n parity OK; renderer build ✅; assertion transpiles. **CDP could not run locally**: the e2e build 403s on the Electron binary download and the `libsignal` WhatsApp-worker dep (same egress block as prior loop PRs); `composer-count.assert.ts` included for CI/reviewer. |
3839
| shortcuts-search | Search box on the Settings → Keyboard Shortcuts page | Codex "keypress search" + VS Code / Claude keybindings search; mirrors OpenWork's own settings-navigator search (#40) | frontend-only | pr-open | [#58](https://github.com/modelstudioai/openwork/issues/58) | [#59](https://github.com/modelstudioai/openwork/pull/59) | loop/shortcuts-search | 2026-07-05 | Pure renderer view over the action registry (`actionsByCategory` + `getHotkeyDisplay`); filters rows by action label **and** rendered key tokens ("keypress search"), hides empty sections, shows empty state. Reuses `common.search`/`noResultsFound`/`clear` (**zero** new i18n keys). Added `data-testid="settings-item-<id>"` to settings-nav items for e2e navigation. typecheck/renderer-build/i18n-parity zero-delta vs main; touched-area tests pass. CDP assertion written (app launch egress-blocked locally, same as prior rounds). |

0 commit comments

Comments
 (0)