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); } }; },