diff --git a/src/frontend/flask_app/blueprints/auth.py b/src/frontend/flask_app/blueprints/auth.py
index 387d38c..e0abfd6 100644
--- a/src/frontend/flask_app/blueprints/auth.py
+++ b/src/frontend/flask_app/blueprints/auth.py
@@ -1,7 +1,7 @@
"""Authentication blueprint - login, logout, profile."""
from functools import wraps
-from flask import Blueprint, abort, flash, redirect, render_template, request, session, url_for
+from flask import Blueprint, abort, flash, jsonify, redirect, render_template, request, session, url_for
from flask_babel import gettext as _
from services.api_client import api_client
@@ -133,6 +133,27 @@ def logout():
return redirect(url_for("auth.login"))
+@auth_bp.route("/set-theme", methods=["POST"])
+@login_required
+def set_theme():
+ """Persist the user's theme toggle (light/dark) to session and profile.
+
+ Called via AJAX from the navbar theme switch so the choice survives
+ navigation and matches on the next login. Best-effort on the API side:
+ the session is updated regardless so the current browsing stays correct.
+ """
+ theme = (request.get_json(silent=True) or {}).get("theme", "")
+ if theme not in ("light", "dark"):
+ return jsonify({"error": True, "detail": "invalid theme"}), 400
+
+ session["theme"] = theme
+ try:
+ api_client.put("/api/auth/me", data={"theme_pref": theme})
+ except Exception:
+ pass # Non-fatal: session theme is already updated for this session.
+ return jsonify({"ok": True, "theme": theme})
+
+
@auth_bp.route("/profile", methods=["GET", "POST"])
@login_required
def profile():
diff --git a/src/frontend/flask_app/static/js/alpine-init.js b/src/frontend/flask_app/static/js/alpine-init.js
index e5c0e46..926941b 100644
--- a/src/frontend/flask_app/static/js/alpine-init.js
+++ b/src/frontend/flask_app/static/js/alpine-init.js
@@ -16,9 +16,21 @@
/**
* Resolve the initial dark-mode state.
- * Priority: localStorage > system preference > light (default).
+ * Priority: server (per-user theme_pref) > localStorage > system preference > light.
+ *
+ * The server value (window.__SERVER_THEME) is authoritative for logged-in
+ * users: it reflects the theme_pref of whoever is currently authenticated,
+ * so switching user actually switches theme instead of inheriting the
+ * previous user's localStorage value. It is empty for anonymous pages.
*/
function getInitialDark() {
+ var server = (typeof window !== 'undefined' && window.__SERVER_THEME) || '';
+ if (server === 'dark' || server === 'light') {
+ // Keep localStorage in sync so the rest of the app stays consistent.
+ try { localStorage.setItem(STORAGE_KEY, server); } catch (_) {}
+ return server === 'dark';
+ }
+
var stored = null;
try {
stored = localStorage.getItem(STORAGE_KEY);
@@ -70,6 +82,7 @@
toggle: function () {
this.dark = !this.dark;
this._apply();
+ this._persist();
},
set: function (dark) {
@@ -77,6 +90,24 @@
this._apply();
},
+ // Persist an explicit user choice to the server (session + theme_pref),
+ // so it survives navigation and matches on the next login. Best-effort.
+ _persist: function () {
+ try {
+ var csrf = document.querySelector('meta[name=csrf-token]');
+ fetch('/auth/set-theme', {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/json',
+ 'X-CSRFToken': (csrf && csrf.content) || ''
+ },
+ body: JSON.stringify({ theme: this.dark ? 'dark' : 'light' })
+ }).catch(function () {});
+ } catch (_) {
+ // Non-fatal: localStorage already holds the choice for this browser.
+ }
+ },
+
_apply: function () {
if (this.dark) {
document.documentElement.classList.add('dark');
diff --git a/src/frontend/flask_app/templates/base.html b/src/frontend/flask_app/templates/base.html
index 10b0a96..86eaae4 100644
--- a/src/frontend/flask_app/templates/base.html
+++ b/src/frontend/flask_app/templates/base.html
@@ -24,6 +24,9 @@
+
+
+