From 061e0e0ec51d96ba71a738a19c2af090f8d73c81 Mon Sep 17 00:00:00 2001 From: Yaacov Date: Wed, 22 Jul 2026 21:05:43 +0300 Subject: [PATCH 1/3] Increase default base font size to 15px --- apps/web/src/appSettings.test.ts | 4 +-- apps/web/src/appSettings.ts | 2 +- .../web/src/components/timelineHeight.test.ts | 34 +++++++++---------- 3 files changed, 20 insertions(+), 20 deletions(-) diff --git a/apps/web/src/appSettings.test.ts b/apps/web/src/appSettings.test.ts index e908aa68d..f0c3911ee 100644 --- a/apps/web/src/appSettings.test.ts +++ b/apps/web/src/appSettings.test.ts @@ -294,8 +294,8 @@ describe("timestamp format defaults", () => { }); describe("chat font size defaults", () => { - it("defaults chat font size to 12px", () => { - expect(DEFAULT_CHAT_FONT_SIZE_PX).toBe(12); + it("defaults chat font size to 15px", () => { + expect(DEFAULT_CHAT_FONT_SIZE_PX).toBe(15); }); it("clamps chat font size updates into the supported range", () => { diff --git a/apps/web/src/appSettings.ts b/apps/web/src/appSettings.ts index 31faa1ca7..84de9e4fd 100644 --- a/apps/web/src/appSettings.ts +++ b/apps/web/src/appSettings.ts @@ -47,7 +47,7 @@ const MAX_CUSTOM_MODEL_COUNT = 32; export const MAX_CUSTOM_MODEL_LENGTH = 256; export const MIN_CHAT_FONT_SIZE_PX = 11; export const MAX_CHAT_FONT_SIZE_PX = 18; -export const DEFAULT_CHAT_FONT_SIZE_PX = 12; +export const DEFAULT_CHAT_FONT_SIZE_PX = 15; export const MIN_TERMINAL_FONT_SIZE_PX = 10; export const MAX_TERMINAL_FONT_SIZE_PX = 22; export const DEFAULT_TERMINAL_FONT_SIZE_PX = 12; diff --git a/apps/web/src/components/timelineHeight.test.ts b/apps/web/src/components/timelineHeight.test.ts index 094a5b512..839cf4e2f 100644 --- a/apps/web/src/components/timelineHeight.test.ts +++ b/apps/web/src/components/timelineHeight.test.ts @@ -16,7 +16,7 @@ describe("estimateTimelineMessageHeight", () => { role: "assistant", text: "a".repeat(144), }), - ).toBe(117); + ).toBe(126.75); }); it("uses assistant sizing rules for system messages", () => { @@ -25,7 +25,7 @@ describe("estimateTimelineMessageHeight", () => { role: "system", text: "a".repeat(144), }), - ).toBe(117); + ).toBe(126.75); }); it("adds one attachment row for one or two user attachments", () => { @@ -35,7 +35,7 @@ describe("estimateTimelineMessageHeight", () => { text: "hello", attachments: [{ id: "1", type: "image" }], }), - ).toBe(180.5); + ).toBe(185.375); expect( estimateTimelineMessageHeight({ @@ -46,7 +46,7 @@ describe("estimateTimelineMessageHeight", () => { { id: "2", type: "image" }, ], }), - ).toBe(180.5); + ).toBe(185.375); }); it("keeps up to four user image attachments on one row", () => { @@ -60,7 +60,7 @@ describe("estimateTimelineMessageHeight", () => { { id: "3", type: "image" }, ], }), - ).toBe(180.5); + ).toBe(185.375); expect( estimateTimelineMessageHeight({ @@ -73,7 +73,7 @@ describe("estimateTimelineMessageHeight", () => { { id: "4", type: "image" }, ], }), - ).toBe(180.5); + ).toBe(185.375); }); it("adds a second attachment row for five user image attachments", () => { @@ -89,7 +89,7 @@ describe("estimateTimelineMessageHeight", () => { { id: "5", type: "image" }, ], }), - ).toBe(248.5); + ).toBe(253.375); }); it("caps long user message estimates to the shared 12-line clamp", () => { @@ -98,7 +98,7 @@ describe("estimateTimelineMessageHeight", () => { role: "user", text: "a".repeat(56 * 120), }), - ).toBe(351); + ).toBe(409.5); }); it("clamps messages with more than 12 explicit lines and includes the disclosure", () => { @@ -107,7 +107,7 @@ describe("estimateTimelineMessageHeight", () => { role: "user", text: Array.from({ length: 13 }, (_, index) => `line ${index + 1}`).join("\n"), }), - ).toBe(351); + ).toBe(409.5); }); it("counts explicit newlines for user message estimates", () => { @@ -116,7 +116,7 @@ describe("estimateTimelineMessageHeight", () => { role: "user", text: "first\nsecond\nthird", }), - ).toBe(155.5); + ).toBe(170.125); }); it("adds terminal context chrome without counting the hidden block as message text", () => { @@ -174,8 +174,8 @@ describe("estimateTimelineMessageHeight", () => { text: "a".repeat(52), }; - expect(estimateTimelineMessageHeight(message, { timelineWidthPx: 320 })).toBe(136); - expect(estimateTimelineMessageHeight(message, { timelineWidthPx: 768 })).toBe(116.5); + expect(estimateTimelineMessageHeight(message, { timelineWidthPx: 320 })).toBe(145.75); + expect(estimateTimelineMessageHeight(message, { timelineWidthPx: 768 })).toBe(121.375); }); it("does not clamp user wrapping too aggressively on very narrow layouts", () => { @@ -184,8 +184,8 @@ describe("estimateTimelineMessageHeight", () => { text: "a".repeat(20), }; - expect(estimateTimelineMessageHeight(message, { timelineWidthPx: 100 })).toBe(155.5); - expect(estimateTimelineMessageHeight(message, { timelineWidthPx: 320 })).toBe(116.5); + expect(estimateTimelineMessageHeight(message, { timelineWidthPx: 100 })).toBe(194.5); + expect(estimateTimelineMessageHeight(message, { timelineWidthPx: 320 })).toBe(121.375); }); it("uses narrower width to increase assistant line wrapping", () => { @@ -194,8 +194,8 @@ describe("estimateTimelineMessageHeight", () => { text: "a".repeat(200), }; - expect(estimateTimelineMessageHeight(message, { timelineWidthPx: 320 })).toBe(156); - expect(estimateTimelineMessageHeight(message, { timelineWidthPx: 768 })).toBe(117); + expect(estimateTimelineMessageHeight(message, { timelineWidthPx: 320 })).toBe(224.25); + expect(estimateTimelineMessageHeight(message, { timelineWidthPx: 768 })).toBe(151.125); }); it("adds diff summary chrome to assistant message estimates", () => { @@ -208,7 +208,7 @@ describe("estimateTimelineMessageHeight", () => { }, { timelineWidthPx: 768 }, ), - ).toBe(209.5); + ).toBe(214.375); }); it("accounts for inline code spans that wrap wider than plain text", () => { From 06a02c0ceffe1bc2260db3963472905a936535f9 Mon Sep 17 00:00:00 2001 From: Yaacov Date: Wed, 22 Jul 2026 22:41:08 +0300 Subject: [PATCH 2/3] Fix manual font size editing --- .../settings/SettingNumberInput.browser.tsx | 79 +++++++++++++++++ .../settings/SettingNumberInput.tsx | 88 +++++++++++++++++++ apps/web/src/routes/_chat.settings.tsx | 23 +++-- 3 files changed, 178 insertions(+), 12 deletions(-) create mode 100644 apps/web/src/components/settings/SettingNumberInput.browser.tsx create mode 100644 apps/web/src/components/settings/SettingNumberInput.tsx diff --git a/apps/web/src/components/settings/SettingNumberInput.browser.tsx b/apps/web/src/components/settings/SettingNumberInput.browser.tsx new file mode 100644 index 000000000..a5f108cd1 --- /dev/null +++ b/apps/web/src/components/settings/SettingNumberInput.browser.tsx @@ -0,0 +1,79 @@ +// FILE: SettingNumberInput.browser.tsx +// Purpose: Browser regressions for manual settings number entry and commit-time normalization. +// Layer: Vitest browser tests + +import "../../index.css"; + +import { afterEach, describe, expect, it, vi } from "vitest"; +import { page, userEvent } from "vitest/browser"; +import { render } from "vitest-browser-react"; + +import { normalizeChatFontSizePx, normalizeTerminalFontSizePx } from "../../appSettings"; +import { SettingNumberInput } from "./SettingNumberInput"; + +describe("SettingNumberInput", () => { + afterEach(() => { + document.body.innerHTML = ""; + }); + + it("keeps partial base-font input editable and commits on Enter", async () => { + const onCommit = vi.fn(); + await render( + , + ); + + const input = page.getByRole("spinbutton", { name: "Base font size in pixels" }); + await input.fill("1"); + expect(document.querySelector("input")?.value).toBe("1"); + expect(onCommit).not.toHaveBeenCalled(); + + await input.fill("14"); + expect(document.querySelector("input")?.value).toBe("14"); + await userEvent.keyboard("{Enter}"); + + expect(onCommit).toHaveBeenCalledOnce(); + expect(onCommit).toHaveBeenCalledWith(14); + }); + + it("reverts an empty draft and clamps out-of-range terminal input on blur", async () => { + const onCommit = vi.fn(); + await render( + , + ); + + const input = page.getByRole("spinbutton", { name: "Terminal font size in pixels" }); + await input.fill(""); + expect(document.querySelector("input")?.value).toBe(""); + document.querySelector("input")?.blur(); + await vi.waitFor(() => { + expect(document.querySelector("input")?.value).toBe("12"); + }); + expect(onCommit).not.toHaveBeenCalled(); + + await input.fill("99"); + document.querySelector("input")?.blur(); + await vi.waitFor(() => { + expect(document.querySelector("input")?.value).toBe("22"); + }); + expect(onCommit).toHaveBeenCalledOnce(); + expect(onCommit).toHaveBeenCalledWith(22); + }); +}); diff --git a/apps/web/src/components/settings/SettingNumberInput.tsx b/apps/web/src/components/settings/SettingNumberInput.tsx new file mode 100644 index 000000000..879811b16 --- /dev/null +++ b/apps/web/src/components/settings/SettingNumberInput.tsx @@ -0,0 +1,88 @@ +// FILE: SettingNumberInput.tsx +// Purpose: Let settings number fields keep an editable draft and normalize only when committed. +// Layer: Settings UI components + +import { type ComponentProps, useCallback, useEffect, useRef, useState } from "react"; + +import { Input } from "~/components/ui/input"; + +type SettingNumberInputProps = Omit< + ComponentProps, + "value" | "defaultValue" | "onChange" +> & { + /** Committed settings value. */ + value: number; + /** Applies the setting's rounding and range rules when the draft is committed. */ + normalizeValue: (value: number) => number; + /** Called after blur or Enter when the normalized value changed. */ + onCommit: (value: number) => void; +}; + +export function SettingNumberInput({ + value, + normalizeValue, + onCommit, + onBlur, + onFocus, + onKeyDown, + ...inputProps +}: SettingNumberInputProps) { + const [draft, setDraft] = useState(String(value)); + const draftRef = useRef(String(value)); + const focusedRef = useRef(false); + const valueRef = useRef(value); + valueRef.current = value; + const normalizeValueRef = useRef(normalizeValue); + normalizeValueRef.current = normalizeValue; + const onCommitRef = useRef(onCommit); + onCommitRef.current = onCommit; + + const replaceDraft = useCallback((nextDraft: string) => { + draftRef.current = nextDraft; + setDraft(nextDraft); + }, []); + + // Honor changes from elsewhere (including Restore defaults) without replacing text mid-edit. + useEffect(() => { + if (!focusedRef.current) { + replaceDraft(String(value)); + } + }, [replaceDraft, value]); + + const commit = useCallback(() => { + const nextValue = Number(draftRef.current.trim()); + if (draftRef.current.trim() === "" || !Number.isFinite(nextValue)) { + replaceDraft(String(valueRef.current)); + return; + } + + const normalizedValue = normalizeValueRef.current(nextValue); + replaceDraft(String(normalizedValue)); + if (normalizedValue !== valueRef.current) { + onCommitRef.current(normalizedValue); + } + }, [replaceDraft]); + + return ( + replaceDraft(event.target.value)} + onFocus={(event) => { + focusedRef.current = true; + onFocus?.(event); + }} + onBlur={(event) => { + focusedRef.current = false; + commit(); + onBlur?.(event); + }} + onKeyDown={(event) => { + if (event.key === "Enter") { + event.currentTarget.blur(); + } + onKeyDown?.(event); + }} + /> + ); +} diff --git a/apps/web/src/routes/_chat.settings.tsx b/apps/web/src/routes/_chat.settings.tsx index d4da3a2f3..00f2cd0fb 100644 --- a/apps/web/src/routes/_chat.settings.tsx +++ b/apps/web/src/routes/_chat.settings.tsx @@ -88,6 +88,7 @@ import { Switch } from "../components/ui/switch"; import { toastManager } from "../components/ui/toast"; import { ThemePackEditor } from "../components/ThemePackEditor"; import { DebouncedSettingTextInput } from "../components/settings/DebouncedSettingTextInput"; +import { SettingNumberInput } from "../components/settings/SettingNumberInput"; import { SettingsCard, SettingsListRow, @@ -2075,7 +2076,7 @@ function SettingsRouteView() { } control={
- { - const nextValue = event.target.value.trim(); - if (nextValue.length === 0) return; + value={settings.chatFontSizePx} + normalizeValue={normalizeChatFontSizePx} + onCommit={(chatFontSizePx) => { updateSettings({ - chatFontSizePx: normalizeChatFontSizePx(Number(nextValue)), + chatFontSizePx, }); }} aria-label="Base font size in pixels" @@ -2116,7 +2116,7 @@ function SettingsRouteView() { } control={
- { - const nextValue = event.target.value.trim(); - if (nextValue.length === 0) return; + value={settings.terminalFontSizePx} + normalizeValue={normalizeTerminalFontSizePx} + onCommit={(terminalFontSizePx) => { updateSettings({ - terminalFontSizePx: normalizeTerminalFontSizePx(Number(nextValue)), + terminalFontSizePx, }); }} aria-label="Terminal font size in pixels" From e9a46de572eb00b08b596bcb17e459e932c05a2d Mon Sep 17 00:00:00 2001 From: Yaacov Date: Wed, 22 Jul 2026 22:47:45 +0300 Subject: [PATCH 3/3] Preserve external font setting updates --- .../settings/SettingNumberInput.browser.tsx | 35 +++++++++++++++++++ .../settings/SettingNumberInput.tsx | 15 +++++++- 2 files changed, 49 insertions(+), 1 deletion(-) diff --git a/apps/web/src/components/settings/SettingNumberInput.browser.tsx b/apps/web/src/components/settings/SettingNumberInput.browser.tsx index a5f108cd1..e1deb9654 100644 --- a/apps/web/src/components/settings/SettingNumberInput.browser.tsx +++ b/apps/web/src/components/settings/SettingNumberInput.browser.tsx @@ -76,4 +76,39 @@ describe("SettingNumberInput", () => { expect(onCommit).toHaveBeenCalledOnce(); expect(onCommit).toHaveBeenCalledWith(22); }); + + it("does not overwrite an external update unless the focused draft was edited", async () => { + const onCommit = vi.fn(); + const view = (value: number) => ( + + ); + const screen = await render(view(15)); + const input = page.getByRole("spinbutton", { name: "Base font size in pixels" }); + + await input.click(); + await screen.rerender(view(16)); + expect(document.querySelector("input")?.value).toBe("15"); + document.querySelector("input")?.blur(); + await vi.waitFor(() => { + expect(document.querySelector("input")?.value).toBe("16"); + }); + expect(onCommit).not.toHaveBeenCalled(); + + await input.fill("14"); + await screen.rerender(view(17)); + expect(document.querySelector("input")?.value).toBe("14"); + document.querySelector("input")?.blur(); + await vi.waitFor(() => { + expect(onCommit).toHaveBeenCalledWith(14); + }); + }); }); diff --git a/apps/web/src/components/settings/SettingNumberInput.tsx b/apps/web/src/components/settings/SettingNumberInput.tsx index 879811b16..7bc5086f2 100644 --- a/apps/web/src/components/settings/SettingNumberInput.tsx +++ b/apps/web/src/components/settings/SettingNumberInput.tsx @@ -30,6 +30,7 @@ export function SettingNumberInput({ const [draft, setDraft] = useState(String(value)); const draftRef = useRef(String(value)); const focusedRef = useRef(false); + const dirtyRef = useRef(false); const valueRef = useRef(value); valueRef.current = value; const normalizeValueRef = useRef(normalizeValue); @@ -45,11 +46,19 @@ export function SettingNumberInput({ // Honor changes from elsewhere (including Restore defaults) without replacing text mid-edit. useEffect(() => { if (!focusedRef.current) { + dirtyRef.current = false; replaceDraft(String(value)); } }, [replaceDraft, value]); const commit = useCallback(() => { + const wasDirty = dirtyRef.current; + dirtyRef.current = false; + if (!wasDirty) { + replaceDraft(String(valueRef.current)); + return; + } + const nextValue = Number(draftRef.current.trim()); if (draftRef.current.trim() === "" || !Number.isFinite(nextValue)) { replaceDraft(String(valueRef.current)); @@ -67,9 +76,13 @@ export function SettingNumberInput({ replaceDraft(event.target.value)} + onChange={(event) => { + dirtyRef.current = true; + replaceDraft(event.target.value); + }} onFocus={(event) => { focusedRef.current = true; + dirtyRef.current = false; onFocus?.(event); }} onBlur={(event) => {