Compare commits

...

2 Commits

Author SHA1 Message Date
SabhyaC26 88c38e1b5f test(e2e): cover UI font size setting
Add a Playwright test mirroring test_theme_toggle.py for the new
Appearance font-size stepper: stepping the value updates the applied
--ui-font-scale on <html> and persists the px choice across a reload,
and the −/+ buttons disable at the 12/20 bounds.

Co-authored-by: Isaac
2026-07-06 21:32:49 +00:00
SabhyaC26 369314144c feat(web): add UI font size setting to Appearance
Add a font-size control to Settings → Appearance that scales the whole
interface. The web UI is Tailwind v4 (typography and spacing in rem), so
scaling the root font-size reflows everything uniformly — the same lever
the mobile bump already uses.

The choice is stored as an absolute px value (default 16, range 12–20) and
applied as a --ui-font-scale multiplier on the document root, so it composes
with the mobile @media bump instead of overriding it. Applied before first
paint to avoid a flash, and persisted per-device in localStorage.

The control is a segmented pill ([ − | value | + ]) styled after Cursor's
appearance settings. The theme picker is unchanged.

Co-authored-by: Isaac
2026-07-06 21:10:38 +00:00
7 changed files with 446 additions and 30 deletions
+104
View File
@@ -0,0 +1,104 @@
"""E2E: the Settings → Appearance font-size stepper scales the UI and persists.
The font-size control lives on the Settings page (``pages/SettingsPage.tsx``,
``UiFontSizeControl``): a segmented pill with a ```` button, a numeric value,
and a ``+`` button under a ``role="group"`` labelled "Font size". Stepping the
value writes the px choice to ``localStorage["omnigent:ui-font-size"]`` and
applies it as the ``--ui-font-scale`` custom property on ``<html>`` (see
``lib/uiFontPreferences.ts``).
Because the web UI is Tailwind v4 (typography *and* spacing in ``rem``), scaling
the root font-size via that variable reflows the whole UI uniformly. The default
is 16px (scale 1); the range is 1220px, so the ````/``+`` buttons disable at
the bounds.
No LLM turn is involved.
"""
from __future__ import annotations
from playwright.sync_api import Page, expect
STORAGE_KEY = "omnigent:ui-font-size"
def _ui_font_scale(page: Page) -> str:
"""The ``--ui-font-scale`` custom property applied to ``<html>``."""
return page.evaluate(
"() => getComputedStyle(document.documentElement)"
".getPropertyValue('--ui-font-scale').trim()"
)
def _stored_size(page: Page) -> str | None:
"""The persisted font-size preference, or None when unset (default 16)."""
return page.evaluate(f"() => window.localStorage.getItem('{STORAGE_KEY}')")
def _open_appearance(page: Page, base_url: str) -> None:
"""Navigate to the Settings Appearance section and wait for the control."""
page.goto(f"{base_url}/settings/appearance")
expect(page.get_by_role("group", name="Font size")).to_be_visible(timeout=30_000)
def test_ui_font_size_scales_and_persists(page: Page, seeded_session: tuple[str, str]) -> None:
"""Stepping the size updates the scale + value live and survives a reload.
A fresh context has no stored preference → default 16px, scale 1. Increasing
the size bumps ``--ui-font-scale`` above 1 and persists the px value; a page
reload restores it (no reset, no flash back to the default).
"""
base_url, _session_id = seeded_session
_open_appearance(page, base_url)
value = page.get_by_test_id("ui-font-size-input")
increase = page.get_by_test_id("ui-font-size-inc")
# Fresh context → default 16px, unit scale, nothing stored.
expect(value).to_have_value("16")
assert _stored_size(page) is None, "expected no persisted size on a fresh load"
assert _ui_font_scale(page) == "1", "fresh load should apply the unit scale"
# → 18px: two steps up. The value, the applied scale, and storage all move.
increase.click()
increase.click()
expect(value).to_have_value("18")
assert _stored_size(page) == "18"
# 18 / 16 base = 1.125.
assert _ui_font_scale(page) == "1.125", "root scale did not track the stepped size"
# The choice survives a full reload (persisted + re-applied before paint).
page.reload()
expect(page.get_by_role("group", name="Font size")).to_be_visible(timeout=30_000)
expect(page.get_by_test_id("ui-font-size-input")).to_have_value("18")
assert _ui_font_scale(page) == "1.125", "scale was not restored after reload"
def test_ui_font_size_steppers_clamp_at_bounds(
page: Page, seeded_session: tuple[str, str]
) -> None:
"""The ````/``+`` buttons disable at the 12px min and 20px max."""
base_url, _session_id = seeded_session
# Seed the max before the app boots so the "+" button renders disabled.
page.goto(base_url)
page.evaluate(f"() => window.localStorage.setItem('{STORAGE_KEY}', '20')")
_open_appearance(page, base_url)
value = page.get_by_test_id("ui-font-size-input")
decrease = page.get_by_test_id("ui-font-size-dec")
increase = page.get_by_test_id("ui-font-size-inc")
# At the 20px max, only "+" is disabled.
expect(value).to_have_value("20")
expect(increase).to_be_disabled()
expect(decrease).to_be_enabled()
# Hold "" down to the 12px min; there it flips to "" disabled, "+" enabled.
for _ in range(8):
if decrease.is_disabled():
break
decrease.click()
expect(value).to_have_value("12")
expect(decrease).to_be_disabled()
expect(increase).to_be_enabled()
+13 -2
View File
@@ -103,6 +103,12 @@
* buttons stay neutral grey (hue 240). See designs/UI/WEB_UI.md (still
* describes the older cobalt direction). */
:root {
/* User-controlled UI font size, as a unitless multiplier on the root
* font-size (see Appearance settings / lib/uiFontPreferences.ts). 1 = the
* browser default; the root `html` rules multiply it into their font-size so
* the whole rem-based UI scales. Overridden at runtime on documentElement. */
--ui-font-scale: 1;
/* ---- Inset system -------------------------------------------------------
* Single source of truth for how far content must stay clear of screen
* chrome. The SAME bundle runs in a browser, in Electron, and inside the
@@ -717,6 +723,10 @@
}
html {
@apply font-sans;
/* Root size for the whole rem-based UI. `1em` resolves against the
* browser default so a customized default is preserved; --ui-font-scale
* layers the user's Appearance choice on top. */
font-size: calc(1em * var(--ui-font-scale));
}
/* Text selection — brand pink highlight. Light: gentle wash + dark pink
* text. Dark: solid brand pink background + white text. */
@@ -736,14 +746,15 @@
* Tailwind's `md` breakpoint. Tailwind v4 sizes typography AND spacing in
* `rem`, so the whole UI scales up ~12.5% uniformly — easier to read at
* arm's length and slightly more forgiving touch targets. Desktop is
* untouched.
* untouched. `--ui-font-scale` (the user's Appearance choice) multiplies in
* here too, so the bump composes with the setting instead of being lost.
*
* Note that `rem` in `@media` queries always refers to the user-agent's
* default (16px) per the CSS spec — so `48rem` here is the same 768px
* Tailwind uses for `md:*` utilities, regardless of our root override. */
@media (width < 48rem) {
html {
font-size: 18px;
font-size: calc(1.125em * var(--ui-font-scale));
}
}
+67
View File
@@ -0,0 +1,67 @@
import { afterEach, describe, expect, it } from "vitest";
import {
applyUiFontScale,
readUiFontSizePx,
UI_FONT_SIZE_DEFAULT,
UI_FONT_SIZE_MAX,
UI_FONT_SIZE_MIN,
writeUiFontSizePx,
} from "./uiFontPreferences";
const STORAGE_KEY = "omnigent:ui-font-size";
afterEach(() => {
localStorage.clear();
document.documentElement.style.removeProperty("--ui-font-scale");
});
describe("uiFontPreferences", () => {
it("returns the default when nothing is stored", () => {
expect(readUiFontSizePx()).toBe(UI_FONT_SIZE_DEFAULT);
});
it("round-trips a valid size", () => {
writeUiFontSizePx(18);
expect(readUiFontSizePx()).toBe(18);
});
it("clamps a stored value above the range", () => {
localStorage.setItem(STORAGE_KEY, JSON.stringify(99));
expect(readUiFontSizePx()).toBe(UI_FONT_SIZE_MAX);
});
it("clamps a stored value below the range", () => {
localStorage.setItem(STORAGE_KEY, JSON.stringify(4));
expect(readUiFontSizePx()).toBe(UI_FONT_SIZE_MIN);
});
it("clamps out-of-range values on write", () => {
writeUiFontSizePx(40);
expect(readUiFontSizePx()).toBe(UI_FONT_SIZE_MAX);
writeUiFontSizePx(2);
expect(readUiFontSizePx()).toBe(UI_FONT_SIZE_MIN);
});
it("falls back to the default on malformed JSON", () => {
// Corrupt localStorage should not break app boot.
localStorage.setItem(STORAGE_KEY, "}{not json");
expect(readUiFontSizePx()).toBe(UI_FONT_SIZE_DEFAULT);
});
it("falls back to the default on a non-numeric value", () => {
localStorage.setItem(STORAGE_KEY, JSON.stringify("large"));
expect(readUiFontSizePx()).toBe(UI_FONT_SIZE_DEFAULT);
});
it("applies the size as a scale multiplier on the document root", () => {
applyUiFontScale(20);
// 20 / 16 base = 1.25.
expect(document.documentElement.style.getPropertyValue("--ui-font-scale")).toBe("1.25");
});
it("clamps before applying the scale", () => {
applyUiFontScale(99);
// Clamped to the 20px max → 20 / 16 = 1.25.
expect(document.documentElement.style.getPropertyValue("--ui-font-scale")).toBe("1.25");
});
});
+75
View File
@@ -0,0 +1,75 @@
// Persisted, app-global preference for the UI font size.
//
// The web UI is Tailwind v4, which sizes typography AND spacing in `rem`, so
// scaling the root `<html>` font-size reflows the entire UI uniformly. Rather
// than write an inline `html { font-size }` — which would override the mobile
// `@media` bump in index.css — this stores an absolute px choice and applies it
// as a scale multiplier (`--ui-font-scale`) that the root font-size rules
// multiply into. The base rule uses `calc(1em * var(--ui-font-scale))`, so the
// user's browser-default size is preserved and the displayed px maps 1:1 for
// the default-16px case.
const STORAGE_KEY = "omnigent:ui-font-size";
/** Reference size that a scale of 1 corresponds to (Tailwind/browser default). */
const BASE_FONT_SIZE_PX = 16;
export const UI_FONT_SIZE_DEFAULT = 16;
export const UI_FONT_SIZE_MIN = 12;
export const UI_FONT_SIZE_MAX = 20;
export const UI_FONT_SIZE_STEP = 1;
/** Clamp an arbitrary number into the supported px range. */
export function clampUiFontSizePx(px: number): number {
return Math.min(UI_FONT_SIZE_MAX, Math.max(UI_FONT_SIZE_MIN, Math.round(px)));
}
function isValidPx(value: unknown): value is number {
return typeof value === "number" && Number.isFinite(value);
}
/**
* Read the persisted UI font size in px.
*
* Returns the default when nothing is stored, on a server render (no `window`),
* or when the stored value is missing/malformed — never throws, so a corrupt
* entry can't break app boot. A stored value outside the range is clamped.
*/
export function readUiFontSizePx(): number {
if (typeof window === "undefined") return UI_FONT_SIZE_DEFAULT;
try {
const raw = window.localStorage.getItem(STORAGE_KEY);
if (!raw) return UI_FONT_SIZE_DEFAULT;
const parsed: unknown = JSON.parse(raw);
if (!isValidPx(parsed)) return UI_FONT_SIZE_DEFAULT;
return clampUiFontSizePx(parsed);
} catch {
return UI_FONT_SIZE_DEFAULT;
}
}
/**
* Persist the UI font size (px). The value is clamped to the supported range
* before writing. Swallows quota/access errors so a failed write can't break
* the app.
*/
export function writeUiFontSizePx(px: number): void {
if (typeof window === "undefined") return;
try {
window.localStorage.setItem(STORAGE_KEY, JSON.stringify(clampUiFontSizePx(px)));
} catch {
// localStorage quota or access errors shouldn't break the app.
}
}
/**
* Apply the given px size to the DOM by setting the `--ui-font-scale` variable
* on the document root. The root font-size rules in index.css multiply this in,
* so the whole rem-based UI (text + spacing) scales, and the mobile bump still
* composes on top. This is the single source of the DOM side-effect.
*/
export function applyUiFontScale(px: number): void {
if (typeof document === "undefined") return;
const scale = clampUiFontSizePx(px) / BASE_FONT_SIZE_PX;
document.documentElement.style.setProperty("--ui-font-scale", String(scale));
}
+4
View File
@@ -14,6 +14,7 @@ import { CapabilitiesProvider } from "./lib/CapabilitiesContext";
import { resolveIdentity } from "./lib/identity";
import { initNativeInsets } from "./lib/nativeInsets";
import { initBrowserTelemetry } from "./lib/telemetry";
import { applyUiFontScale, readUiFontSizePx } from "./lib/uiFontPreferences";
import { initChatStore } from "./store/chatStore";
import "./index.css";
@@ -48,6 +49,9 @@ void resolveIdentity();
// No-op off the iOS shell (the inset vars stay at their env()-only defaults).
initNativeInsets();
// Apply the saved UI font size before first paint so there's no size flash.
applyUiFontScale(readUiFontSizePx());
// Probe /v1/info BEFORE the first render so the route table knows
// whether to mount accounts routes. The probe is unauthed and the
// failure path resolves to "accounts off" — so even a stalled or
+30
View File
@@ -107,6 +107,36 @@ describe("SettingsPage", () => {
expect(mocks.setTheme).toHaveBeenCalledWith("dark");
});
it("shows the default UI font size and steps it up, persisting the choice", () => {
localStorage.clear();
renderPage("/settings/appearance");
const input = screen.getByTestId("ui-font-size-input") as HTMLInputElement;
// No stored preference → 16px default.
expect(input.value).toBe("16");
fireEvent.click(screen.getByTestId("ui-font-size-inc"));
expect(input.value).toBe("17");
// The choice is persisted so it survives a refresh.
expect(localStorage.getItem("omnigent:ui-font-size")).toBe("17");
// The scale is applied live to the document root (17 / 16).
expect(document.documentElement.style.getPropertyValue("--ui-font-scale")).toBe("1.0625");
});
it("disables the steppers at the min and max bounds", () => {
localStorage.setItem("omnigent:ui-font-size", "20");
renderPage("/settings/appearance");
// At the 20px max, only the increase button is disabled.
expect(screen.getByTestId("ui-font-size-inc")).toBeDisabled();
expect(screen.getByTestId("ui-font-size-dec")).not.toBeDisabled();
cleanup();
localStorage.setItem("omnigent:ui-font-size", "12");
renderPage("/settings/appearance");
// At the 12px min, only the decrease button is disabled.
expect(screen.getByTestId("ui-font-size-dec")).toBeDisabled();
expect(screen.getByTestId("ui-font-size-inc")).not.toBeDisabled();
});
it("defaults bare /settings to Account when a login session exists, else Appearance", async () => {
// Login session (accounts OR OIDC) → Account leads, so /settings lands on it.
renderPage("/settings");
+153 -28
View File
@@ -32,7 +32,7 @@ import {
Trash2Icon,
UserCogIcon,
} from "lucide-react";
import { LaptopMinimalIcon, MoonIcon, SunIcon } from "lucide-react";
import { LaptopMinimalIcon, MinusIcon, MoonIcon, PlusIcon, SunIcon } from "lucide-react";
import { useTheme } from "next-themes";
import { PageScroll } from "@/components/PageScroll";
import { Button } from "@/components/ui/button";
@@ -59,6 +59,15 @@ import { conversationDisplayLabel } from "@/shell/sidebarNav";
import { absoluteTime } from "@/lib/relativeTime";
import { useSettingsRoute } from "@/shell/settingsNav";
import { type ThemeMode, normalizeThemeMode } from "@/components/theme/themeMode";
import {
applyUiFontScale,
clampUiFontSizePx,
readUiFontSizePx,
UI_FONT_SIZE_MAX,
UI_FONT_SIZE_MIN,
UI_FONT_SIZE_STEP,
writeUiFontSizePx,
} from "@/lib/uiFontPreferences";
import { useIsEmbedded } from "@/lib/embedded";
import { type CliStatus, getCliStatus, isElectronShell, resetCliPath } from "@/lib/nativeBridge";
import { cn } from "@/lib/utils";
@@ -147,38 +156,154 @@ function AppearanceSection() {
return (
<Section title="Appearance" description="Choose how Omnigent looks on this device.">
{isEmbedded ? (
<p className="text-sm text-muted-foreground">
Appearance is controlled by the host application.
</p>
) : (
<div className="grid grid-cols-3 gap-3" role="radiogroup" aria-label="Theme">
{themeCards.map(({ mode: cardMode, label, icon: Icon }) => {
const selected = mode === cardMode;
return (
<button
key={cardMode}
type="button"
role="radio"
aria-checked={selected}
data-testid={`theme-${cardMode}`}
onClick={() => setTheme(cardMode)}
className={cn(
"flex flex-col items-center gap-2 rounded-lg border-2 p-4 transition-colors hover:bg-muted",
selected ? "border-primary bg-primary/5" : "border-border",
)}
>
<Icon className="size-6 text-muted-foreground" />
<span className="text-sm font-medium">{label}</span>
</button>
);
})}
<div className="flex flex-col gap-8">
<div className="flex flex-col gap-3">
<span className="text-sm font-medium">Theme</span>
{/* Embedded: the host owns the theme (embed.tsx forces light), so the
selector would be a no-op — match ThemeModeMenu and hide it. */}
{isEmbedded ? (
<p className="text-sm text-muted-foreground">
Theme is controlled by the host application.
</p>
) : (
<div className="grid grid-cols-3 gap-3" role="radiogroup" aria-label="Theme">
{themeCards.map(({ mode: cardMode, label, icon: Icon }) => {
const selected = mode === cardMode;
return (
<button
key={cardMode}
type="button"
role="radio"
aria-checked={selected}
data-testid={`theme-${cardMode}`}
onClick={() => setTheme(cardMode)}
className={cn(
"flex flex-col items-center gap-2 rounded-lg border-2 p-4 transition-colors hover:bg-muted",
selected ? "border-primary bg-primary/5" : "border-border",
)}
>
<Icon className="size-6 text-muted-foreground" />
<span className="text-sm font-medium">{label}</span>
</button>
);
})}
</div>
)}
</div>
)}
<UiFontSizeControl />
</div>
</Section>
);
}
/**
* UI font size stepper. Scales the whole rem-based UI via the --ui-font-scale
* variable (see lib/uiFontPreferences.ts). Applied live and persisted on every
* change; unlike the theme picker it stays visible when embedded, since it's a
* per-device readability pref that doesn't conflict with host theming.
*/
function UiFontSizeControl() {
const [px, setPx] = useState(() => readUiFontSizePx());
const update = useCallback((next: number) => {
const clamped = clampUiFontSizePx(next);
setPx(clamped);
writeUiFontSizePx(clamped);
applyUiFontScale(clamped);
}, []);
const atMin = px <= UI_FONT_SIZE_MIN;
const atMax = px >= UI_FONT_SIZE_MAX;
return (
<div className="flex flex-wrap items-center justify-between gap-x-6 gap-y-3">
<div className="flex flex-col">
<span className="text-sm font-medium">Font size</span>
<span className="text-sm text-muted-foreground">
Scale the interface text and spacing on this device.
</span>
</div>
{/* One cohesive pill: [ | value px | + ]. Segments share the pill
border via inner dividers rather than floating as separate boxes. */}
<div
role="group"
aria-label="Font size"
className={cn(
"inline-flex h-9 items-stretch overflow-hidden rounded-lg border border-input bg-background transition-colors dark:bg-input/30",
"focus-within:border-ring focus-within:ring-3 focus-within:ring-ring/50",
)}
>
<StepperButton
label="Decrease font size"
testId="ui-font-size-dec"
disabled={atMin}
onClick={() => update(px - UI_FONT_SIZE_STEP)}
>
<MinusIcon className="size-4" />
</StepperButton>
<div className="flex items-center border-x border-input px-2 tabular-nums">
<input
type="number"
inputMode="numeric"
min={UI_FONT_SIZE_MIN}
max={UI_FONT_SIZE_MAX}
step={UI_FONT_SIZE_STEP}
aria-label="Font size in pixels"
data-testid="ui-font-size-input"
className="w-8 bg-transparent text-center text-sm font-medium tabular-nums outline-none [appearance:textfield] [&::-webkit-inner-spin-button]:appearance-none [&::-webkit-outer-spin-button]:appearance-none"
value={px}
onChange={(e) => {
const next = Number(e.target.value);
if (Number.isFinite(next)) update(next);
}}
/>
</div>
<StepperButton
label="Increase font size"
testId="ui-font-size-inc"
disabled={atMax}
onClick={() => update(px + UI_FONT_SIZE_STEP)}
>
<PlusIcon className="size-4" />
</StepperButton>
</div>
</div>
);
}
/** Flanking +/- segment of the font-size pill: square, ghost-hover, no border. */
function StepperButton({
label,
testId,
disabled,
onClick,
children,
}: {
label: string;
testId: string;
disabled: boolean;
onClick: () => void;
children: ReactNode;
}) {
return (
<button
type="button"
aria-label={label}
data-testid={testId}
disabled={disabled}
onClick={onClick}
className={cn(
"flex w-9 items-center justify-center text-muted-foreground transition-colors",
"hover:bg-muted hover:text-foreground dark:hover:bg-muted/50",
"disabled:pointer-events-none disabled:opacity-40",
)}
>
{children}
</button>
);
}
function ShortcutsSection() {
return (
<Section title="Keyboard shortcuts" description="Speed up common actions with the keyboard.">