diff --git a/.lane/memory/packages/ui/src/components/scroll-view/scroll-view.tsx/01M5JPN2QNMG751NXC43TCM3SR-a-quiet-period-must-end-a-sc.md b/.lane/memory/packages/ui/src/components/scroll-view/scroll-view.tsx/01M5JPN2QNMG751NXC43TCM3SR-a-quiet-period-must-end-a-sc.md index b5bb09d1..2f00776c 100644 --- a/.lane/memory/packages/ui/src/components/scroll-view/scroll-view.tsx/01M5JPN2QNMG751NXC43TCM3SR-a-quiet-period-must-end-a-sc.md +++ b/.lane/memory/packages/ui/src/components/scroll-view/scroll-view.tsx/01M5JPN2QNMG751NXC43TCM3SR-a-quiet-period-must-end-a-sc.md @@ -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 --- diff --git a/.lane/memory/packages/ui/src/components/scroll-view/scroll-view.tsx/01M5ZENGPGKK51DB8FM7QKWRYP-timeline-rows-are-laid-out-w.md b/.lane/memory/packages/ui/src/components/scroll-view/scroll-view.tsx/01M5ZENGPGKK51DB8FM7QKWRYP-timeline-rows-are-laid-out-w.md new file mode 100644 index 00000000..65fef34a --- /dev/null +++ b/.lane/memory/packages/ui/src/components/scroll-view/scroll-view.tsx/01M5ZENGPGKK51DB8FM7QKWRYP-timeline-rows-are-laid-out-w.md @@ -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 diff --git a/apps/app/src/main.tsx b/apps/app/src/main.tsx index 7a8a489f..38d56645 100644 --- a/apps/app/src/main.tsx +++ b/apps/app/src/main.tsx @@ -1,6 +1,7 @@ import { createRoot } from "react-dom/client"; import "./styles.css"; +import "./text-size"; import "./viewport"; import { gateway } from "./address"; diff --git a/apps/app/src/navigation.tsx b/apps/app/src/navigation.tsx index 901131dd..b014b48d 100644 --- a/apps/app/src/navigation.tsx +++ b/apps/app/src/navigation.tsx @@ -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"; @@ -100,6 +101,7 @@ const ACTIONS = [ "prs", "nav-toggle", "channels-toggle", + ...TEXT_SIZE_ACTIONS, ]; export function WindowControls({ onOpen, onClone, onSettings, onPage, connected }: { @@ -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); } }); diff --git a/apps/app/src/palette.tsx b/apps/app/src/palette.tsx index ebf1a963..97b7161e 100644 --- a/apps/app/src/palette.tsx +++ b/apps/app/src/palette.tsx @@ -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"; diff --git a/apps/app/src/text-size.ts b/apps/app/src/text-size.ts new file mode 100644 index 00000000..5f41e064 --- /dev/null +++ b/apps/app/src/text-size.ts @@ -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()); +}); diff --git a/apps/desktop/src/index.ts b/apps/desktop/src/index.ts index 4a58661d..16d34a38 100644 --- a/apps/desktop/src/index.ts +++ b/apps/desktop/src/index.ts @@ -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. @@ -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, ) diff --git a/packages/ui/src/components/scroll-view/scroll-view.tsx b/packages/ui/src/components/scroll-view/scroll-view.tsx index 07b03705..21a7c30c 100644 --- a/packages/ui/src/components/scroll-view/scroll-view.tsx +++ b/packages/ui/src/components/scroll-view/scroll-view.tsx @@ -47,6 +47,11 @@ type ScrollViewProps = Omit, "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. */ @@ -65,6 +70,7 @@ function ScrollView( end = 0, overscan = 300, children, + scale = 1, onRangeChange, onScrollStart, onScrollEnd, @@ -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, @@ -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) + :
{children(idx, width / scale)}
; + return createPortal(item, container, String(idx)); })} ); diff --git a/packages/ui/src/components/timeline/timeline.tsx b/packages/ui/src/components/timeline/timeline.tsx index f91a0006..0769298c 100644 --- a/packages/ui/src/components/timeline/timeline.tsx +++ b/packages/ui/src/components/timeline/timeline.tsx @@ -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, @@ -1776,6 +1777,7 @@ function Timeline(input: TimelineProps) { show, dismiss, } = useTimeline(input); + let scale = useTextScale(); return ( @@ -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} diff --git a/packages/ui/src/lib/text-scale.ts b/packages/ui/src/lib/text-scale.ts new file mode 100644 index 00000000..3fc6f456 --- /dev/null +++ b/packages/ui/src/lib/text-scale.ts @@ -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); +} diff --git a/packages/ui/src/shadcn.css b/packages/ui/src/shadcn.css index 579b5b6f..45ef728f 100644 --- a/packages/ui/src/shadcn.css +++ b/packages/ui/src/shadcn.css @@ -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 {