Skip to content

fix(theme): native chrome + menu + popups follow app pref at runtime#400

Merged
dryotta merged 4 commits into
mainfrom
fix/theme-popup-and-chrome-refresh
May 17, 2026
Merged

fix(theme): native chrome + menu + popups follow app pref at runtime#400
dryotta merged 4 commits into
mainfrom
fix/theme-popup-and-chrome-refresh

Conversation

@dryotta

@dryotta dryotta commented May 17, 2026

Copy link
Copy Markdown
Owner

Summary

Reported by user during dev-mode testing:

  • System dark + app light → menu bar correct, but popup dropdowns stay dark.
  • Switching theme from the View menu → native title bar does not refresh.
  • Opening a new window after toggling theme → new window's menu wrong-themed.

Root causes

  1. set_theme IPC only persisted to disk. Renderer-side updated, but the Win32 DWM title bar, the muda HMENU, the popup menus, and macOS NSApp.appearance were never touched post-build.

  2. WebviewWindowBuilder::theme() at build time only flips Windows DWM immersive-dark-mode. Does NOT route through muda (HMENU theme) and does NOT set the process-wide popup SetPreferredAppMode. So newly-built windows always had wrong-themed menus when app pref disagreed with the OS.

  3. AppHandle::set_theme on Windows is silently no-op for our case. Forwards to event_loop.set_theme (process-wide), which lands in tao's update_theme reading window_state.preferred_theme.or(event_loop_preferred_theme). Per-window preferred_theme (set via .theme(Some(_)) at builder time) short-circuits the .or(...), so the event-loop value is ignored — title bar never repaints on runtime theme switch.

  4. muda only paints the menu BAR, not popup dropdowns. Dropdowns are USER32-drawn HMENUs using GetSysColor(COLOR_MENU). To override per-app we need the undocumented uxtheme.dll!SetPreferredAppMode (ordinal 135) + FlushMenuThemes (ordinal 136) — same trick Windows Terminal, Notepad++, VS Code use.

The fix

New src-tauri/src/commands/theme.rs (split out of config.rs)

  • ThemeApplier trait (production: AppHandle iterates webview_windows() + calls per-window WebviewWindow::set_theme + on Windows flips popup mode; tests: mock that records every invocation — regression guard mirroring menu.rs::MenuEmitter).
  • dispatch_set_theme(applier, pref) — pure adapter, testable without an AppHandle.
  • set_theme IPC: persist via config::set_theme_at, then dispatch_set_theme(&app, &theme). Side-effect IPC explicitly labeled in rustdoc.
  • resolve_persisted_theme(app) -> PersistedTheme { bg, theme, raw_pref } — single disk read shared between the builder lookup and the post-build helper.
  • apply_theme_to_window(window, native) — per-window post-build helper.
  • popup_theme (Windows-only) — lazy-loaded uxtheme.dll FFI for ordinals 135 + 136 with OnceLock caching and graceful no-op degradation on Win10 < 1903.

lib.rs

  • build_main_window returns (WebviewWindow, PersistedTheme) so macOS setup() reuses pref (no second disk read).
  • create_app_window applies the theme post-build for the freshly-attached muda menu.
  • macOS setup(): apply_theme_to_window is called AFTER app.set_menu() so NSApp.appearance flips with the global menu attached.

menu.rs

  • Theme events changed from Broadcast to Targeted(firing_label). The IPC chokepoint iterates every window applying native theme; cross-window propagation runs once via existing useCrossWindowPrefsSync. O(N²) → O(N).

Tests

  • Rust (commands::theme::tests): 19 tests — 4 mapper, 4 ThemeApplier dispatch (regression guard), 8 resolver branch matrix, OS-detect smoke, Windows-only popup_theme FFI smoke, error-swallow source-text guard.
  • Rust integration (menu_test.rs): routing table updated; theme-Targeted regression guard with O(N²)→O(N) rationale.
  • Frontend (vitest): 2 new source-text guards in main-window-menu-at-build-time.test.ts (post-build call order, macOS post-set_menu ordering); new cross-window DOM update test in useCrossWindowPrefsSync.test.ts.
  • Native E2E (e2e/native/10-theme-runtime-toggle.spec.ts): drives set_theme IPC end-to-end on the real binary, verifies disk persistence + <html data-theme>, asserts garbage rejection with typed InvalidTheme.

Review process

Driven by 4 parallel critical-posture reviewers (code-review agent) per round across 2 iterations of findings + 1 final pass:

  • Round 1 (4 reviewers) raised: file-size budget violation, O(N²) on broadcast, macOS ordering race (HIGH), test gap for IPC dispatch (CRITICAL), duplicate disk read.
  • Round 2 (4 reviewers) verified round-1 fixes and raised: macOS duplicate disk read regression (HIGH — round-1's macOS ordering fix re-read disk), popup_theme FFI zero coverage (HIGH), error-swallow guard missing (MEDIUM), cross-window DOM update test missing (MEDIUM).
  • Round 3 final pass (running — will iterate if blockers surface).

What this PR does NOT change

  • Windows IFileDialog (open file/folder picker) — not themable per-app by any documented Win32 API. Same constraint VS Code / Chrome / Office hit. Accepted under Non-Goals.
  • Windows "system" mode menu-bar snapshot — when user pref is "system" and the OS theme changes while the app is running, the muda-painted menu bar stays at the snapshotted theme until the next set_theme call. Popups + chrome + renderer DOM all track the OS dynamically; only the menu bar lags. Documented in set_theme IPC rustdoc; tracked as a follow-up that would need a WM_SETTINGCHANGE listener in Rust.

Verification

  • cargo test: 792+ tests passing (lib + integration + per-test-file).
  • vitest: 2349 frontend tests passing.
  • tsc --noEmit: exit 0.
  • Manual dev-mode QA on Windows: title bar, menu bar, and popup dropdowns all flip in real time when toggling theme via View menu (verified by user during round-1 dev iteration).

File-size budgets (architecture rule 23)

  • config.rs: 495 → 293 lines (net reduction).
  • theme.rs: new, 636 total (~400 production + ~200 tests; production under 400).
  • lib.rs: 1218 → 1251 (+33; already on the architecture watchlist).

dryotta and others added 4 commits May 16, 2026 17:02
Round-1 of review-driven refactor for the theme-switch bug. Closes the
gap where:
  - The system theme is dark but the user picks Light in the app menu,
    and the native title bar + menu bar + popup dropdowns stay dark.
  - Switching theme via the View menu does not refresh the title bar.
  - Opening a new window after a theme change shows a freshly-built
    window with the wrong-themed menu.

## Root causes

1. The set_theme IPC only persisted to disk and updated Zustand /
   <html data-theme>. The native side (DWM title bar, muda HMENU,
   Win32 popup menus, NSApp.appearance) was never updated post-build.

2. WebviewWindowBuilder::theme() at builder time only sets Windows
   DWM immersive-dark-mode. It does NOT route through muda (per-window
   HMENU) and does NOT flip the process-wide popup-menu mode.

3. AppHandle::set_theme on Windows forwards to event_loop.set_theme
   which posts a per-window message. tao's update_theme reads
   window_state.preferred_theme.or(event_loop_preferred_theme),
   short-circuiting on Some. Our builder sets it to Some(...), so
   the event-loop value is silently ignored - the title bar never
   repaints on runtime theme switch.

4. muda's set_theme_for_hwnd paints the menu BAR but not the dropdown
   popups - those are USER32 HMENUs drawn with GetSysColor(COLOR_MENU)
   and follow the OS theme unless the process calls the undocumented
   uxtheme SetPreferredAppMode with ForceDark / ForceLight and
   FlushMenuThemes.

## Fix

- New commands/theme.rs module owning the dispatcher chokepoint,
  ThemeApplier trait (production: AppHandle; tests: mock that records
  calls), apply_theme_to_window post-build helper, and a Windows-only
  popup_theme submodule that calls uxtheme ordinals 135 + 136 to
  override the process popup-menu mode.

- set_theme IPC now iterates app.webview_windows() calling per-window
  WebviewWindow::set_theme (bypasses the broken event-loop
  short-circuit; updates per-window DWM bit + muda HMENU + forces
  WM_NCACTIVATE redraw), then flips the process popup mode.
  Cross-window propagation: single IPC iterates all windows, so
  menu-theme events are now Targeted (firing window) instead of
  Broadcast (every renderer re-firing the IPC). This collapses O(N^2)
  IPC x window calls to O(N).

- lib.rs build_main_window + create_app_window now use
  resolve_persisted_theme(app) - a SINGLE disk read returning bg +
  theme + raw_pref - instead of resolve_window_bg + persisted_theme_pref
  separately (eliminates duplicate onboarding.json read on the
  cold-start hot path).

- macOS ordering: apply_theme_to_window is called AFTER app.set_menu(...)
  in setup() so NSApp.appearance flips with the global menu attached.

## Tests

- New commands::theme::tests: 4 theme_to_tauri mapping cases, 4
  ThemeApplier dispatch cases (mock records each invocation -
  regression guard against silent removal of the load-bearing
  applier.apply_theme(...) line), 8 resolve_window_bg_with branch
  cases, detect_os_theme smoke.

- Updated menu_test.rs routing pin to assert theme-* is Targeted (was
  Broadcast). Renamed the bug-1 flip-side test to a Targeted regression
  guard with explicit O(N^2) -> O(N) rationale.

- New source-text guards in main-window-menu-at-build-time.test.ts:
  (a) apply_theme_to_window is called AFTER builder.build() in both
  window factories, (b) on macOS the call happens AFTER
  app.set_menu(...) in setup.

## File-size budgets (rule 23)

- config.rs: 495 -> 293 lines (under 400; net reduction).
- theme.rs: new, 522 total / ~350 production (under 400).
- lib.rs: 1218 -> 1229 (+11; already on watchlist, change is net
  additive to the macOS post-set_menu block).

## Docs

docs/features/{settings,app-chrome}.md describe the new runtime
native-theme propagation and the Win32 IFileDialog OS-limitation (not
themable per-app - same constraint VS Code / Chrome / Office hit on
Windows; accepted under Non-Goals).

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
…window DOM test

Addresses all blocking findings from the 4 round-2 reviewers
(arch-pattern-r2 / security-perf-r2 / test-coverage-r2 / platform-quirks-r2)
plus a new native E2E spec.

## Blocking fixes

1. macOS duplicate disk read (arch-r2 Issue 2 HIGH + security-perf-r2
   Finding 1 MEDIUM regression). build_main_window now returns
   (WebviewWindow, PersistedTheme); setup() reuses the returned `pref`
   on macOS instead of re-reading onboarding.json post-set_menu. This
   closes the regression introduced by round-1's macOS ordering fix.

2. popup_theme FFI zero coverage (test-r2 NEW GAP 1 HIGH). Adds a
   Windows-only smoke test that exercises every PreferredAppMode
   variant of popup_theme::apply (Light/Dark/None + a second pass to
   verify OnceLock caching). The test guards against crashes from a
   future regression in the ordinal numbers, transmute signatures, or
   OnceLock init.

3. apply_theme_to_window error-swallow source-text guard (test-r2
   Finding 2 residual MEDIUM). Adds a structural assertion that the
   function body uses `if let Err(e)` + `log::warn!` and never
   `.unwrap()` / `.expect()`. Prevents a future maintainer from
   "improving" the function to panic-on-error and breaking cold-start
   for users hitting a transient construction race.

4. Cross-window DOM update test for theme (test-r2 NEW GAP 2 MEDIUM).
   New useCrossWindowPrefsSync test composes both useCrossWindowPrefsSync
   and useApplyTheme, fires a synthetic storage event, and asserts
   `<html data-theme>` actually updates. Previously only the Zustand
   side of the chain was tested.

## Should-fix

5. Native E2E spec for theme runtime toggle (test-r2 Finding 4
   STILL-OUTSTANDING HIGH). New e2e/native/10-theme-runtime-toggle.spec.ts:
     - 10.1 drives set_theme("dark"/"light"/"system"), verifies
       onboarding.json persists each pref, and polls `<html data-theme>`
       via the real binary.
     - 10.2 asserts the IPC rejects garbage with a typed InvalidTheme
       error (closed-enum validator contract).
   Documents what the spec CANNOT cover (Win32 DWM/HMENU/popup colour
   introspection requires Win32 APIs Playwright can't drive; round-1
   source-text guards cover the structural call chain).

## Documentation improvements

- theme.rs module preamble: explicit ownership boundary vs config.rs
  (validation + disk writes in config.rs; runtime application +
  cold-start resolver in theme.rs). (arch-r2 Issue 5)
- set_theme IPC rustdoc: explicit "Side-effect IPC (Rust-First pattern)"
  label so the renderer-invisible native mutation is documented; new
  "Windows system-mode limitation" block describing the menu-bar
  snapshot vs popup-menu auto-tracking asymmetry. (arch-r2 Issue 6 +
  platform-r2 Finding 2 documentation gap)
- popup_theme module rustdoc: explicit Windows version requirement
  (ordinals 135 + 136 stable on Win10 1903+) and DLL planting analysis
  (uxtheme.dll is a Known DLL so LoadLibraryA is safe; at parity with
  tao). (platform-r2 NIT findings)
- create_app_window post-build comment: clarifies why no macOS ordering
  concern for secondary windows (NSApp.appearance already stable from
  main-window setup). (arch-r2 Issue 3)

## File-size budgets (rule 23)

- config.rs: 293 lines (under 400) - no change from round-1.
- theme.rs: 636 total / ~400 production (the new tests + smoke +
  source-text guard add ~50 test lines + ~25 doc lines). Production
  still under 400.
- lib.rs: 1251 (was 1229 round-1, was 1218 baseline). +22 from round-1
  for the macOS-dupread refactor (returning PersistedTheme, suppressing
  unused-var on non-macOS). Already on watchlist.

## Verification

- cargo test: 773 lib + 19 commands::theme + all integration tests
  passing.
- vitest: 2349 passing (was 2348 round-1; +1 for new cross-window
  DOM update test).
- tsc --noEmit: exit 0.

## Outstanding review notes (accepted / deferred)

- platform-r2 Finding 2 (Windows "system" mode menu-bar snapshot):
  documented as a known limitation in the IPC rustdoc. Fixing
  would require a WM_SETTINGCHANGE listener in Rust that re-fires
  set_theme when the OS theme changes and the user pref is "system".
  Tracked as a follow-up. Asymmetry is small (only the menu bar
  stays stale; chrome + popups + renderer DOM all track the OS).
- platform-r2 Finding 3 (useApplyTheme prefers-color-scheme): not a
  bug, verified that the media query fires in both WebView2 and
  WKWebView when the OS theme changes.
- security-perf-r2 macOS per-window iteration redundancy: optional
  optimization, deferred. Per-window WebviewWindow::set_theme on macOS
  IS idempotent (Objective-C setAppearance: on NSApp); the loop costs
  N message sends which is negligible.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
The native E2E spec was asserting that <html data-theme> updates after
invoking set_theme via __TAURI_INTERNALS__.invoke(...) directly. That
assertion is wrong by design: the Rust set_theme IPC writes disk and
mutates native chrome but does NOT update Zustand. The renderer's
useThemePref::setTheme hook is what writes to Zustand; the IPC alone
bypasses the renderer state machine, so data-theme never flips and
the poll times out.

The renderer-side propagation chain (storage event -> Zustand ->
useApplyTheme -> data-theme) is already covered by the vitest test
useCrossWindowPrefsSync.test.ts added in the round-2 commit.

This spec now covers what only an end-to-end native test can cover:

  - 10.1 verifies set_theme IPC persists each valid pref (dark, light,
    system) to onboarding.json - read back via onboarding_state IPC.
  - 10.2 verifies set_theme rejects garbage with the typed
    InvalidTheme error (closed-enum validator contract).

Documents the scope (CAN/CANNOT) inline so future maintainers don't
re-introduce the wrong-mental-model assertions.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Round-2 added the Side-effect IPC + O(N^2) -> O(N) + Windows system-mode
limitation blocks to the set_theme rustdoc. specta picks these up as
JSDoc on the TypeScript binding, so bindings.ts needed regeneration to
stay in sync. The bindings-drift CI gate is asserting this lockstep.

Generated via: cd src-tauri ; MDOWNREVIEW_GEN_BINDINGS_ONLY=1 cargo run --features codegen

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
@dryotta dryotta merged commit 9dbb4ff into main May 17, 2026
15 checks passed
@dryotta dryotta deleted the fix/theme-popup-and-chrome-refresh branch May 17, 2026 00:48
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