From 91531b478437bc27d78d797e0515084bf69d082f Mon Sep 17 00:00:00 2001 From: John Coffey Date: Sat, 19 Sep 2026 00:56:35 -0700 Subject: [PATCH] Theme menu: the same palettes as INBUXA webmail Under the user menu, Theme offers ihasmail's palettes, in the same order and under the same names. ihasmail is the default look, the colors the admin already had, until someone picks another. The choice is remembered and applied before the first paint, and dark mode still toggles on its own. The other palettes' colors are ihasmail's, already contrast-checked, mapped onto the admin's tokens by scripts/import-palettes.py; re-running it re-syncs them. NOTICE carries the palettes' MIT notices and the fonts' OFL notices. --- NOTICE | 109 +++++ src/components/layout/TopBar.tsx | 35 +- src/i18n/en.json | 4 + src/index.css | 1 + src/lib/palettes.ts | 52 +++ src/main.tsx | 5 + src/palettes.css | 672 +++++++++++++++++++++++++++++++ src/stores/uiStore.ts | 12 + 8 files changed, 889 insertions(+), 1 deletion(-) create mode 100644 NOTICE create mode 100644 src/lib/palettes.ts create mode 100644 src/palettes.css 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) => ( + + + ))} + + + + + 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); } }; },