diff --git a/NOTICE b/NOTICE
new file mode 100644
index 0000000..21d4d0b
--- /dev/null
+++ b/NOTICE
@@ -0,0 +1,109 @@
+# Third-party notices
+
+INBUXA Admin is licensed under the AGPL-3.0; see LICENSES/. This file records
+work by other people that ships inside it and the terms it comes under.
+
+## Color palettes
+
+Ten of the palettes offered under the user menu › Theme are the work of their own
+projects and are used under the MIT license. Only the published color values
+are used — no code, and nothing from anyone else's reimplementation of them.
+The values as fetched from each project are recorded in ihasmail's repository,
+in `.palette-sources/palettes-upstream.md`, and the shades between them are
+derived by ihasmail's `scripts/build-palettes.py`, which also lifts any tier that
+would not meet the contrast ihasmail claims. INBUXA Admin takes ihasmail's
+derived colors as they are (`scripts/import-palettes.py`).
+
+### Dracula and Alucard
+
+Copyright (c) 2016 Dracula Theme — https://github.com/dracula/dracula-theme
+Licensed under the MIT license. "Dracula" is the dark variant and "Alucard" the
+light one; both are published in that repository's own "Color Palette (OSS)"
+section.
+
+### Gruvbox
+
+Copyright (c) 2018 Pavel Pertsev — https://github.com/morhetz/gruvbox
+Licensed under the MIT license.
+
+### Rosé Pine
+
+Copyright (c) 2021 Rosé Pine — https://github.com/rose-pine/rose-pine-theme
+Licensed under the MIT license. The light variant is "Dawn".
+
+### Tokyo Night
+
+Copyright (c) 2019 enkia — https://github.com/enkia/tokyo-night-vscode-theme
+Licensed under the MIT license. The light variant is "Day".
+
+### Catppuccin
+
+Copyright (c) 2021 Catppuccin — https://github.com/catppuccin/palette
+Licensed under the MIT license. "Mocha" is the dark variant and "Latte" the
+light one; both are published in that repository's palette.json.
+
+### Solarized
+
+Copyright (c) 2011 Ethan Schoonover — https://github.com/altercation/solarized
+Licensed under the MIT license. Light and dark are both original to it, and
+share one set of accent values by design.
+
+### Ayu
+
+Copyright (c) Konstantin Pschera — https://github.com/ayu-theme/ayu-colors
+Licensed under the MIT license. The two signature accent colors come from the
+same author's ayu-theme/vscode-ayu, also MIT.
+
+### Kanagawa
+
+Copyright (c) 2021 Tommaso Laurenzi — https://github.com/rebelot/kanagawa.nvim
+Licensed under the MIT license. "Wave" is the dark variant and "Lotus" the
+light one. The theme takes its name from Hokusai's print.
+
+### Everforest
+
+Copyright (c) 2019 Sainnhe Park — https://github.com/sainnhe/everforest
+Licensed under the MIT license. The medium-contrast variant of each mode is
+the one used here.
+
+### Primer
+
+Copyright (c) GitHub, Inc. — https://github.com/primer/primitives
+Licensed under the MIT license, which covers the color values. "GitHub" and
+the Invertocat logo are trademarks of GitHub, Inc.; this palette is named
+"Primer" after the design system and is neither affiliated with nor endorsed
+by GitHub.
+---
+
+The MIT license, under which all ten are used:
+
+ Permission is hereby granted, free of charge, to any person obtaining a
+ copy of this software and associated documentation files (the "Software"),
+ to deal in the Software without restriction, including without limitation
+ the rights to use, copy, modify, merge, publish, distribute, sublicense,
+ and/or sell copies of the Software, and to permit persons to whom the
+ Software is furnished to do so, subject to the following conditions:
+
+ The above copyright notice and this permission notice shall be included in
+ all copies or substantial portions of the Software.
+
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
+ FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
+ DEALINGS IN THE SOFTWARE.
+
+---
+
+## Fonts
+
+The interface's typefaces are bundled with it, from the Fontsource packages,
+under the SIL Open Font License 1.1. The full license text ships with each
+package (`LICENSE` in `@fontsource-variable/inter` and
+`@fontsource-variable/space-grotesk`), and it applies to the font files in the
+built app.
+
+- **Inter**: Copyright (c) 2016 The Inter Project Authors (https://github.com/rsms/inter).
+- **Space Grotesk**: Copyright (c) 2020 The Space Grotesk Project Authors (https://github.com/floriankarsten/space-grotesk).
diff --git a/src/components/layout/TopBar.tsx b/src/components/layout/TopBar.tsx
index 5db0c4c..0b64c31 100644
--- a/src/components/layout/TopBar.tsx
+++ b/src/components/layout/TopBar.tsx
@@ -8,7 +8,7 @@
import { Link, useNavigate } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import * as LucideIcons from 'lucide-react';
-const { Sun, Moon, User, LogOut, Check, Menu, Search, FileCode } = LucideIcons;
+const { Sun, Moon, User, LogOut, Check, Menu, Search, FileCode, Palette } = LucideIcons;
import { Button } from '@/components/ui/button';
import { CommandPalette } from '@/components/common/CommandPalette';
import {
@@ -17,9 +17,15 @@ import {
DropdownMenuGroup,
DropdownMenuItem,
DropdownMenuLabel,
+ DropdownMenuRadioGroup,
+ DropdownMenuRadioItem,
DropdownMenuSeparator,
+ DropdownMenuSub,
+ DropdownMenuSubContent,
+ DropdownMenuSubTrigger,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
+import { isPaletteId, PALETTES } from '@/lib/palettes';
import Logo from '@/components/common/Logo';
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
import { EnterpriseUpsell } from '@/components/common/EnterpriseUpsell';
@@ -49,6 +55,8 @@ export function TopBar() {
const navigate = useNavigate();
const theme = useUIStore((s) => s.theme);
const toggleTheme = useUIStore((s) => s.toggleTheme);
+ const palette = useUIStore((s) => s.palette);
+ const setPalette = useUIStore((s) => s.setPalette);
const toggleSidebar = useUIStore((s) => s.toggleSidebar);
const setActiveSection = useUIStore((s) => s.setActiveSection);
const activeSection = useUIStore((s) => s.activeSection);
@@ -216,6 +224,31 @@ export function TopBar() {
>
)}
+ {/* INBUXA: the same palettes as INBUXA webmail. */}
+
+
+
+ {t('theme.menu', 'Theme')}
+
+
+ isPaletteId(v) && setPalette(v)}>
+ {PALETTES.map((p) => (
+
+
+
+ {p.id === 'default' ? t('theme.classic', 'Classic') : p.name}
+
+
+ ))}
+
+
+
+
+
diff --git a/src/i18n/en.json b/src/i18n/en.json
index be197d3..dda7fcf 100644
--- a/src/i18n/en.json
+++ b/src/i18n/en.json
@@ -391,5 +391,9 @@
"evening": "Good evening",
"subtitle": "Here's how your mail server is doing.",
"signedInAs": "Signed in as {{username}}"
+ },
+ "theme": {
+ "menu": "Theme",
+ "classic": "Classic"
}
}
diff --git a/src/index.css b/src/index.css
index a7945b4..e1923df 100644
--- a/src/index.css
+++ b/src/index.css
@@ -6,6 +6,7 @@
*/
@import 'tailwindcss';
+@import './palettes.css';
:root {
/* INBUXA, light: warm paper, the mark's navy for text, its teal to act. */
diff --git a/src/lib/palettes.ts b/src/lib/palettes.ts
new file mode 100644
index 0000000..227043e
--- /dev/null
+++ b/src/lib/palettes.ts
@@ -0,0 +1,52 @@
+/*
+ * SPDX-FileCopyrightText: 2026 Coffey Labs
+ *
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+
+/**
+ * The themes the admin offers: the same palettes as INBUXA webmail, in the
+ * same order and under the same names. "ihasmail" is the default, the look in
+ * index.css. The others' colors are in palettes.css, generated from ihasmail's
+ * by scripts/import-palettes.py.
+ */
+export type PaletteId =
+ | 'default'
+ | 'ihasmail'
+ | 'dracula'
+ | 'gruvbox'
+ | 'rose-pine'
+ | 'tokyo-night'
+ | 'catppuccin'
+ | 'solarized'
+ | 'ayu'
+ | 'kanagawa'
+ | 'everforest'
+ | 'primer';
+
+export const DEFAULT_PALETTE: PaletteId = 'ihasmail';
+
+/** `swatch` is the palette's accent, light and dark, for the menu's color dot. */
+export const PALETTES: { id: PaletteId; name: string; swatch: [string, string] }[] = [
+ { id: 'default', name: 'Classic', swatch: ['#0f766e', '#2dd4bf'] },
+ { id: 'ihasmail', name: 'ihasmail', swatch: ['#0d8a82', '#46cac3'] },
+ { id: 'dracula', name: 'Dracula', swatch: ['#644ac9', '#bd93f9'] },
+ { id: 'gruvbox', name: 'Gruvbox', swatch: ['#076678', '#83a598'] },
+ { id: 'rose-pine', name: 'Rosé Pine', swatch: ['#907aa9', '#c4a7e7'] },
+ { id: 'tokyo-night', name: 'Tokyo Night', swatch: ['#2959aa', '#7aa2f7'] },
+ { id: 'catppuccin', name: 'Catppuccin', swatch: ['#8839ef', '#cba6f7'] },
+ { id: 'solarized', name: 'Solarized', swatch: ['#268bd2', '#268bd2'] },
+ { id: 'ayu', name: 'Ayu', swatch: ['#cb7f14', '#e6b450'] },
+ { id: 'kanagawa', name: 'Kanagawa', swatch: ['#624c83', '#7e9cd8'] },
+ { id: 'everforest', name: 'Everforest', swatch: ['#7d8f01', '#a7c080'] },
+ { id: 'primer', name: 'Primer', swatch: ['#0969da', '#58a6ff'] },
+];
+
+export function isPaletteId(v: unknown): v is PaletteId {
+ return PALETTES.some((p) => p.id === v);
+}
+
+export function applyPalette(id: PaletteId) {
+ if (id === DEFAULT_PALETTE) delete document.documentElement.dataset.palette;
+ else document.documentElement.dataset.palette = id;
+}
diff --git a/src/main.tsx b/src/main.tsx
index 2aa61c1..2bd9811 100644
--- a/src/main.tsx
+++ b/src/main.tsx
@@ -27,6 +27,11 @@ import { loadLogoOnce } from './lib/logoCache';
if (persisted) {
const parsed = JSON.parse(persisted);
const theme = parsed?.state?.theme;
+ // INBUXA: the palette too, before the first paint, so it doesn't flash.
+ const palette = parsed?.state?.palette;
+ if (typeof palette === 'string' && palette !== 'ihasmail' && /^[a-z-]+$/.test(palette)) {
+ document.documentElement.dataset.palette = palette;
+ }
if (theme === 'dark' || theme === 'light') {
document.documentElement.classList.toggle('dark', theme === 'dark');
return;
diff --git a/src/palettes.css b/src/palettes.css
new file mode 100644
index 0000000..a59b1b9
--- /dev/null
+++ b/src/palettes.css
@@ -0,0 +1,672 @@
+/*
+ * SPDX-FileCopyrightText: 2026 Coffey Labs
+ *
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+
+/*
+ * Generated by scripts/import-palettes.py from ihasmail's palettes. Don't
+ * edit by hand. The palettes' own credits are in NOTICE.
+ *
+ * No data-palette attribute is the default, "ihasmail": the tokens in index.css.
+ */
+
+:root[data-palette="default"] {
+ --background: #f6f8fa;
+ --foreground: #111827;
+ --card: #ffffff;
+ --card-foreground: #111827;
+ --popover: #ffffff;
+ --popover-foreground: #111827;
+ --content-background: #eef1f4;
+ --primary: #0f766e;
+ --primary-foreground: #ffffff;
+ --secondary: #eef1f4;
+ --secondary-foreground: #111827;
+ --muted: #eef1f4;
+ --muted-foreground: #5b6472;
+ --accent: #d9f1ee;
+ --accent-foreground: #0b5750;
+ --highlight: #b45309;
+ --highlight-soft: #fef3c7;
+ --destructive: #dc2626;
+ --border: #e3e7ec;
+ --input: #cbd2da;
+ --ring: #0f766e;
+ --chart-1: #0f766e;
+ --chart-2: #b45309;
+ --chart-3: #2563eb;
+ --chart-4: #16a34a;
+ --chart-5: #9333ea;
+ --destructive-foreground: #ffffff;
+}
+
+.dark[data-palette="default"] {
+ --background: #0b1220;
+ --foreground: #e5e9f0;
+ --card: #111a2b;
+ --card-foreground: #e5e9f0;
+ --popover: #111a2b;
+ --popover-foreground: #e5e9f0;
+ --content-background: #0d1524;
+ --primary: #2dd4bf;
+ --primary-foreground: #052e2b;
+ --secondary: #0d1524;
+ --secondary-foreground: #e5e9f0;
+ --muted: #0d1524;
+ --muted-foreground: #9aa5b8;
+ --accent: rgba(45, 212, 191, 0.16);
+ --accent-foreground: #99f6e4;
+ --highlight: #fbbf24;
+ --highlight-soft: rgba(251, 191, 36, 0.15);
+ --destructive: #f87171;
+ --border: #1f2a3d;
+ --input: #31405a;
+ --ring: #2dd4bf;
+ --chart-1: #2dd4bf;
+ --chart-2: #fbbf24;
+ --chart-3: #60a5fa;
+ --chart-4: #4ade80;
+ --chart-5: #c084fc;
+ --destructive-foreground: #0b1720;
+}
+
+:root[data-palette="dracula"] {
+ --background: #fffbeb;
+ --foreground: #1f1f1f;
+ --card: #ffffff;
+ --card-foreground: #1f1f1f;
+ --popover: #ffffff;
+ --popover-foreground: #1f1f1f;
+ --content-background: #f6f1de;
+ --primary: #644ac9;
+ --primary-foreground: #ffffff;
+ --secondary: #f6f1de;
+ --secondary-foreground: #1f1f1f;
+ --muted: #f6f1de;
+ --muted-foreground: #6c664b;
+ --accent: #e9e2e6;
+ --accent-foreground: #644ac9;
+ --highlight: #a34d14;
+ --highlight-soft: rgba(163, 77, 20, 0.15);
+ --destructive: #cb3a2a;
+ --border: #cfcfde;
+ --input: #91919a;
+ --ring: #644ac9;
+ --chart-1: #644ac9;
+ --chart-2: #a34d14;
+ --chart-3: #036a96;
+ --chart-4: #14710a;
+ --chart-5: #a3144d;
+ --destructive-foreground: #ffffff;
+}
+
+.dark[data-palette="dracula"] {
+ --background: #282a36;
+ --foreground: #f8f8f2;
+ --card: #2f3140;
+ --card-foreground: #f8f8f2;
+ --popover: #2f3140;
+ --popover-foreground: #f8f8f2;
+ --content-background: #21222c;
+ --primary: #bd93f9;
+ --primary-foreground: #282a36;
+ --secondary: #21222c;
+ --secondary-foreground: #f8f8f2;
+ --muted: #21222c;
+ --muted-foreground: #8390b7;
+ --accent: rgba(189, 147, 249, 0.16);
+ --accent-foreground: #bf96f9;
+ --highlight: #ffb86c;
+ --highlight-soft: rgba(255, 184, 108, 0.15);
+ --destructive: #ff5555;
+ --border: #44475a;
+ --input: #717381;
+ --ring: #bd93f9;
+ --chart-1: #bd93f9;
+ --chart-2: #ffb86c;
+ --chart-3: #8be9fd;
+ --chart-4: #50fa7b;
+ --chart-5: #ff79c6;
+ --destructive-foreground: #0b1720;
+}
+
+:root[data-palette="gruvbox"] {
+ --background: #fbf1c7;
+ --foreground: #3c3836;
+ --card: #f9f5d7;
+ --card-foreground: #3c3836;
+ --popover: #f9f5d7;
+ --popover-foreground: #3c3836;
+ --content-background: #f2e5bc;
+ --primary: #076678;
+ --primary-foreground: #ffffff;
+ --secondary: #f2e5bc;
+ --secondary-foreground: #3c3836;
+ --muted: #f2e5bc;
+ --muted-foreground: #776b60;
+ --accent: #d9debc;
+ --accent-foreground: #076678;
+ --highlight: #af3a03;
+ --highlight-soft: rgba(175, 58, 3, 0.15);
+ --destructive: #9d0006;
+ --border: #d5c4a1;
+ --input: #948871;
+ --ring: #076678;
+ --chart-1: #076678;
+ --chart-2: #af3a03;
+ --chart-3: #076678;
+ --chart-4: #746f0d;
+ --chart-5: #8f3f71;
+ --destructive-foreground: #ffffff;
+}
+
+.dark[data-palette="gruvbox"] {
+ --background: #282828;
+ --foreground: #ebdbb2;
+ --card: #32302f;
+ --card-foreground: #ebdbb2;
+ --popover: #32302f;
+ --popover-foreground: #ebdbb2;
+ --content-background: #1d2021;
+ --primary: #83a598;
+ --primary-foreground: #282828;
+ --secondary: #1d2021;
+ --secondary-foreground: #ebdbb2;
+ --muted: #1d2021;
+ --muted-foreground: #a89984;
+ --accent: rgba(131, 165, 152, 0.16);
+ --accent-foreground: #8cab9f;
+ --highlight: #fe8019;
+ --highlight-soft: rgba(254, 128, 25, 0.15);
+ --destructive: #fb5440;
+ --border: #504945;
+ --input: #787168;
+ --ring: #83a598;
+ --chart-1: #83a598;
+ --chart-2: #fe8019;
+ --chart-3: #83a598;
+ --chart-4: #b8bb26;
+ --chart-5: #d3869b;
+ --destructive-foreground: #0b1720;
+}
+
+:root[data-palette="rose-pine"] {
+ --background: #faf4ed;
+ --foreground: #464261;
+ --card: #fffaf3;
+ --card-foreground: #464261;
+ --popover: #fffaf3;
+ --popover-foreground: #464261;
+ --content-background: #f2e9e1;
+ --primary: #907aa9;
+ --primary-foreground: #ffffff;
+ --secondary: #f2e9e1;
+ --secondary-foreground: #464261;
+ --muted: #f2e9e1;
+ --muted-foreground: #716d89;
+ --accent: #ebe3e3;
+ --accent-foreground: #705f84;
+ --highlight: #986622;
+ --highlight-soft: rgba(152, 102, 34, 0.15);
+ --destructive: #a45a6f;
+ --border: #dfd9d2;
+ --input: #908c8b;
+ --ring: #907aa9;
+ --chart-1: #907aa9;
+ --chart-2: #986622;
+ --chart-3: #286983;
+ --chart-4: #467881;
+ --chart-5: #7a6890;
+ --destructive-foreground: #ffffff;
+}
+
+.dark[data-palette="rose-pine"] {
+ --background: #191724;
+ --foreground: #e0def4;
+ --card: #1f1d2e;
+ --card-foreground: #e0def4;
+ --popover: #1f1d2e;
+ --popover-foreground: #e0def4;
+ --content-background: #14121f;
+ --primary: #c4a7e7;
+ --primary-foreground: #191724;
+ --secondary: #14121f;
+ --secondary-foreground: #e0def4;
+ --muted: #14121f;
+ --muted-foreground: #908caa;
+ --accent: rgba(196, 167, 231, 0.16);
+ --accent-foreground: #c4a7e7;
+ --highlight: #f6c177;
+ --highlight-soft: rgba(246, 193, 119, 0.15);
+ --destructive: #eb6f92;
+ --border: #26233a;
+ --input: #666376;
+ --ring: #c4a7e7;
+ --chart-1: #c4a7e7;
+ --chart-2: #f6c177;
+ --chart-3: #9ccfd8;
+ --chart-4: #5089a0;
+ --chart-5: #c4a7e7;
+ --destructive-foreground: #0b1720;
+}
+
+:root[data-palette="tokyo-night"] {
+ --background: #e6e7ed;
+ --foreground: #343b59;
+ --card: #f2f3f7;
+ --card-foreground: #343b59;
+ --popover: #f2f3f7;
+ --popover-foreground: #343b59;
+ --content-background: #d6d8df;
+ --primary: #2959aa;
+ --primary-foreground: #ffffff;
+ --secondary: #d6d8df;
+ --secondary-foreground: #343b59;
+ --muted: #d6d8df;
+ --muted-foreground: #484c61;
+ --accent: #ccd3e4;
+ --accent-foreground: #2959aa;
+ --highlight: #8f5e15;
+ --highlight-soft: rgba(143, 94, 21, 0.15);
+ --destructive: #8c4351;
+ --border: #c1c2c7;
+ --input: #83848a;
+ --ring: #2959aa;
+ --chart-1: #2959aa;
+ --chart-2: #8f5e15;
+ --chart-3: #006c86;
+ --chart-4: #385f0d;
+ --chart-5: #65359d;
+ --destructive-foreground: #ffffff;
+}
+
+.dark[data-palette="tokyo-night"] {
+ --background: #1a1b26;
+ --foreground: #c0caf5;
+ --card: #1f2130;
+ --card-foreground: #c0caf5;
+ --popover: #1f2130;
+ --popover-foreground: #c0caf5;
+ --content-background: #16161e;
+ --primary: #7aa2f7;
+ --primary-foreground: #1a1b26;
+ --secondary: #16161e;
+ --secondary-foreground: #c0caf5;
+ --muted: #16161e;
+ --muted-foreground: #a9b1d6;
+ --accent: rgba(122, 162, 247, 0.16);
+ --accent-foreground: #7aa2f7;
+ --highlight: #e0af68;
+ --highlight-soft: rgba(224, 175, 104, 0.15);
+ --destructive: #f7768e;
+ --border: #363b54;
+ --input: #62677f;
+ --ring: #7aa2f7;
+ --chart-1: #7aa2f7;
+ --chart-2: #e0af68;
+ --chart-3: #7dcfff;
+ --chart-4: #9ece6a;
+ --chart-5: #bb9af7;
+ --destructive-foreground: #0b1720;
+}
+
+:root[data-palette="catppuccin"] {
+ --background: #e6e9ef;
+ --foreground: #484b64;
+ --card: #eff1f5;
+ --card-foreground: #484b64;
+ --popover: #eff1f5;
+ --popover-foreground: #484b64;
+ --content-background: #dce0e8;
+ --primary: #8839ef;
+ --primary-foreground: #ffffff;
+ --secondary: #dce0e8;
+ --secondary-foreground: #484b64;
+ --muted: #dce0e8;
+ --muted-foreground: #64677c;
+ --accent: #d9d0ef;
+ --accent-foreground: #7632d0;
+ --highlight: #b24608;
+ --highlight-soft: rgba(178, 70, 8, 0.15);
+ --destructive: #d00f38;
+ --border: #ccd0da;
+ --input: #83858e;
+ --ring: #8839ef;
+ --chart-1: #8839ef;
+ --chart-2: #b24608;
+ --chart-3: #1c5fe4;
+ --chart-4: #307820;
+ --chart-5: #9a4e86;
+ --destructive-foreground: #ffffff;
+}
+
+.dark[data-palette="catppuccin"] {
+ --background: #1e1e2e;
+ --foreground: #cdd6f4;
+ --card: #313244;
+ --card-foreground: #cdd6f4;
+ --popover: #313244;
+ --popover-foreground: #cdd6f4;
+ --content-background: #181825;
+ --primary: #cba6f7;
+ --primary-foreground: #1e1e2e;
+ --secondary: #181825;
+ --secondary-foreground: #cdd6f4;
+ --muted: #181825;
+ --muted-foreground: #a6adc8;
+ --accent: rgba(203, 166, 247, 0.16);
+ --accent-foreground: #cba6f7;
+ --highlight: #fab387;
+ --highlight-soft: rgba(250, 179, 135, 0.15);
+ --destructive: #f38ba8;
+ --border: #45475a;
+ --input: #66697c;
+ --ring: #cba6f7;
+ --chart-1: #cba6f7;
+ --chart-2: #fab387;
+ --chart-3: #89b4fa;
+ --chart-4: #a6e3a1;
+ --chart-5: #f5c2e7;
+ --destructive-foreground: #0b1720;
+}
+
+:root[data-palette="solarized"] {
+ --background: #fdf6e3;
+ --foreground: #47565c;
+ --card: #fffdf6;
+ --card-foreground: #47565c;
+ --popover: #fffdf6;
+ --popover-foreground: #47565c;
+ --content-background: #eee8d5;
+ --primary: #268bd2;
+ --primary-foreground: #ffffff;
+ --secondary: #eee8d5;
+ --secondary-foreground: #47565c;
+ --muted: #eee8d5;
+ --muted-foreground: #687272;
+ --accent: #dfe7e1;
+ --accent-foreground: #1d6ba2;
+ --highlight: #c34815;
+ --highlight-soft: rgba(195, 72, 21, 0.15);
+ --destructive: #d5302e;
+ --border: #e6dfc8;
+ --input: #928f83;
+ --ring: #268bd2;
+ --chart-1: #268bd2;
+ --chart-2: #c34815;
+ --chart-3: #217e77;
+ --chart-4: #687700;
+ --chart-5: #666ab8;
+ --destructive-foreground: #ffffff;
+}
+
+.dark[data-palette="solarized"] {
+ --background: #002b36;
+ --foreground: #a8b4b6;
+ --card: #073642;
+ --card-foreground: #a8b4b6;
+ --popover: #073642;
+ --popover-foreground: #a8b4b6;
+ --content-background: #001f28;
+ --primary: #268bd2;
+ --primary-foreground: #002b36;
+ --secondary: #001f28;
+ --secondary-foreground: #a8b4b6;
+ --muted: #001f28;
+ --muted-foreground: #809196;
+ --accent: rgba(38, 139, 210, 0.16);
+ --accent-foreground: #56a5dc;
+ --highlight: #d67147;
+ --highlight-soft: rgba(214, 113, 71, 0.15);
+ --destructive: #e56563;
+ --border: #0d4552;
+ --input: #4e767f;
+ --ring: #268bd2;
+ --chart-1: #268bd2;
+ --chart-2: #d67147;
+ --chart-3: #2aa198;
+ --chart-4: #859900;
+ --chart-5: #8488cd;
+ --destructive-foreground: #0b1720;
+}
+
+:root[data-palette="ayu"] {
+ --background: #f8f9fa;
+ --foreground: #52565b;
+ --card: #fcfcfc;
+ --card-foreground: #52565b;
+ --popover: #fcfcfc;
+ --popover-foreground: #52565b;
+ --content-background: #ebeef0;
+ --primary: #cb7f14;
+ --primary-foreground: #ffffff;
+ --secondary: #ebeef0;
+ --secondary-foreground: #52565b;
+ --muted: #ebeef0;
+ --muted-foreground: #697381;
+ --accent: #f2e8da;
+ --accent-foreground: #945d0f;
+ --highlight: #af5d23;
+ --highlight-soft: rgba(175, 93, 35, 0.15);
+ --destructive: #b45555;
+ --border: #dfe2e5;
+ --input: #8d9092;
+ --ring: #cb7f14;
+ --chart-1: #cb7f14;
+ --chart-2: #af5d23;
+ --chart-3: #3a7a90;
+ --chart-4: #5e7d00;
+ --chart-5: #8664a7;
+ --destructive-foreground: #ffffff;
+}
+
+.dark[data-palette="ayu"] {
+ --background: #0d1017;
+ --foreground: #bfbdb6;
+ --card: #10141c;
+ --card-foreground: #bfbdb6;
+ --popover: #10141c;
+ --popover-foreground: #bfbdb6;
+ --content-background: #070a0f;
+ --primary: #e6b450;
+ --primary-foreground: #0d1017;
+ --secondary: #070a0f;
+ --secondary-foreground: #bfbdb6;
+ --muted: #070a0f;
+ --muted-foreground: #747c8e;
+ --accent: rgba(230, 180, 80, 0.16);
+ --accent-foreground: #e6b450;
+ --highlight: #ff8f40;
+ --highlight-soft: rgba(255, 143, 64, 0.15);
+ --destructive: #f07178;
+ --border: #1b1f29;
+ --input: #5f6167;
+ --ring: #e6b450;
+ --chart-1: #e6b450;
+ --chart-2: #ff8f40;
+ --chart-3: #39bae6;
+ --chart-4: #aad94c;
+ --chart-5: #d2a6ff;
+ --destructive-foreground: #0b1720;
+}
+
+:root[data-palette="kanagawa"] {
+ --background: #e5ddb0;
+ --foreground: #434350;
+ --card: #f2ecbc;
+ --card-foreground: #434350;
+ --popover: #f2ecbc;
+ --popover-foreground: #434350;
+ --content-background: #dcd5ac;
+ --primary: #624c83;
+ --primary-foreground: #ffffff;
+ --secondary: #dcd5ac;
+ --secondary-foreground: #434350;
+ --muted: #dcd5ac;
+ --muted-foreground: #636155;
+ --accent: #d3c9aa;
+ --accent-foreground: #604a80;
+ --highlight: #934e00;
+ --highlight-soft: rgba(147, 78, 0, 0.15);
+ --destructive: #ac3747;
+ --border: #d5cea3;
+ --input: #807c65;
+ --ring: #624c83;
+ --chart-1: #624c83;
+ --chart-2: #934e00;
+ --chart-3: #47618f;
+ --chart-4: #53673b;
+ --chart-5: #914a62;
+ --destructive-foreground: #ffffff;
+}
+
+.dark[data-palette="kanagawa"] {
+ --background: #1f1f28;
+ --foreground: #dcd7ba;
+ --card: #2a2a37;
+ --card-foreground: #dcd7ba;
+ --popover: #2a2a37;
+ --popover-foreground: #dcd7ba;
+ --content-background: #16161d;
+ --primary: #7e9cd8;
+ --primary-foreground: #1f1f28;
+ --secondary: #16161d;
+ --secondary-foreground: #dcd7ba;
+ --muted: #16161d;
+ --muted-foreground: #898881;
+ --accent: rgba(126, 156, 216, 0.16);
+ --accent-foreground: #7e9cd8;
+ --highlight: #ff9e3b;
+ --highlight-soft: rgba(255, 158, 59, 0.15);
+ --destructive: #ed5050;
+ --border: #363646;
+ --input: #696970;
+ --ring: #7e9cd8;
+ --chart-1: #7e9cd8;
+ --chart-2: #ff9e3b;
+ --chart-3: #7fb4ca;
+ --chart-4: #98bb6c;
+ --chart-5: #d27e99;
+ --destructive-foreground: #0b1720;
+}
+
+:root[data-palette="everforest"] {
+ --background: #efebd4;
+ --foreground: #444e54;
+ --card: #fdf6e3;
+ --card-foreground: #444e54;
+ --popover: #fdf6e3;
+ --popover-foreground: #444e54;
+ --content-background: #e6e2cc;
+ --primary: #7d8f01;
+ --primary-foreground: #ffffff;
+ --secondary: #e6e2cc;
+ --secondary-foreground: #444e54;
+ --muted: #e6e2cc;
+ --muted-foreground: #646c63;
+ --accent: #dfdeb6;
+ --accent-foreground: #5a6701;
+ --highlight: #a45419;
+ --highlight-soft: rgba(164, 84, 25, 0.15);
+ --destructive: #b83f3d;
+ --border: #bdc3af;
+ --input: #84887c;
+ --ring: #7d8f01;
+ --chart-1: #7d8f01;
+ --chart-2: #a45419;
+ --chart-3: #2c7096;
+ --chart-4: #616f01;
+ --chart-5: #9e4b84;
+ --destructive-foreground: #ffffff;
+}
+
+.dark[data-palette="everforest"] {
+ --background: #2d353b;
+ --foreground: #d3c6aa;
+ --card: #343f44;
+ --card-foreground: #d3c6aa;
+ --popover: #343f44;
+ --popover-foreground: #d3c6aa;
+ --content-background: #232a2e;
+ --primary: #a7c080;
+ --primary-foreground: #2d353b;
+ --secondary: #232a2e;
+ --secondary-foreground: #d3c6aa;
+ --muted: #232a2e;
+ --muted-foreground: #949f97;
+ --accent: rgba(167, 192, 128, 0.16);
+ --accent-foreground: #a7c080;
+ --highlight: #e69875;
+ --highlight-soft: rgba(230, 152, 117, 0.15);
+ --destructive: #e67e80;
+ --border: #475258;
+ --input: #787e7e;
+ --ring: #a7c080;
+ --chart-1: #a7c080;
+ --chart-2: #e69875;
+ --chart-3: #7fbbb3;
+ --chart-4: #a7c080;
+ --chart-5: #d699b6;
+ --destructive-foreground: #0b1720;
+}
+
+:root[data-palette="primer"] {
+ --background: #f6f8fa;
+ --foreground: #25292e;
+ --card: #ffffff;
+ --card-foreground: #25292e;
+ --popover: #ffffff;
+ --popover-foreground: #25292e;
+ --content-background: #eff2f5;
+ --primary: #0969da;
+ --primary-foreground: #ffffff;
+ --secondary: #eff2f5;
+ --secondary-foreground: #25292e;
+ --muted: #eff2f5;
+ --muted-foreground: #59636e;
+ --accent: #d5e4f6;
+ --accent-foreground: #0861c9;
+ --highlight: #9a6700;
+ --highlight-soft: rgba(154, 103, 0, 0.15);
+ --destructive: #cf222e;
+ --border: #d1d9e0;
+ --input: #898f94;
+ --ring: #0969da;
+ --chart-1: #0969da;
+ --chart-2: #9a6700;
+ --chart-3: #0550ae;
+ --chart-4: #116329;
+ --chart-5: #8250df;
+ --destructive-foreground: #ffffff;
+}
+
+.dark[data-palette="primer"] {
+ --background: #0d1117;
+ --foreground: #f0f6fc;
+ --card: #151b23;
+ --card-foreground: #f0f6fc;
+ --popover: #151b23;
+ --popover-foreground: #f0f6fc;
+ --content-background: #010409;
+ --primary: #58a6ff;
+ --primary-foreground: #0d1117;
+ --secondary: #010409;
+ --secondary-foreground: #f0f6fc;
+ --muted: #010409;
+ --muted-foreground: #9198a1;
+ --accent: rgba(88, 166, 255, 0.16);
+ --accent-foreground: #58a6ff;
+ --highlight: #e3b341;
+ --highlight-soft: rgba(227, 179, 65, 0.15);
+ --destructive: #ff7b72;
+ --border: #3d444d;
+ --input: #5a6169;
+ --ring: #58a6ff;
+ --chart-1: #58a6ff;
+ --chart-2: #e3b341;
+ --chart-3: #79c0ff;
+ --chart-4: #56d364;
+ --chart-5: #d2a8ff;
+ --destructive-foreground: #0b1720;
+}
diff --git a/src/stores/uiStore.ts b/src/stores/uiStore.ts
index 1e4fd1f..4be95d5 100644
--- a/src/stores/uiStore.ts
+++ b/src/stores/uiStore.ts
@@ -5,6 +5,7 @@
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
*/
+import { applyPalette, DEFAULT_PALETTE, isPaletteId, type PaletteId } from '@/lib/palettes';
import { create } from 'zustand';
import { persist } from 'zustand/middleware';
@@ -12,6 +13,8 @@ type Theme = 'light' | 'dark';
interface UIState {
theme: Theme;
+ /** INBUXA: the color palette, the same set INBUXA webmail offers. */
+ palette: PaletteId;
sidebarOpen: boolean;
/** INBUXA: the sidebar folded to a rail of icon tiles, on wide screens. */
sidebarCollapsed: boolean;
@@ -19,6 +22,7 @@ interface UIState {
toggleTheme: () => void;
setTheme: (theme: Theme) => void;
+ setPalette: (palette: PaletteId) => void;
toggleSidebar: () => void;
setSidebarOpen: (open: boolean) => void;
toggleSidebarCollapsed: () => void;
@@ -40,6 +44,7 @@ export const useUIStore = create()(
typeof window !== 'undefined' && window.matchMedia?.('(prefers-color-scheme: dark)').matches ? 'dark' : 'light',
sidebarOpen: typeof window !== 'undefined' ? (window.matchMedia?.('(min-width: 768px)').matches ?? true) : true,
sidebarCollapsed: false,
+ palette: DEFAULT_PALETTE,
activeSection: '',
toggleTheme: () => {
@@ -61,6 +66,11 @@ export const useUIStore = create()(
set({ sidebarOpen: open });
},
+ setPalette: (palette) => {
+ applyPalette(palette);
+ set({ palette });
+ },
+
toggleSidebarCollapsed: () => {
set({ sidebarCollapsed: !get().sidebarCollapsed });
},
@@ -73,12 +83,14 @@ export const useUIStore = create()(
name: 'inbuxa-ui',
partialize: (state) => ({
theme: state.theme,
+ palette: state.palette,
sidebarCollapsed: state.sidebarCollapsed,
}),
onRehydrateStorage: () => {
return (state) => {
if (state) {
applyThemeClass(state.theme);
+ applyPalette(isPaletteId(state.palette) ? state.palette : DEFAULT_PALETTE);
}
};
},