Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 88c38e1b5f | |||
| 369314144c |
@@ -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 12–20px, 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
@@ -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));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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));
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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.">
|
||||
|
||||
Reference in New Issue
Block a user