From ac0d17e57e81f0f57180f9b8e596b5290cdbf23b Mon Sep 17 00:00:00 2001
From: Jed Fox <git@jedfox.com>
Date: Thu, 20 Jul 2023 12:34:17 -0400
Subject: [PATCH] Begin integrating support for themes (#1367)

---
 packages/desktop-client/src/components/App.js |   3 +-
 .../src/components/ThemeSelector.tsx          |  33 +++++
 .../desktop-client/src/components/Titlebar.js |   3 +
 .../src/components/settings/Experimental.js   |  92 -------------
 .../src/components/settings/Experimental.tsx  | 121 ++++++++++++++++++
 .../src/components/settings/Themes.js         |  29 +++++
 .../src/components/settings/index.js          |   6 +-
 .../src/hooks/useFeatureFlag.ts               |  14 +-
 .../desktop-client/src/icons/v2/MoonStars.js  |  24 ++++
 packages/desktop-client/src/icons/v2/Sun.js   |  20 +++
 .../src/icons/v2/moon-stars.svg               |   4 +
 packages/desktop-client/src/icons/v2/sun.svg  |   3 +
 packages/desktop-client/src/style/colors.ts   |  70 ++++++++++
 packages/desktop-client/src/style/index.ts    |   3 +
 packages/desktop-client/src/style/palette.ts  |  71 ++++++++++
 .../src/{style.tsx => style/styles.ts}        |  79 +-----------
 packages/desktop-client/src/style/theme.tsx   |  33 +++++
 .../desktop-client/src/style/themes/dark.ts   | 115 +++++++++++++++++
 .../src/style/themes/development.ts           | 115 +++++++++++++++++
 .../desktop-client/src/style/themes/light.ts  | 115 +++++++++++++++++
 .../src/client/state-types/prefs.d.ts         |   5 +-
 packages/loot-core/src/server/main.ts         |   9 ++
 upcoming-release-notes/1367.md                |   6 +
 23 files changed, 788 insertions(+), 185 deletions(-)
 create mode 100644 packages/desktop-client/src/components/ThemeSelector.tsx
 delete mode 100644 packages/desktop-client/src/components/settings/Experimental.js
 create mode 100644 packages/desktop-client/src/components/settings/Experimental.tsx
 create mode 100644 packages/desktop-client/src/components/settings/Themes.js
 create mode 100644 packages/desktop-client/src/icons/v2/MoonStars.js
 create mode 100644 packages/desktop-client/src/icons/v2/Sun.js
 create mode 100644 packages/desktop-client/src/icons/v2/moon-stars.svg
 create mode 100644 packages/desktop-client/src/icons/v2/sun.svg
 create mode 100644 packages/desktop-client/src/style/colors.ts
 create mode 100644 packages/desktop-client/src/style/index.ts
 create mode 100644 packages/desktop-client/src/style/palette.ts
 rename packages/desktop-client/src/{style.tsx => style/styles.ts} (74%)
 create mode 100644 packages/desktop-client/src/style/theme.tsx
 create mode 100644 packages/desktop-client/src/style/themes/dark.ts
 create mode 100644 packages/desktop-client/src/style/themes/development.ts
 create mode 100644 packages/desktop-client/src/style/themes/light.ts
 create mode 100644 upcoming-release-notes/1367.md

diff --git a/packages/desktop-client/src/components/App.js b/packages/desktop-client/src/components/App.js
index 19e2ea5f7..ca8410104 100644
--- a/packages/desktop-client/src/components/App.js
+++ b/packages/desktop-client/src/components/App.js
@@ -11,7 +11,7 @@ import {
 
 import installPolyfills from '../polyfills';
 import { ResponsiveProvider } from '../ResponsiveProvider';
-import { styles, hasHiddenScrollbars } from '../style';
+import { styles, hasHiddenScrollbars, ThemeStyle } from '../style';
 
 import AppBackground from './AppBackground';
 import DevelopmentTopBar from './DevelopmentTopBar';
@@ -134,6 +134,7 @@ class App extends Component {
             <MobileWebMessage />
           </div>
         </div>
+        <ThemeStyle />
       </ResponsiveProvider>
     );
   }
diff --git a/packages/desktop-client/src/components/ThemeSelector.tsx b/packages/desktop-client/src/components/ThemeSelector.tsx
new file mode 100644
index 000000000..b745303d7
--- /dev/null
+++ b/packages/desktop-client/src/components/ThemeSelector.tsx
@@ -0,0 +1,33 @@
+import React from 'react';
+
+import { useActions } from '../hooks/useActions';
+import MoonStars from '../icons/v2/MoonStars';
+import Sun from '../icons/v2/Sun';
+import { useResponsive } from '../ResponsiveProvider';
+import { useTheme } from '../style';
+
+import { Button } from './common';
+
+export function ThemeSelector() {
+  let theme = useTheme();
+  let { saveGlobalPrefs } = useActions();
+
+  let { isNarrowWidth } = useResponsive();
+
+  return isNarrowWidth ? null : (
+    <Button
+      bare
+      onClick={() => {
+        saveGlobalPrefs({
+          theme: theme === 'dark' ? 'light' : 'dark',
+        });
+      }}
+    >
+      {theme === 'light' ? (
+        <MoonStars style={{ width: 13, height: 13, color: 'inherit' }} />
+      ) : (
+        <Sun style={{ width: 13, height: 13, color: 'inherit' }} />
+      )}
+    </Button>
+  );
+}
diff --git a/packages/desktop-client/src/components/Titlebar.js b/packages/desktop-client/src/components/Titlebar.js
index be94a677e..450250852 100644
--- a/packages/desktop-client/src/components/Titlebar.js
+++ b/packages/desktop-client/src/components/Titlebar.js
@@ -42,6 +42,7 @@ import { useSidebar } from './FloatableSidebar';
 import LoggedInUser from './LoggedInUser';
 import { useServerURL } from './ServerContext';
 import useSheetValue from './spreadsheet/useSheetValue';
+import { ThemeSelector } from './ThemeSelector';
 
 export let TitlebarContext = createContext();
 
@@ -288,6 +289,7 @@ function Titlebar({
   const serverURL = useServerURL();
 
   let privacyModeFeatureFlag = useFeatureFlag('privacyMode');
+  let themesFlag = useFeatureFlag('themes');
   let onTogglePrivacy = enabled => {
     savePrefs({ isPrivacyEnabled: enabled });
   };
@@ -372,6 +374,7 @@ function Titlebar({
       </Routes>
       <View style={{ flex: 1 }} />
       <UncategorizedButton />
+      {themesFlag && <ThemeSelector />}
       {privacyModeFeatureFlag && (
         <PrivacyButton
           localPrefs={localPrefs}
diff --git a/packages/desktop-client/src/components/settings/Experimental.js b/packages/desktop-client/src/components/settings/Experimental.js
deleted file mode 100644
index 103e0ba1f..000000000
--- a/packages/desktop-client/src/components/settings/Experimental.js
+++ /dev/null
@@ -1,92 +0,0 @@
-import React, { useState } from 'react';
-
-import { useAllFeatureFlags } from '../../hooks/useFeatureFlag';
-import { colors } from '../../style';
-import { LinkButton, Text, View } from '../common';
-import { Checkbox } from '../forms';
-
-import { Setting } from './UI';
-
-export default function ExperimentalFeatures({ prefs, savePrefs }) {
-  let [expanded, setExpanded] = useState(false);
-  const flags = useAllFeatureFlags();
-  let disabled = prefs.budgetType === 'report' && flags.reportBudget;
-
-  return (
-    <Setting
-      primaryAction={
-        expanded ? (
-          <View style={{ gap: '1em' }}>
-            <label
-              style={{
-                display: 'flex',
-                color: disabled ? colors.n5 : 'inherit',
-              }}
-            >
-              <Checkbox
-                id="report-budget-flag"
-                checked={flags.reportBudget}
-                onChange={() => {
-                  savePrefs({ 'flags.reportBudget': !flags.reportBudget });
-                }}
-                disabled={disabled}
-              />{' '}
-              <View>
-                Budget mode toggle
-                {disabled && (
-                  <Text style={{ color: colors.r3, fontWeight: 500 }}>
-                    Switch to a rollover budget before turning off this feature
-                  </Text>
-                )}
-              </View>
-            </label>
-
-            <label style={{ display: 'flex' }}>
-              <Checkbox
-                id="goal-templates-flag"
-                checked={flags.goalTemplatesEnabled}
-                onChange={() => {
-                  savePrefs({
-                    'flags.goalTemplatesEnabled': !flags.goalTemplatesEnabled,
-                  });
-                }}
-              />{' '}
-              <View>Goal templates</View>
-            </label>
-
-            <label style={{ display: 'flex' }}>
-              <Checkbox
-                id="privacy-mode-flag"
-                checked={flags.privacyMode}
-                onChange={() => {
-                  savePrefs({
-                    'flags.privacyMode': !flags.privacyMode,
-                  });
-                }}
-              />{' '}
-              <View>Privacy mode</View>
-            </label>
-          </View>
-        ) : (
-          <LinkButton
-            onClick={() => setExpanded(true)}
-            style={{
-              flexShrink: 0,
-              alignSelf: 'flex-start',
-              color: colors.p4,
-            }}
-          >
-            I understand the risks, show experimental features
-          </LinkButton>
-        )
-      }
-    >
-      <Text>
-        <strong>Experimental features.</strong> These features are not fully
-        tested and may not work as expected. THEY MAY CAUSE IRRECOVERABLE DATA
-        LOSS. They may do nothing at all. Only enable them if you know what you
-        are doing.
-      </Text>
-    </Setting>
-  );
-}
diff --git a/packages/desktop-client/src/components/settings/Experimental.tsx b/packages/desktop-client/src/components/settings/Experimental.tsx
new file mode 100644
index 000000000..e2478e95d
--- /dev/null
+++ b/packages/desktop-client/src/components/settings/Experimental.tsx
@@ -0,0 +1,121 @@
+import { type ReactNode, useState } from 'react';
+import { useSelector } from 'react-redux';
+
+import type { FeatureFlag } from 'loot-core/src/client/state-types/prefs';
+
+import { useActions } from '../../hooks/useActions';
+import useFeatureFlag from '../../hooks/useFeatureFlag';
+import { colors, useTheme } from '../../style';
+import { LinkButton, Text, View } from '../common';
+import { Checkbox } from '../forms';
+
+import { Setting } from './UI';
+
+type FeatureToggleProps = {
+  flag: FeatureFlag;
+  disableToggle?: boolean;
+  error?: ReactNode;
+  children: ReactNode;
+};
+
+function FeatureToggle({
+  flag,
+  disableToggle = false,
+  error,
+  children,
+}: FeatureToggleProps) {
+  let { savePrefs } = useActions();
+  let enabled = useFeatureFlag(flag);
+
+  return (
+    <label style={{ display: 'flex' }}>
+      <Checkbox
+        checked={enabled}
+        onChange={() => {
+          savePrefs({
+            [`flags.${flag}`]: !enabled,
+          });
+        }}
+        disabled={disableToggle}
+      />
+      <View style={{ color: disableToggle ? colors.n5 : 'inherit' }}>
+        {children}
+        {disableToggle && (
+          <Text style={{ color: colors.r3, fontWeight: 500 }}>{error}</Text>
+        )}
+      </View>
+    </label>
+  );
+}
+
+function ReportBudgetFeature() {
+  let budgetType = useSelector(state => state.prefs.local?.budgetType);
+  let enabled = useFeatureFlag('reportBudget');
+  let blockToggleOff = budgetType === 'report' && enabled;
+  return (
+    <FeatureToggle
+      flag="reportBudget"
+      disableToggle={blockToggleOff}
+      error="Switch to a rollover budget before turning off this feature"
+    >
+      Budget mode toggle
+    </FeatureToggle>
+  );
+}
+
+function ThemeFeature() {
+  let theme = useTheme();
+  let enabled = useFeatureFlag('themes');
+  let blockToggleOff = theme !== 'light' && enabled;
+  return (
+    <FeatureToggle
+      flag="themes"
+      disableToggle={blockToggleOff}
+      error="Switch to the light theme before turning off this feature"
+    >
+      Dark mode
+    </FeatureToggle>
+  );
+}
+
+export default function ExperimentalFeatures() {
+  let [expanded, setExpanded] = useState(false);
+
+  return (
+    <Setting
+      primaryAction={
+        expanded ? (
+          <View style={{ gap: '1em' }}>
+            <ReportBudgetFeature />
+
+            <FeatureToggle flag="goalTemplatesEnabled">
+              Goal templates
+            </FeatureToggle>
+
+            <FeatureToggle flag="privacyMode">Privacy mode</FeatureToggle>
+
+            <ThemeFeature />
+          </View>
+        ) : (
+          <LinkButton
+            onClick={() => setExpanded(true)}
+            style={{
+              flexShrink: 0,
+              alignSelf: 'flex-start',
+              color: colors.p4,
+            }}
+          >
+            I understand the risks, show experimental features
+          </LinkButton>
+        )
+      }
+    >
+      <Text>
+        <strong>Experimental features.</strong> These features are not fully
+        tested and may not work as expected. THEY MAY CAUSE IRRECOVERABLE DATA
+        LOSS. They may do nothing at all. Only enable them if you know what you
+        are doing.
+      </Text>
+    </Setting>
+  );
+}
diff --git a/packages/desktop-client/src/components/settings/Themes.js b/packages/desktop-client/src/components/settings/Themes.js
new file mode 100644
index 000000000..b48f756b0
--- /dev/null
+++ b/packages/desktop-client/src/components/settings/Themes.js
@@ -0,0 +1,29 @@
+import React from 'react';
+
+import { themeNames, useTheme } from '../../style';
+import { Button, Select, Text } from '../common';
+
+import { Setting } from './UI';
+
+export default function ThemeSettings({ saveGlobalPrefs }) {
+  let theme = useTheme();
+  return (
+    <Setting
+      primaryAction={
+        <Button bounce={false} style={{ padding: 0 }}>
+          <Select
+            onChange={value => {
+              saveGlobalPrefs({ theme: value });
+            }}
+            value={theme}
+            options={themeNames.map(name => [name, name])}
+          />
+        </Button>
+      }
+    >
+      <Text>
+        <strong>Themes</strong> change the user interface colors.
+      </Text>
+    </Setting>
+  );
+}
diff --git a/packages/desktop-client/src/components/settings/index.js b/packages/desktop-client/src/components/settings/index.js
index cbb01e5b2..d72b20d5a 100644
--- a/packages/desktop-client/src/components/settings/index.js
+++ b/packages/desktop-client/src/components/settings/index.js
@@ -7,6 +7,7 @@ import * as actions from 'loot-core/src/client/actions';
 import * as Platform from 'loot-core/src/client/platform';
 import { listen } from 'loot-core/src/platform/client/fetch';
 
+import useFeatureFlag from '../../hooks/useFeatureFlag';
 import useLatestVersion, { useIsOutdated } from '../../hooks/useLatestVersion';
 import { useResponsive } from '../../ResponsiveProvider';
 import { colors } from '../../style';
@@ -24,6 +25,7 @@ import FixSplitsTool from './FixSplits';
 import FormatSettings from './Format';
 import GlobalSettings from './Global';
 import { ResetCache, ResetSync } from './Reset';
+import ThemeSettings from './Themes';
 import { AdvancedToggle, Setting } from './UI';
 
 function About() {
@@ -123,6 +125,7 @@ function Settings({
   }, [loadPrefs]);
 
   const { isNarrowWidth } = useResponsive();
+  const themesFlag = useFeatureFlag('themes');
 
   return (
     <View
@@ -169,6 +172,7 @@ function Settings({
             />
           )}
 
+          {themesFlag && <ThemeSettings saveGlobalPrefs={saveGlobalPrefs} />}
           <FormatSettings prefs={prefs} savePrefs={savePrefs} />
           <EncryptionSettings prefs={prefs} pushModal={pushModal} />
           <ExportBudget prefs={prefs} />
@@ -178,7 +182,7 @@ function Settings({
             <ResetCache />
             <ResetSync isEnabled={!!prefs.groupId} resetSync={resetSync} />
             <FixSplitsTool />
-            <ExperimentalFeatures prefs={prefs} savePrefs={savePrefs} />
+            <ExperimentalFeatures />
           </AdvancedToggle>
         </View>
       </Page>
diff --git a/packages/desktop-client/src/hooks/useFeatureFlag.ts b/packages/desktop-client/src/hooks/useFeatureFlag.ts
index 7d3a49101..6d47a634e 100644
--- a/packages/desktop-client/src/hooks/useFeatureFlag.ts
+++ b/packages/desktop-client/src/hooks/useFeatureFlag.ts
@@ -6,6 +6,7 @@ const DEFAULT_FEATURE_FLAG_STATE: Record<FeatureFlag, boolean> = {
   reportBudget: false,
   goalTemplatesEnabled: false,
   privacyMode: false,
+  themes: false,
 };
 
 export default function useFeatureFlag(name: FeatureFlag): boolean {
@@ -17,16 +18,3 @@ export default function useFeatureFlag(name: FeatureFlag): boolean {
       : value;
   });
 }
-
-export function useAllFeatureFlags(): Record<FeatureFlag, boolean> {
-  return useSelector(state => {
-    return {
-      ...DEFAULT_FEATURE_FLAG_STATE,
-      ...Object.fromEntries(
-        Object.entries(state.prefs.local)
-          .filter(([key]) => key.startsWith('flags.'))
-          .map(([key, value]) => [key.replace('flags.', ''), value]),
-      ),
-    };
-  });
-}
diff --git a/packages/desktop-client/src/icons/v2/MoonStars.js b/packages/desktop-client/src/icons/v2/MoonStars.js
new file mode 100644
index 000000000..8bdff417e
--- /dev/null
+++ b/packages/desktop-client/src/icons/v2/MoonStars.js
@@ -0,0 +1,24 @@
+import * as React from 'react';
+const SvgMoonStars = props => (
+  <svg
+    {...props}
+    xmlns="http://www.w3.org/2000/svg"
+    fill="currentColor"
+    className="bi bi-moon-stars"
+    viewBox="0 0 16 16"
+    style={{
+      color: '#242134',
+      ...props.style,
+    }}
+  >
+    <path
+      d="M6 .278a.768.768 0 0 1 .08.858 7.208 7.208 0 0 0-.878 3.46c0 4.021 3.278 7.277 7.318 7.277.527 0 1.04-.055 1.533-.16a.787.787 0 0 1 .81.316.733.733 0 0 1-.031.893A8.349 8.349 0 0 1 8.344 16C3.734 16 0 12.286 0 7.71 0 4.266 2.114 1.312 5.124.06A.752.752 0 0 1 6 .278zM4.858 1.311A7.269 7.269 0 0 0 1.025 7.71c0 4.02 3.279 7.276 7.319 7.276a7.316 7.316 0 0 0 5.205-2.162c-.337.042-.68.063-1.029.063-4.61 0-8.343-3.714-8.343-8.29 0-1.167.242-2.278.681-3.286z"
+      fill="currentColor"
+    />
+    <path
+      d="M10.794 3.148a.217.217 0 0 1 .412 0l.387 1.162c.173.518.579.924 1.097 1.097l1.162.387a.217.217 0 0 1 0 .412l-1.162.387a1.734 1.734 0 0 0-1.097 1.097l-.387 1.162a.217.217 0 0 1-.412 0l-.387-1.162A1.734 1.734 0 0 0 9.31 6.593l-1.162-.387a.217.217 0 0 1 0-.412l1.162-.387a1.734 1.734 0 0 0 1.097-1.097l.387-1.162zM13.863.099a.145.145 0 0 1 .274 0l.258.774c.115.346.386.617.732.732l.774.258a.145.145 0 0 1 0 .274l-.774.258a1.156 1.156 0 0 0-.732.732l-.258.774a.145.145 0 0 1-.274 0l-.258-.774a1.156 1.156 0 0 0-.732-.732l-.774-.258a.145.145 0 0 1 0-.274l.774-.258c.346-.115.617-.386.732-.732L13.863.1z"
+      fill="currentColor"
+    />
+  </svg>
+);
+export default SvgMoonStars;
diff --git a/packages/desktop-client/src/icons/v2/Sun.js b/packages/desktop-client/src/icons/v2/Sun.js
new file mode 100644
index 000000000..cadc6aaf8
--- /dev/null
+++ b/packages/desktop-client/src/icons/v2/Sun.js
@@ -0,0 +1,20 @@
+import * as React from 'react';
+const SvgSun = props => (
+  <svg
+    {...props}
+    xmlns="http://www.w3.org/2000/svg"
+    fill="currentColor"
+    className="bi bi-sun"
+    viewBox="0 0 16 16"
+    style={{
+      color: '#242134',
+      ...props.style,
+    }}
+  >
+    <path
+      d="M8 11a3 3 0 1 1 0-6 3 3 0 0 1 0 6zm0 1a4 4 0 1 0 0-8 4 4 0 0 0 0 8zM8 0a.5.5 0 0 1 .5.5v2a.5.5 0 0 1-1 0v-2A.5.5 0 0 1 8 0zm0 13a.5.5 0 0 1 .5.5v2a.5.5 0 0 1-1 0v-2A.5.5 0 0 1 8 13zm8-5a.5.5 0 0 1-.5.5h-2a.5.5 0 0 1 0-1h2a.5.5 0 0 1 .5.5zM3 8a.5.5 0 0 1-.5.5h-2a.5.5 0 0 1 0-1h2A.5.5 0 0 1 3 8zm10.657-5.657a.5.5 0 0 1 0 .707l-1.414 1.415a.5.5 0 1 1-.707-.708l1.414-1.414a.5.5 0 0 1 .707 0zm-9.193 9.193a.5.5 0 0 1 0 .707L3.05 13.657a.5.5 0 0 1-.707-.707l1.414-1.414a.5.5 0 0 1 .707 0zm9.193 2.121a.5.5 0 0 1-.707 0l-1.414-1.414a.5.5 0 0 1 .707-.707l1.414 1.414a.5.5 0 0 1 0 .707zM4.464 4.465a.5.5 0 0 1-.707 0L2.343 3.05a.5.5 0 1 1 .707-.707l1.414 1.414a.5.5 0 0 1 0 .708z"
+      fill="currentColor"
+    />
+  </svg>
+);
+export default SvgSun;
diff --git a/packages/desktop-client/src/icons/v2/moon-stars.svg b/packages/desktop-client/src/icons/v2/moon-stars.svg
new file mode 100644
index 000000000..9731bee61
--- /dev/null
+++ b/packages/desktop-client/src/icons/v2/moon-stars.svg
@@ -0,0 +1,4 @@
+<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="bi bi-moon-stars" viewBox="0 0 16 16">
+  <path d="M6 .278a.768.768 0 0 1 .08.858 7.208 7.208 0 0 0-.878 3.46c0 4.021 3.278 7.277 7.318 7.277.527 0 1.04-.055 1.533-.16a.787.787 0 0 1 .81.316.733.733 0 0 1-.031.893A8.349 8.349 0 0 1 8.344 16C3.734 16 0 12.286 0 7.71 0 4.266 2.114 1.312 5.124.06A.752.752 0 0 1 6 .278zM4.858 1.311A7.269 7.269 0 0 0 1.025 7.71c0 4.02 3.279 7.276 7.319 7.276a7.316 7.316 0 0 0 5.205-2.162c-.337.042-.68.063-1.029.063-4.61 0-8.343-3.714-8.343-8.29 0-1.167.242-2.278.681-3.286z"/>
+  <path d="M10.794 3.148a.217.217 0 0 1 .412 0l.387 1.162c.173.518.579.924 1.097 1.097l1.162.387a.217.217 0 0 1 0 .412l-1.162.387a1.734 1.734 0 0 0-1.097 1.097l-.387 1.162a.217.217 0 0 1-.412 0l-.387-1.162A1.734 1.734 0 0 0 9.31 6.593l-1.162-.387a.217.217 0 0 1 0-.412l1.162-.387a1.734 1.734 0 0 0 1.097-1.097l.387-1.162zM13.863.099a.145.145 0 0 1 .274 0l.258.774c.115.346.386.617.732.732l.774.258a.145.145 0 0 1 0 .274l-.774.258a1.156 1.156 0 0 0-.732.732l-.258.774a.145.145 0 0 1-.274 0l-.258-.774a1.156 1.156 0 0 0-.732-.732l-.774-.258a.145.145 0 0 1 0-.274l.774-.258c.346-.115.617-.386.732-.732L13.863.1z"/>
+</svg>
diff --git a/packages/desktop-client/src/icons/v2/sun.svg b/packages/desktop-client/src/icons/v2/sun.svg
new file mode 100644
index 000000000..13fc3dbef
--- /dev/null
+++ b/packages/desktop-client/src/icons/v2/sun.svg
@@ -0,0 +1,3 @@
+<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="bi bi-sun" viewBox="0 0 16 16">
+  <path d="M8 11a3 3 0 1 1 0-6 3 3 0 0 1 0 6zm0 1a4 4 0 1 0 0-8 4 4 0 0 0 0 8zM8 0a.5.5 0 0 1 .5.5v2a.5.5 0 0 1-1 0v-2A.5.5 0 0 1 8 0zm0 13a.5.5 0 0 1 .5.5v2a.5.5 0 0 1-1 0v-2A.5.5 0 0 1 8 13zm8-5a.5.5 0 0 1-.5.5h-2a.5.5 0 0 1 0-1h2a.5.5 0 0 1 .5.5zM3 8a.5.5 0 0 1-.5.5h-2a.5.5 0 0 1 0-1h2A.5.5 0 0 1 3 8zm10.657-5.657a.5.5 0 0 1 0 .707l-1.414 1.415a.5.5 0 1 1-.707-.708l1.414-1.414a.5.5 0 0 1 .707 0zm-9.193 9.193a.5.5 0 0 1 0 .707L3.05 13.657a.5.5 0 0 1-.707-.707l1.414-1.414a.5.5 0 0 1 .707 0zm9.193 2.121a.5.5 0 0 1-.707 0l-1.414-1.414a.5.5 0 0 1 .707-.707l1.414 1.414a.5.5 0 0 1 0 .707zM4.464 4.465a.5.5 0 0 1-.707 0L2.343 3.05a.5.5 0 1 1 .707-.707l1.414 1.414a.5.5 0 0 1 0 .708z"/>
+</svg>
diff --git a/packages/desktop-client/src/style/colors.ts b/packages/desktop-client/src/style/colors.ts
new file mode 100644
index 000000000..9248454d5
--- /dev/null
+++ b/packages/desktop-client/src/style/colors.ts
@@ -0,0 +1,70 @@
+export const y1 = '#733309';
+export const y2 = '#87540d';
+export const y3 = '#B88115';
+export const y4 = '#D4A31C';
+export const y5 = '#E6BB20';
+export const y6 = '#F2D047';
+export const y7 = '#F5E35D';
+export const y8 = '#FCF088';
+export const y9 = '#FFF7C4';
+export const y10 = '#FFFBEA';
+export const y11 = '#FFFEFA';
+export const r1 = '#610316';
+export const r2 = '#8A041A';
+export const r3 = '#AB091E';
+export const r4 = '#CF1124';
+export const r5 = '#E12D39';
+export const r6 = '#EF4E4E';
+export const r7 = '#F86A6A';
+export const r8 = '#FF9B9B';
+export const r9 = '#FFBDBD';
+export const r10 = '#FFE3E3';
+export const r11 = '#FFF1F1';
+export const b1 = '#034388';
+export const b2 = '#0B5FA3';
+export const b3 = '#1271BF';
+export const b4 = '#1980D4';
+export const b5 = '#2B8FED';
+export const b6 = '#40A5F7';
+export const b7 = '#66B5FA';
+export const b8 = '#8BCAFD';
+export const b9 = '#B3D9FF';
+export const b10 = '#E3F0FF';
+export const b11 = '#F5FCFF';
+export const n1 = '#102A43';
+export const n2 = '#243B53';
+export const n3 = '#334E68';
+export const n4 = '#486581';
+export const n5 = '#627D98';
+export const n6 = '#829AB1';
+export const n7 = '#9FB3C8';
+export const n8 = '#BCCCDC';
+export const n9 = '#D9E2EC';
+export const n10 = '#E8ECF0';
+export const n11 = '#F7FAFC';
+export const g1 = '#014D40';
+export const g2 = '#0C6B58';
+export const g3 = '#147D64';
+export const g4 = '#199473';
+export const g5 = '#27AB83';
+export const g6 = '#3EBD93';
+export const g7 = '#65D6AD';
+export const g8 = '#8EEDC7';
+export const g9 = '#C6F7E2';
+export const g10 = '#EFFCF6';
+export const g11 = '#FAFFFD';
+export const p1 = '#44056E';
+export const p2 = '#580A94';
+export const p3 = '#690CB0';
+export const p4 = '#7A0ECC';
+export const p5 = '#8719E0';
+export const p6 = '#9446ED';
+export const p7 = '#A368FC';
+export const p8 = '#B990FF';
+export const p9 = '#DAC4FF';
+export const p10 = '#F2EBFE';
+export const p11 = '#F9F6FE';
+
+export const border = n10;
+export const hover = '#fafafa';
+export const selected = b9;
diff --git a/packages/desktop-client/src/style/index.ts b/packages/desktop-client/src/style/index.ts
new file mode 100644
index 000000000..fb5f2ea25
--- /dev/null
+++ b/packages/desktop-client/src/style/index.ts
@@ -0,0 +1,3 @@
+export * as colors from './colors';
+export * from './styles';
+export * from './theme';
diff --git a/packages/desktop-client/src/style/palette.ts b/packages/desktop-client/src/style/palette.ts
new file mode 100644
index 000000000..9ab65750b
--- /dev/null
+++ b/packages/desktop-client/src/style/palette.ts
@@ -0,0 +1,71 @@
+import * as oldColors from './colors';
+
+// Only for use in contextual color definitions
+export const gray50 = oldColors.n11;
+export const gray100 = oldColors.n10;
+export const gray150 = oldColors.n9;
+export const gray200 = oldColors.n8;
+export const gray300 = oldColors.n7;
+export const gray400 = oldColors.n6;
+export const gray500 = oldColors.n5;
+export const gray600 = oldColors.n4;
+export const gray700 = oldColors.n3;
+export const gray800 = oldColors.n2;
+export const gray900 = oldColors.n1;
+export const navy50 = oldColors.b11;
+export const navy100 = oldColors.b10;
+export const navy150 = oldColors.b9;
+export const navy200 = oldColors.b8;
+export const navy300 = oldColors.b7;
+export const navy400 = oldColors.b6;
+export const navy500 = oldColors.b5;
+export const navy600 = oldColors.b4;
+export const navy700 = oldColors.b3;
+export const navy800 = oldColors.b2;
+export const navy900 = oldColors.b1;
+export const green50 = oldColors.g11;
+export const green100 = oldColors.g10;
+export const green150 = oldColors.g9;
+export const green200 = oldColors.g8;
+export const green300 = oldColors.g7;
+export const green400 = oldColors.g6;
+export const green500 = oldColors.g5;
+export const green600 = oldColors.g4;
+export const green700 = oldColors.g3;
+export const green800 = oldColors.g2;
+export const green900 = oldColors.g1;
+export const orange50 = oldColors.y11;
+export const orange100 = oldColors.y10;
+export const orange150 = oldColors.y9;
+export const orange200 = oldColors.y8;
+export const orange300 = oldColors.y7;
+export const orange400 = oldColors.y6;
+export const orange500 = oldColors.y5;
+export const orange600 = oldColors.y4;
+export const orange700 = oldColors.y3;
+export const orange800 = oldColors.y2;
+export const orange900 = oldColors.y1;
+export const red50 = oldColors.r11;
+export const red100 = oldColors.r10;
+export const red150 = oldColors.r9;
+export const red200 = oldColors.r8;
+export const red300 = oldColors.r7;
+export const red400 = oldColors.r6;
+export const red500 = oldColors.r5;
+export const red600 = oldColors.r4;
+export const red700 = oldColors.r3;
+export const red800 = oldColors.r2;
+export const red900 = oldColors.r1;
+export const purple50 = oldColors.p11;
+export const purple100 = oldColors.p10;
+export const purple150 = oldColors.p9;
+export const purple200 = oldColors.p8;
+export const purple300 = oldColors.p7;
+export const purple400 = oldColors.p6;
+export const purple500 = oldColors.p5;
+export const purple600 = oldColors.p4;
+export const purple700 = oldColors.p3;
+export const purple800 = oldColors.p2;
+export const purple900 = oldColors.p1;
+export const white = '#ffffff';
+export const black = '#000000';
diff --git a/packages/desktop-client/src/style.tsx b/packages/desktop-client/src/style/styles.ts
similarity index 74%
rename from packages/desktop-client/src/style.tsx
rename to packages/desktop-client/src/style/styles.ts
index 49a7d05a5..a90283339 100644
--- a/packages/desktop-client/src/style.tsx
+++ b/packages/desktop-client/src/style/styles.ts
@@ -2,84 +2,9 @@ import { keyframes } from 'glamor';
 
 import * as Platform from 'loot-core/src/client/platform';
 
-import tokens from './tokens';
+import tokens from '../tokens';
 
-export const colors = {
-  y1: '#733309',
-  y2: '#87540d',
-  y3: '#B88115',
-  y4: '#D4A31C',
-  y5: '#E6BB20',
-  y6: '#F2D047',
-  y7: '#F5E35D',
-  y8: '#FCF088',
-  y9: '#FFF7C4',
-  y10: '#FFFBEA',
-  y11: '#FFFEFA',
-  r1: '#610316',
-  r2: '#8A041A',
-  r3: '#AB091E',
-  r4: '#CF1124',
-  r5: '#E12D39',
-  r6: '#EF4E4E',
-  r7: '#F86A6A',
-  r8: '#FF9B9B',
-  r9: '#FFBDBD',
-  r10: '#FFE3E3',
-  r11: '#FFF1F1',
-  b1: '#034388',
-  b2: '#0B5FA3',
-  b3: '#1271BF',
-  b4: '#1980D4',
-  b5: '#2B8FED',
-  b6: '#40A5F7',
-  b7: '#66B5FA',
-  b8: '#8BCAFD',
-  b9: '#B3D9FF',
-  b10: '#E3F0FF',
-  b11: '#F5FCFF',
-  n1: '#102A43',
-  n2: '#243B53',
-  n3: '#334E68',
-  n4: '#486581',
-  n5: '#627D98',
-  n6: '#829AB1',
-  n7: '#9FB3C8',
-  n8: '#BCCCDC',
-  n9: '#D9E2EC',
-  n10: '#E8ECF0',
-  n11: '#F7FAFC',
-  g1: '#014D40',
-  g2: '#0C6B58',
-  g3: '#147D64',
-  g4: '#199473',
-  g5: '#27AB83',
-  g6: '#3EBD93',
-  g7: '#65D6AD',
-  g8: '#8EEDC7',
-  g9: '#C6F7E2',
-  g10: '#EFFCF6',
-  g11: '#FAFFFD',
-  p1: '#44056E',
-  p2: '#580A94',
-  p3: '#690CB0',
-  p4: '#7A0ECC',
-  p5: '#8719E0',
-  p6: '#9446ED',
-  p7: '#A368FC',
-  p8: '#B990FF',
-  p9: '#DAC4FF',
-  p10: '#F2EBFE',
-  p11: '#F9F6FE',
-
-  get border() {
-    return this.n10;
-  },
-  hover: '#fafafa',
-  get selected() {
-    return this.b9;
-  },
-};
+import * as colors from './colors';
 
 export const styles = {
   veryLargeText: {
diff --git a/packages/desktop-client/src/style/theme.tsx b/packages/desktop-client/src/style/theme.tsx
new file mode 100644
index 000000000..e2ec92f30
--- /dev/null
+++ b/packages/desktop-client/src/style/theme.tsx
@@ -0,0 +1,33 @@
+import { useSelector } from 'react-redux';
+
+import type { Theme } from 'loot-core/src/client/state-types/prefs';
+import { isNonProductionEnvironment } from 'loot-core/src/shared/environment';
+
+import * as darkTheme from './themes/dark';
+import * as developmentTheme from './themes/development';
+import * as lightTheme from './themes/light';
+
+const themes = {
+  light: lightTheme,
+  dark: darkTheme,
+  ...(isNonProductionEnvironment() && { development: developmentTheme }),
+};
+
+export const themeNames = Object.keys(themes) as Theme[];
+
+export function useTheme() {
+  return useSelector(state => state.prefs.global?.theme) || 'light';
+}
+
+export function ThemeStyle() {
+  let theme = useTheme();
+  let themeColors = themes[theme];
+  let css = Object.keys(themeColors)
+    .map(key => `  --color-${key}: ${themeColors[key]};`)
+    .join('\n');
+  return <style>{`:root {\n${css}}`}</style>;
+}
+
+export const theme = Object.fromEntries(
+  Object.keys(lightTheme).map(key => [key, `var(--color-${key})`]),
+) as Record<keyof typeof lightTheme, string>;
diff --git a/packages/desktop-client/src/style/themes/dark.ts b/packages/desktop-client/src/style/themes/dark.ts
new file mode 100644
index 000000000..dcf3ccaf0
--- /dev/null
+++ b/packages/desktop-client/src/style/themes/dark.ts
@@ -0,0 +1,115 @@
+import * as colorPalette from '../palette';
+
+export const pageBackground = colorPalette.gray900;
+export const pageBackgroundModalActive = colorPalette.gray800;
+export const pageBackgroundTopLeft = colorPalette.gray900;
+export const pageBackgroundBottomRight = colorPalette.gray700;
+export const pageBackgroundLineTop = colorPalette.purple400;
+export const pageBackgroundLineMid = colorPalette.gray900;
+export const pageBackgroundLineBottom = colorPalette.gray150;
+export const pageText = colorPalette.gray150;
+export const pageTextSubdued = colorPalette.gray500;
+export const pageTextPositive = colorPalette.purple400;
+export const pageTextLink = colorPalette.purple400;
+export const modalBackground = colorPalette.gray800;
+export const modalBorder = colorPalette.gray600;
+export const cardBackground = colorPalette.gray800;
+export const cardBorder = colorPalette.purple400;
+export const cardShadow = colorPalette.gray700;
+export const tableBackground = colorPalette.gray800;
+export const tableRowBackgroundHover = colorPalette.gray700;
+export const tableText = colorPalette.gray150;
+export const tableTextSelected = colorPalette.gray150;
+export const tableTextHover = colorPalette.gray400;
+export const tableTextEditing = colorPalette.black;
+export const tableTextEditingBackground = colorPalette.purple400;
+export const tableTextInactive = colorPalette.gray500;
+export const tableHeaderText = colorPalette.gray300;
+export const tableHeaderBackground = colorPalette.gray700;
+export const tableBorder = colorPalette.gray600;
+export const tableBorderSelected = colorPalette.purple400;
+export const tableBorderHover = colorPalette.purple300;
+export const tableBorderSeparator = colorPalette.gray400;
+export const tableRowBackgroundHighlight = colorPalette.purple800;
+export const tableRowBackgroundHighlightText = colorPalette.gray150;
+export const tableRowHeaderBackground = colorPalette.gray700;
+export const tableRowHeaderText = colorPalette.gray150;
+export const sidebarBackground = colorPalette.gray800;
+export const sidebarItemBackground = colorPalette.gray800;
+export const sidebarItemBackgroundSelected = colorPalette.gray800;
+export const sidebarItemBackgroundHover = colorPalette.gray700;
+export const sidebarItemAccent = colorPalette.gray800;
+export const sidebarItemAccentSelected = colorPalette.purple400;
+export const sidebarItemAccentHover = colorPalette.gray700;
+export const sidebarItemText = colorPalette.gray150;
+export const sidebarItemTextSelected = colorPalette.purple400;
+export const sidebarItemTextHover = colorPalette.gray150;
+export const tooltipBackground = colorPalette.gray600;
+export const tooltipBorder = colorPalette.gray500;
+export const menuBackground = colorPalette.gray600;
+export const menuItemBackground = colorPalette.gray600;
+export const menuItemBackgroundHover = colorPalette.gray500;
+export const menuItemText = colorPalette.gray100;
+export const menuItemTextHover = colorPalette.gray50;
+export const menuItemTextSelected = colorPalette.gray200;
+export const menuItemTextHeader = colorPalette.purple400;
+export const menuBorder = colorPalette.gray800;
+export const menuBorderHover = colorPalette.purple400;
+export const altMenuBackground = colorPalette.gray700;
+export const altMenuItemBackground = colorPalette.gray700;
+export const altMenuItemBackgroundHover = colorPalette.gray600;
+export const altMenuItemText = colorPalette.gray150;
+export const altMenuItemTextHover = colorPalette.gray150;
+export const altMenuItemTextSelected = colorPalette.gray150;
+export const altMenuItemTextHeader = colorPalette.purple500;
+export const altMenuBorder = colorPalette.gray200;
+export const altMenuBorderHover = colorPalette.purple400;
+export const buttonAltMenuText = colorPalette.gray150;
+export const buttonAltMenuTextHover = colorPalette.gray100;
+export const buttonAltMenuTextSelected = colorPalette.gray100;
+export const buttonAltMenuBackground = colorPalette.gray800;
+export const buttonAltMenuBackgroundHover = colorPalette.gray600;
+export const buttonAltMenuBorder = colorPalette.gray600;
+export const buttonPositiveText = colorPalette.black;
+export const buttonPositiveTextHover = colorPalette.gray150;
+export const buttonPositiveTextSelected = colorPalette.black;
+export const buttonPositiveBackground = colorPalette.purple400;
+export const buttonPositiveBackgroundHover = colorPalette.gray800;
+export const buttonPositiveBorder = colorPalette.purple400;
+export const buttonNeutralText = colorPalette.gray150;
+export const buttonNeutralTextHover = colorPalette.gray150;
+export const buttonNeutralBackground = colorPalette.gray800;
+export const buttonNeutralBackgroundHover = colorPalette.gray600;
+export const buttonNeutralBorder = colorPalette.gray300;
+export const buttonDisabledText = colorPalette.gray500;
+export const buttonDisabledBackground = colorPalette.gray800;
+export const buttonDisabledBorder = colorPalette.gray500;
+export const buttonShadow = colorPalette.gray700;
+export const noticeBackground = colorPalette.green800;
+export const noticeText = colorPalette.green300;
+export const noticeAccent = colorPalette.green500;
+export const warningBackground = colorPalette.orange800;
+export const warningText = colorPalette.orange200;
+export const warningAccent = colorPalette.orange500;
+export const errorBackground = colorPalette.red800;
+export const errorText = colorPalette.red200;
+export const errorAccent = colorPalette.red500;
+export const formLabelText = colorPalette.purple150;
+export const formInputBackground = colorPalette.gray800;
+export const formInputBackgroundSelected = colorPalette.purple400;
+export const formInputBackgroundSelection = colorPalette.purple400;
+export const formInputBorder = colorPalette.gray600;
+export const formInputTextReadOnlySelection = colorPalette.gray800;
+export const formInputBorderSelected = colorPalette.purple400;
+export const formInputText = colorPalette.gray150;
+export const formInputTextSelected = colorPalette.black;
+export const formInputTextPlaceholder = colorPalette.gray150;
+export const formInputTextSelection = colorPalette.gray800;
+export const formInputShadowSelected = colorPalette.purple400;
+export const formInputTextHighlight = colorPalette.purple400;
+export const pillBackground = colorPalette.gray600;
+export const pillText = colorPalette.gray200;
+export const pillBorder = colorPalette.gray700;
+export const pillBackgroundSelected = colorPalette.purple600;
+export const pillTextSelected = colorPalette.gray150;
+export const pillBorderSelected = colorPalette.purple400;
diff --git a/packages/desktop-client/src/style/themes/development.ts b/packages/desktop-client/src/style/themes/development.ts
new file mode 100644
index 000000000..fcfff3e3f
--- /dev/null
+++ b/packages/desktop-client/src/style/themes/development.ts
@@ -0,0 +1,115 @@
+import * as colorPalette from '../palette';
+
+export const pageBackground = colorPalette.navy600;
+export const pageBackgroundModalActive = colorPalette.navy700;
+export const pageBackgroundTopLeft = colorPalette.green300;
+export const pageBackgroundBottomRight = colorPalette.red600;
+export const pageBackgroundLineTop = colorPalette.gray50;
+export const pageBackgroundLineMid = colorPalette.green500;
+export const pageBackgroundLineBottom = colorPalette.orange200;
+export const pageText = colorPalette.navy300;
+export const pageTextSubdued = colorPalette.navy500;
+export const pageTextPositive = colorPalette.navy50;
+export const pageTextLink = colorPalette.navy400;
+export const modalBackground = colorPalette.gray900;
+export const modalBorder = colorPalette.gray200;
+export const cardBackground = colorPalette.purple700;
+export const cardBorder = colorPalette.purple400;
+export const cardShadow = colorPalette.purple100;
+export const tableBackground = colorPalette.red900;
+export const tableRowBackgroundHover = colorPalette.red800;
+export const tableText = colorPalette.red200;
+export const tableTextSelected = colorPalette.red150;
+export const tableTextHover = colorPalette.red400;
+export const tableTextEditing = colorPalette.black;
+export const tableTextEditingBackground = colorPalette.red200;
+export const tableTextInactive = colorPalette.red500;
+export const tableHeaderText = colorPalette.red700;
+export const tableHeaderBackground = colorPalette.red300;
+export const tableBorder = colorPalette.red200;
+export const tableBorderSelected = colorPalette.purple400;
+export const tableBorderHover = colorPalette.purple300;
+export const tableBorderSeparator = colorPalette.gray400;
+export const tableRowBackgroundHighlight = colorPalette.red700;
+export const tableRowBackgroundHighlightText = colorPalette.red200;
+export const tableRowHeaderBackground = colorPalette.red100;
+export const tableRowHeaderText = colorPalette.red700;
+export const sidebarBackground = colorPalette.orange800;
+export const sidebarItemBackground = colorPalette.orange700;
+export const sidebarItemBackgroundSelected = colorPalette.orange900;
+export const sidebarItemBackgroundHover = colorPalette.orange500;
+export const sidebarItemAccent = colorPalette.orange200;
+export const sidebarItemAccentSelected = colorPalette.orange400;
+export const sidebarItemAccentHover = colorPalette.orange200;
+export const sidebarItemText = colorPalette.orange200;
+export const sidebarItemTextSelected = colorPalette.orange400;
+export const sidebarItemTextHover = colorPalette.orange150;
+export const tooltipBackground = colorPalette.white;
+export const tooltipBorder = colorPalette.black;
+export const menuBackground = colorPalette.green800;
+export const menuItemBackground = colorPalette.green700;
+export const menuItemBackgroundHover = colorPalette.green500;
+export const menuItemText = colorPalette.green200;
+export const menuItemTextHover = colorPalette.green50;
+export const menuItemTextSelected = colorPalette.green500;
+export const menuItemTextHeader = colorPalette.green300;
+export const menuBorder = colorPalette.green500;
+export const menuBorderHover = colorPalette.green900;
+export const altMenuBackground = colorPalette.gray700;
+export const altMenuItemBackground = colorPalette.gray700;
+export const altMenuItemBackgroundHover = colorPalette.gray600;
+export const altMenuItemText = colorPalette.gray150;
+export const altMenuItemTextHover = colorPalette.gray150;
+export const altMenuItemTextSelected = colorPalette.gray150;
+export const altMenuItemTextHeader = colorPalette.purple500;
+export const altMenuBorder = colorPalette.gray200;
+export const altMenuBorderHover = colorPalette.purple400;
+export const buttonAltMenuText = colorPalette.gray150;
+export const buttonAltMenuTextHover = colorPalette.gray100;
+export const buttonAltMenuTextSelected = colorPalette.gray100;
+export const buttonAltMenuBackground = colorPalette.gray800;
+export const buttonAltMenuBackgroundHover = colorPalette.gray600;
+export const buttonAltMenuBorder = colorPalette.gray600;
+export const buttonPositiveText = colorPalette.purple200;
+export const buttonPositiveTextHover = colorPalette.purple50;
+export const buttonPositiveTextSelected = colorPalette.purple600;
+export const buttonPositiveBackground = colorPalette.purple400;
+export const buttonPositiveBackgroundHover = colorPalette.purple800;
+export const buttonPositiveBorder = colorPalette.purple700;
+export const buttonNeutralText = colorPalette.gray50;
+export const buttonNeutralTextHover = colorPalette.gray200;
+export const buttonNeutralBackground = colorPalette.gray400;
+export const buttonNeutralBackgroundHover = colorPalette.gray500;
+export const buttonNeutralBorder = colorPalette.gray800;
+export const buttonDisabledText = colorPalette.gray500;
+export const buttonDisabledBackground = colorPalette.gray800;
+export const buttonDisabledBorder = colorPalette.gray500;
+export const buttonShadow = colorPalette.gray700;
+export const noticeBackground = colorPalette.green800;
+export const noticeText = colorPalette.green300;
+export const noticeAccent = colorPalette.green500;
+export const warningBackground = colorPalette.orange800;
+export const warningText = colorPalette.orange200;
+export const warningAccent = colorPalette.orange500;
+export const errorBackground = colorPalette.red800;
+export const errorText = colorPalette.red200;
+export const errorAccent = colorPalette.red500;
+export const formLabelText = colorPalette.purple200;
+export const formInputBackground = colorPalette.purple700;
+export const formInputBackgroundSelected = colorPalette.purple400;
+export const formInputBackgroundSelection = colorPalette.purple400;
+export const formInputBorder = colorPalette.purple600;
+export const formInputTextReadOnlySelection = colorPalette.purple800;
+export const formInputBorderSelected = colorPalette.purple100;
+export const formInputText = colorPalette.purple150;
+export const formInputTextSelected = colorPalette.purple800;
+export const formInputTextPlaceholder = colorPalette.gray150;
+export const formInputTextSelection = colorPalette.gray800;
+export const formInputShadowSelected = colorPalette.purple400;
+export const formInputTextHighlight = colorPalette.purple400;
+export const pillBackground = colorPalette.green800;
+export const pillText = colorPalette.green600;
+export const pillBorder = colorPalette.green200;
+export const pillBackgroundSelected = colorPalette.green100;
+export const pillTextSelected = colorPalette.green700;
+export const pillBorderSelected = colorPalette.green900;
diff --git a/packages/desktop-client/src/style/themes/light.ts b/packages/desktop-client/src/style/themes/light.ts
new file mode 100644
index 000000000..79e11a362
--- /dev/null
+++ b/packages/desktop-client/src/style/themes/light.ts
@@ -0,0 +1,115 @@
+import * as colorPalette from '../palette';
+
+export const pageBackground = colorPalette.gray100;
+export const pageBackgroundModalActive = colorPalette.gray200;
+export const pageBackgroundTopLeft = colorPalette.gray100;
+export const pageBackgroundBottomRight = colorPalette.navy100;
+export const pageBackgroundLineTop = colorPalette.white;
+export const pageBackgroundLineMid = colorPalette.gray100;
+export const pageBackgroundLineBottom = colorPalette.navy150;
+export const pageText = colorPalette.gray700;
+export const pageTextSubdued = colorPalette.gray300;
+export const pageTextPositive = colorPalette.purple500;
+export const pageTextLink = colorPalette.navy600;
+export const modalBackground = colorPalette.white;
+export const modalBorder = colorPalette.white;
+export const cardBackground = colorPalette.white;
+export const cardBorder = colorPalette.purple500;
+export const cardShadow = colorPalette.gray700;
+export const tableBackground = colorPalette.white;
+export const tableRowBackgroundHover = colorPalette.navy100;
+export const tableText = colorPalette.gray700;
+export const tableTextSelected = colorPalette.gray700;
+export const tableTextHover = colorPalette.gray900;
+export const tableTextEditing = colorPalette.gray50;
+export const tableTextEditingBackground = colorPalette.purple500;
+export const tableTextInactive = colorPalette.gray300;
+export const tableHeaderText = colorPalette.gray500;
+export const tableHeaderBackground = colorPalette.gray50;
+export const tableBorder = colorPalette.gray150;
+export const tableBorderSelected = colorPalette.purple500;
+export const tableBorderHover = colorPalette.purple400;
+export const tableBorderSeparator = colorPalette.gray400;
+export const tableRowBackgroundHighlight = colorPalette.purple100;
+export const tableRowBackgroundHighlightText = colorPalette.gray700;
+export const tableRowHeaderBackground = colorPalette.gray50;
+export const tableRowHeaderText = colorPalette.gray800;
+export const sidebarBackground = colorPalette.navy800;
+export const sidebarItemBackground = colorPalette.navy800;
+export const sidebarItemBackgroundSelected = colorPalette.navy800;
+export const sidebarItemBackgroundHover = colorPalette.navy700;
+export const sidebarItemAccent = colorPalette.navy800;
+export const sidebarItemAccentSelected = colorPalette.purple300;
+export const sidebarItemAccentHover = colorPalette.navy700;
+export const sidebarItemText = colorPalette.gray100;
+export const sidebarItemTextSelected = colorPalette.purple300;
+export const sidebarItemTextHover = colorPalette.gray50;
+export const tooltipBackground = colorPalette.gray50;
+export const tooltipBorder = colorPalette.gray50;
+export const menuBackground = colorPalette.gray50;
+export const menuItemBackground = colorPalette.gray50;
+export const menuItemBackgroundHover = colorPalette.gray150;
+export const menuItemText = colorPalette.gray800;
+export const menuItemTextHover = colorPalette.gray800;
+export const menuItemTextSelected = colorPalette.gray800;
+export const menuItemTextHeader = colorPalette.purple600;
+export const menuBorder = colorPalette.gray100;
+export const menuBorderHover = colorPalette.purple100;
+export const altMenuBackground = colorPalette.navy800;
+export const altMenuItemBackground = colorPalette.navy800;
+export const altMenuItemBackgroundHover = colorPalette.navy700;
+export const altMenuItemText = colorPalette.gray100;
+export const altMenuItemTextHover = colorPalette.gray50;
+export const altMenuItemTextSelected = colorPalette.purple300;
+export const altMenuItemTextHeader = colorPalette.purple300;
+export const altMenuBorder = colorPalette.navy700;
+export const altMenuBorderHover = colorPalette.purple300;
+export const buttonAltMenuText = colorPalette.gray100;
+export const buttonAltMenuTextHover = colorPalette.gray50;
+export const buttonAltMenuTextSelected = colorPalette.gray50;
+export const buttonAltMenuBackground = colorPalette.navy800;
+export const buttonAltMenuBackgroundHover = colorPalette.navy700;
+export const buttonAltMenuBorder = colorPalette.gray200;
+export const buttonPositiveText = colorPalette.gray50;
+export const buttonPositiveTextHover = colorPalette.purple600;
+export const buttonPositiveTextSelected = colorPalette.gray50;
+export const buttonPositiveBackground = colorPalette.purple600;
+export const buttonPositiveBackgroundHover = colorPalette.gray50;
+export const buttonPositiveBorder = colorPalette.purple600;
+export const buttonNeutralText = colorPalette.gray700;
+export const buttonNeutralTextHover = colorPalette.gray800;
+export const buttonNeutralBackground = colorPalette.gray50;
+export const buttonNeutralBackgroundHover = colorPalette.gray100;
+export const buttonNeutralBorder = colorPalette.gray200;
+export const buttonDisabledText = colorPalette.gray300;
+export const buttonDisabledBackground = colorPalette.gray50;
+export const buttonDisabledBorder = colorPalette.gray300;
+export const buttonShadow = colorPalette.purple500;
+export const noticeBackground = colorPalette.green50;
+export const noticeText = colorPalette.green500;
+export const noticeAccent = colorPalette.green200;
+export const warningBackground = colorPalette.orange50;
+export const warningText = colorPalette.orange500;
+export const warningAccent = colorPalette.orange200;
+export const errorBackground = colorPalette.red50;
+export const errorText = colorPalette.red500;
+export const errorAccent = colorPalette.red200;
+export const formLabelText = colorPalette.navy500;
+export const formInputBackground = colorPalette.gray50;
+export const formInputBackgroundSelected = colorPalette.purple500;
+export const formInputBackgroundSelection = colorPalette.purple500;
+export const formInputBorder = colorPalette.gray300;
+export const formInputTextReadOnlySelection = colorPalette.gray50;
+export const formInputBorderSelected = colorPalette.purple500;
+export const formInputText = colorPalette.gray700;
+export const formInputTextSelected = colorPalette.gray50;
+export const formInputTextPlaceholder = colorPalette.gray300;
+export const formInputTextSelection = colorPalette.gray100;
+export const formInputShadowSelected = colorPalette.purple500;
+export const formInputTextHighlight = colorPalette.purple500;
+export const pillBackground = colorPalette.gray150;
+export const pillText = colorPalette.gray800;
+export const pillBorder = colorPalette.gray150;
+export const pillBackgroundSelected = colorPalette.purple150;
+export const pillTextSelected = colorPalette.gray700;
+export const pillBorderSelected = colorPalette.purple500;
diff --git a/packages/loot-core/src/client/state-types/prefs.d.ts b/packages/loot-core/src/client/state-types/prefs.d.ts
index 2bd95b03d..644b5f84f 100644
--- a/packages/loot-core/src/client/state-types/prefs.d.ts
+++ b/packages/loot-core/src/client/state-types/prefs.d.ts
@@ -4,7 +4,8 @@ import type * as constants from '../constants';
 export type FeatureFlag =
   | 'reportBudget'
   | 'goalTemplatesEnabled'
-  | 'privacyMode';
+  | 'privacyMode'
+  | 'themes';
 
 type NullableValues<T> = { [K in keyof T]: T[K] | null };
 
@@ -45,9 +46,11 @@ export type LocalPrefs = NullableValues<
   } & Record<`flags.${FeatureFlag}`, boolean>
 >;
 
+export type Theme = 'light' | 'dark' | 'development';
 export type GlobalPrefs = NullableValues<{
   floatingSidebar: boolean;
   maxMonths: number;
+  theme: Theme;
   documentDir: string; // Electron only
 }>;
 
diff --git a/packages/loot-core/src/server/main.ts b/packages/loot-core/src/server/main.ts
index 00fee7dae..26966dde2 100644
--- a/packages/loot-core/src/server/main.ts
+++ b/packages/loot-core/src/server/main.ts
@@ -1472,6 +1472,9 @@ handlers['save-global-prefs'] = async function (prefs) {
   if ('floatingSidebar' in prefs) {
     await asyncStorage.setItem('floating-sidebar', '' + prefs.floatingSidebar);
   }
+  if ('theme' in prefs) {
+    await asyncStorage.setItem('theme', prefs.theme);
+  }
   return 'ok';
 };
 
@@ -1482,12 +1485,14 @@ handlers['load-global-prefs'] = async function () {
     [, autoUpdate],
     [, documentDir],
     [, encryptKey],
+    [, theme],
   ] = await asyncStorage.multiGet([
     'floating-sidebar',
     'max-months',
     'auto-update',
     'document-dir',
     'encrypt-key',
+    'theme',
   ]);
   return {
     floatingSidebar: floatingSidebar === 'true' ? true : false,
@@ -1495,6 +1500,10 @@ handlers['load-global-prefs'] = async function () {
     autoUpdate: autoUpdate == null || autoUpdate === 'true' ? true : false,
     documentDir: documentDir || getDefaultDocumentDir(),
     keyId: encryptKey && JSON.parse(encryptKey).id,
+    theme:
+      theme === 'light' || theme === 'dark' || theme === 'development'
+        ? theme
+        : 'light',
   };
 };
 
diff --git a/upcoming-release-notes/1367.md b/upcoming-release-notes/1367.md
new file mode 100644
index 000000000..24454c31d
--- /dev/null
+++ b/upcoming-release-notes/1367.md
@@ -0,0 +1,6 @@
+---
+category: Features
+authors: [biohzrddd, j-f1]
+---
+
+Add an initial feature flag and infrastructure for building out dark and custom themes.
-- 
GitLab