Skip to content

fix(components): pick the conversation font size from a fixed scale - #718

Merged
lodystage[bot] merged 1 commit into
mainfrom
fix/font-size-selection-logic
Sep 15, 2026
Merged

lodystage[bot] merged 1 commit into
mainfrom
fix/font-size-selection-logic

Conversation

@lodystage

@lodystage lodystage Bot commented Sep 15, 2026

Copy link
Copy Markdown
Contributor

Related issue

Problem / pressure

The conversation font size in Settings > Appearance was a number field bounded by 9-32px, and every keystroke went through a clamp whose result was written straight back into the controlled input. The field therefore rewrote what the user was typing: aiming for 24, the first 2 became 9, the next keystroke read 94, and that landed on 32. Emptying the field to start over did nothing either, because a blank value parses as NaN and left the previous number in place. A number on its own also says nothing about how large the text will actually be.

Summary

  • CONVERSATION_FONT_SIZES (8, 12, 14, 16, 20, 24, 28, 32) replaces CONVERSATION_FONT_SIZE_MIN / _MAX as the one source for both the offered options and the snap targets.
  • normalizeConversationFontSize keeps only its persistence job: it snaps to the nearest offered size (ties round up) instead of clamping into a range. Legacy small / default / large migration is unchanged.
  • Desktop reuses the theme row's PreviewSelect; mobile reuses the inline picker. Both label options through one buildConversationFontSizeChoices helper, so they cannot describe the same stored value differently (14 px · Default / 14 px · 默认).
  • Each surface renders a sample sentence at the selected size below the row, in the shape the Terminal section already uses for its font preview. It uses conversationTextFontSizeStyle, the helper the conversation renderers use, so the preview cannot drift from the real thing.
  • Desktop feeds the picker's hover callback into local state rather than the setting: hovering a size redraws the sample while the saved value and the trigger stay put; closing the menu without choosing drops back.
  • New Mobile/MobileAppearanceSettings story (default / largest / smallest).

The terminal font size in the same panel deliberately keeps its number field: its range tops out at 24px and its default (13px) is not on this scale, so giving it presets means choosing a second scale — a separate decision.

Decision record: .agents/notes/implemented/simplification/2026-09-14-conversation-font-size-scale.md (+ .zh.md).

Visual explanation

Where the size comes from, and where it is shown:

flowchart TD
  A["CONVERSATION_FONT_SIZES<br/>8 12 14 16 20 24 28 32<br/>atoms/settings.ts"]
  B["normalizeConversationFontSize<br/>snap to nearest, ties up"]
  C["conversationFontSizeAtom<br/>localStorage"]
  D["buildConversationFontSizeChoices<br/>settings/conversation-font-size-options.ts"]
  E["Desktop: PreviewSelect<br/>settings/appearance-setting.tsx"]
  F["Mobile: MobileSettingsPickerTrigger<br/>mobile/mobile-appearance-settings.tsx"]
  G["Sample line<br/>conversationTextFontSizeStyle"]
  H["Conversation renderers<br/>ai-gui/view.tsx, markdown-renderer.tsx"]

  A --> B --> C
  A --> D
  D --> E
  D --> F
  C --> E
  C --> F
  E -- "commit" --> C
  F -- "commit" --> C
  E -- "hover -> local previewFontSize<br/>(never written to the setting)" --> G
  C --> G
  C --> H
  G -.->|same helper| H
Loading

The old path had no step between the keystroke and storage: onChange -> clamp -> setState -> value, which is exactly what made the field fight the user.

Before / after

Before After
Number field, min=9 max=32 step=1; each keystroke clamped and written back, so typing 24 walked 9 -> 94 -> 32 Eight-step picker; a size is selected, never typed, so there is nothing to rewrite
Clearing the field left the old value (NaN ignored) No field to clear
Persisted 13px stayed 13px, a size nothing in the UI names Persisted 13px snaps to 14px, the nearest offered size, which the picker shows as selected
Nothing showed how large the chosen size actually is Sample sentence under the row at the selected size; desktop previews a hovered size live without saving it
Smallest offered size 9px Smallest offered size 8px

Screenshots (desktop light/dark/zh, menu open with a hovered size; mobile default/open/8px/32px/dark/zh) were attached in the authoring session; the mobile ones come from the new story, the desktop ones from a temporary Vite page rendering AppearanceSettingsView (see Test plan).

Test plan

  • corepack pnpm --filter @lody/components run typecheck — passes.
  • corepack pnpm lint (type-aware oxlint) — 0 errors.
  • corepack pnpm lint:i18n — all keys present in both languages.
  • corepack pnpm run docs check"errors": []; remaining warnings are pre-existing.
  • NODE_ENV=development corepack pnpm exec vitest run tests/appearance-settings.test.tsx tests/terminal-settings.test.ts — 11 passed. Coverage added: the exact eight offered options, hover preview without commit, Escape restoring the saved size, committing 24px, snapping (13 -> 14, 9 -> 8, 30 -> 32, 400 -> 32, 14.7 -> 14) and legacy preset migration, and the mobile row having no number field while rendering its sample at the stored size.
  • Also ran the neighbouring atom consumers: tests/local-terminal-panel.test.tsx, tests/interface-font-controller.test.tsx — 21 passed.
  • Skipped: the full repository suite.
  • Rendering caveat: Settings/AppearanceSettings cannot be read in a built Storybook — it hangs in Storybook's "preparing" state with no error, as does Settings/BillingSettings. Both reach settings/index.tsx, which every settings surface imports for settingContainerClass and which imports them all back; that chunk cycle deadlocks under vite-plugin-top-level-await. It predates this change and was left alone. The desktop layout was verified through a temporary Vite dev page rendering AppearanceSettingsView directly (deleted afterwards); the mobile layout was verified through the new story in the built Storybook.

Context handoff

Instructions for reviewing agents

  • Review focus: packages/components/src/atoms/settings.ts (the snap replacing the clamp, and that CONVERSATION_FONT_SIZES is the only list) and the desktop hover-preview wiring in appearance-setting.tsx (previewFontSize must never reach onConversationFontSizeChange).
  • Decisions to challenge: snapping rather than clamping on read; ties rounding up; offering 8px where the old floor was 9px; leaving the terminal font size as a number field; reusing PreviewSelect instead of OptionSelector.
  • Plausible failures / evidence gaps: users with a persisted off-scale size silently move one step on next load (intended, but it is a visible change); the sample sentence is capped at 520px on desktop so an open menu does not cover it, which is a layout guess rather than a measured constraint; no e2e covers this row.

Authoring context

  • User goal / directives: replace the free-form font-size input with a common preset-based control offering exactly 8/12/14/16/20/24/28/32, because typing a number that the app then rewrites is hostile; then add a preview so the size is visible, and show the rendered result for both desktop and mobile.
  • Constraints / non-goals: conversation font size only; the terminal font size keeps its current control; no Spec exists for appearance settings, so none was changed; the pre-existing Storybook deadlock was not fixed.
  • Risk-bearing decisions: changing the meaning of the persisted lody-conversation-font-size value — any stored number now resolves to one of eight sizes.
  • Destructive or irreversible behavior: none. The stored key is rewritten only when the user picks a size; reads snap in memory, and an older build would still read whatever is stored.
  • Deliberately not done or tested: the full repository suite; an e2e for the new control; the terminal font size; the settings-barrel chunk cycle that keeps this story out of Storybook.
  • Unknowns / confidence: high confidence in the behaviour (covered by tests and read in both layouts); the open question is product taste — whether these eight steps are the right ladder.

The conversation font size was a number field whose every keystroke was
clamped into 9-32px and written straight back, so the field rewrote what
the user was typing: aiming for 24 turned the first `2` into `9`, the
next keystroke read `94`, and that landed on 32. Emptying the field to
start over did nothing, because a blank value parsed as NaN and left the
previous number in place.

Settings now offers eight sizes - 8, 12, 14, 16, 20, 24, 28, 32 -
through the theme row's picker on desktop and the inline picker on
mobile, with one shared label source so the two surfaces cannot describe
the same stored value differently. `normalizeConversationFontSize` keeps
only its persistence job and snaps to the nearest offered size (ties
round up), so a value persisted by an older build keeps the closest size
the user chose instead of being clamped to a bound that matches no
option.

A number is not a size, so each surface renders a sample sentence at the
selected size below the row, in the shape the Terminal section already
uses. Desktop feeds the picker's hover callback into local state rather
than the setting, so hovering a size redraws the sample while the saved
value stays put and closing the menu without choosing drops back.

Model: claude-opus-5[1m]

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@lodystage
lodystage Bot merged commit d8bba55 into main Sep 15, 2026
6 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant