Compare commits

..
Author SHA1 Message Date
Mohamed Boudra 308cca5872 fix(app): refresh retained settings labels and normalized sizes
Keep sidebar typography current and explicitly replace normalized numeric input text now that appearance changes no longer recreate the form. Reuse the shared styling test fixture in the providers suite.
2026-11-13 16:12:39 +01:00
Mohamed Boudra 23b435940b fix(app): preserve settings scroll through appearance changes
Keep settings outside the screen-wide appearance remount while refreshing numeric typography at text and input leaves. This preserves scroll and form edits without leaving web styles stale.
2026-11-13 15:57:53 +01:00
16 changed files with 212 additions and 65 deletions
+1 -1
View File
@@ -346,7 +346,7 @@ Gotchas:
- **Narrow the discriminated union before spreading.** `updateTheme`'s updater returns the theme union; spreading the union widens `colorScheme` to `"light" | "dark"`, which is assignable to neither concrete member. Branch on `t.colorScheme` so each branch spreads a single narrowed theme type (no `as`).
- **`lineHeight.diff` is the code/diff line-height axis** — it is coupled to the code-font-size control (≈ `codeFontSize * 1.5`). Do NOT use it for prose. Markdown body line-height scales with content size (`Math.round(theme.fontSize.content * 1.4)`); routing prose through `lineHeight.diff` clips text at small code sizes.
- **High-churn draft values** (live-while-typing in the appearance preview) bypass the theme: apply them as inline styles marked with `inlineUnistylesStyle` so per-keystroke values don't grow the `#unistyles-web` CSS registry.
- **Web numeric appearance tokens need a render; native tracked styles update in place.** On web, string tokens such as colors become CSS variables, but numeric tokens (font sizes, line heights) are baked into generated classes. `AppearanceStyleBoundary` remounts web shell chrome and explorer content to refresh those classes. Native sidebars and explorer hosts stay outside appearance keys: their gesture refs outlive detached dependents until Gesture Handler's passive cleanup, so remounting a related gesture can query an unmounted view. Their tracked native styles and existing themed leaf props update without replacing the gesture hosts or draggable lists. Parsed PR markdown uses its own `withUnistyles` style mapping.
- **Web numeric appearance tokens need a render; native tracked styles update in place.** On web, string tokens such as colors become CSS variables, but numeric tokens (font sizes, line heights) are baked into generated classes. `AppearanceStyleBoundary` remounts web shell chrome and explorer content to refresh those classes. Settings retains its screen and form; `AppearanceText` refreshes numeric text styles at the leaf, and its font fields use `withUnistyles` to refresh input styles without replacing the inputs. Native sidebars and explorer hosts stay outside appearance keys: their gesture refs outlive detached dependents until Gesture Handler's passive cleanup, so remounting a related gesture can query an unmounted view. Their tracked native styles and existing themed leaf props update without replacing the gesture hosts or draggable lists. Parsed PR markdown uses its own `withUnistyles` style mapping.
- **Appearance keys belong below native lifetime owners.** `ThemedStack` wraps screen content through `screenLayout`, preserving navigator identity; nested navigators are listed in `nestedNavigatorScreens` and own their own screen boundaries. The remaining shell surfaces retain their appearance refresh separately from native panel hosts. A key above a native stack can detach its screen container inside a FragmentManager transaction during settings hydration. Keep both navigators and retained panel gesture hosts outside those keys. `applyAppearance` patches the active theme before inactive registry entries so subscribers receive the committed values in the same update. Do not add local appearance keys or a boundary above a navigator.
- **Dynamic font tokens stay widened.** `fontFamily`, `fontSize`, and `lineHeight` on `commonTheme` are annotated `string`/`number` (not narrowed by `as const`) so the updater's return assigns; the platform default stacks live in `DEFAULT_UI_FONT_STACK` / `DEFAULT_MONO_FONT_STACK`.
@@ -2297,13 +2297,9 @@ async function changeCodeTypographyFromSettings(
await page.getByTestId("sidebar-settings").click();
await expect(page).toHaveURL(new RegExp(`${buildSettingsSectionRoute("general")}|/settings$`));
await page.getByRole("button", { name: "Appearance" }).click();
// A committed font change remounts the screen (AppearanceStyleBoundary), and the new form starts
// from the stored size. Edit the size only in the remounted form, or the remount drops it.
const sizeField = page.getByLabel("Code font size");
await sizeField.evaluate((field) => field.setAttribute("data-e2e-before-font-family", ""));
await page.getByLabel("Code font family").fill(typography.fontFamily);
await page.getByLabel("Code font family").press("Enter");
await expect(page.locator("[data-e2e-before-font-family]")).toHaveCount(0);
await sizeField.fill(String(typography.fontSize));
await page.getByLabel("Code font size").press("Enter");
await expect(page.getByLabel("Code font family")).toHaveValue(typography.fontFamily);
@@ -14,12 +14,17 @@ import {
revealSettingsSidebarSection,
expectSettingsSidebarScrollSettled,
scrollSettingsSidebarUp,
changeSettingsInterfaceFontSize,
selectSettingsHostSectionsRapidly,
expectSettingsSidebarScrollOffset,
scrollSettingsDetailDown,
expectSettingsDetailScrollOffset,
openSettingsSection,
changeSettingsSyntaxTheme,
revealSettingsSyntaxTheme,
expectSettingsSyntaxColorChanged,
changeSettingsTypography,
normalizeSettingsFontSizes,
changeSettingsInterfaceFontSizeKeepingSidebarPosition,
expectSettingsHeader,
openAddHostFlow,
selectHostConnectionType,
@@ -84,13 +89,32 @@ test.describe("Settings sidebar scroll position", () => {
await expectSettingsSidebarScrollOffset(page, 0);
});
test("keeps the sidebar position through interface font changes", async ({ page }) => {
test("updates appearance without losing the detail scroll or form edits", async ({ page }) => {
await gotoAppShell(page);
await openSettings(page);
await openSettingsSection(page, "appearance");
const offset = await scrollSettingsSidebarToEnd(page);
await changeSettingsInterfaceFontSize(page, 20);
await expectSettingsSidebarScrollOffset(page, offset);
const { offset, keywordColor } = await revealSettingsSyntaxTheme(page);
await changeSettingsSyntaxTheme(page, "Dracula");
await expectSettingsDetailScrollOffset(page, offset);
await expectSettingsSyntaxColorChanged(page, keywordColor);
await changeSettingsTypography(page);
});
test("shows normalized font sizes after committing out-of-range and empty values", async ({
page,
}) => {
await gotoAppShell(page);
await openSettings(page);
await openSettingsSection(page, "appearance");
await normalizeSettingsFontSizes(page);
});
test("keeps the sidebar context through interface font changes", async ({ page }) => {
await gotoAppShell(page);
await openSettings(page);
await openSettingsSection(page, "appearance");
await scrollSettingsSidebarToEnd(page);
const offset = await changeSettingsInterfaceFontSizeKeepingSidebarPosition(page, 20);
await openSettingsHostSection(page, getServerId(), "providers");
await expectSettingsSidebarScrollOffset(page, offset);
});
@@ -106,6 +106,33 @@ export async function changeSettingsInterfaceFontSize(page: Page, size: number):
await input.fill(String(size));
await input.press("Tab");
await expect(input).toHaveValue(String(size));
await expect(
page.getByRole("navigation", { name: "Settings" }).getByText("Appearance", { exact: true }),
).toHaveCSS("font-size", `${size}px`);
}
export async function changeSettingsInterfaceFontSizeKeepingSidebarPosition(
page: Page,
size: number,
): Promise<number> {
const navigation = page.getByRole("navigation", { name: "Settings" });
const anchor = navigation.getByRole("button", { name: "About", exact: true });
const before = await anchor.evaluate((node) => ({
top: node.getBoundingClientRect().top,
height: node.getBoundingClientRect().height,
}));
await changeSettingsInterfaceFontSize(page, size);
// Larger fonts reflow rows. Preserve the visible destination, rather than its old pixel offset.
await expect
.poll(() =>
anchor.evaluate((node, top) => Math.abs(node.getBoundingClientRect().top - top), before.top),
)
.toBeLessThan(before.height);
const offset = await page
.getByTestId("settings-sidebar-scroll-body")
.evaluate((node) => node.scrollTop);
expect(offset).toBeGreaterThan(0);
return offset;
}
export async function scrollSettingsSidebarToEnd(page: Page): Promise<number> {
@@ -613,3 +640,100 @@ export async function expectRestartRecoveryPrompt(page: Page, prompt: string) {
await sheet.getByRole("button", { name: "Cancel", exact: true }).click();
await expect(sheet).toHaveCount(0);
}
export async function revealSettingsSyntaxTheme(page: Page) {
await page.getByLabel(/Highlight theme:/).scrollIntoViewIfNeeded();
const offset = await page
.getByTestId("settings-detail-scroll-body")
.evaluate((node) => node.scrollTop);
expect(offset).toBeGreaterThan(0);
const keywordColor = await page
.getByRole("img", { name: "Live preview of content typography, syntax theme, and code font" })
.getByText("export", { exact: true })
.first()
.evaluate((node) => getComputedStyle(node).color);
return { offset, keywordColor };
}
export async function changeSettingsSyntaxTheme(page: Page, theme: string): Promise<void> {
await page.getByLabel(/Highlight theme:/).click();
await page.getByRole("menuitem", { name: theme, exact: true }).click();
await expect(page.getByLabel(`Highlight theme: ${theme}`, { exact: true })).toBeVisible();
}
export async function expectSettingsSyntaxColorChanged(
page: Page,
previousColor: string,
): Promise<void> {
await expect(
page
.getByRole("img", { name: "Live preview of content typography, syntax theme, and code font" })
.getByText("export", { exact: true })
.first(),
).not.toHaveCSS("color", previousColor);
}
export async function changeSettingsTypography(page: Page): Promise<void> {
const input = page.getByRole("textbox", { name: "Interface font size", exact: true });
await input.fill("18");
await input.press("Enter");
await expect(input).toHaveValue("18");
await expect(input).toHaveCSS("font-size", "18px");
await expect(page.getByText("Code size", { exact: true })).toHaveCSS("font-size", "18px");
const navigation = page.getByRole("navigation", { name: "Settings" });
await expect(navigation.getByText("Appearance", { exact: true })).toHaveCSS("font-size", "18px");
await expect(navigation.getByText("Providers", { exact: true })).toHaveCSS("font-size", "18px");
await expect(page.getByText("Dracula", { exact: true })).toHaveCSS("font-size", "18px");
const familyInput = page.getByRole("textbox", { name: "Code font family", exact: true });
await familyInput.fill("Courier New, Courier, monospace");
await familyInput.press("Enter");
const codeInput = page.getByRole("textbox", { name: "Code font size", exact: true });
await codeInput.fill("16");
await codeInput.press("Enter");
await expect(codeInput).toHaveValue("16");
const preview = page.getByRole("img", {
name: "Live preview of content typography, syntax theme, and code font",
});
await expect(preview.getByText("export", { exact: true }).first()).toHaveCSS("font-size", "16px");
await expect(preview.getByText("export", { exact: true }).first()).toHaveCSS(
"line-height",
"24px",
);
await expect(preview.getByText("export", { exact: true }).first()).toHaveCSS(
"font-family",
/Courier/,
);
const contentInput = page.getByRole("textbox", { name: "Content font size", exact: true });
await contentInput.fill("20");
await contentInput.press("Enter");
await expect(preview.getByText("Readable content and code preview", { exact: true })).toHaveCSS(
"font-size",
"20px",
);
}
export async function normalizeSettingsFontSizes(page: Page): Promise<void> {
for (const [name, minimum, maximum] of [
["Interface font size", "10", "21"],
["Content font size", "10", "21"],
["Code font size", "9", "22"],
] as const) {
const input = page.getByRole("textbox", { name, exact: true });
await input.fill("999");
await input.press("Enter");
await expect(input).toHaveValue(maximum);
// Clamp again when the saved value is already at the maximum.
await input.fill("999");
await input.press("Tab");
await expect(input).toHaveValue(maximum);
await input.fill("");
await input.press("Enter");
await expect(input).toHaveValue(maximum);
await input.fill("1");
await input.press("Enter");
await expect(input).toHaveValue(minimum);
await input.fill("1");
await input.press("Tab");
await expect(input).toHaveValue(minimum);
}
}
+3
View File
@@ -864,6 +864,8 @@ const ROOT_STACK_SCREEN_OPTIONS = {
animation: "none" as const,
};
const ROOT_STACK_NESTED_NAVIGATOR_SCREENS = ["h/[serverId]"] as const;
// Settings owns reactive typography leaves; its scroll and form state survive appearance edits.
const ROOT_STACK_SCREEN_OWNED_APPEARANCE = ["settings/[section]"] as const;
function RootStack() {
const storeReady = useStoreReady();
@@ -871,6 +873,7 @@ function RootStack() {
<ThemedStack
screenOptions={ROOT_STACK_SCREEN_OPTIONS}
nestedNavigatorScreens={ROOT_STACK_NESTED_NAVIGATOR_SCREENS}
screenOwnedAppearance={ROOT_STACK_SCREEN_OWNED_APPEARANCE}
>
<Stack.Screen name="index" />
<Stack.Protected guard={storeReady}>
+3
View File
@@ -0,0 +1,3 @@
import { Text } from "react-native";
export { Text as AppearanceText };
+12
View File
@@ -0,0 +1,12 @@
import { Text as NativeText } from "react-native";
import { withUnistyles } from "react-native-unistyles";
import type { Theme } from "@/styles/theme";
// Web numeric tokens are baked into classes. Subscribe this leaf so withUnistyles
// resolves its style prop again without replacing the surrounding form or scroll view.
function typographyChanges(theme: Theme) {
void theme.fontSize;
return {};
}
export const AppearanceText = withUnistyles(NativeText, typographyChanges);
+2 -1
View File
@@ -1,6 +1,7 @@
import { AppearanceText as Text } from "@/appearance/text";
import { useCallback, useMemo } from "react";
import { useTranslation } from "react-i18next";
import { Text, View, type PressableStateCallbackType } from "react-native";
import { View, type PressableStateCallbackType } from "react-native";
import { StyleSheet } from "react-native-unistyles";
import { PressHighlight } from "@/components/ui/press-highlight";
import { DEFAULT_THEME_PREFERENCE, useAppSettings, type AppSettings } from "@/hooks/use-settings";
@@ -1,5 +1,6 @@
import { AppearanceText as Text } from "@/appearance/text";
import { useMemo, type ReactNode } from "react";
import { Platform, ScrollView, Text, View } from "react-native";
import { Platform, ScrollView, View } from "react-native";
import { KeyboardAwareScrollView } from "react-native-keyboard-controller";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { StyleSheet } from "react-native-unistyles";
@@ -1,5 +1,6 @@
import { AppearanceText as Text } from "@/appearance/text";
import { useMemo, type ReactNode } from "react";
import { Text, View, type StyleProp, type ViewStyle } from "react-native";
import { View, type StyleProp, type ViewStyle } from "react-native";
import { StyleSheet } from "react-native-unistyles";
import { ExternalLink, type ExternalLinkProps } from "@/components/ui/external-link";
import { SettingsInfoTip } from "./settings-info-tip";
+2 -1
View File
@@ -1,3 +1,4 @@
import { AppearanceText as Text } from "@/appearance/text";
import { LoadingSpinner } from "@/components/ui/loading-spinner";
import { useContainedControlSize } from "@/components/ui/control-size";
import {
@@ -10,7 +11,7 @@ import {
type ReactElement,
type ReactNode,
} from "react";
import { Pressable, Text, View } from "react-native";
import { Pressable, View } from "react-native";
import type {
PressableProps,
PressableStateCallbackType,
@@ -1,5 +1,6 @@
import { AppearanceText as Text } from "@/appearance/text";
import { useCallback, useMemo, type ReactElement, type ReactNode } from "react";
import { Text, View } from "react-native";
import { View } from "react-native";
import { StyleSheet, withUnistyles } from "react-native-unistyles";
import { ChevronDown } from "lucide-react-native";
import {
+2 -8
View File
@@ -1,15 +1,9 @@
import { AppearanceText as Text } from "@/appearance/text";
import { useHardwareKeyboardPresent } from "@/keyboard";
import { useAddHost } from "@/hosts/add-host";
import { useCallback, useMemo, useRef, useState } from "react";
import type { ComponentType, ReactNode } from "react";
import {
Alert,
Pressable,
ScrollView,
Text,
View,
type PressableStateCallbackType,
} from "react-native";
import { Alert, Pressable, ScrollView, View, type PressableStateCallbackType } from "react-native";
import { useRouter } from "expo-router";
import { useFocusEffect } from "expo-router/react-navigation";
import { useSafeAreaInsets } from "react-native-safe-area-context";
@@ -1,6 +1,7 @@
import { AppearanceText as Text } from "@/appearance/text";
import { useMemo } from "react";
import { useTranslation } from "react-i18next";
import { Text, View, type TextStyle } from "react-native";
import { View, type TextStyle } from "react-native";
import { StyleSheet } from "react-native-unistyles";
import type { HighlightToken } from "@getpaseo/highlight";
import { isWeb } from "@/constants/platform";
@@ -1,8 +1,9 @@
import { AppearanceText as Text } from "@/appearance/text";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import type { TFunction } from "i18next";
import { useTranslation } from "react-i18next";
import { Text, View } from "react-native";
import { StyleSheet } from "react-native-unistyles";
import { View } from "react-native";
import { StyleSheet, withUnistyles } from "react-native-unistyles";
import {
SYNTAX_THEME_OPTIONS,
type SyntaxThemeId,
@@ -13,10 +14,7 @@ import { DropdownTrigger } from "@/components/ui/dropdown-trigger";
import { SettingsSection } from "@/components/settings/headings/settings-section";
import { ThemePicker } from "@/appearance/theme-picker";
import { Button } from "@/components/ui/button";
import {
EditingTextInput as TextInput,
type EditingTextInputHandle,
} from "@/components/ui/text-input";
import { EditingTextInput, type EditingTextInputHandle } from "@/components/ui/text-input";
import {
MAX_CODE_FONT_SIZE,
MAX_CONTENT_FONT_SIZE,
@@ -36,6 +34,12 @@ import { isNative } from "@/constants/platform";
import { settingsStyles } from "@/styles/settings";
import { AppearancePreview } from "./appearance-preview";
// Subscribe the input leaf to numeric theme changes without resetting its editing state.
const TextInput = withUnistyles(EditingTextInput, (theme) => {
void theme.fontSize;
return {};
});
// Platform default stacks can be the bare native tokens ("normal"/"monospace");
// those read as a bug, so show a human label in the placeholder instead.
const BARE_DEFAULT_STACKS: ReadonlySet<string> = new Set(["normal", "monospace"]);
@@ -124,7 +128,7 @@ interface FontSizeRowProps {
draft: string;
withBorder?: boolean;
onChangeDraft: (value: string) => void;
onCommit: () => void;
onCommit: () => string;
}
function FontSizeRow({
@@ -136,6 +140,12 @@ function FontSizeRow({
onChangeDraft,
onCommit,
}: FontSizeRowProps) {
const inputRef = useRef<EditingTextInputHandle>(null);
const handleCommit = useCallback(() => {
const committed = onCommit();
inputRef.current?.replaceText(committed);
}, [onCommit]);
return (
<View style={withBorder ? styles.rowWithBorder : settingsStyles.row}>
<View style={settingsStyles.rowContent}>
@@ -144,10 +154,11 @@ function FontSizeRow({
</View>
<View style={styles.sizeField}>
<TextInput
ref={inputRef}
initialValue={draft}
onChangeText={onChangeDraft}
onBlur={onCommit}
onSubmitEditing={onCommit}
onBlur={handleCommit}
onSubmitEditing={handleCommit}
keyboardType="number-pad"
inputMode="numeric"
selectTextOnFocus
@@ -390,6 +401,7 @@ export function AppearanceSection() {
if (next !== settings.uiBaseFontSize) {
void updateSettings({ uiBaseFontSize: next });
}
return String(next);
}, [settings.uiBaseFontSize, uiBaseSizeDraft, updateSettings]);
const commitCodeSize = useCallback(() => {
@@ -402,6 +414,7 @@ export function AppearanceSection() {
if (next !== settings.codeFontSize) {
void updateSettings({ codeFontSize: next });
}
return String(next);
}, [codeSizeDraft, settings.codeFontSize, updateSettings]);
const commitContentSize = useCallback(() => {
@@ -414,6 +427,7 @@ export function AppearanceSection() {
if (next !== settings.contentFontSize) {
void updateSettings({ contentFontSize: next });
}
return String(next);
}, [contentSizeDraft, settings.contentFontSize, updateSettings]);
// Live-while-typing: the in-progress drafts drive the preview without
@@ -8,29 +8,8 @@ import type { ProviderSnapshotEntry } from "@getpaseo/protocol/agent-types";
import type { MutableDaemonConfig } from "@getpaseo/protocol/messages";
import { ToastApiProvider } from "@/contexts/toast-api-context";
const { theme, snapshotState, configState, patchConfigMock, openProviderSettingsMock } = vi.hoisted(
const { snapshotState, configState, patchConfigMock, openProviderSettingsMock } = vi.hoisted(
() => ({
theme: {
spacing: { 1: 4, "1.5": 6, 2: 8, 3: 12, 4: 16, 6: 24 },
iconSize: { sm: 14, md: 20 },
fontSize: { xs: 11, sm: 13, base: 15 },
fontWeight: { normal: "400" },
borderRadius: { lg: 8 },
opacity: { 50: 0.5 },
colors: {
surface1: "#111",
surface2: "#222",
surface3: "#333",
foreground: "#fff",
foregroundMuted: "#aaa",
border: "#555",
accent: "#0a84ff",
statusSuccess: "#00ff00",
statusWarning: "#ff9500",
statusDanger: "#ff0000",
palette: { red: { 300: "#ff6b6b" }, white: "#fff" },
},
},
snapshotState: {
entries: undefined as ProviderSnapshotEntry[] | undefined,
isLoading: false,
@@ -44,8 +23,8 @@ const { theme, snapshotState, configState, patchConfigMock, openProviderSettings
}),
);
vi.mock("react-native", () => ({
Platform: { OS: "web" },
vi.mock("react-native", async (importOriginal) => ({
...(await importOriginal<typeof import("react-native")>()),
View: ({ children, testID }: { children?: React.ReactNode; testID?: string }) =>
React.createElement("div", { "data-testid": testID }, children),
Text: ({ children }: { children?: React.ReactNode }) =>
@@ -87,14 +66,6 @@ vi.mock("react-native", () => ({
ActivityIndicator: () => React.createElement("span", { "data-testid": "activity-indicator" }),
}));
vi.mock("react-native-unistyles", () => ({
StyleSheet: {
create: (factory: unknown) =>
typeof factory === "function" ? (factory as (t: typeof theme) => unknown)(theme) : factory,
},
useUnistyles: () => ({ theme, rt: { breakpoint: "md" } }),
}));
vi.mock("lucide-react-native", () => {
const icon = (name: string) => () => React.createElement("span", { "data-icon": name });
return {