Skip to content

Commit e1ab6a2

Browse files
authored
feat(app): change text size with Cmd+Plus, Cmd+Minus and Cmd+0, including messages (#709)
* feat(app): change text size with Cmd+Plus, Cmd+Minus and Cmd+0, including messages Why: packages/ui/src/components/scroll-view/scroll-view.tsx#function ScrollView | timeline rows are laid out with fixed-pixel fonts, so text size reaches them only through scale: rows lay out at width / scale under CSS zoom while scroll offsets stay in viewport pixels * lane: sync zoom memory * chore: record text size notes
1 parent fe66277 commit e1ab6a2

11 files changed

Lines changed: 113 additions & 5 deletions

File tree

‎.lane/memory/packages/ui/src/components/scroll-view/scroll-view.tsx/01M5JPN2QNMG751NXC43TCM3SR-a-quiet-period-must-end-a-sc.md‎

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,8 +4,9 @@ anchor: '@file'
44
created: 2026-10-22T21:00:43Z
55
norm: '1'
66
sig: e0cbdab303939db3
7-
body_hash: 6885f9b9dd17486d
8-
raw_hash: 38eb59c30e3b5a2c
7+
body_hash: 73bb4d19f1854f92
8+
raw_hash: cacd0602d2503dcd
9+
vouched: 2026-10-27T19:51:30Z
910
lines: 1-374
1011
---
1112

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
---
2+
id: 01M5ZENGPGKK51DB8FM7QKWRYP
3+
anchor: function ScrollView
4+
created: 2026-10-27T19:51:16Z
5+
norm: '1'
6+
sig: 224e2d61febb9c30
7+
body_hash: 4107981bbf661009
8+
raw_hash: c8bb66355be64899
9+
lines: 65-378
10+
---
11+
12+
timeline rows are laid out with fixed-pixel fonts, so text size reaches them only through scale: rows lay out at width / scale under CSS zoom while scroll offsets stay in viewport pixels

‎apps/app/src/main.tsx‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { createRoot } from "react-dom/client";
22

33
import "./styles.css";
4+
import "./text-size";
45
import "./viewport";
56

67
import { gateway } from "./address";

‎apps/app/src/navigation.tsx‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import {
1212
import { IconHash, IconHome, IconIssue, IconPullRequest } from "@ace/ui/icons";
1313

1414
import { desktop } from "./desktop";
15+
import { TEXT_SIZE_ACTIONS, textSize } from "./text-size";
1516
import { share } from "./window-storage";
1617

1718
export type Page = "dashboard" | "channels" | "issues" | "prs";
@@ -100,6 +101,7 @@ const ACTIONS = [
100101
"prs",
101102
"nav-toggle",
102103
"channels-toggle",
104+
...TEXT_SIZE_ACTIONS,
103105
];
104106

105107
export function WindowControls({ onOpen, onClone, onSettings, onPage, connected }: {
@@ -135,6 +137,10 @@ export function WindowControls({ onOpen, onClone, onSettings, onPage, connected
135137
return nav.setOpen((value) => !value);
136138
case "channels-toggle":
137139
return left.setOpen((value) => !value);
140+
case "text-bigger":
141+
case "text-smaller":
142+
case "text-reset":
143+
return textSize(action);
138144
}
139145
});
140146

‎apps/app/src/palette.tsx‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,9 @@ const ACTIONS = [
3838
...(desktop ? [action("updates", "Check for updates…", "Ace")] : []),
3939
action("nav-toggle", "Toggle navigation", "View", "b"),
4040
action("channels-toggle", "Toggle channels sidebar", "View", "b", true),
41+
action("text-bigger", "Bigger text", "View", "+"),
42+
action("text-smaller", "Smaller text", "View", "-"),
43+
action("text-reset", "Actual size text", "View", "0"),
4144
];
4245

4346
const CREATE = "channel-new";

‎apps/app/src/text-size.ts‎

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
/**
2+
* Text size scales the whole interface through the root font size, which rem sizes follow. Page
3+
* zoom would also scale it, but would stop CSS pixels matching the window points that native
4+
* overlays such as the live view are placed in.
5+
*/
6+
const KEY = "ace:text-size";
7+
const BASE = 16;
8+
const SIZES = [12, 13, 14, 15, 16, 17, 18, 20, 22, 24];
9+
10+
export const TEXT_SIZE_ACTIONS = ["text-bigger", "text-smaller", "text-reset"];
11+
12+
function stored(): number {
13+
const size = Number(localStorage.getItem(KEY));
14+
return SIZES.includes(size) ? size : BASE;
15+
}
16+
17+
function apply(size: number) {
18+
document.documentElement.style.setProperty("--font-size", `${size}px`);
19+
}
20+
21+
export function textSize(action: string) {
22+
const index = SIZES.indexOf(stored());
23+
const size = action === "text-bigger"
24+
? SIZES[Math.min(index + 1, SIZES.length - 1)]
25+
: action === "text-smaller"
26+
? SIZES[Math.max(index - 1, 0)]
27+
: BASE;
28+
if (size === BASE) localStorage.removeItem(KEY);
29+
else localStorage.setItem(KEY, String(size));
30+
apply(size);
31+
}
32+
33+
apply(stored());
34+
// Every window shares the size, so a change in one resizes the others.
35+
window.addEventListener("storage", (event) => {
36+
if (event.key === KEY) apply(stored());
37+
});

‎apps/desktop/src/index.ts‎

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -233,6 +233,10 @@ ApplicationMenu.setApplicationMenu([
233233
action: "channels-toggle",
234234
accelerator: "CmdOrCtrl+Shift+b",
235235
},
236+
{ type: "divider" },
237+
{ label: "Bigger Text", action: "text-bigger", accelerator: "CmdOrCtrl+Plus" },
238+
{ label: "Smaller Text", action: "text-smaller", accelerator: "CmdOrCtrl+-" },
239+
{ label: "Actual Size", action: "text-reset", accelerator: "CmdOrCtrl+0" },
236240
],
237241
},
238242
// The collapsed navigation rail hides the minimize and zoom buttons, so the menu keeps both.
@@ -304,6 +308,9 @@ Electrobun.events.on("application-menu-clicked", ({ data }) => {
304308
"prs",
305309
"nav-toggle",
306310
"channels-toggle",
311+
"text-bigger",
312+
"text-smaller",
313+
"text-reset",
307314
].includes(
308315
data.action,
309316
)

‎packages/ui/src/components/scroll-view/scroll-view.tsx‎

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,11 @@ type ScrollViewProps = Omit<ComponentPropsWithRef<"div">, "children" | "onScroll
4747
overscan?: number;
4848
/** Render callback — receives item index and viewport width. */
4949
children: ScrollViewRender;
50+
/**
51+
* Zooms items, which lay out at the viewport width divided by it. Scroll offsets stay in
52+
* viewport pixels, so layouts measured in fixed pixels can grow with the text size.
53+
*/
54+
scale?: number;
5055
/** Called with the actually visible item range and bottom-pinned state. */
5156
onRangeChange?: (range: ScrollViewRange) => void;
5257
/** Called when native scrolling starts changing the viewport. */
@@ -65,6 +70,7 @@ function ScrollView(
6570
end = 0,
6671
overscan = 300,
6772
children,
73+
scale = 1,
6874
onRangeChange,
6975
onScrollStart,
7076
onScrollEnd,
@@ -95,7 +101,11 @@ function ScrollView(
95101
// inflate the initial visible range and trigger a very expensive first render.
96102
let ready = width > 0;
97103

98-
let heightAt = useMemo(() => (i: number) => height(i, width), [height, width]);
104+
let heightAt = useMemo(() => (i: number) => height(i, width / scale) * scale, [
105+
height,
106+
scale,
107+
width,
108+
]);
99109
let frame = useMemo(() => ready ? scroll.frame(count, heightAt, gap, end) : EMPTY, [
100110
count,
101111
end,
@@ -358,7 +368,10 @@ function ScrollView(
358368
if (!frame.items[idx]) return null;
359369
let container = cache.current.get(idx);
360370
if (!container) return null;
361-
return createPortal(children(idx, width), container, String(idx));
371+
let item = scale === 1
372+
? children(idx, width)
373+
: <div style={{ zoom: scale }}>{children(idx, width / scale)}</div>;
374+
return createPortal(item, container, String(idx));
362375
})}
363376
</div>
364377
);

‎packages/ui/src/components/timeline/timeline.tsx‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,7 @@ import { TurnSummary } from "../turn-summary/turn-summary";
4141
import type { Block } from "../../lib/block";
4242
import { iconFor } from "../../lib/tool-icon";
4343
import { agentColor, agentLabel } from "../../lib/agent";
44+
import { useTextScale } from "../../lib/text-scale";
4445
import { cn } from "../../lib/utils";
4546
import {
4647
type AgentTurn,
@@ -1776,6 +1777,7 @@ function Timeline(input: TimelineProps) {
17761777
show,
17771778
dismiss,
17781779
} = useTimeline(input);
1780+
let scale = useTextScale();
17791781
return (
17801782
<ExecActionProvider value={onExecAction ?? null}>
17811783
<ToolProvider value={{ expandedResults, toggle, toggleResult }}>
@@ -1786,6 +1788,7 @@ function Timeline(input: TimelineProps) {
17861788
itemHeight(timeline[i]!, blocks, Math.min(w, LANE), openGroups, visibleWorking)}
17871789
gap={GAP}
17881790
end={TAIL}
1791+
scale={scale}
17891792
onRangeChange={setRange}
17901793
onScrollStart={onScrollStart}
17911794
onScrollEnd={onScrollEnd}

‎packages/ui/src/lib/text-scale.ts‎

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
import { useSyncExternalStore } from "react";
2+
3+
/** The root font size against the 16px default, which the app's text size setting changes. */
4+
function measure(): number {
5+
return parseFloat(getComputedStyle(document.documentElement).fontSize) / 16;
6+
}
7+
8+
let scale: number | undefined;
9+
10+
function subscribe(change: () => void) {
11+
let observer = new MutationObserver(() => {
12+
scale = measure();
13+
change();
14+
});
15+
observer.observe(document.documentElement, { attributes: true, attributeFilter: ["style"] });
16+
return () => observer.disconnect();
17+
}
18+
19+
/**
20+
* For layouts measured in fixed pixels, such as timeline text, which rem-based sizing doesn't
21+
* reach.
22+
*/
23+
export function useTextScale(): number {
24+
return useSyncExternalStore(subscribe, () => scale ??= measure(), () => 1);
25+
}

0 commit comments

Comments
 (0)