From a70b136f1e3700d8febfbbb4b2db230044f585ee Mon Sep 17 00:00:00 2001 From: Talisson Costa Date: Mon, 27 Jul 2026 17:34:24 -0300 Subject: [PATCH 1/4] feat: theme toggle in the top bar behind dark_mode_nav_toggle darkMode.ts becomes a small reactive theme store: light/dark/system (system follows prefers-color-scheme), legacy dark_mode storage migrates, and a storage listener updates already-open tabs. The onboarding ThemeToggle is promoted to components/ThemeToggle and gains a labelled Light/Dark/System menu (the icon set has no system glyph, so a menu beats a blind three-state cycle). The account-settings switch shares the same store so neither control goes stale. Co-Authored-By: Claude Fable 5 --- .../components/ThemeToggle.stories.tsx | 22 +++++ frontend/web/components/DarkModeSwitch.tsx | 18 ++-- .../components/ThemeToggle/ThemeToggle.tsx | 64 ++++++++++++++ .../onboarding => }/ThemeToggle/index.ts | 0 .../navigation/navbars/TopNavbar.tsx | 7 ++ .../OnboardingFlow/OnboardingFlow.tsx | 2 +- .../onboarding/ThemeToggle/ThemeToggle.tsx | 32 ------- frontend/web/project/darkMode.ts | 86 ++++++++++++++++--- 8 files changed, 178 insertions(+), 53 deletions(-) create mode 100644 frontend/documentation/components/ThemeToggle.stories.tsx create mode 100644 frontend/web/components/ThemeToggle/ThemeToggle.tsx rename frontend/web/components/{pages/onboarding => }/ThemeToggle/index.ts (100%) delete mode 100644 frontend/web/components/pages/onboarding/ThemeToggle/ThemeToggle.tsx diff --git a/frontend/documentation/components/ThemeToggle.stories.tsx b/frontend/documentation/components/ThemeToggle.stories.tsx new file mode 100644 index 000000000000..e24085ec6de5 --- /dev/null +++ b/frontend/documentation/components/ThemeToggle.stories.tsx @@ -0,0 +1,22 @@ +import type { Meta, StoryObj } from 'storybook' + +import ThemeToggle from 'components/ThemeToggle' + +const meta: Meta = { + component: ThemeToggle, + parameters: { + docs: { + description: { + component: + 'Always-visible theme control: an icon button showing the resolved theme, opening a Light / Dark / System menu. Selecting an option flips the theme live (including this Storybook canvas) and persists it.', + }, + }, + layout: 'centered', + }, + title: 'Components/ThemeToggle', +} +export default meta + +type Story = StoryObj + +export const Default: Story = {} diff --git a/frontend/web/components/DarkModeSwitch.tsx b/frontend/web/components/DarkModeSwitch.tsx index c4e1aef475cc..987a4024b3f8 100644 --- a/frontend/web/components/DarkModeSwitch.tsx +++ b/frontend/web/components/DarkModeSwitch.tsx @@ -1,24 +1,22 @@ -import React, { FC, useState } from 'react' +import React, { FC } from 'react' import ConfigProvider from 'common/providers/ConfigProvider' import Setting from './Setting' -import { getDarkMode, setDarkMode as persistDarkMode } from 'project/darkMode' +import { setDarkMode, useTheme } from 'project/darkMode' type DarkModeSwitchType = {} +// Shares theme state with the nav ThemeToggle via useTheme, so neither +// control goes stale when the other flips the theme. Toggling here sets an +// explicit light/dark preference (leaving 'system' mode). const DarkModeSwitch: FC = ({}) => { - const [darkModeLocal, setDarkModeLocal] = useState(getDarkMode()) + const { isDark } = useTheme() - const toggleDarkMode = () => { - const newDarkMode = !getDarkMode() - setDarkModeLocal(newDarkMode) - persistDarkMode(newDarkMode) - } return ( setDarkMode(!isDark)} /> ) } diff --git a/frontend/web/components/ThemeToggle/ThemeToggle.tsx b/frontend/web/components/ThemeToggle/ThemeToggle.tsx new file mode 100644 index 000000000000..39148d7b92de --- /dev/null +++ b/frontend/web/components/ThemeToggle/ThemeToggle.tsx @@ -0,0 +1,64 @@ +import React, { useState } from 'react' +import cn from 'classnames' +import Button from 'components/base/forms/Button' +import Icon from 'components/icons/Icon' +import InlineModal from 'components/InlineModal' +import { ThemeSetting, useTheme } from 'project/darkMode' + +const THEME_OPTIONS: { label: string; value: ThemeSetting }[] = [ + { label: 'Light', value: 'light' }, + { label: 'Dark', value: 'dark' }, + { label: 'System', value: 'system' }, +] + +// Always-visible theme control: an icon button showing the resolved theme, +// opening a Light / Dark / System menu. The icon set has no "system" glyph, +// which is why this is a labelled menu rather than a three-state cycle. +const ThemeToggle = () => { + const [isOpen, setIsOpen] = useState(false) + const { isDark, setThemeSetting, setting } = useTheme() + + return ( +
+ + setIsOpen(false)} + hideClose + relativeToParent + className='inline-modal--sm right mt-1' + > +
+ {THEME_OPTIONS.map((option) => ( + + ))} +
+
+
+ ) +} + +export default ThemeToggle diff --git a/frontend/web/components/pages/onboarding/ThemeToggle/index.ts b/frontend/web/components/ThemeToggle/index.ts similarity index 100% rename from frontend/web/components/pages/onboarding/ThemeToggle/index.ts rename to frontend/web/components/ThemeToggle/index.ts diff --git a/frontend/web/components/navigation/navbars/TopNavbar.tsx b/frontend/web/components/navigation/navbars/TopNavbar.tsx index 8eb7f5b2c147..2035bb844b1a 100644 --- a/frontend/web/components/navigation/navbars/TopNavbar.tsx +++ b/frontend/web/components/navigation/navbars/TopNavbar.tsx @@ -7,6 +7,7 @@ import Icon from 'components/icons/Icon' import Headway from 'components/Headway' import { Project } from 'common/types/responses' import AccountDropdown from 'components/navigation/AccountDropdown' +import ThemeToggle from 'components/ThemeToggle' type TopNavType = { activeProject: Project | undefined @@ -46,6 +47,12 @@ const TopNavbar: FC = ({ activeProject, projectId }) => { + {Utils.getFlagsmithHasFeature('dark_mode_nav_toggle') && ( +
+ +
+ )} + {Utils.getFlagsmithHasFeature('persona_based_views') ? ( ) : ( diff --git a/frontend/web/components/pages/onboarding/OnboardingFlow/OnboardingFlow.tsx b/frontend/web/components/pages/onboarding/OnboardingFlow/OnboardingFlow.tsx index 34424e6e9daa..7f8f8acdcfc7 100644 --- a/frontend/web/components/pages/onboarding/OnboardingFlow/OnboardingFlow.tsx +++ b/frontend/web/components/pages/onboarding/OnboardingFlow/OnboardingFlow.tsx @@ -3,7 +3,7 @@ import { useHistory } from 'react-router-dom' import Button from 'components/base/forms/Button' import Icon from 'components/icons/Icon' import OnboardingHeader from 'components/pages/onboarding/OnboardingHeader' -import ThemeToggle from 'components/pages/onboarding/ThemeToggle' +import ThemeToggle from 'components/ThemeToggle' import OnboardingConnectPanel from 'components/pages/onboarding/OnboardingConnectPanel' import OnboardingTerminal from 'components/pages/onboarding/OnboardingTerminal' import OnboardingFlagsTable from 'components/pages/onboarding/OnboardingFlagsTable' diff --git a/frontend/web/components/pages/onboarding/ThemeToggle/ThemeToggle.tsx b/frontend/web/components/pages/onboarding/ThemeToggle/ThemeToggle.tsx deleted file mode 100644 index 3f91408d403b..000000000000 --- a/frontend/web/components/pages/onboarding/ThemeToggle/ThemeToggle.tsx +++ /dev/null @@ -1,32 +0,0 @@ -import React, { useState } from 'react' -import Button from 'components/base/forms/Button' -import Icon from 'components/icons/Icon' -import { getDarkMode, setDarkMode } from 'project/darkMode' - -// Compact light/dark toggle for the chromeless onboarding flow (which has no -// app nav to reach the theme setting). setDarkMode flips the theme live - it -// toggles the body class + data-bs-theme and persists to storage - so the -// local state only exists to re-render this button's own icon. Uses the -// existing icon-button (Button theme='icon'); migrate to the dedicated -// icon-button primitive once that lands. -const ThemeToggle = () => { - const [dark, setDark] = useState(getDarkMode()) - - const toggle = () => { - const next = !dark - setDark(next) - setDarkMode(next) - } - - return ( - - ) -} - -export default ThemeToggle diff --git a/frontend/web/project/darkMode.ts b/frontend/web/project/darkMode.ts index d342dee08647..b21cfdf289bf 100644 --- a/frontend/web/project/darkMode.ts +++ b/frontend/web/project/darkMode.ts @@ -1,20 +1,86 @@ +import { useSyncExternalStore } from 'react' import { storageGet, storageSet } from 'common/safeLocalStorage' -export const getDarkMode = () => { - return storageGet('dark_mode') === 'true' +export type ThemeSetting = 'light' | 'dark' | 'system' + +const THEME_KEY = 'theme' +// Pre-theme-setting storage: 'true' | 'false' under 'dark_mode'. +const LEGACY_KEY = 'dark_mode' + +const media = + typeof window !== 'undefined' && window.matchMedia + ? window.matchMedia('(prefers-color-scheme: dark)') + : null + +export const getThemeSetting = (): ThemeSetting => { + const stored = storageGet(THEME_KEY) + if (stored === 'light' || stored === 'dark' || stored === 'system') { + return stored + } + const legacy = storageGet(LEGACY_KEY) + if (legacy === 'true') return 'dark' + // No stored preference defaults to light, matching the previous behaviour; + // 'system' is opt-in so existing users' themes do not flip on deploy. + return 'light' } -export const setDarkMode = (enabled: boolean) => { - if (enabled) { - storageSet('dark_mode', 'true') - document.body.classList.add('dark') + +export const getDarkMode = (): boolean => { + const setting = getThemeSetting() + return setting === 'system' ? !!media?.matches : setting === 'dark' +} + +const listeners = new Set<() => void>() + +const apply = () => { + const dark = getDarkMode() + document.body.classList.toggle('dark', dark) + if (dark) { document.documentElement.setAttribute('data-bs-theme', 'dark') } else { - storageSet('dark_mode', 'false') - document.body.classList.remove('dark') document.documentElement.removeAttribute('data-bs-theme') } + listeners.forEach((listener) => listener()) +} + +export const setThemeSetting = (setting: ThemeSetting) => { + storageSet(THEME_KEY, setting) + apply() +} + +// Back-compat boolean setter (account settings switch, legacy callers). +export const setDarkMode = (enabled: boolean) => { + setThemeSetting(enabled ? 'dark' : 'light') +} + +const subscribe = (listener: () => void) => { + listeners.add(listener) + return () => { + listeners.delete(listener) + } } -if (storageGet('dark_mode')) { - setDarkMode(getDarkMode()) +// Reactive theme state; all theme UI (nav toggle, settings switch) shares it +// so no control goes stale when another one changes the theme. +export const useTheme = () => { + const setting = useSyncExternalStore(subscribe, getThemeSetting) + const isDark = useSyncExternalStore(subscribe, getDarkMode) + return { isDark, setThemeSetting, setting } } + +// Follow OS appearance changes while in system mode. +media?.addEventListener?.('change', () => { + if (getThemeSetting() === 'system') { + apply() + } +}) + +// Reflect theme changes made in other browser tabs immediately. +if (typeof window !== 'undefined') { + window.addEventListener('storage', (e) => { + if (e.key === THEME_KEY || e.key === LEGACY_KEY) { + apply() + } + }) +} + +apply() From 88a21070834cd9449473f7aefa1a3c75cdc4eb07 Mon Sep 17 00:00:00 2001 From: Talisson Costa Date: Mon, 27 Jul 2026 17:41:59 -0300 Subject: [PATCH 2/4] feat: simplify to a one-click toggle, system informs the default Drops the Light/Dark/System menu: the OS preference decides the theme until the user makes an explicit choice, and the nav control is a plain sun/moon flip like onboarding's. Deviates from #7702's second criterion as written (no selectable system option); flagged on the PR for the issue author. Co-Authored-By: Claude Fable 5 --- .../components/ThemeToggle.stories.tsx | 2 +- .../components/ThemeToggle/ThemeToggle.tsx | 68 ++++--------------- frontend/web/project/darkMode.ts | 37 ++++------ 3 files changed, 29 insertions(+), 78 deletions(-) diff --git a/frontend/documentation/components/ThemeToggle.stories.tsx b/frontend/documentation/components/ThemeToggle.stories.tsx index e24085ec6de5..b604cf60c2ac 100644 --- a/frontend/documentation/components/ThemeToggle.stories.tsx +++ b/frontend/documentation/components/ThemeToggle.stories.tsx @@ -8,7 +8,7 @@ const meta: Meta = { docs: { description: { component: - 'Always-visible theme control: an icon button showing the resolved theme, opening a Light / Dark / System menu. Selecting an option flips the theme live (including this Storybook canvas) and persists it.', + 'One-click light/dark toggle (the icon shows what you switch to). With no stored choice the theme follows the OS; the first click pins an explicit preference. Clicking flips this Storybook canvas live.', }, }, layout: 'centered', diff --git a/frontend/web/components/ThemeToggle/ThemeToggle.tsx b/frontend/web/components/ThemeToggle/ThemeToggle.tsx index 39148d7b92de..649e6b97f549 100644 --- a/frontend/web/components/ThemeToggle/ThemeToggle.tsx +++ b/frontend/web/components/ThemeToggle/ThemeToggle.tsx @@ -1,63 +1,23 @@ -import React, { useState } from 'react' -import cn from 'classnames' +import React from 'react' import Button from 'components/base/forms/Button' import Icon from 'components/icons/Icon' -import InlineModal from 'components/InlineModal' -import { ThemeSetting, useTheme } from 'project/darkMode' +import { useTheme } from 'project/darkMode' -const THEME_OPTIONS: { label: string; value: ThemeSetting }[] = [ - { label: 'Light', value: 'light' }, - { label: 'Dark', value: 'dark' }, - { label: 'System', value: 'system' }, -] - -// Always-visible theme control: an icon button showing the resolved theme, -// opening a Light / Dark / System menu. The icon set has no "system" glyph, -// which is why this is a labelled menu rather than a three-state cycle. +// One-click light/dark flip (the icon shows what you switch to). With no +// stored choice the theme follows the OS; the first click pins an explicit +// preference. State is shared via useTheme, so the account-settings switch +// and other tabs stay in sync. const ThemeToggle = () => { - const [isOpen, setIsOpen] = useState(false) - const { isDark, setThemeSetting, setting } = useTheme() + const { isDark, setDarkMode } = useTheme() return ( -
- - setIsOpen(false)} - hideClose - relativeToParent - className='inline-modal--sm right mt-1' - > -
- {THEME_OPTIONS.map((option) => ( - - ))} -
-
-
+ ) } diff --git a/frontend/web/project/darkMode.ts b/frontend/web/project/darkMode.ts index b21cfdf289bf..b8a7c52e49e1 100644 --- a/frontend/web/project/darkMode.ts +++ b/frontend/web/project/darkMode.ts @@ -1,10 +1,9 @@ import { useSyncExternalStore } from 'react' import { storageGet, storageSet } from 'common/safeLocalStorage' -export type ThemeSetting = 'light' | 'dark' | 'system' - +// 'light' | 'dark' once the user has explicitly chosen. const THEME_KEY = 'theme' -// Pre-theme-setting storage: 'true' | 'false' under 'dark_mode'. +// Pre-theme storage: 'true' | 'false' under 'dark_mode'. const LEGACY_KEY = 'dark_mode' const media = @@ -12,21 +11,19 @@ const media = ? window.matchMedia('(prefers-color-scheme: dark)') : null -export const getThemeSetting = (): ThemeSetting => { +const getStoredChoice = (): 'light' | 'dark' | null => { const stored = storageGet(THEME_KEY) - if (stored === 'light' || stored === 'dark' || stored === 'system') { - return stored - } + if (stored === 'light' || stored === 'dark') return stored const legacy = storageGet(LEGACY_KEY) if (legacy === 'true') return 'dark' - // No stored preference defaults to light, matching the previous behaviour; - // 'system' is opt-in so existing users' themes do not flip on deploy. - return 'light' + if (legacy === 'false') return 'light' + return null } +// An explicit choice wins; without one the OS preference decides. export const getDarkMode = (): boolean => { - const setting = getThemeSetting() - return setting === 'system' ? !!media?.matches : setting === 'dark' + const choice = getStoredChoice() + return choice ? choice === 'dark' : !!media?.matches } const listeners = new Set<() => void>() @@ -42,14 +39,9 @@ const apply = () => { listeners.forEach((listener) => listener()) } -export const setThemeSetting = (setting: ThemeSetting) => { - storageSet(THEME_KEY, setting) - apply() -} - -// Back-compat boolean setter (account settings switch, legacy callers). export const setDarkMode = (enabled: boolean) => { - setThemeSetting(enabled ? 'dark' : 'light') + storageSet(THEME_KEY, enabled ? 'dark' : 'light') + apply() } const subscribe = (listener: () => void) => { @@ -62,14 +54,13 @@ const subscribe = (listener: () => void) => { // Reactive theme state; all theme UI (nav toggle, settings switch) shares it // so no control goes stale when another one changes the theme. export const useTheme = () => { - const setting = useSyncExternalStore(subscribe, getThemeSetting) const isDark = useSyncExternalStore(subscribe, getDarkMode) - return { isDark, setThemeSetting, setting } + return { isDark, setDarkMode } } -// Follow OS appearance changes while in system mode. +// Follow OS appearance changes until the user makes an explicit choice. media?.addEventListener?.('change', () => { - if (getThemeSetting() === 'system') { + if (!getStoredChoice()) { apply() } }) From c9684f4b728f0fd4ad3125ac98ebf6e35d812653 Mon Sep 17 00:00:00 2001 From: Talisson Costa Date: Mon, 27 Jul 2026 17:49:01 -0300 Subject: [PATCH 3/4] style: theme toggle sits left of Getting Started Co-Authored-By: Claude Fable 5 --- .../web/components/navigation/navbars/TopNavbar.tsx | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) diff --git a/frontend/web/components/navigation/navbars/TopNavbar.tsx b/frontend/web/components/navigation/navbars/TopNavbar.tsx index 2035bb844b1a..060b22bf5e32 100644 --- a/frontend/web/components/navigation/navbars/TopNavbar.tsx +++ b/frontend/web/components/navigation/navbars/TopNavbar.tsx @@ -26,6 +26,11 @@ const TopNavbar: FC = ({ activeProject, projectId }) => {
+ {Utils.getFlagsmithHasFeature('dark_mode_nav_toggle') && ( +
+ +
+ )} = ({ activeProject, projectId }) => { - {Utils.getFlagsmithHasFeature('dark_mode_nav_toggle') && ( -
- -
- )} - {Utils.getFlagsmithHasFeature('persona_based_views') ? ( ) : ( From a7e78ba43322adfa96b854210929aafeaf584cf5 Mon Sep 17 00:00:00 2001 From: Talisson Costa Date: Wed, 29 Jul 2026 10:11:55 -0300 Subject: [PATCH 4/4] fix: harden dark mode storage handling (blocked storage, cross-tab clear) --- frontend/web/project/darkMode.ts | 20 +++++++++++++++----- 1 file changed, 15 insertions(+), 5 deletions(-) diff --git a/frontend/web/project/darkMode.ts b/frontend/web/project/darkMode.ts index b8a7c52e49e1..3a107f6fa625 100644 --- a/frontend/web/project/darkMode.ts +++ b/frontend/web/project/darkMode.ts @@ -1,23 +1,31 @@ import { useSyncExternalStore } from 'react' import { storageGet, storageSet } from 'common/safeLocalStorage' -// 'light' | 'dark' once the user has explicitly chosen. +type ThemeChoice = 'light' | 'dark' + +// A ThemeChoice once the user has explicitly chosen. const THEME_KEY = 'theme' // Pre-theme storage: 'true' | 'false' under 'dark_mode'. const LEGACY_KEY = 'dark_mode' +// Fallback when localStorage is unavailable (private mode, quota, blocked): +// hold the explicit choice in memory so the toggle still works this session. +let inMemoryChoice: ThemeChoice | null = null + const media = typeof window !== 'undefined' && window.matchMedia ? window.matchMedia('(prefers-color-scheme: dark)') : null -const getStoredChoice = (): 'light' | 'dark' | null => { +const getStoredChoice = (): ThemeChoice | null => { const stored = storageGet(THEME_KEY) if (stored === 'light' || stored === 'dark') return stored const legacy = storageGet(LEGACY_KEY) if (legacy === 'true') return 'dark' if (legacy === 'false') return 'light' - return null + // Storage wins when present so cross-tab changes propagate; fall back to the + // in-memory choice only when storage has nothing (e.g. writes are blocked). + return inMemoryChoice } // An explicit choice wins; without one the OS preference decides. @@ -40,7 +48,8 @@ const apply = () => { } export const setDarkMode = (enabled: boolean) => { - storageSet(THEME_KEY, enabled ? 'dark' : 'light') + inMemoryChoice = enabled ? 'dark' : 'light' + storageSet(THEME_KEY, inMemoryChoice) apply() } @@ -68,7 +77,8 @@ media?.addEventListener?.('change', () => { // Reflect theme changes made in other browser tabs immediately. if (typeof window !== 'undefined') { window.addEventListener('storage', (e) => { - if (e.key === THEME_KEY || e.key === LEGACY_KEY) { + // e.key is null on localStorage.clear() — revert to OS theme then too. + if (e.key === null || e.key === THEME_KEY || e.key === LEGACY_KEY) { apply() } })