diff --git a/CHANGELOG.md b/CHANGELOG.md index bf45d0bddf..519d5f0f53 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -150,6 +150,13 @@ between releases do not reach them. Remote-PC runtime bundles are versioned sepa - A draft in the message box keeps its full height when the window or the pane is resized, or the chat width changes: the box used to keep the height of its old line breaks until the next key press. ([#463](https://github.com/devswha/herdr-web-ui/pull/463)) +- With a browser font size other than 16px (from 11.4px up), the Default chat width is never + wider than Wide. + Its widest was a fixed 960px while Wide follows the font size, so at a 13px font Wide (936px) + made the chat narrower than Default on a large monitor. Default's widest now follows the font + size too (960px at 16px, 1200px at 20px), and it is never narrower than Narrow's 820px: under + an 11.4px font that 820px is itself wider than Wide. + ([#474](https://github.com/devswha/herdr-web-ui/pull/474)) - An update asked for in the first moments after the app starts, or right after another update, waits until the start is over. It used to stop the app in the middle of its start-up check, and the app then fell back to the source checkout or did not come up. diff --git a/DESIGN.md b/DESIGN.md index 50b990d0bd..e6d1fe2276 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -208,7 +208,7 @@ Comfortable values are `:root`; the final column is the complete compact overrid | `--rail-w` | `3px` | — | Selected-row rail | | `--hairline` | `1px` | — | Borders | | `--content-w` | `820px` | — | Settings and dialog content | -| `--chat-w` | `--content-w`, then the pane's lane in px | — | Chat lane: transcript, composer column, held list. Settings → Chat width: Narrow `--content-w`; Default follows the pane (min 820px, max 960px, 71% of the pane between); Wide `72rem` (1152px); Full `100%` | +| `--chat-w` | `--content-w`, then the pane's lane as one length | — | Chat lane: transcript, composer column, held list. Settings → Chat width: Narrow `--content-w`; Default follows the pane (min 820px, max `60rem` = 960px, 71% of the pane between; the px floor wins where 60rem is under it); Wide `72rem` (1152px); Full `100%` | | `--palette-w` | `640px` | — | Command palette | | `--palette-top` | `12vh` | — | Palette top offset | @@ -482,10 +482,16 @@ One set for both themes: the card is island black wherever it shows. ### Chat turn (`.chat-turn`) - The chat lens is a centered `--chat-w` transcript over the still-attached terminal surface. - At the Default chat width the lane follows the pane: min 820px, max 960px, 71% of the pane - (`.terminal-stack`) between. `PaneTerminal` measures the pane and writes the lane on it as one px - length (`chatLaneWidth`, `lib/settings.ts`); `--chat-w` never holds that percentage, because each - column would resolve it against its own box and they would differ by their gutters. + At the Default chat width the lane follows the pane: min 820px, max 60rem (960px at a 16px + root), 71% of the pane (`.terminal-stack`) between. `PaneTerminal` measures the pane and writes + the lane on it as one length, `min(max(820px, 60rem), px)` (`chatLaneLength`, + `lib/settings.ts`); `--chat-w` never holds that percentage, because each column would resolve + it against its own box and they would differ by their gutters. + The ceiling is in rem because Wide is (`72rem`), and it stays `60rem` inside the length, so + Default is never wider than Wide at a root font of 11.4px and up, also when that size changes + while the page is open (under 11.4px the 820px floor is itself wider than Wide's 72rem). The + floor stays in px (`--content-w` is px); under a 13.67px root, where 60rem is less than 820px, the + floor wins and Default is the same lane as Narrow. Structured Claude/omp transcripts fall back to ANSI-stripped pane scrollback when unavailable. - The register is Codex / gajae-code-app: a quiet document. User turns are right-aligned neutral cards (`--bg-elevated`, a `--bubble-border` edge, `--radius-lg` on all four corners, ≤80% wide, diff --git a/scripts/ui-regression.ts b/scripts/ui-regression.ts index f9093f7cda..8f744d5af6 100644 --- a/scripts/ui-regression.ts +++ b/scripts/ui-regression.ts @@ -393,7 +393,7 @@ try { if (process.env.UI_EVIDENCE_DIR) await page.screenshot({ path: join(process.env.UI_EVIDENCE_DIR, "multiple-queue.png") }); // one column: the held list, the box and the conversation share their edges, and the status // line is the box's own last row, inside it. - // The default Chat width follows the pane: min 820px, max 960px, 71% of the pane between. This + // The default Chat width follows the pane: min 820px, max 60rem (960px at this 16px root), 71% of the pane between. This // pane is under 1148px, so the lane is its 820px floor, as at Narrow; at Wide the lane is wider // than the pane and the column is the pane less its gutters. A larger window grows the lane const chatWidth = async (name: string): Promise => { diff --git a/src/components/PaneTerminal.tsx b/src/components/PaneTerminal.tsx index 363d23dde9..34ed6bdaa3 100644 --- a/src/components/PaneTerminal.tsx +++ b/src/components/PaneTerminal.tsx @@ -28,7 +28,7 @@ import { RenderBoundary } from "./RenderBoundary.tsx"; import { Composer } from "./Composer.tsx"; import type { AgentStatus, ClientRole, ConversationMetadata, InteractivePrompt, ServerMessage } from "../../shared/protocol.ts"; import type { PaneView } from "../lib/actions.ts"; -import { chatLaneWidth, useSettings, terminalTheme, type Palette, type ResolvedTheme } from "../lib/settings.ts"; +import { chatLaneLength, useSettings, terminalTheme, type Palette, type ResolvedTheme } from "../lib/settings.ts"; import { loadFontStack, TERMINAL_FONT_STACK, terminalFontStack } from "../lib/fontFamily.ts"; import { useT } from "../lib/i18n.ts"; import { isAppShortcut } from "../lib/shortcuts.ts"; @@ -184,10 +184,11 @@ export function PaneTerminal({ const { settings, update: updateSettings } = useSettings(); const shortcutSettings = useRef(settings.shortcutOverrides); shortcutSettings.current = settings.shortcutOverrides; - // Settings → Chat width, Default: the lane follows this pane. One px length on the stack, which + // Settings → Chat width, Default: the lane follows this pane. One length on the stack, which // the transcript, the composer column, the held list and the menus all inherit: a percentage // would resolve against each one's own box and leave them a gutter apart. The other steps are - // fixed and stay with the stylesheet (styles.css) + // fixed and stay with the stylesheet (styles.css). The lane's ceiling stays 60rem inside the + // length, so a change of the browser's font size moves it at once, as it moves Wide's 72rem useLayoutEffect(() => { const stack = stackRef.current; if (!stack) return; @@ -195,7 +196,7 @@ export function PaneTerminal({ stack.style.removeProperty("--chat-w"); return; } - const apply = (): void => stack.style.setProperty("--chat-w", `${chatLaneWidth(stack.clientWidth)}px`); + const apply = (): void => stack.style.setProperty("--chat-w", chatLaneLength(stack.clientWidth)); apply(); const observer = new ResizeObserver(apply); observer.observe(stack); diff --git a/src/lib/settings.test.ts b/src/lib/settings.test.ts index ea6c344f6c..04bc252851 100644 --- a/src/lib/settings.test.ts +++ b/src/lib/settings.test.ts @@ -3,7 +3,7 @@ import { describe, expect, it } from "bun:test"; import { readFileSync } from "node:fs"; import { join } from "node:path"; import { FONT_FAMILY_MAX_CHARS } from "./fontFamily.ts"; -import { alertPrefs, CHAT_FONT_MAX, CHAT_FONT_MIN, CHAT_LANE_MAX, CHAT_LANE_MIN, CHAT_WIDTHS, chatFontSize, chatLaneWidth, DEFAULT_SETTINGS, QUICK_REPLIES_MAX, QUICK_REPLY_MAX_CHARS, quickReplyButtons, sanitizeSettings, terminalTheme, forgetPaneViews } from "./settings.ts"; +import { alertPrefs, CHAT_FONT_MAX, CHAT_FONT_MIN, CHAT_LANE_MAX_REM, CHAT_LANE_MIN, CHAT_WIDTHS, chatFontSize, chatLaneLength, chatLaneWidth, DEFAULT_SETTINGS, QUICK_REPLIES_MAX, QUICK_REPLY_MAX_CHARS, quickReplyButtons, sanitizeSettings, terminalTheme, forgetPaneViews } from "./settings.ts"; it("keeps the screen wake lock off until this device explicitly enables it", () => { expect(DEFAULT_SETTINGS.keepScreenOn).toBe(false); @@ -54,7 +54,7 @@ describe("chat width", () => { const css = (name: string): string => readFileSync(join(import.meta.dir, "..", name), "utf8"); const tokens = css("styles.css"); expect(tokens).toContain("--content-w: 820px;"); - // unmeasured, the lane is the Default rule's floor; the measured one is chatLaneWidth's, in px + // unmeasured, the lane is the Default rule's floor; the measured one is chatLaneLength's expect(tokens).toContain("--chat-w: var(--content-w);"); expect(tokens).toContain(`--content-w: ${CHAT_LANE_MIN}px;`); const step = (name: string): string | undefined => new RegExp(`\\[data-chat-width="${name}"\\] \\{\\s*--chat-w: ([^;]+);`).exec(tokens)?.[1]; @@ -66,7 +66,9 @@ describe("chat width", () => { // a percentage in the token would resolve against each column's own box; Full is the one // step that means exactly that expect([...tokens.matchAll(/--chat-w: ([^;]+);/g)].map((match) => match[1]).filter((value) => value?.includes("%"))).toEqual(["100%"]); - expect(css("components/PaneTerminal.tsx")).toContain("chatLaneWidth("); + expect(css("components/PaneTerminal.tsx")).toContain(`setProperty("--chat-w", chatLaneLength(`); + // the root font size is not read in JS: the ceiling is 60rem in the length itself + expect(css("components/PaneTerminal.tsx")).not.toContain("chatLaneWidth("); // Settings and New workspace stay on --content-w. That the chat columns share the lane is // measured in the browser (scripts/ui-regression.ts), not read from the stylesheets for (const file of ["components/SettingsDialog.css", "components/NewSessionDialog.css"]) { @@ -77,11 +79,11 @@ describe("chat width", () => { }); describe("the default chat lane", () => { - it("is 71.43% of the pane, never under 820px or over 960px", () => { + it("is 71.43% of the pane, never under 820px or over 60rem (960px at a 16px root)", () => { expect(CHAT_LANE_MIN).toBe(820); - expect(CHAT_LANE_MAX).toBe(960); + expect(CHAT_LANE_MAX_REM).toBe(60); // the pane beside a 320px sidebar in a 1280, 1440, 1680 and 1920 window - expect([960, 1120, 1360, 1600].map(chatLaneWidth)).toEqual([820, 820, 960, 960]); + expect([960, 1120, 1360, 1600].map((pane) => chatLaneWidth(pane))).toEqual([820, 820, 960, 960]); // between the two ends it follows the pane, in whole px expect(chatLaneWidth(1148)).toBe(820); expect(chatLaneWidth(1149)).toBe(821); @@ -94,6 +96,56 @@ describe("the default chat lane", () => { it("is the floor for a phone, an unmeasured pane or a width that is not a number", () => { for (const width of [0, 390, 780, -1, Number.NaN, Number.POSITIVE_INFINITY]) expect(chatLaneWidth(width)).toBe(820); }); + + it("has its ceiling in rem, so it is never wider than Wide's 72rem at a root font of 11.4px and up", () => { + const wide = (root: number): number => 72 * root; + // 16px: the sizes above, whether the root is passed or not + expect([960, 1149, 1300, 1344, 1600, 4000].map((pane) => chatLaneWidth(pane, 16))).toEqual([820, 821, 929, 960, 960, 960]); + // 20px: 60rem is 1200px, reached by a 1680px pane; Wide is 1440px + expect([1148, 1344, 1600, 1679, 1680, 4000].map((pane) => chatLaneWidth(pane, 20))).toEqual([820, 960, 1143, 1199, 1200, 1200]); + // 13px: 60rem is 780px, under the 820px floor. The floor wins: the lane is Narrow's at every + // pane, and Wide (936px) is still the wider one. Before, a large pane got 960px here + expect([960, 1149, 1344, 1600, 4000].map((pane) => chatLaneWidth(pane, 13))).toEqual([820, 820, 820, 820, 820]); + // the two ends meet at 820 / 60 = 13.67px; a root just over it has a ceiling just over the floor + expect(chatLaneWidth(4000, 14)).toBe(840); + // a fractional root + expect(chatLaneWidth(4000, 17.6)).toBe(1056); + for (const root of [13, 14, 16, 17.6, 20, 24]) { + for (const pane of [0, 960, 1344, 1920, 4000]) { + const lane = chatLaneWidth(pane, root); + expect(lane).toBeGreaterThanOrEqual(CHAT_LANE_MIN); + expect(lane).toBeLessThanOrEqual(wide(root)); + } + } + }); + + it("resolves the ceiling at 16px when the root font size is not a usable number", () => { + for (const root of [0, -16, Number.NaN, Number.POSITIVE_INFINITY]) expect(chatLaneWidth(4000, root)).toBe(960); + }); + + it("is written as one length with the ceiling left in rem, so it follows the root font size without being measured again", () => { + expect(chatLaneLength(1600)).toBe("min(max(820px, 60rem), 1143px)"); + expect(chatLaneLength(4000)).toBe("min(max(820px, 60rem), 2857px)"); + for (const pane of [0, 390, -1, Number.NaN, Number.POSITIVE_INFINITY]) expect(chatLaneLength(pane)).toBe("min(max(820px, 60rem), 820px)"); + // no percentage (each column would resolve it against its own box), and no root size baked in + // as px: the only px in it are the floor and the pane's share + const resolve = (length: string, root: number): number => { + const match = /^min\(max\((\d+)px, (\d+)rem\), (\d+)px\)$/.exec(length); + if (!match) throw new Error(`not the lane's form: ${length}`); + return Math.min(Math.max(Number(match[1]), Number(match[2]) * root), Number(match[3])); + }; + // one length, written once at a 16px root, is right at every other root: a font size changed + // while the tab is open needs no new measurement. A px ceiling written at 16px stayed 960px + // at 13px, wider than Wide's 936px, until the pane was next resized + const written = chatLaneLength(4000); + expect([13, 14, 16, 17.6, 20].map((root) => resolve(written, root))).toEqual([820, 840, 960, 1056, 1200]); + for (const root of [13, 14, 16, 17.6, 20, 24]) { + for (const pane of [0, 960, 1149, 1344, 1600, 1920, 4000]) { + expect(resolve(chatLaneLength(pane), root)).toBe(chatLaneWidth(pane, root)); + expect(resolve(chatLaneLength(pane), root)).toBeLessThanOrEqual(72 * root); + } + } + }); }); describe("font families", () => { diff --git a/src/lib/settings.ts b/src/lib/settings.ts index b2a2761a15..abd5ad6f10 100644 --- a/src/lib/settings.ts +++ b/src/lib/settings.ts @@ -22,7 +22,7 @@ export type UsageGlance = "week" | "session"; * catppuccin: Catppuccin Mocha in dark, Latte in light */ export type Palette = "amber" | "report" | "charcoal" | "catppuccin"; /** the chat lane's widest: the transcript, the composer column and the held list share it (--chat-w in src/styles.css). - * narrow: 820px; default: follows the pane (chatLaneWidth); wide: 72rem; full: the pane, less the gutters */ + * narrow: 820px; default: follows the pane, up to 60rem (chatLaneWidth); wide: 72rem; full: the pane, less the gutters */ export type ChatWidth = "narrow" | "default" | "wide" | "full"; export const CHAT_WIDTHS: readonly ChatWidth[] = ["narrow", "default", "wide", "full"]; /** the lens a pane opens in until it is switched there: auto is chat for an agent on a touch screen, else terminal */ @@ -170,22 +170,45 @@ export function chatFontSize(settings: Settings): number { return settings.chatFontSize ?? CHAT_BASE_FONT[settings.density]; } -/** The Default chat lane never runs narrower or wider than this, in px. */ +/** The Default chat lane never runs narrower than this, in px: --content-w, the Narrow step. */ export const CHAT_LANE_MIN = 820; -export const CHAT_LANE_MAX = 960; +/** ...or wider than this, in rem: 960px at a 16px root. In rem because Wide is (72rem, styles.css), + * so Default stays the narrower of the two at a root font of 11.39px and up; under that the + * 820px floor wins and is itself wider than Wide (see chatLaneLength). */ +export const CHAT_LANE_MAX_REM = 60; /** The share of its pane the Default chat lane takes between the two. */ export const CHAT_LANE_RATIO = 0.7143; +/** The root font size chatLaneWidth resolves the rem ceiling with when none is given. */ +export const ROOT_FONT_PX = 16; + +/** The Default lane's pane-following part in whole px, before the rem ceiling: 71.43% of the pane, min 820px. */ +function chatLaneFollow(paneWidth: number): number { + if (!Number.isFinite(paneWidth)) return CHAT_LANE_MIN; + return Math.max(CHAT_LANE_MIN, Math.round(paneWidth * CHAT_LANE_RATIO)); +} /** - * The Default chat lane for a pane this wide, in whole px: 71.43% of the pane, min 820, max 960. - * A length, not a CSS percentage: the lane's columns sit in boxes of different widths (the + * The Default chat lane for a pane this wide, as the CSS length PaneTerminal writes to --chat-w: + * 71.43% of the pane, min 820px, max 60rem. + * A length with no percentage in it: the lane's columns sit in boxes of different widths (the * transcript and the composer column inside a gutter, the held list and the menus outside it), - * and one px value is what keeps them equal. A pane narrower than the result is unaffected: + * and one length is what keeps them equal. A pane narrower than the result is unaffected: * every column is min(100%, lane). + * The ceiling is left to the stylesheet engine as 60rem, not resolved here: Wide's 72rem follows + * the root font size the moment it changes, and so must this, or Default would be the wider of + * the two until the pane was next measured. + * Where the two ends cross (60rem is under 820px below a 13.67px root) the floor wins: Default is + * then the same lane as Narrow, never narrower than it, and still no wider than Wide down to an + * 11.39px root, under which Narrow's own 820px is already wider than Wide's 72rem. */ -export function chatLaneWidth(paneWidth: number): number { - if (!Number.isFinite(paneWidth)) return CHAT_LANE_MIN; - return Math.min(CHAT_LANE_MAX, Math.max(CHAT_LANE_MIN, Math.round(paneWidth * CHAT_LANE_RATIO))); +export function chatLaneLength(paneWidth: number): string { + return `min(max(${CHAT_LANE_MIN}px, ${CHAT_LANE_MAX_REM}rem), ${chatLaneFollow(paneWidth)}px)`; +} + +/** What chatLaneLength resolves to at this root font size, in px (960 at most at 16px): the rule in numbers. */ +export function chatLaneWidth(paneWidth: number, rootFontPx: number = ROOT_FONT_PX): number { + const root = Number.isFinite(rootFontPx) && rootFontPx > 0 ? rootFontPx : ROOT_FONT_PX; + return Math.min(Math.max(CHAT_LANE_MIN, CHAT_LANE_MAX_REM * root), chatLaneFollow(paneWidth)); } /** Only known keys with the right type survive: a stale or hand-edited record never breaks the UI. */ diff --git a/src/styles.css b/src/styles.css index d84f893140..844dee8fcb 100644 --- a/src/styles.css +++ b/src/styles.css @@ -119,8 +119,8 @@ --hairline: 1px; --content-w: 820px; /* the chat lane: transcript, composer column, held list (Settings → Chat width, lib/settings.ts). - At the Default step PaneTerminal writes the pane's own lane on .terminal-stack, in px - (chatLaneWidth: 71.43% of the pane, min 820px, max 960px); this is that rule's floor, for a + At the Default step PaneTerminal writes the pane's own lane on .terminal-stack, as one + length (chatLaneLength: 71.43% of the pane in px, min 820px, max 60rem); this is that rule's floor, for a pane not measured yet. Never a percentage here: each column would resolve it against its own box and the columns would differ by their gutters */ --chat-w: var(--content-w);