Skip to content

fix(app): theme switcher implementation #2167

Open
AchuAshwath wants to merge 4 commits intokriasoft:mainfrom
AchuAshwath:fix/theme-switcher
Open

fix(app): theme switcher implementation #2167
AchuAshwath wants to merge 4 commits intokriasoft:mainfrom
AchuAshwath:fix/theme-switcher

Conversation

@AchuAshwath
Copy link
Contributor

Summary

  • Add app-level ThemeProvider and wire the settings dark-mode switch to shared theme state.
  • Persist user theme preference with fallback to system preference.
  • Handle storage edge cases safely and support cross-tab synchronization.
  • Add focused tests for theme behavior and failure modes.

Why

The dark-mode switch in Settings was previously not connected to application theme behavior. This change makes theme switching functional, persistent, and robust across browser/storage conditions.

Changes

  • Wrap routed app with ThemeProvider in apps/app/index.tsx.
  • Implement theme state management in apps/app/lib/theme.tsx:
    • storage -> system preference -> default resolution
    • class sync on <html> with useLayoutEffect
    • guarded localStorage read/write
    • storage event handling for multi-tab sync
  • Connect Switch in apps/app/routes/(app)/settings.tsx to theme state.
  • Add apps/app/lib/theme.test.tsx with behavior-first coverage for:
    • persisted theme
    • system fallback
    • DOM class + storage updates
    • storage event sync
    • storage read/write failure handling

In ThemeProvider, the context value is memoized with:

useMemo(() => ({ theme, setTheme, toggleTheme }), [theme])

Since toggleTheme is currently stabilized with useCallback([]), is [theme] sufficient here, or do we prefer [theme, toggleTheme] for explicitness/future-proofing if toggleTheme dependencies change later?

Signed-off-by: AchuAshwath <achuashwath88@gmail.com>
Made-with: Cursor
Copilot AI review requested due to automatic review settings March 12, 2026 17:24
Copy link

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Wires the settings dark-mode toggle to actual theme state via a new ThemeProvider context, with localStorage persistence, system-preference fallback, and cross-tab sync via storage events.

Changes:

  • New ThemeProvider context (apps/app/lib/theme.tsx) managing theme state, DOM class sync, guarded localStorage persistence, and cross-tab synchronization.
  • Settings page switch connected to theme context; app entry point wrapped with ThemeProvider.
  • Comprehensive test suite covering persisted theme, system fallback, DOM/storage sync, and failure modes.

Reviewed changes

Copilot reviewed 4 out of 4 changed files in this pull request and generated 1 comment.

File Description
apps/app/lib/theme.tsx New theme context provider with localStorage persistence, system preference fallback, and cross-tab sync
apps/app/index.tsx Wraps RouterProvider with ThemeProvider at the app root
apps/app/routes/(app)/settings.tsx Connects the dark-mode Switch to the theme context
apps/app/lib/theme.test.tsx Tests for theme initialization, toggling, storage events, and failure handling

You can also share your feedback on Copilot code review. Take the survey.

setTheme,
toggleTheme,
}),
[theme],
Copy link

Copilot AI Mar 12, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The useMemo dependency array [theme] omits toggleTheme. While toggleTheme is currently stable (due to useCallback([], [])), this will break silently if toggleTheme ever gains dependencies—and it will trigger an react-hooks/exhaustive-deps lint warning. Include toggleTheme in the dependency array for correctness and future safety. (setTheme from useState is guaranteed stable by React, so it doesn't need to be listed.)

Suggested change
[theme],
[theme, toggleTheme],

Copilot uses AI. Check for mistakes.
@koistya
Copy link
Member

koistya commented Mar 13, 2026

Theme switching is much closer now, but I still see a few issues worth fixing before merge.

1. First paint still flashes the light theme

ThemeProvider only applies the dark class in useLayoutEffect, which runs after React mounts. On a cold load, users with a dark preference will still get a light first paint before the class is added.

That is especially visible here because the app background/text colors come from CSS variables on :root / .dark.

Suggested fix:

  • add a tiny inline script in apps/app/index.html that reads the stored preference before /index.tsx runs
  • if there is no explicit preference yet, fall back to matchMedia("(prefers-color-scheme: dark)")
  • set document.documentElement.classList.toggle("dark", isDark) there

2. The "system fallback" is immediately persisted as an explicit choice

Right now getInitialTheme() falls back to the OS preference, but useLayoutEffect then writes that resolved value straight into localStorage:

  • no stored value
  • OS is dark
  • app initializes to "dark"
  • effect persists "dark" to app-theme

After that first mount, the app is no longer "following system" at all; it has silently converted the OS-derived value into a fixed user preference.

So even if the user never touched the toggle:

  • future visits keep using the old stored value
  • OS theme changes are ignored
  • there is no way back to "follow system" from the UI

I think the state needs to distinguish:

  • persisted user preference: light | dark | system
  • resolved theme applied to the DOM: light | dark

Then only persist the preference, and derive the resolved theme from matchMedia when the preference is system.

3. No listener for live OS theme changes

Related to the above: there is no matchMedia(...).addEventListener("change", ...) listener, so the app never updates when the OS theme changes while the app is open.

If the intended behavior is "follow system unless the user explicitly overrides it", this needs a media-query change listener in addition to storing a system preference.

4. meta[name="theme-color"] stays light in dark mode

apps/app/index.html still hardcodes:

<meta name="theme-color" content="#fafafa" />

So browser chrome on mobile can stay light even when the app itself is dark. This should be updated alongside the root class, either:

  • via a second dark/light pair of meta tags if the browser supports it, or
  • by mutating the existing meta tag when the resolved theme changes

Test gap

The new tests cover localStorage reads/writes and storage events well, but they do not cover the main behavioral gap above:

  • "no stored value" should not become a permanent explicit "light" / "dark" preference on first mount
  • OS theme changes should update the resolved theme when preference is system
  • first-paint behavior is handled outside React, so that part needs coverage at the index.html / bootstrap level or at least explicit manual verification

If you want to keep this PR scoped, I’d suggest:

  1. add a pre-paint script in index.html
  2. store a theme preference (light | dark | system) instead of only a resolved theme
  3. add a media-query change listener for the system case
  4. update theme-color from the resolved theme

…nd accessibility

- Refactor theme state to support light/dark/system preference model
- Add pre-paint bootstrap script in index.html to prevent first-load flash
- Persist preference (not resolved theme) to avoid overwriting system choice
- Add live OS theme change listener for system-follow behavior
- Sync meta theme-color on theme changes
- Suppress transitions during theme apply to reduce visual flash
- Replace custom radio with accessible RadioGroup + RadioGroupItem
- Expand tests for preference persistence and theme-color updates
- Add sync comments between bootstrap and runtime logic
@AchuAshwath
Copy link
Contributor Author

AchuAshwath commented Mar 16, 2026

@koistya Added a few more things to this PR:

  • 3-way preference — light, dark, or "system" (follows OS)
  • Pre-paint script — eliminates first-load flash by setting theme before React loads
  • Live OS listener — app updates automatically when OS theme changes (if set to "system")
  • Transition suppression — removes the "flash" when toggling themes
  • Accessible toggle — swapped custom buttons for RadioGroup
  • meta theme-color sync — mobile browser chrome now matches the theme
  • Sync comments — added notes in both bootstrap and runtime to keep them in sync going forward

⚠️ One note: the transition suppression uses * { transition: none !important; }. Works great but is broad — if we see animation issues later, we'll scope it to color properties only.

Quick manual check

  • Cold load with OS dark mode (no stored pref) — should skip flash
  • Toggle theme — should feel instant
  • Set to "System", then change OS theme — app should react live

Tests are updated and all pass. Let me know if anything needs tweaking!

Copy link

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 6 out of 6 changed files in this pull request and generated 1 comment.


You can also share your feedback on Copilot code review. Take the survey.

Comment on lines +171 to +178
const value = useMemo(
() => ({
theme,
preference,
setPreference,
}),
[theme, preference],
);
@AchuAshwath AchuAshwath changed the title fix(app): wire settings theme switcher with resilient persistence fix(app): theme switcher implementation Mar 18, 2026
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.

3 participants