Skip to content

fix(settings): make the interface font size a pixel slider - #1962

Merged
h4yfans merged 13 commits into
mainfrom
refactor/appearance-font-size-drag
Sep 2, 2026
Merged

fix(settings): make the interface font size a pixel slider#1962
h4yfans merged 13 commits into
mainfrom
refactor/appearance-font-size-drag

Conversation

@h4yfans

@h4yfans h4yfans commented Sep 2, 2026

Copy link
Copy Markdown
Collaborator

Summary

Appearance → Font Size is now a slider in pixels (12–24, default 16) instead of the Small / Medium / Large segmented control. The size previews live while you drag and settles into one write when you let go.

The legacy fontSize enum stays in the data model. It is not a type change: turning fontSize into a number would make an older build drop the entire synced settings payload on Zod validation, stalling every other setting on that device. Instead a new fontSizePx field rides alongside, and every write sets both — { fontSizePx: px, fontSize: toLegacyFontSize(px) }. Existing installs derive their pixel value from whichever bucket they are on (14 / 16 / 20) the first time they read; anyone on the default medium lands on 16.

packages/contracts/src/font-size.ts owns the whole thing: the bucket table and two pure functions, so the boundary values live in exactly one place.

Cross-version conflict

The two fields carry independent per-device sync clocks, so their clocks cannot be compared against each other to decide which one moved last — the counters have unrelated histories. resolveFontSizePx uses a value invariant instead. Every build with the slider writes the pair atomically, so a coherent pair always satisfies toLegacyFontSize(px) === fontSize. A build from before the slider moves only the bucket, and the invariant it breaks by doing so is the signal that the bucket carries the more recent intent. Both directions work: your slider value survives an unrelated settings merge, and an older device's "make it Small" is honored here.

Also in here

  • The slider's accessible name goes on the Thumb. Radix puts role="slider" there, not on the Root, and a single-thumb slider gets no generated label, so the name on the Root was not exposed at all. The old segmented control had a working name, so this was a regression.
  • One debounced commit path replaces onValueCommit. Radix skips that callback entirely when a drag returns to where it started (which stranded the draft state) and fires it on every keydown (so a held arrow key meant a dozen IPC round trips, config.json rewrites and encrypted settings uploads). Unmount flushes the pending write rather than dropping it.
  • resolveFontSizePx rounds as well as clamps, so the .int() both schemas declare stops being something the runtime can violate.

Release note

Font size is now a slider you drag, in pixels, instead of three fixed sizes. Your current size carries over.

Test plan

  • pnpm typecheck — 19/19
  • pnpm --filter @memry/desktop test:renderer — 8712 passed
  • pnpm --filter @memry/desktop test:main — 7804 passed
  • pnpm --filter @memry/cli test — 37/37
  • pnpm lint — 0 errors
  • pnpm ipc:check, pnpm --filter @memry/desktop i18n:check, pnpm docs:impact --base origin/main --strict
  • The migration and the two-device conflict are covered end to end in settings-handler.font-size.test.ts, driving real writePreferences / readPreferences against a temp vault. Both guards were run against the previous mechanism first and observed to fail.

Not verified on the real surface. The live drag preview, persist-on-release and held-arrow-key behavior have not been watched in an actual Electron window — the evidence for them is jsdom plus mutation testing.

GeneralSettingsSchema.fontSizePx and VaultPreferencesSchema.fontSizePx
both declare .int(), but resolveFontSizePx only clamped. readPreferences
is hand-rolled and never parses through the schema, so a hand-edited
fontSizePx of 16.4 in config.json reached the root element as '16.4px',
was announced as aria-valuenow="16.4", and made every step-1 arrow
produce another fraction that never lands on a legacy bucket.
Radix puts role="slider" on the Thumb, and a single-thumb slider gets no
generated label, so the Thumb resolved its name from its own props and
found nothing. The Root the aria-label landed on is a role-less span, so
a screen reader announced the font size control unnamed — a regression
against the SegmentedControl it replaced. Route the name to the Thumb.

Neither audio-player slider passes an aria-label, so no consumer needed
migrating. The drag tests move to the Root, which is now the only one of
the two elements the label does not select.

The reset button had no styled focus indicator; give it the same
focus-visible ring the other settings sections use.
Replaces four overlapping guards with a single path. onValueChange
previews and schedules; there is no onValueCommit, whose semantics caused
three of these:

- Radix skips onValueCommit when pointer-up lands on the value pointer-down
  started from, so dragging out to 20 and back to 16 left fontSizePxDraft
  stuck at 16 forever. A later remote change then resized the app while the
  slider and readout kept showing 16.
- Radix commits on every keydown, so holding ArrowRight from 12 to 24 fired
  twelve IPC round trips, twelve config.json rewrites and twelve encrypted
  settings uploads. The S/M/L control it replaced could manage three.
- The commit cleared the draft unconditionally after its await, discarding
  one a later call owned, which made a held arrow key jump backwards.
- The preview writes the root font size directly and useThemeSync cannot
  undo it, so unmounting mid-drag stranded the whole interface at the
  previewed size until restart. The unmount cleanup now flushes the pending
  write instead of dropping it.

A commit whose value already equals the saved one skips the write and just
releases the draft, and the draft is only released by the call that still
owns it.

The three new tests fail against the previous mechanism: the row reports
aria-valuenow 16 against a remote 22, updateSettings is called 6 times
instead of 1, and the unmount saves nothing while the page sits at 12px.
general.fontSize and general.fontSizePx carry independent field clocks,
and mergeRemote only writes a field the inbound payload actually carries.
A device on a build from before the slider pushes only the bucket, so its
"make it Small" left the pixel value untouched, resolveFontSizePx
preferred the pixel value unconditionally, and the change was ignored
forever while config.json kept fontSize 'small' next to fontSizePx 22.

Reconcile at the propagate boundary: pass the merged field clocks through
with the merged settings and let the bucket supply the pixel value when
its clock is strictly newer. Concurrent and missing-clock cases keep the
pixel value.

Derived locally, deliberately. No updateField call and no clock bump, so
the synced payload is untouched and every device that receives the same
merge derives the same value. Deterministic, idempotent, and clear of the
echo-dedupe path.

Against the previous code the two-device test writes fontSizePx 22 beside
fontSize 'small'; with the fix it writes 14.
resolveFontSizePx preferred a finite fontSizePx unconditionally, so a device
on a build from before the slider could only ever move general.fontSize and
its change was ignored forever.

Every build that has the slider writes the pair atomically as
{ fontSizePx: px, fontSize: toLegacyFontSize(px) }, so a coherent pair always
satisfies toLegacyFontSize(px) === fontSize. An old build breaks that
invariant precisely when it moves the bucket alone, so the pixel value is
trusted only while the pair agrees, and every read path self-heals: the
renderer row, useThemeSync, readPreferences and the settings cache all funnel
through here.

The two fields carry independent per-field vector clocks, so nothing here
compares them. An absent or unknown bucket cannot contradict a pixel value,
and rounding happens first so a fractional value is not failed against an
invariant it could never satisfy.
reconcileFontSizePx decided between general.fontSizePx and general.fontSize by
calling compare() on their two field clocks. SettingsSyncManager.updateField
increments a clock per field path, so the two are independent per-device
counters with unrelated histories and comparing them is unsound.

It also regressed the migration it existed for. A user who had changed the
font size before upgrading carries general.fontSize one tick ahead of
general.fontSizePx forever, because the very first slider drag writes both
fields and every later drag bumps both by one. Any inbound settings payload
about any setting at all then read 'before' and discarded the slider value,
so a theme change from another device silently reset the user's size.

The value-coherence rule in resolveFontSizePx replaces it, so the handler
needs no clocks: the two-device scenario the deleted tests proved now runs
end to end through the real writePreferences/readPreferences pair in
settings-handler.font-size.test.ts, alongside the regression guard for the
unrelated-merge case this commit's mechanism failed (it resolved 20, not 22).
Three fixtures set fontSizePx to 22 and left fontSize at 'medium'. No build
writes that pair — a save always writes { fontSizePx: px, fontSize:
toLegacyFontSize(px) } — and resolveFontSizePx now reads a disagreeing pair as
an older device having moved the bucket alone, so it resolved 16 and the reset
button had nothing to write.

Also fixes the ApplyContext shape in the new two-device test, which carried
deviceId/vaultId rather than the emit the interface declares.
#1961 replaced the Custom Font text field with a system font picker and
moved FONT_FAMILY_MAP into lib/interface-font, touching the same
Typography rows and the same theme-sync effects as this branch.

Both features are kept. The font size row is the slider from this branch;
the font family row is the picker from main. Dropped from both sides: the
FONT_SIZE_MAP and FONT_SIZE_OPTIONS this branch had already replaced, and
the customFontDraft / commitCustomFont state whose input #1961 removed.
@github-actions github-actions Bot added bug Something isn't working dependencies documentation Improvements or additions to documentation test labels Sep 2, 2026
@h4yfans
h4yfans marked this pull request as ready for review September 2, 2026 16:14
@h4yfans
h4yfans merged commit 34a8594 into main Sep 2, 2026
15 of 16 checks passed
@github-actions

github-actions Bot commented Sep 2, 2026

Copy link
Copy Markdown

React Doctor found 1 new issue in 1 file · 1 warning · score 92 / 100 (Great) · 1 fixed · vs main

1 warning

src/renderer/src/components/tasks/task-detail-drawer.tsx

  • ⚠️ L164 React function has high control-flow complexity no-high-complexity-react-function

Reviewed by React Doctor for commit 4091575. See inline comments for fixes.

@codecov

codecov Bot commented Sep 2, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.

📢 Thoughts on this report? Let us know!

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

bug Something isn't working dependencies documentation Improvements or additions to documentation test

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant