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
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,9 @@ anchor: '@file'
created: 2026-10-22T21:00:43Z
norm: '1'
sig: e0cbdab303939db3
body_hash: 6885f9b9dd17486d
raw_hash: 38eb59c30e3b5a2c
body_hash: 73bb4d19f1854f92
raw_hash: cacd0602d2503dcd
vouched: 2026-10-27T19:51:30Z
lines: 1-374
---

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
---
id: 01M5ZENGPGKK51DB8FM7QKWRYP
anchor: function ScrollView
created: 2026-10-27T19:51:16Z
norm: '1'
sig: 224e2d61febb9c30
body_hash: 4107981bbf661009
raw_hash: c8bb66355be64899
lines: 65-378
---

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
1 change: 1 addition & 0 deletions apps/app/src/main.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { createRoot } from "react-dom/client";

import "./styles.css";
import "./text-size";
import "./viewport";

import { gateway } from "./address";
Expand Down
6 changes: 6 additions & 0 deletions apps/app/src/navigation.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import {
import { IconHash, IconHome, IconIssue, IconPullRequest } from "@ace/ui/icons";

import { desktop } from "./desktop";
import { TEXT_SIZE_ACTIONS, textSize } from "./text-size";
import { share } from "./window-storage";

export type Page = "dashboard" | "channels" | "issues" | "prs";
Expand Down Expand Up @@ -100,6 +101,7 @@ const ACTIONS = [
"prs",
"nav-toggle",
"channels-toggle",
...TEXT_SIZE_ACTIONS,
];

export function WindowControls({ onOpen, onClone, onSettings, onPage, connected }: {
Expand Down Expand Up @@ -135,6 +137,10 @@ export function WindowControls({ onOpen, onClone, onSettings, onPage, connected
return nav.setOpen((value) => !value);
case "channels-toggle":
return left.setOpen((value) => !value);
case "text-bigger":
case "text-smaller":
case "text-reset":
return textSize(action);
}
});

Expand Down
3 changes: 3 additions & 0 deletions apps/app/src/palette.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,9 @@ const ACTIONS = [
...(desktop ? [action("updates", "Check for updates…", "Ace")] : []),
action("nav-toggle", "Toggle navigation", "View", "b"),
action("channels-toggle", "Toggle channels sidebar", "View", "b", true),
action("text-bigger", "Bigger text", "View", "+"),
action("text-smaller", "Smaller text", "View", "-"),
action("text-reset", "Actual size text", "View", "0"),
];

const CREATE = "channel-new";
Expand Down
37 changes: 37 additions & 0 deletions apps/app/src/text-size.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
/**
* Text size scales the whole interface through the root font size, which rem sizes follow. Page
* zoom would also scale it, but would stop CSS pixels matching the window points that native
* overlays such as the live view are placed in.
*/
const KEY = "ace:text-size";
const BASE = 16;
const SIZES = [12, 13, 14, 15, 16, 17, 18, 20, 22, 24];

export const TEXT_SIZE_ACTIONS = ["text-bigger", "text-smaller", "text-reset"];

function stored(): number {
const size = Number(localStorage.getItem(KEY));
return SIZES.includes(size) ? size : BASE;
}

function apply(size: number) {
document.documentElement.style.setProperty("--font-size", `${size}px`);
}

export function textSize(action: string) {
const index = SIZES.indexOf(stored());
const size = action === "text-bigger"
? SIZES[Math.min(index + 1, SIZES.length - 1)]
: action === "text-smaller"
? SIZES[Math.max(index - 1, 0)]
: BASE;
if (size === BASE) localStorage.removeItem(KEY);
else localStorage.setItem(KEY, String(size));
apply(size);
}

apply(stored());
// Every window shares the size, so a change in one resizes the others.
window.addEventListener("storage", (event) => {
if (event.key === KEY) apply(stored());
});
7 changes: 7 additions & 0 deletions apps/desktop/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -233,6 +233,10 @@ ApplicationMenu.setApplicationMenu([
action: "channels-toggle",
accelerator: "CmdOrCtrl+Shift+b",
},
{ type: "divider" },
{ label: "Bigger Text", action: "text-bigger", accelerator: "CmdOrCtrl+Plus" },
{ label: "Smaller Text", action: "text-smaller", accelerator: "CmdOrCtrl+-" },
{ label: "Actual Size", action: "text-reset", accelerator: "CmdOrCtrl+0" },
],
},
// The collapsed navigation rail hides the minimize and zoom buttons, so the menu keeps both.
Expand Down Expand Up @@ -304,6 +308,9 @@ Electrobun.events.on("application-menu-clicked", ({ data }) => {
"prs",
"nav-toggle",
"channels-toggle",
"text-bigger",
"text-smaller",
"text-reset",
].includes(
data.action,
)
Expand Down
17 changes: 15 additions & 2 deletions packages/ui/src/components/scroll-view/scroll-view.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,11 @@ type ScrollViewProps = Omit<ComponentPropsWithRef<"div">, "children" | "onScroll
overscan?: number;
/** Render callback — receives item index and viewport width. */
children: ScrollViewRender;
/**
* Zooms items, which lay out at the viewport width divided by it. Scroll offsets stay in
* viewport pixels, so layouts measured in fixed pixels can grow with the text size.
*/
scale?: number;
/** Called with the actually visible item range and bottom-pinned state. */
onRangeChange?: (range: ScrollViewRange) => void;
/** Called when native scrolling starts changing the viewport. */
Expand All @@ -65,6 +70,7 @@ function ScrollView(
end = 0,
overscan = 300,
children,
scale = 1,
onRangeChange,
onScrollStart,
onScrollEnd,
Expand Down Expand Up @@ -95,7 +101,11 @@ function ScrollView(
// inflate the initial visible range and trigger a very expensive first render.
let ready = width > 0;

let heightAt = useMemo(() => (i: number) => height(i, width), [height, width]);
let heightAt = useMemo(() => (i: number) => height(i, width / scale) * scale, [
height,
scale,
width,
]);
let frame = useMemo(() => ready ? scroll.frame(count, heightAt, gap, end) : EMPTY, [
count,
end,
Expand Down Expand Up @@ -358,7 +368,10 @@ function ScrollView(
if (!frame.items[idx]) return null;
let container = cache.current.get(idx);
if (!container) return null;
return createPortal(children(idx, width), container, String(idx));
let item = scale === 1
? children(idx, width)
: <div style={{ zoom: scale }}>{children(idx, width / scale)}</div>;
return createPortal(item, container, String(idx));
})}
</div>
);
Expand Down
3 changes: 3 additions & 0 deletions packages/ui/src/components/timeline/timeline.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@ import { TurnSummary } from "../turn-summary/turn-summary";
import type { Block } from "../../lib/block";
import { iconFor } from "../../lib/tool-icon";
import { agentColor, agentLabel } from "../../lib/agent";
import { useTextScale } from "../../lib/text-scale";
import { cn } from "../../lib/utils";
import {
type AgentTurn,
Expand Down Expand Up @@ -1776,6 +1777,7 @@ function Timeline(input: TimelineProps) {
show,
dismiss,
} = useTimeline(input);
let scale = useTextScale();
return (
<ExecActionProvider value={onExecAction ?? null}>
<ToolProvider value={{ expandedResults, toggle, toggleResult }}>
Expand All @@ -1786,6 +1788,7 @@ function Timeline(input: TimelineProps) {
itemHeight(timeline[i]!, blocks, Math.min(w, LANE), openGroups, visibleWorking)}
gap={GAP}
end={TAIL}
scale={scale}
onRangeChange={setRange}
onScrollStart={onScrollStart}
onScrollEnd={onScrollEnd}
Expand Down
25 changes: 25 additions & 0 deletions packages/ui/src/lib/text-scale.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
import { useSyncExternalStore } from "react";

/** The root font size against the 16px default, which the app's text size setting changes. */
function measure(): number {
return parseFloat(getComputedStyle(document.documentElement).fontSize) / 16;
}

let scale: number | undefined;

function subscribe(change: () => void) {
let observer = new MutationObserver(() => {
scale = measure();
change();
});
observer.observe(document.documentElement, { attributes: true, attributeFilter: ["style"] });
return () => observer.disconnect();
}

/**
* For layouts measured in fixed pixels, such as timeline text, which rem-based sizing doesn't
* reach.
*/
export function useTextScale(): number {
return useSyncExternalStore(subscribe, () => scale ??= measure(), () => 1);
}
2 changes: 1 addition & 1 deletion packages/ui/src/shadcn.css
Original file line number Diff line number Diff line change
Expand Up @@ -568,7 +568,7 @@
html {
@apply bg-background font-sans;
color-scheme: light;
font-size: clamp(12px, var(--font-size, 1rem), 20px);
font-size: clamp(12px, var(--font-size, 1rem), 24px);
}

html.dark {
Expand Down