Skip to content

fix(settings): give theme, zoom and preview width the persistence every other setting has - #618

Merged
PathGao merged 3 commits into
masterfrom
fix/persist-theme-and-zoom
Aug 12, 2026
Merged

fix(settings): give theme, zoom and preview width the persistence every other setting has#618
PathGao merged 3 commits into
masterfrom
fix/persist-theme-and-zoom

Conversation

@PathGao

@PathGao PathGao commented Aug 12, 2026

Copy link
Copy Markdown
Collaborator

settings.svelte.ts has a PersistedSetting mechanism: one $effect per key, compare-and-set to stop the echo, and a storage listener for the other windows. Its comment says why — Markpad opens multiple windows, each webview gets its own store instance, and they share one localStorage.

Three values were never wired into it and wrote localStorage by hand.

The bug you can see

Open two windows and change the theme in one. Every other switch in that same appearance panel reaches the other window live. The theme <select>, sitting in the same panel, does not — the second window stays on the old theme until it restarts.

The bug you can't see yet

zoomLevel was read with parseInt(localStorage.getItem('zoomLevel') || '100', 10) and never validated. One corrupt value gives NaN, which renders as style="zoom: NaN" — and Math.min(NaN + 10, 500) is also NaN, so neither the wheel nor the shortcuts can get out of it. Only the reset button could.

The repo already had the cure: parseStoredNumber / clampToRange / stepWithinRange, used by all five font and width settings. Settings.svelte:47-60 even records the bug they were written for (an empty input becoming font-size: nullpx).

What changed

All three are now ordinary PersistedSetting entries — same shape as the ~35 already there, no parallel mechanism.

key notes
theme theme unprefixed on purpose: src/app.html:8 reads that exact key in its first-paint script
zoomLevel zoomLevel numberSetting(…, ZOOM_LEVEL_RANGE, …), plus zoomIn() / zoomOut() / resetZoom() on the store
isFullWidth preview.fullWidth legacy key read through readStoredKey, never written — how editor.openFileMode already handles its own

Duplicated constants collapsed: 25/500 from 3 copies to 1 (ZOOM_LEVEL_RANGE); reset-to-100 from 3 copies to 1. Editor.svelte's zoomLevel stopped being $bindable — nothing writes it back any more.

Both as any are gone. ThemeSetting = 'system' | 'light' | 'dark' | \vscode:${string}`plusresolveTheme(). The two casts in Settings.svelteexisted only becausethemewas a barestring`.

The VS Code theme startup flash, fixed without touching Rust

app.rs reads theme.txt to pick the window's background before the webview paints, and its vocabulary is "dark" / "light" / everything else. Every vscode:* theme fell into the last arm and followed the system appearance — so a dark VS Code theme on a light system flashed white at startup.

save_theme now receives the resolved appearance rather than the theme name, read from the dataset.themeType that parseAndApplyVscodeTheme already publishes from the theme's own type field. No Rust logic changedapp.rs already matched those two strings. An old-format theme.txt lands in the fallback arm and self-corrects on the first theme application.

The guardrail

A singleImplementationConvention row: localStorage.setItem may only appear in settings.svelte.ts, which defines writeStoredSetting.

  • recentFiles.ts was checked and is deliberately not exempted. Its read-modify-write and its own storage listener are justified — the recent list is a collection every window appends to, not a scalar — but its write already goes through writeStoredSetting, so it does not match the marker and needs no exception.
  • tabs.svelte.ts is listed and labelled a known gap, not an exemption. editor.splitScrollSync has the milder half of the same defect: one key, one write, so nothing clobbers — but no listener, so a sibling window stays stale until restart. It is allowed only because it sat outside this change's boundary. Filed as follow-up.

Falsification

Removing the three entries and putting localStorage.setItem('theme', theme) back, with the tests untouched:

✖ a theme picked in one window reaches the others without a restart
✖ the persisted theme key is the one app.html paints from
✖ an unusable stored theme falls back rather than being applied
✖ a corrupt stored zoom level loads as 100, not as NaN
✖ zoom and preview width follow the other windows too
✖ the legacy full-width key is honoured once and then superseded
✖ single implementation: localStorage is written through one function
   pass 51 / fail 7

Restored: all green.

Rebased onto #615

This branch was written before the vitest pilot landed, so its new settingsPersistence tests were rewritten onto that runner: the hand-rolled localStorageShim and captured storageListeners became jsdom's real localStorage and a real dispatched StorageEvent, flushSync() after construction (the listener is installed inside an $effect), and readSource switched to the cwd-relative form because import.meta.url is http: under vitest.

Verification

  • npm test 964 pass / 0 fail
  • npm run test:vitest 62 pass / 0 fail
  • npm run check 769 files / 0 errors / 0 warnings
  • cargo test 145 pass / 0 fail

🤖 Generated with Claude Code

PathGao and others added 2 commits August 12, 2026 14:51
…ry other setting has

`settings.svelte.ts` has a `PersistedSetting` mechanism -- one `$effect` per
key, a compare-and-set write, a `storage` listener -- and the comment on
`installPersistedSettings` says what it is for: Markpad opens several windows,
each a separate webview with its own store instance, all sharing one
localStorage. Three values sat outside it and wrote localStorage by hand.

`theme` is the visible bug. Changing it in window A's settings panel left every
other window on the old theme until it restarted, while every switch beside it
in that same panel synced live -- because those were persisted entries and got
the listener for free, and the theme was not: the whole app had two `storage`
listeners and neither was looking at `theme`. Its write had no compare-and-set
either. It is an entry now, keyed `theme` still, because `src/app.html` reads
that key in its first-paint script and renaming it brings back the white flash
that script exists to prevent.

`zoomLevel` was read back with `parseInt(localStorage.getItem('zoomLevel') ||
'100', 10)` and no validation, so a corrupt key became NaN -- and NaN is a trap
rather than a glitch: the preview renders `zoom: NaN`, and both ways out are
`Math.min(NaN + 10, 500)`, which is NaN too. Wheel and chords were dead and
only the reset button could recover the window. It goes through
`NumericSettingRange` now, like the five font and width settings whose own
`nullpx` story is recorded in Settings.svelte. The 25/500 pair had been copied
into three files and the neutral 100 into three more; `ZOOM_LEVEL_RANGE` and
the three store operations that read it are the only copy left.

`preview.fullWidth` had the same bare read and write plus a legacy-key
migration. The migration survives as `LEGACY_PREVIEW_FULL_WIDTH_KEY`, read and
never written, the treatment `editor.openFileMode` and `editor.autoSaveEdits`
already give theirs. The old key is no longer deleted, because a `read` that
touches exactly one field -- the property the whole mechanism rests on -- cannot
remove a second key. It is inert either way: `??` prefers the new one from the
first write onwards.

`theme` also gets a union type, the way `LanguageCode` has one, which removes
both `as any` casts in Settings.svelte. `resolveTheme` is what the untyped
edges (the `<select>`, the title bar, localStorage) go through, so an
unrecognised theme lands on `system` instead of on the document.

And `save_theme` now stores the resolved appearance rather than the theme name.
`theme.txt` has exactly one reader -- `app.rs`, picking a window's background
colour before any webview exists -- and its vocabulary there is "dark",
"light", and "anything else means ask the OS". Every `vscode:` theme fell into
that last arm, so a dark VS Code theme on a light desktop flashed a white window
on every launch. Whether such a theme is dark is inside its own JSON, which only
the frontend parses; sending the answer avoids a second theme parser in Rust and
needs no change to `app.rs` at all. A file written in the older format still
lands in the fallback arm and self-corrects on the next theme application.

The keymap harness records writes to the settings store now, not only to
component locals, or the preview-width chords would come back as firing
nothing. `shortcutRegistry.test.ts` asks which zoom operation each chord runs;
that zoomIn really raises the level is asserted against the real store, where
the arithmetic moved to.

Falsified by deleting the three entries from `createSettingsPersistence` and
putting the bare `setItem` back: six of the new assertions go red, including
cross-window theme and `zoomLevel = 'banana'`. 1016 tests pass, 145 Rust tests
pass, `npm run check` is clean.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The previous commit moved three values into `PersistedSetting`. This is the
rule that stops a fourth appearing, because nothing else could see one: the
values were persisted, the app worked in a single window, and the defect was
visible only when a second window was open.

The marker is the bare `localStorage.setItem(` call, which is the defect itself
rather than a proxy for it -- it is the write no `storage` listener answers, no
compare-and-set guards and no range validates. `writeStoredSetting` is all
three, so the file that defines it is the one allowed site.

`utils/recentFiles.ts` was checked and is deliberately absent rather than
allowed. It does keep persistence logic of its own -- a read-modify-write and
its own `storage` listener -- and that is correct: the recent-file list is a
collection every window appends to, not a scalar preference, so re-reading
before each change is the point and folding it into a `read`/`load` pair would
lose it. But its write already goes through `writeStoredSetting` (#370), so it
does not match this marker at all and needs no exception.

`stores/tabs.svelte.ts` is listed, and the comment says it is a known gap
rather than an exemption: `editor.splitScrollSync` is an ordinary scalar
preference that belongs in `createSettingsPersistence`, and it has the milder
half of the same defect -- one key and one write, so it cannot clobber its
neighbours, but no listener, so a second window keeps its own answer until it
restarts. It is allowed only because collecting it means editing the tab store,
which was outside the scope of this change.

Falsified by putting `localStorage.setItem('theme', theme)` back into
MarkdownViewer.svelte: the rule goes red and names the file.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…ttings from it (#619)

`editor.preZenState` was the one entry in `createSettingsPersistence` that
trusted what it found. Its neighbours all validate -- `normalizeEditorToolbarOrder`,
`parseStoredNumber`, `isSupportedLanguage`, `raw === 'left' || raw === 'right'`
-- while this one did `JSON.parse(raw)` straight onto the field and caught only
the parse error, which is the failure a corrupt record is least likely to be.

It is also the entry whose value is copied onto six other settings: leaving zen
mode assigns `this.showTabs = this.preZenState.showTabs` and five more like it.
So a record whose shape this version cannot read did not stay contained. A
snapshot missing `showTabs` -- an older release, a half-written key -- restored
`showTabs = undefined`; the write effect stores `String(undefined)`, so
localStorage got the *string* `"undefined"`; and `raw === 'true'` is false
forever after. The tab bar was gone on that launch and on every later one, with
only the settings dialog to bring it back. A field of the wrong type is the
same chain with `"yes"` in place of `"undefined"`.

`normalizePreZenState` accepts all six fields at the right types or nothing.
Partial repair was the other option and is worse: a record this version cannot
read comes from a version whose remaining five fields it cannot vouch for
either, and mixing kept fields with invented ones restores a state the user
never had. Rejecting it whole leaves one rule to reason about -- either the
snapshot is the user's or there is no snapshot.

That makes "no snapshot" a state zen mode has to leave through, so it now does.
`toggleZenMode` restores from `this.preZenState ?? DEFAULT_PRE_ZEN_STATE`
instead of skipping the restore when there is nothing to restore from. The old
`if (this.preZenState)` guard turned "no snapshot" into "stay flattened", which
is exactly the symptom being fixed: the tab bar, status bar and line numbers
stay hidden with nothing in the UI saying zen mode was ever involved. The
defaults are the store's own field initializers, and a test pins them to those
initializers so the two copies cannot drift.

`load` now reads like `titlebar.toolbarPlacement` above it -- `parseStoredRecord`
for the JSON, then a normalizer for the shape -- so unparseable text, a
non-object and an array all arrive as `null` without a `try`/`catch` of its own.

Falsified twice, each half separately. Restoring the bare `JSON.parse` turns
two tests red, and the probe shows the whole chain per corrupt form: a wrong
type stores `"yes"` and restarts to `false`, a missing field and a non-object
both store `"undefined"` and restart to `false`. Restoring the
`if (this.preZenState)` guard instead turns the end-to-end test red on its own,
which is what says both halves carry weight -- unparseable JSON left the field
`null` even before this change, and only the fallback gets the tab bar back
from there.

Not fixed, deliberately: a window that enters zen mode inside the few
milliseconds between receiving a sibling's `showTabs=false` and its
`preZenState` snapshots the already-flattened `false`. That snapshot is
well-formed, so no validation can tell it from a real one. It needs a keystroke
inside a millisecond window, and the damage it can still do is now one boolean
mislaid once -- recoverable from the settings dialog and persisted correctly
after that -- rather than a tab bar that cannot come back.

Co-authored-by: PathGao <gaoyanbo@gaoyanbodeMacBook-Air.local>
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
@PathGao
PathGao merged commit 813d292 into master Aug 12, 2026
4 checks passed
@PathGao
PathGao deleted the fix/persist-theme-and-zoom branch August 12, 2026 07:43
PathGao added a commit that referenced this pull request Aug 12, 2026
…echanism (#638)

* fix(settings): give the split scroll-sync preference the storage listener

`editor.splitScrollSync` was the last localStorage write outside
`writeStoredSetting`: the tab store read it in its constructor and wrote it
with a bare `setItem`. That is the milder half of the defect #618 collected
the other three writers for — one key and one write, so it could not clobber
its neighbours the way a whole-snapshot write did — but it still had the other
half. Nothing listened for `storage`, so flipping scroll sync in one window
left every other window seeding its next split from its own construction-time
answer until it was restarted, while every preference beside it synced live.

It is a preference, not tab state. `Tab.isScrollSynced` is the per-tab value:
it is what the title bar toggles, it lives in the window-state snapshot, and
it stays where it is. This is the single sticky scalar every tab and every
window seeds a new split from, so it moves to `SettingsStore` as one more
entry in `createSettingsPersistence` — compare-and-set on write and the
store's own `storage` listener, both for free. `TabManager` keeps the name the
tab code reads it under, because `splitScrollSyncPreference` and
`isScrollSynced` being spelled differently is what says they are not the same
value.

The known-gap line in the `localStorage is written through one function` rule
goes with it: the allowed list is now `settings.svelte.ts` alone.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(recent-files): re-apply the change a sibling window overwrote

`updateStoredRecentFiles` re-reads before writing, which fixed the reported
defect — a window publishing a snapshot of its own stale array — and its own
comment recorded what was left: the read and the write are still two calls,
localStorage has no compare-and-swap, and the spec's storage mutex is
implemented nowhere. A sibling's `setItem` landing between them overwrites the
entry that sibling had just added with a list computed before it existed.

The cycle now reads back what actually landed and, if that is not what it just
wrote, applies the mutation again over the list that won. It converges because
every mutation here is "apply my one change to whatever list you hand me":
promote, drop and rename are each idempotent and each merge, so re-applying
folds this window's change into the sibling's rather than dropping one of the
two. Both windows run the same code, so the loser of an interleaving is the
one that retries. The attempt cap is what stops two busy windows live-locking
on a synchronous click; exhausting it loses one entry, which is what every
interleaving used to cost. The surviving hole is a sibling write that lands
after the read-back — and that window is reading back too, so it merges into
what this one published.

Not the other answer: Rust owning the list under a `Mutex`, the way
`update_pinned_tags` in window_runtime.rs owns the pin list. That lock is
available there because every window is a thread of one Rust process; these
writers are separate webview processes with no primitive between them, so a
backend list would still have to be pushed out to each window — which is what
the `storage` event already does. And Rust is the authority on pins because
Rust reads them: it owns the window list and consults a tag when opening and
closing windows. Nothing in the backend reads recent files. Moving them there
would turn a list the home screen renders synchronously into an async IPC
round trip at every reader, to buy a lock over a value whose worst-case loss
is one row the next open restores.

The list also keeps its own read-modify-write and its own `storage` listener
rather than becoming a `PersistedSetting`: it is a collection every window
appends to, not a scalar preference, and folding it into that mechanism would
throw away the re-read this change is about.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

---------

Co-authored-by: PathGao <gaoyanbo@gaoyanbodeMacBook-Air.local>
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant