You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
Yes — drafted with Claude Code, then reviewed and refined by me. All claims have been verified against the installed Unfold source.
What version of Unfold are you using?
0.94.0 (regression observed when upgrading from 0.73.1)
What version of Django are you using?
5.2.8
What browser are you using?
Chrome (latest, on Windows 11). Also reproducible in Edge. Not reproducible in fresh browser profiles (e.g. Playwright spawning a clean profile per session) — fresh profiles have empty localStorage, which is what makes this bug easy to miss in automated/CI testing.
Did you checked changelog/commit history, if the bug is not already fixed?
Yes. Reviewed releases 0.74.0 → 0.94.0 in CHANGELOG.md; no entry mentions a fix for THEME / adminTheme / Alpine.$persist reconciliation. The pattern is still present in main at the time of filing.
Did you searched other issues, if the bug is not already fixed?
Yes. Searched for THEME, force theme, theme persist, adminTheme, prefers-color-scheme, dark mode setting, theme switcher. The closest related issue is #1746 ("Disable dark mode") — closed with maintainer reply pointing to the THEME setting as the solution. That comment is what motivated this report: the documented solution doesn't actually work for users with prior session state.
Did you checked documentation?
Yes. https://unfoldadmin.com/docs/configuration/settings/ — the THEME option is documented as: "Force theme: 'dark' or 'light'. Will disable theme switcher". Both halves of that promise are violated in current behavior (see below).
Are you able to replicate the bug in the demo site?
The public demo (https://demo.unfoldadmin.com) doesn't appear to set THEME to a fixed value, so it can't reproduce the conflict between server-set theme and stale localStorage. The bug only manifests when the project configures UNFOLD = {"THEME": "light"} (or "dark") and the user already has an adminTheme value persisted in localStorage that disagrees with the server.
Repository with reproduced bug
No minimal repo prepared yet — happy to create one if it would help triage. The bug is fully visible in the published unfold/static/unfold/js/app.js source though, so any project on 0.94 that sets THEME will exhibit it; repro is essentially "any Django + Unfold 0.94 project with THEME: 'light', then poison localStorage with adminTheme = '"dark"'".
Describe your issue
Summary
Setting UNFOLD = {"THEME": "light"} in Django settings is silently overridden by any value in localStorage.adminTheme. The docs promise this setting "Will disable theme switcher", but in practice it only takes effect while localStorage.adminTheme is unset — i.e. a user's first-ever visit, immediately after clearing site data, or in a fresh browser profile. As soon as anything has written to that key (auto-detection on first load, a manual toggle, or the hidden Ctrl+E keyboard binding), localStorage wins on every subsequent page load and the server has no way to recover.
This is a regression introduced when upgrading django-unfold from 0.73.1 → 0.94.0. In 0.73.1, THEME: "light" reliably forced light mode for all users on every reload — the persisted localStorage value (if any) did not override the server. After upgrading to 0.94.0, the same setting is only honored while localStorage.adminTheme is unset; once persisted, localStorage takes precedence forever. I haven't bisected the exact intermediate release that introduced it, but the Tailwind 4 migration (~0.56) and the adoption of Alpine.$persist for adminTheme are the most likely candidates.
Reproduction
In any Django project using Unfold 0.94, set:
UNFOLD= {
"THEME": "light", # Per docs: "Force theme: 'dark' or 'light'. Will disable theme switcher"
}
Open the admin in a browser whose system preference is dark, or press Ctrl+E once anywhere in the admin (this triggers the hidden toggle binding at app.js ~73–80 and persists adminTheme = "dark" to localStorage).
From now on, in that browser profile, every admin page loads in dark mode regardless of the server's THEME setting. The server cannot recover the user without manual localStorage.removeItem("adminTheme") in DevTools.
Expected behavior
When THEME is explicitly set in Django settings, the server-side value should be authoritative on every page load. localStorage should not be able to override it. The docs explicitly promise "Will disable theme switcher" — so a user-side toggle (whether visible button or hidden keyboard shortcut) should not be able to make a sticky change that survives reloads.
Actual behavior
<html> ends up with class="dark" even though the server renders x-data="theme('light')" in unfold/templates/unfold/layouts/skeleton.html:18, and the docs claim the switcher is disabled. The Ctrl+E keyboard binding in app.js (~73–80) also remains active and can flip the persisted value at any time.
Alpine.$persist reads from localStorage on init and only falls back to defaultTheme when localStorage.adminTheme is absent. As soon as that key exists (regardless of whether the user set it deliberately, the keyboard shortcut wrote it, or auto-detection on a previous visit populated it), the server-rendered theme('light') in skeleton.html:18 is ignored.
Additionally, the keyboard handler in the same file (~73–80) writes to this.adminTheme (which routes through the persistor), so the toggle is not actually disabled when THEME is set — it merely lacks a visible UI element. A user can still flip it via Ctrl+E and persist that flip across reloads.
Suggested fix
Reconcile localStorage with the server-passed defaultTheme whenever defaultTheme is explicitly set (i.e. when THEME is configured in Django settings). A minimal change in the theme() function:
functiontheme(defaultTheme="auto"){// When the server has an explicit THEME, the server is authoritative —// overwrite any stale persisted value before $persist reads it.if(defaultTheme&&defaultTheme!=="auto"){constserialized=JSON.stringify(defaultTheme);if(localStorage.getItem("adminTheme")!==serialized){localStorage.setItem("adminTheme",serialized);}}return{// ...adminTheme: Alpine.$persist(defaultTheme).as('adminTheme'),// ...
Bonus: when THEME is set, also short-circuit the Ctrl+E handler so the documented "Will disable theme switcher" promise is honored end-to-end (no visible toggle and no hidden keybinding).
Related
Disable dark mode #1746 ("Disable dark mode") — closed with maintainer reply pointing to THEME as the solution. This issue is why that solution doesn't actually work for users with prior session state.
Did you used AI to write this issue?
Yes — drafted with Claude Code, then reviewed and refined by me. All claims have been verified against the installed Unfold source.
What version of Unfold are you using?
0.94.0 (regression observed when upgrading from 0.73.1)
What version of Django are you using?
5.2.8
What browser are you using?
Chrome (latest, on Windows 11). Also reproducible in Edge. Not reproducible in fresh browser profiles (e.g. Playwright spawning a clean profile per session) — fresh profiles have empty localStorage, which is what makes this bug easy to miss in automated/CI testing.
Did you checked changelog/commit history, if the bug is not already fixed?
Yes. Reviewed releases 0.74.0 → 0.94.0 in CHANGELOG.md; no entry mentions a fix for
THEME/adminTheme/Alpine.$persistreconciliation. The pattern is still present inmainat the time of filing.Did you searched other issues, if the bug is not already fixed?
Yes. Searched for
THEME,force theme,theme persist,adminTheme,prefers-color-scheme,dark mode setting,theme switcher. The closest related issue is #1746 ("Disable dark mode") — closed with maintainer reply pointing to theTHEMEsetting as the solution. That comment is what motivated this report: the documented solution doesn't actually work for users with prior session state.Did you checked documentation?
Yes. https://unfoldadmin.com/docs/configuration/settings/ — the
THEMEoption is documented as: "Force theme: 'dark' or 'light'. Will disable theme switcher". Both halves of that promise are violated in current behavior (see below).Are you able to replicate the bug in the demo site?
The public demo (https://demo.unfoldadmin.com) doesn't appear to set
THEMEto a fixed value, so it can't reproduce the conflict between server-set theme and stale localStorage. The bug only manifests when the project configuresUNFOLD = {"THEME": "light"}(or"dark") and the user already has anadminThemevalue persisted in localStorage that disagrees with the server.Repository with reproduced bug
No minimal repo prepared yet — happy to create one if it would help triage. The bug is fully visible in the published
unfold/static/unfold/js/app.jssource though, so any project on 0.94 that setsTHEMEwill exhibit it; repro is essentially "any Django + Unfold 0.94 project withTHEME: 'light', then poison localStorage withadminTheme = '"dark"'".Describe your issue
Summary
Setting
UNFOLD = {"THEME": "light"}in Django settings is silently overridden by any value inlocalStorage.adminTheme. The docs promise this setting "Will disable theme switcher", but in practice it only takes effect whilelocalStorage.adminThemeis unset — i.e. a user's first-ever visit, immediately after clearing site data, or in a fresh browser profile. As soon as anything has written to that key (auto-detection on first load, a manual toggle, or the hiddenCtrl+Ekeyboard binding), localStorage wins on every subsequent page load and the server has no way to recover.This is a regression introduced when upgrading django-unfold from 0.73.1 → 0.94.0. In 0.73.1,
THEME: "light"reliably forced light mode for all users on every reload — the persisted localStorage value (if any) did not override the server. After upgrading to 0.94.0, the same setting is only honored whilelocalStorage.adminThemeis unset; once persisted, localStorage takes precedence forever. I haven't bisected the exact intermediate release that introduced it, but the Tailwind 4 migration (~0.56) and the adoption ofAlpine.$persistforadminThemeare the most likely candidates.Reproduction
Ctrl+Eonce anywhere in the admin (this triggers the hidden toggle binding atapp.js~73–80 and persistsadminTheme = "dark"to localStorage).THEMEsetting. The server cannot recover the user without manuallocalStorage.removeItem("adminTheme")in DevTools.Expected behavior
When
THEMEis explicitly set in Django settings, the server-side value should be authoritative on every page load. localStorage should not be able to override it. The docs explicitly promise "Will disable theme switcher" — so a user-side toggle (whether visible button or hidden keyboard shortcut) should not be able to make a sticky change that survives reloads.Actual behavior
<html>ends up withclass="dark"even though the server rendersx-data="theme('light')"inunfold/templates/unfold/layouts/skeleton.html:18, and the docs claim the switcher is disabled. TheCtrl+Ekeyboard binding inapp.js(~73–80) also remains active and can flip the persisted value at any time.Root cause
In
unfold/static/unfold/js/app.js(around line 24):Alpine.$persistreads from localStorage on init and only falls back todefaultThemewhenlocalStorage.adminThemeis absent. As soon as that key exists (regardless of whether the user set it deliberately, the keyboard shortcut wrote it, or auto-detection on a previous visit populated it), the server-renderedtheme('light')inskeleton.html:18is ignored.Additionally, the keyboard handler in the same file (~73–80) writes to
this.adminTheme(which routes through the persistor), so the toggle is not actually disabled whenTHEMEis set — it merely lacks a visible UI element. A user can still flip it viaCtrl+Eand persist that flip across reloads.Suggested fix
Reconcile localStorage with the server-passed
defaultThemewheneverdefaultThemeis explicitly set (i.e. whenTHEMEis configured in Django settings). A minimal change in thetheme()function:Bonus: when
THEMEis set, also short-circuit theCtrl+Ehandler so the documented "Will disable theme switcher" promise is honored end-to-end (no visible toggle and no hidden keybinding).Related
THEMEas the solution. This issue is why that solution doesn't actually work for users with prior session state.