diff --git a/apps/web/src/App.tsx b/apps/web/src/App.tsx
index 4abe841..eda674e 100644
--- a/apps/web/src/App.tsx
+++ b/apps/web/src/App.tsx
@@ -13,6 +13,7 @@ import { OnboardingHouseholdPage } from "./pages/onboarding/OnboardingHouseholdP
import { AccountSettingsPage } from "./pages/settings/AccountSettingsPage";
import { HouseholdSettingsPage } from "./pages/settings/HouseholdSettingsPage";
import { PreferencesPage } from "./pages/settings/PreferencesPage";
+import { UserPreferencesPage } from "./pages/settings/UserPreferencesPage";
/**
* Top-level route table. Every authenticated section is nested under one
@@ -22,12 +23,12 @@ import { PreferencesPage } from "./pages/settings/PreferencesPage";
* {@link RedirectIfAuthenticated}). Anything else falls back to `/`, which
* itself redirects to `/login` if needed.
*
- * `/parametres/*` (compte/préférences/foyer) are the settings pages,
- * reachable from the sidebar's bottom "Paramètres" menu and the account
- * menu (see `AppLayout`) — nested under `AppLayout` like every other
- * authenticated section. `/foyer` is the old, pre-split combined page's
- * path; it now just redirects to `/parametres/foyer` so an existing
- * bookmark/link keeps working.
+ * `/parametres/*` (compte/préférences alimentaires/foyer/préférences
+ * utilisateur) are the settings pages, reachable from the sidebar's bottom
+ * "Paramètres" menu and the account menu (see `AppLayout`) — nested under
+ * `AppLayout` like every other authenticated section. `/foyer` is the old,
+ * pre-split combined page's path; it now just redirects to
+ * `/parametres/foyer` so an existing bookmark/link keeps working.
*
* `/onboarding/*` (regime/foyer/allergens, in that order) is also
* `RequireAuth`-gated — reached right after signup, once a session already
@@ -51,6 +52,7 @@ export function App() {
} />
} />
} />
+ } />
} />
{
+ return this.request("/preferences");
+ }
+
+ /** Sets the current user's theme preference. */
+ public updatePreferences(theme: ThemePreference): Promise {
+ return this.request("/preferences", { method: "PATCH", body: JSON.stringify({ theme }) });
+ }
}
/** Single shared instance — this client is stateless, no need for one per caller. */
diff --git a/apps/web/src/features/theme/ThemeContext.tsx b/apps/web/src/features/theme/ThemeContext.tsx
new file mode 100644
index 0000000..f8c1021
--- /dev/null
+++ b/apps/web/src/features/theme/ThemeContext.tsx
@@ -0,0 +1,89 @@
+import type { ThemePreference } from "@batch-cooking/shared";
+import { type ReactNode, createContext, useCallback, useContext, useEffect, useState } from "react";
+import { apiClient } from "../../api/client";
+import { useAuth } from "../auth/AuthContext";
+
+/** Shape of the theme state/actions exposed via {@link useTheme}. */
+interface ThemeContextValue {
+ /** The signed-in user's theme preference — `"SYSTEM"` (the default) for a logged-out visitor too, nothing to load a preference for. */
+ theme: ThemePreference;
+ /** Persists `theme` and applies it immediately. Throws `ApiError` on failure. */
+ setTheme: (theme: ThemePreference) => Promise;
+}
+
+/** React context carrying {@link ThemeContextValue} — always accessed through {@link useTheme}, never directly. */
+const ThemeContext = createContext(null);
+
+/**
+ * Applies `theme` to the document. `SYSTEM` *removes* the override rather
+ * than setting a literal `"system"` value `_theme.scss` wouldn't recognize
+ * — with no `data-theme` attribute at all, its `prefers-color-scheme`
+ * media query decides instead, exactly the "system" behavior.
+ */
+function applyTheme(theme: ThemePreference) {
+ if (theme === "SYSTEM") {
+ delete document.documentElement.dataset.theme;
+ } else {
+ document.documentElement.dataset.theme = theme.toLowerCase();
+ }
+}
+
+/**
+ * Provides the signed-in user's theme preference and keeps the document in
+ * sync with it. Must be nested inside `AuthProvider` (reads `useAuth()`'s
+ * `user` to know when a profile is available to load a preference for) —
+ * see `main.tsx`.
+ *
+ * Keyed on `user?.id` rather than `user` itself: `AuthContext`'s `user`
+ * object is a fresh reference every time it refreshes (e.g. after an
+ * unrelated `refreshUser()` call elsewhere), which shouldn't re-trigger a
+ * preferences fetch — only actually signing in/out or switching accounts
+ * should.
+ */
+export function ThemeProvider({ children }: { children: ReactNode }) {
+ const { user } = useAuth();
+ const [theme, setThemeState] = useState("SYSTEM");
+
+ // biome-ignore lint/correctness/useExhaustiveDependencies: keyed on the id on purpose, see the doc comment above.
+ useEffect(() => {
+ if (!user) {
+ setThemeState("SYSTEM");
+ applyTheme("SYSTEM");
+ return;
+ }
+
+ let cancelled = false;
+ apiClient
+ .getPreferences()
+ .then((preferences) => {
+ if (cancelled) return;
+ setThemeState(preferences.theme);
+ applyTheme(preferences.theme);
+ })
+ // Failing to load a preference (e.g. a network hiccup) shouldn't break
+ // the rest of the app — worst case the theme just stays at its
+ // current/default value, same "not fatal" spirit as AuthContext's own
+ // `me()` call on mount.
+ .catch(() => {});
+ return () => {
+ cancelled = true;
+ };
+ }, [user?.id]);
+
+ const setTheme = useCallback(async (newTheme: ThemePreference) => {
+ await apiClient.updatePreferences(newTheme);
+ setThemeState(newTheme);
+ applyTheme(newTheme);
+ }, []);
+
+ return {children};
+}
+
+/** Reads the current theme state/actions. Must be called from within a {@link ThemeProvider}. */
+export function useTheme(): ThemeContextValue {
+ const ctx = useContext(ThemeContext);
+ if (!ctx) {
+ throw new Error("useTheme must be used within a ThemeProvider");
+ }
+ return ctx;
+}
diff --git a/apps/web/src/layouts/AppLayout.tsx b/apps/web/src/layouts/AppLayout.tsx
index eb58f9a..0033027 100644
--- a/apps/web/src/layouts/AppLayout.tsx
+++ b/apps/web/src/layouts/AppLayout.tsx
@@ -23,6 +23,7 @@ const SETTINGS_ITEMS = [
{ to: "/parametres/compte", key: "account" },
{ to: "/parametres/preferences", key: "preferences" },
{ to: "/parametres/foyer", key: "household" },
+ { to: "/parametres/preferences-utilisateur", key: "userPreferences" },
] as const;
/**
@@ -71,8 +72,9 @@ export function AppLayout() {
}
/**
- * Collapsible "Paramètres" section revealing the three settings pages
- * (Compte/Préférences/Foyer — see `pages/settings/`). Starts open whenever
+ * Collapsible "Paramètres" section revealing the settings pages (Compte /
+ * Préférences alimentaires / Foyer / Préférences utilisateur — see
+ * `pages/settings/`). Starts open whenever
* the current route is already under `/parametres`, so following a link
* there (e.g. from {@link AccountMenu}) doesn't land on a collapsed menu;
* otherwise starts closed to keep the sidebar's main focus on the primary
diff --git a/apps/web/src/locales/fr/translation.json b/apps/web/src/locales/fr/translation.json
index 35a1e30..ff633b0 100644
--- a/apps/web/src/locales/fr/translation.json
+++ b/apps/web/src/locales/fr/translation.json
@@ -68,8 +68,9 @@
"toggle": "Paramètres",
"nav": {
"account": "Compte",
- "preferences": "Préférences",
- "household": "Foyer"
+ "preferences": "Préférences alimentaires",
+ "household": "Foyer",
+ "userPreferences": "Préférences utilisateur"
}
},
"accountMenu": {
@@ -145,6 +146,15 @@
"intolerancesLabel": "Intolérances"
}
},
+ "userPreferences": {
+ "title": "Préférences utilisateur",
+ "themeLabel": "Thème",
+ "theme": {
+ "LIGHT": "Clair",
+ "DARK": "Sombre",
+ "SYSTEM": "Système"
+ }
+ },
"household": {
"title": "Foyer",
"form": {
diff --git a/apps/web/src/main.tsx b/apps/web/src/main.tsx
index 7416923..8f16472 100644
--- a/apps/web/src/main.tsx
+++ b/apps/web/src/main.tsx
@@ -3,6 +3,7 @@ import { createRoot } from "react-dom/client";
import { BrowserRouter } from "react-router-dom";
import { App } from "./App";
import { AuthProvider } from "./features/auth/AuthContext";
+import { ThemeProvider } from "./features/theme/ThemeContext";
// Side-effect import: initializes the i18next instance before anything
// renders (react-i18next reads it via context under the hood). See i18n/i18n.ts.
import "./i18n/i18n";
@@ -19,7 +20,9 @@ createRoot(rootElement).render(
-
+
+
+ ,
diff --git a/apps/web/src/pages/settings/UserPreferencesPage.tsx b/apps/web/src/pages/settings/UserPreferencesPage.tsx
new file mode 100644
index 0000000..5efb2a7
--- /dev/null
+++ b/apps/web/src/pages/settings/UserPreferencesPage.tsx
@@ -0,0 +1,66 @@
+import { ErrorCode, THEME_PREFERENCES, type ThemePreference } from "@batch-cooking/shared";
+import { useState } from "react";
+import { useTranslation } from "react-i18next";
+import { ApiError } from "../../api/client";
+import { useTheme } from "../../features/theme/ThemeContext";
+import { errorMessageService } from "../../services/error-message.service";
+import "./settings-pages.scss";
+
+/** Status of the theme choice's own autosave — see `PreferencesPage` for the same hot-saving pattern. */
+type SaveState = "idle" | "saving" | "saved" | "error";
+
+/**
+ * User (personalization) preferences — routed at
+ * `/parametres/preferences-utilisateur`. Just the theme choice for now
+ * (light/dark/system — see `features/theme/ThemeContext.tsx`), meant to
+ * grow. Distinct from `/parametres/preferences` ("Préférences
+ * alimentaires" — regime/allergies): that page is about the household's
+ * food constraints, this one is about how the app itself looks, unrelated
+ * concerns that happened to share a name before this page existed.
+ */
+export function UserPreferencesPage() {
+ const { t } = useTranslation();
+ const { theme, setTheme } = useTheme();
+ const [saveState, setSaveState] = useState("idle");
+ const [saveError, setSaveError] = useState(null);
+
+ async function handleChange(newTheme: ThemePreference) {
+ setSaveState("saving");
+ try {
+ await setTheme(newTheme);
+ setSaveState("saved");
+ } catch (err) {
+ const code = err instanceof ApiError ? err.code : ErrorCode.INTERNAL_ERROR;
+ setSaveError(errorMessageService.getLabel(code));
+ setSaveState("error");
+ }
+ }
+
+ return (
+
+
{t("userPreferences.title")}
+
+
+
+
+ {saveState === "saving" &&
{t("common.saving")}
}
+ {saveState === "saved" &&
{t("common.saved")}
}
+ {saveState === "error" &&
{saveError}
}
+
+
+ );
+}
diff --git a/apps/web/src/pages/settings/settings-pages.scss b/apps/web/src/pages/settings/settings-pages.scss
index 1338858..9eebd4c 100644
--- a/apps/web/src/pages/settings/settings-pages.scss
+++ b/apps/web/src/pages/settings/settings-pages.scss
@@ -149,3 +149,26 @@ button.settings-page__link-button {
cursor: pointer;
text-decoration: underline;
}
+
+// Theme choice (UserPreferencesPage) — a plain radio group, no fieldset/
+// legend styling exists elsewhere yet to reuse (AllergySelect's `.allergy-
+// select` in profile-forms.scss is checkbox-grid specific).
+.theme-select {
+ border: none;
+ padding: 0;
+ margin: 0;
+
+ legend {
+ padding: 0 0 var(--space-sm);
+ font-weight: 600;
+ font-size: var(--font-size-sm);
+ }
+
+ &__option {
+ display: flex;
+ align-items: center;
+ gap: var(--space-sm);
+ padding: var(--space-xs) 0;
+ cursor: pointer;
+ }
+}
diff --git a/apps/web/src/styles/_theme.scss b/apps/web/src/styles/_theme.scss
index b062d73..1afa0c3 100644
--- a/apps/web/src/styles/_theme.scss
+++ b/apps/web/src/styles/_theme.scss
@@ -107,9 +107,9 @@
// --- Dark mode ---------------------------------------------------------
// Follows the OS/browser preference by default. Guarded with
-// `:root:not([data-theme="light"])` so that, if a manual theme switch is
-// ever added, an explicit "light" choice can override a dark OS setting —
-// today nothing sets `data-theme`, so this simply tracks system preference.
+// `:root:not([data-theme="light"])` so an explicit "light" choice (see
+// apps/web's `ThemeContext`, `SYSTEM` = no `data-theme` attribute at all —
+// this block then decides) can override a dark OS setting.
@media (prefers-color-scheme: dark) {
:root:not([data-theme="light"]) {
--color-background: #14181a;
@@ -138,8 +138,9 @@
}
}
-// Mirrors the block above for a future explicit "dark" choice (e.g. a
-// theme toggle), so it wins over the OS setting in both directions.
+// Mirrors the block above for an explicit "dark" choice (`ThemeContext`
+// sets `data-theme="dark"` on ``), so it wins over the OS setting in
+// both directions.
:root[data-theme="dark"] {
--color-background: #14181a;
--color-surface: #1c221e;