From 169d3c97bd009b9ebe442e9455cd07628118ace0 Mon Sep 17 00:00:00 2001 From: PluS98 Date: Sun, 25 Oct 2026 15:26:59 +0300 Subject: [PATCH 1/7] feat(frontend): pick an accent color for the light, dark and ultra-dark themes A palette button next to the theme switch (sidebar and mobile drawer) opens seven swatches: the panel's own blue (default), red, pink, orange, yellow, green and purple. The color becomes Ant Design's colorPrimary, so buttons, links, the active menu item, tabs and switches follow it; the choice is kept per browser like the theme. Each color has a shade per mode: on light, white text on it stays at 4.5:1 or more; on dark and ultra-dark it is readable on the cards, and orange, yellow and green buttons get dark text where white would not be. The command palette and the Jalali date picker read the color instead of a fixed blue. Default keeps today's look. --- .../command-palette/CommandPalette.css | 22 +++--- .../command-palette/CommandPalette.tsx | 2 +- .../src/components/form/DateTimePicker.tsx | 13 ++-- frontend/src/hooks/useTheme.tsx | 73 +++++++++++++++++-- frontend/src/layouts/AppSidebar.css | 30 ++++++++ frontend/src/layouts/AppSidebar.tsx | 47 +++++++++++- internal/web/translation/ar-EG.json | 10 +++ internal/web/translation/en-US.json | 10 +++ internal/web/translation/es-ES.json | 10 +++ internal/web/translation/fa-IR.json | 10 +++ internal/web/translation/id-ID.json | 10 +++ internal/web/translation/ja-JP.json | 10 +++ internal/web/translation/pt-BR.json | 10 +++ internal/web/translation/ru-RU.json | 10 +++ internal/web/translation/sk-SK.json | 10 +++ internal/web/translation/tr-TR.json | 10 +++ internal/web/translation/uk-UA.json | 10 +++ internal/web/translation/vi-VN.json | 10 +++ internal/web/translation/zh-CN.json | 10 +++ internal/web/translation/zh-TW.json | 10 +++ 20 files changed, 300 insertions(+), 27 deletions(-) diff --git a/frontend/src/components/command-palette/CommandPalette.css b/frontend/src/components/command-palette/CommandPalette.css index 570b71a98d3..f4292dd3319 100644 --- a/frontend/src/components/command-palette/CommandPalette.css +++ b/frontend/src/components/command-palette/CommandPalette.css @@ -117,7 +117,7 @@ html[data-theme='ultra-dark'] .command-palette-modal { font-size: 24px; width: 28px; height: 28px; - color: #1677ff; + color: var(--ant-color-primary); flex-shrink: 0; display: inline-flex; align-items: center; @@ -126,7 +126,7 @@ html[data-theme='ultra-dark'] .command-palette-modal { } .command-palette-search-icon.spinning { - color: #1677ff; + color: var(--ant-color-primary); animation: cp-spin 0.8s linear infinite; } @@ -262,18 +262,18 @@ html[data-theme='ultra-dark'] .command-palette-modal { } .command-palette-modal.light .command-palette-item.active { - background: #e6f4ff; - color: #0958d9; + background: var(--ant-color-primary-bg); + color: var(--ant-color-primary-text-active); } .command-palette-modal.dark .command-palette-item.active { - background: rgba(22, 119, 255, 0.2); - color: #4096ff; + background: color-mix(in srgb, var(--ant-color-primary) 20%, transparent); + color: color-mix(in srgb, var(--ant-color-primary) 80%, #fff); } .command-palette-modal.ultra .command-palette-item.active { - background: rgba(22, 119, 255, 0.28); - color: #69b1ff; + background: color-mix(in srgb, var(--ant-color-primary) 28%, transparent); + color: color-mix(in srgb, var(--ant-color-primary) 65%, #fff); } .command-palette-item-main { @@ -353,7 +353,7 @@ html[data-theme='ultra-dark'] .command-palette-modal { } .command-palette-modal.light .command-palette-action-btn:hover { - color: #0958d9; + color: var(--ant-color-primary-text-active); background: rgba(0, 0, 0, 0.06); } @@ -362,7 +362,7 @@ html[data-theme='ultra-dark'] .command-palette-modal { } .command-palette-modal.dark .command-palette-action-btn:hover { - color: #4096ff; + color: color-mix(in srgb, var(--ant-color-primary) 80%, #fff); background: rgba(255, 255, 255, 0.1); } @@ -371,7 +371,7 @@ html[data-theme='ultra-dark'] .command-palette-modal { } .command-palette-modal.ultra .command-palette-action-btn:hover { - color: #69b1ff; + color: color-mix(in srgb, var(--ant-color-primary) 65%, #fff); background: rgba(255, 255, 255, 0.15); } diff --git a/frontend/src/components/command-palette/CommandPalette.tsx b/frontend/src/components/command-palette/CommandPalette.tsx index ca850552051..201a124507d 100644 --- a/frontend/src/components/command-palette/CommandPalette.tsx +++ b/frontend/src/components/command-palette/CommandPalette.tsx @@ -355,7 +355,7 @@ export default function CommandPalette() { category: 'inbounds', title: ib.remark || ib.tag || `Inbound #${ib.id}`, subtitle: `Port ${ib.port || ''}`, - icon: , + icon: , tag: tags.length > 0 ? (
{tags}
diff --git a/frontend/src/components/form/DateTimePicker.tsx b/frontend/src/components/form/DateTimePicker.tsx index 7ef43328f7b..b80520cb94b 100644 --- a/frontend/src/components/form/DateTimePicker.tsx +++ b/frontend/src/components/form/DateTimePicker.tsx @@ -7,7 +7,7 @@ import type { Dayjs } from 'dayjs'; import { PersianDateTimePicker } from 'persian-calendar-suite'; import { useDatepicker } from '@/hooks/useDatepicker'; -import { useTheme } from '@/hooks/useTheme'; +import { accentPrimary, useTheme } from '@/hooks/useTheme'; import './DateTimePicker.css'; interface DateTimePickerProps { @@ -66,7 +66,7 @@ export default function DateTimePicker({ }: DateTimePickerProps) { const { t } = useTranslation(); const { datepicker } = useDatepicker(); - const { isDark, isUltra } = useTheme(); + const { isDark, isUltra, accent } = useTheme(); const jalaliRef = useRef(null); // Bumped on clear: persian-calendar-suite reads `value` only on mount, so // remounting via key is the only way to reflect an externally cleared value. @@ -83,10 +83,11 @@ export default function DateTimePicker({ }, [clearNonce]); const persianTheme = useMemo(() => { - if (isUltra) return ULTRA_DARK_THEME; - if (isDark) return DARK_THEME; - return LIGHT_THEME; - }, [isDark, isUltra]); + const base = isUltra ? ULTRA_DARK_THEME : isDark ? DARK_THEME : LIGHT_THEME; + if (accent === 'default') return base; + const primaryColor = accentPrimary(accent, isDark); + return { ...base, primaryColor, hoverColor: `${primaryColor}${isDark ? '2e' : '1a'}` }; + }, [isDark, isUltra, accent]); const commitChange = (next: Dayjs | null) => { if (next && maxDate && next.isAfter(maxDate)) { diff --git a/frontend/src/hooks/useTheme.tsx b/frontend/src/hooks/useTheme.tsx index d55b5ab0a11..71d2d8da766 100644 --- a/frontend/src/hooks/useTheme.tsx +++ b/frontend/src/hooks/useTheme.tsx @@ -5,6 +5,39 @@ import type { ThemeConfig } from 'antd'; const STORAGE_DARK = 'dark-mode'; const STORAGE_ULTRA = 'isUltraDarkThemeEnabled'; +const STORAGE_ACCENT = 'accentColor'; + +export const ACCENT_COLORS = [ + 'default', + 'red', + 'pink', + 'orange', + 'yellow', + 'green', + 'purple', +] as const; +export type AccentColor = (typeof ACCENT_COLORS)[number]; + +// light: white text on it stays >= 4.5:1; dark: readable on dark cards, with +// dark text on the button where white text would not be (orange/yellow/green). +const ACCENT_SHADES: Record< + Exclude, + { light: string; dark: string; darkInk: boolean } +> = { + red: { light: '#dc2626', dark: '#ef4444', darkInk: false }, + pink: { light: '#db2777', dark: '#ec4899', darkInk: false }, + orange: { light: '#c2410c', dark: '#fb923c', darkInk: true }, + yellow: { light: '#a16207', dark: '#facc15', darkInk: true }, + green: { light: '#047857', dark: '#34d399', darkInk: true }, + purple: { light: '#7c3aed', dark: '#8b5cf6', darkInk: false }, +}; +const DEFAULT_PRIMARY = { light: '#0958d9', dark: '#1677ff' }; +const DARK_INK = '#141414'; + +export function accentPrimary(accent: AccentColor, isDark: boolean): string { + const shades = accent === 'default' ? DEFAULT_PRIMARY : ACCENT_SHADES[accent]; + return isDark ? shades.dark : shades.light; +} function readBool(key: string, fallback: boolean): boolean { const raw = localStorage.getItem(key); @@ -12,6 +45,13 @@ function readBool(key: string, fallback: boolean): boolean { return raw === 'true'; } +function readAccent(): AccentColor { + const raw = localStorage.getItem(STORAGE_ACCENT); + return (ACCENT_COLORS as readonly string[]).includes(raw ?? '') + ? (raw as AccentColor) + : 'default'; +} + function applyDom(isDark: boolean, isUltra: boolean) { document.body.classList.remove('dark', 'light'); document.body.classList.add(isDark ? 'dark' : 'light'); @@ -32,6 +72,7 @@ function applyDom(isDark: boolean, isUltra: boolean) { // module load so the document is in the right theme before React mounts. const initialDark = readBool(STORAGE_DARK, true); const initialUltra = readBool(STORAGE_ULTRA, false); +const initialAccent = readAccent(); applyDom(initialDark, initialUltra); const DARK_TOKENS = { @@ -112,27 +153,35 @@ const SHARED_STYLE_CONFIG = { cssVar: { key: 'xui' }, } as const; -export function buildAntdThemeConfig(isDark: boolean, isUltra: boolean): ThemeConfig { +export function buildAntdThemeConfig( + isDark: boolean, + isUltra: boolean, + accent: AccentColor = 'default', +): ThemeConfig { if (!isDark) { + const accentToken = accent === 'default' ? {} : { colorPrimary: accentPrimary(accent, false) }; return { ...SHARED_STYLE_CONFIG, algorithm: antdTheme.defaultAlgorithm, - token: LIGHT_CONTRAST_TOKENS, + token: { ...LIGHT_CONTRAST_TOKENS, ...accentToken }, components: { Statistic: STATISTIC_TOKENS, - Button: LIGHT_BUTTON_TOKENS, + Button: accent === 'default' ? LIGHT_BUTTON_TOKENS : {}, }, }; } + const accentToken = accent === 'default' ? {} : { colorPrimary: accentPrimary(accent, true) }; + const darkInk = accent !== 'default' && ACCENT_SHADES[accent].darkInk; return { ...SHARED_STYLE_CONFIG, algorithm: antdTheme.darkAlgorithm, - token: isUltra ? ULTRA_DARK_TOKENS : DARK_TOKENS, + token: { ...(isUltra ? ULTRA_DARK_TOKENS : DARK_TOKENS), ...accentToken }, components: { Layout: isUltra ? ULTRA_DARK_LAYOUT_TOKENS : DARK_LAYOUT_TOKENS, Menu: isUltra ? ULTRA_DARK_MENU_TOKENS : DARK_MENU_TOKENS, Card: isUltra ? ULTRA_DARK_CARD_TOKENS : DARK_CARD_TOKENS, Statistic: STATISTIC_TOKENS, + ...(darkInk ? { Button: { primaryColor: DARK_INK } } : {}), }, }; } @@ -155,6 +204,8 @@ interface ThemeContextValue { isUltra: boolean; toggleTheme: () => void; toggleUltra: () => void; + accent: AccentColor; + setAccent: (accent: AccentColor) => void; antdThemeConfig: ThemeConfig; } @@ -163,6 +214,7 @@ const ThemeContext = createContext(null); export function ThemeProvider({ children }: { children: ReactNode }) { const [isDark, setIsDark] = useState(initialDark); const [isUltra, setIsUltra] = useState(initialUltra); + const [accent, setAccent] = useState(initialAccent); useLayoutEffect(() => { applyDom(isDark, isUltra); @@ -170,14 +222,21 @@ export function ThemeProvider({ children }: { children: ReactNode }) { localStorage.setItem(STORAGE_ULTRA, String(isUltra)); }, [isDark, isUltra]); + useLayoutEffect(() => { + localStorage.setItem(STORAGE_ACCENT, accent); + }, [accent]); + const toggleTheme = useCallback(() => setIsDark((v) => !v), []); const toggleUltra = useCallback(() => setIsUltra((v) => !v), []); - const antdThemeConfig = useMemo(() => buildAntdThemeConfig(isDark, isUltra), [isDark, isUltra]); + const antdThemeConfig = useMemo( + () => buildAntdThemeConfig(isDark, isUltra, accent), + [isDark, isUltra, accent], + ); const value = useMemo( - () => ({ isDark, isUltra, toggleTheme, toggleUltra, antdThemeConfig }), - [isDark, isUltra, toggleTheme, toggleUltra, antdThemeConfig], + () => ({ isDark, isUltra, toggleTheme, toggleUltra, accent, setAccent, antdThemeConfig }), + [isDark, isUltra, toggleTheme, toggleUltra, accent, antdThemeConfig], ); return {children}; diff --git a/frontend/src/layouts/AppSidebar.css b/frontend/src/layouts/AppSidebar.css index ddd1016282c..d5cfd2ce382 100644 --- a/frontend/src/layouts/AppSidebar.css +++ b/frontend/src/layouts/AppSidebar.css @@ -156,6 +156,36 @@ font-size: 16px; } +.accent-swatches { + display: grid; + grid-template-columns: repeat(7, 28px); + gap: 8px; +} + +.accent-swatch { + width: 28px; + height: 28px; + padding: 0; + border: none; + border-radius: 50%; + background: var(--swatch); + cursor: pointer; + transition: transform 0.15s ease; +} + +.accent-swatch:hover, +.accent-swatch:focus-visible { + transform: scale(1.12); + outline: none; +} + +.accent-swatch.active, +.accent-swatch:focus-visible { + box-shadow: + 0 0 0 2px var(--ant-color-bg-elevated), + 0 0 0 4px var(--swatch); +} + .drawer-header-actions { display: inline-flex; align-items: center; diff --git a/frontend/src/layouts/AppSidebar.tsx b/frontend/src/layouts/AppSidebar.tsx index e86cc260dbe..44d46748d15 100644 --- a/frontend/src/layouts/AppSidebar.tsx +++ b/frontend/src/layouts/AppSidebar.tsx @@ -2,10 +2,11 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import type { ComponentType, CSSProperties } from 'react'; import { useLocation, useNavigate } from 'react-router'; import { useTranslation } from 'react-i18next'; -import { Drawer, Layout, Menu, Tooltip } from 'antd'; +import { Drawer, Layout, Menu, Popover, Tooltip } from 'antd'; import type { MenuProps } from 'antd'; import { ApartmentOutlined, + BgColorsOutlined, CloseOutlined, CloudServerOutlined, ClusterOutlined, @@ -31,7 +32,12 @@ import { import { HttpUtil } from '@/utils'; import { formatPanelVersion } from '@/lib/panel-version'; -import { pauseAnimationsUntilLeave, useTheme } from '@/hooks/useTheme'; +import { + ACCENT_COLORS, + accentPrimary, + pauseAnimationsUntilLeave, + useTheme, +} from '@/hooks/useTheme'; import { useAllSettings } from '@/api/queries/useAllSettings'; import { useCommandPalette } from '@/components/command-palette/useCommandPalette'; import { PANEL_ROUTES } from '@/routes/panelRoutes'; @@ -132,6 +138,41 @@ function ThemeCycleButton({ ); } +function AccentPicker({ id, ariaLabel }: { id: string; ariaLabel: string }) { + const { t } = useTranslation(); + const { isDark, accent, setAccent } = useTheme(); + const swatches = ( +
+ {ACCENT_COLORS.map((color) => ( +
+ ); + return ( + + + + ); +} + function readSidebarPinned() { try { return localStorage.getItem(SIDEBAR_PINNED_KEY) === 'true'; @@ -383,6 +424,7 @@ export default function AppSidebar() { + + Date: Sun, 25 Oct 2026 16:22:18 +0300 Subject: [PATCH 2/7] feat(frontend): add an optional glassy style for every theme A switch under the accent colors turns it on, per browser. The page gets a soft glow in the accent color, and cards, the sidebar, the drawer and dialogs become frosted glass (translucent surface + backdrop blur). Popups stay nearly opaque so menus and pickers keep reading over busy pages, and fixed table columns and sticky headers stay solid so scrolled rows never show through them. The main button gets an accent gradient. Works on light, dark and ultra-dark; off keeps today's look. --- frontend/src/hooks/useTheme.tsx | 139 ++++++++++++++++++++++++---- frontend/src/layouts/AppSidebar.css | 16 ++++ frontend/src/layouts/AppSidebar.tsx | 38 ++++---- frontend/src/styles/glass.css | 102 ++++++++++++++++++++ internal/web/translation/ar-EG.json | 1 + internal/web/translation/en-US.json | 1 + internal/web/translation/es-ES.json | 1 + internal/web/translation/fa-IR.json | 1 + internal/web/translation/id-ID.json | 1 + internal/web/translation/ja-JP.json | 1 + internal/web/translation/pt-BR.json | 1 + internal/web/translation/ru-RU.json | 1 + internal/web/translation/sk-SK.json | 1 + internal/web/translation/tr-TR.json | 1 + internal/web/translation/uk-UA.json | 1 + internal/web/translation/vi-VN.json | 1 + internal/web/translation/zh-CN.json | 1 + internal/web/translation/zh-TW.json | 1 + 18 files changed, 273 insertions(+), 36 deletions(-) create mode 100644 frontend/src/styles/glass.css diff --git a/frontend/src/hooks/useTheme.tsx b/frontend/src/hooks/useTheme.tsx index 71d2d8da766..1098e1302c3 100644 --- a/frontend/src/hooks/useTheme.tsx +++ b/frontend/src/hooks/useTheme.tsx @@ -3,9 +3,12 @@ import type { ReactNode } from 'react'; import { theme as antdTheme } from 'antd'; import type { ThemeConfig } from 'antd'; +import '@/styles/glass.css'; + const STORAGE_DARK = 'dark-mode'; const STORAGE_ULTRA = 'isUltraDarkThemeEnabled'; const STORAGE_ACCENT = 'accentColor'; +const STORAGE_GLASS = 'glassTheme'; export const ACCENT_COLORS = [ 'default', @@ -69,11 +72,25 @@ function applyDom(isDark: boolean, isUltra: boolean) { } } +// The glow behind the glass cards is drawn by glass.css from --xui-accent. +function applyGlassDom(isGlass: boolean, accent: AccentColor, isDark: boolean) { + const root = document.documentElement; + if (isGlass) { + root.setAttribute('data-glass', 'on'); + root.style.setProperty('--xui-accent', accentPrimary(accent, isDark)); + } else { + root.removeAttribute('data-glass'); + root.style.removeProperty('--xui-accent'); + } +} + // module load so the document is in the right theme before React mounts. const initialDark = readBool(STORAGE_DARK, true); const initialUltra = readBool(STORAGE_ULTRA, false); const initialAccent = readAccent(); +const initialGlass = readBool(STORAGE_GLASS, false); applyDom(initialDark, initialUltra); +applyGlassDom(initialGlass, initialAccent, initialDark); const DARK_TOKENS = { colorBgBase: '#1a1b1f', @@ -153,14 +170,79 @@ const SHARED_STYLE_CONFIG = { cssVar: { key: 'xui' }, } as const; +// Glassy style: translucent surfaces over the page glow; popups stay nearly +// opaque so menus and pickers remain readable over busy content. +const GLASS_SURFACES = { + light: { + container: 'rgba(255, 255, 255, 0.62)', + elevated: 'rgba(255, 255, 255, 0.94)', + layout: 'rgba(15, 23, 42, 0.04)', + side: 'rgba(255, 255, 255, 0.55)', + border: 'rgba(15, 23, 42, 0.08)', + }, + dark: { + container: 'rgba(22, 18, 34, 0.56)', + elevated: 'rgba(26, 22, 38, 0.95)', + layout: 'rgba(255, 255, 255, 0.04)', + side: 'rgba(14, 11, 24, 0.62)', + border: 'rgba(255, 255, 255, 0.09)', + }, + ultra: { + container: 'rgba(10, 9, 16, 0.6)', + elevated: 'rgba(14, 13, 20, 0.96)', + layout: 'rgba(255, 255, 255, 0.03)', + side: 'rgba(4, 4, 8, 0.7)', + border: 'rgba(255, 255, 255, 0.07)', + }, +}; + +function withGlass(config: ThemeConfig, isDark: boolean, isUltra: boolean): ThemeConfig { + const s = GLASS_SURFACES[isUltra && isDark ? 'ultra' : isDark ? 'dark' : 'light']; + const components = config.components ?? {}; + return { + ...config, + token: { + ...config.token, + colorBgContainer: s.container, + colorBgElevated: s.elevated, + colorBgLayout: s.layout, + colorBorderSecondary: s.border, + }, + components: { + ...components, + Layout: { + ...components.Layout, + bodyBg: 'transparent', + footerBg: 'transparent', + headerBg: s.side, + siderBg: s.side, + lightSiderBg: s.side, + triggerBg: s.side, + }, + Menu: { + ...components.Menu, + itemBg: 'transparent', + subMenuItemBg: 'transparent', + darkItemBg: 'transparent', + darkSubMenuItemBg: 'transparent', + darkPopupBg: s.elevated, + popupBg: s.elevated, + }, + Card: { ...components.Card, colorBorderSecondary: s.border }, + }, + }; +} + export function buildAntdThemeConfig( isDark: boolean, isUltra: boolean, accent: AccentColor = 'default', + isGlass = false, ): ThemeConfig { + let config: ThemeConfig; if (!isDark) { const accentToken = accent === 'default' ? {} : { colorPrimary: accentPrimary(accent, false) }; - return { + config = { ...SHARED_STYLE_CONFIG, algorithm: antdTheme.defaultAlgorithm, token: { ...LIGHT_CONTRAST_TOKENS, ...accentToken }, @@ -169,21 +251,23 @@ export function buildAntdThemeConfig( Button: accent === 'default' ? LIGHT_BUTTON_TOKENS : {}, }, }; + } else { + const accentToken = accent === 'default' ? {} : { colorPrimary: accentPrimary(accent, true) }; + const darkInk = accent !== 'default' && ACCENT_SHADES[accent].darkInk; + config = { + ...SHARED_STYLE_CONFIG, + algorithm: antdTheme.darkAlgorithm, + token: { ...(isUltra ? ULTRA_DARK_TOKENS : DARK_TOKENS), ...accentToken }, + components: { + Layout: isUltra ? ULTRA_DARK_LAYOUT_TOKENS : DARK_LAYOUT_TOKENS, + Menu: isUltra ? ULTRA_DARK_MENU_TOKENS : DARK_MENU_TOKENS, + Card: isUltra ? ULTRA_DARK_CARD_TOKENS : DARK_CARD_TOKENS, + Statistic: STATISTIC_TOKENS, + ...(darkInk ? { Button: { primaryColor: DARK_INK } } : {}), + }, + }; } - const accentToken = accent === 'default' ? {} : { colorPrimary: accentPrimary(accent, true) }; - const darkInk = accent !== 'default' && ACCENT_SHADES[accent].darkInk; - return { - ...SHARED_STYLE_CONFIG, - algorithm: antdTheme.darkAlgorithm, - token: { ...(isUltra ? ULTRA_DARK_TOKENS : DARK_TOKENS), ...accentToken }, - components: { - Layout: isUltra ? ULTRA_DARK_LAYOUT_TOKENS : DARK_LAYOUT_TOKENS, - Menu: isUltra ? ULTRA_DARK_MENU_TOKENS : DARK_MENU_TOKENS, - Card: isUltra ? ULTRA_DARK_CARD_TOKENS : DARK_CARD_TOKENS, - Statistic: STATISTIC_TOKENS, - ...(darkInk ? { Button: { primaryColor: DARK_INK } } : {}), - }, - }; + return isGlass ? withGlass(config, isDark, isUltra) : config; } export function pauseAnimationsUntilLeave(elementId: string): void { @@ -206,6 +290,8 @@ interface ThemeContextValue { toggleUltra: () => void; accent: AccentColor; setAccent: (accent: AccentColor) => void; + isGlass: boolean; + setGlass: (isGlass: boolean) => void; antdThemeConfig: ThemeConfig; } @@ -215,6 +301,7 @@ export function ThemeProvider({ children }: { children: ReactNode }) { const [isDark, setIsDark] = useState(initialDark); const [isUltra, setIsUltra] = useState(initialUltra); const [accent, setAccent] = useState(initialAccent); + const [isGlass, setGlass] = useState(initialGlass); useLayoutEffect(() => { applyDom(isDark, isUltra); @@ -223,20 +310,32 @@ export function ThemeProvider({ children }: { children: ReactNode }) { }, [isDark, isUltra]); useLayoutEffect(() => { + applyGlassDom(isGlass, accent, isDark); localStorage.setItem(STORAGE_ACCENT, accent); - }, [accent]); + localStorage.setItem(STORAGE_GLASS, String(isGlass)); + }, [accent, isGlass, isDark]); const toggleTheme = useCallback(() => setIsDark((v) => !v), []); const toggleUltra = useCallback(() => setIsUltra((v) => !v), []); const antdThemeConfig = useMemo( - () => buildAntdThemeConfig(isDark, isUltra, accent), - [isDark, isUltra, accent], + () => buildAntdThemeConfig(isDark, isUltra, accent, isGlass), + [isDark, isUltra, accent, isGlass], ); const value = useMemo( - () => ({ isDark, isUltra, toggleTheme, toggleUltra, accent, setAccent, antdThemeConfig }), - [isDark, isUltra, toggleTheme, toggleUltra, accent, antdThemeConfig], + () => ({ + isDark, + isUltra, + toggleTheme, + toggleUltra, + accent, + setAccent, + isGlass, + setGlass, + antdThemeConfig, + }), + [isDark, isUltra, toggleTheme, toggleUltra, accent, isGlass, antdThemeConfig], ); return {children}; diff --git a/frontend/src/layouts/AppSidebar.css b/frontend/src/layouts/AppSidebar.css index d5cfd2ce382..d0fadc33c9a 100644 --- a/frontend/src/layouts/AppSidebar.css +++ b/frontend/src/layouts/AppSidebar.css @@ -156,6 +156,22 @@ font-size: 16px; } +.accent-panel { + display: flex; + flex-direction: column; + gap: 14px; +} + +.accent-glass { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + padding-top: 12px; + border-top: 1px solid var(--ant-color-border-secondary); + cursor: pointer; +} + .accent-swatches { display: grid; grid-template-columns: repeat(7, 28px); diff --git a/frontend/src/layouts/AppSidebar.tsx b/frontend/src/layouts/AppSidebar.tsx index 44d46748d15..98a740e6ee1 100644 --- a/frontend/src/layouts/AppSidebar.tsx +++ b/frontend/src/layouts/AppSidebar.tsx @@ -2,7 +2,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import type { ComponentType, CSSProperties } from 'react'; import { useLocation, useNavigate } from 'react-router'; import { useTranslation } from 'react-i18next'; -import { Drawer, Layout, Menu, Popover, Tooltip } from 'antd'; +import { Drawer, Layout, Menu, Popover, Switch, Tooltip } from 'antd'; import type { MenuProps } from 'antd'; import { ApartmentOutlined, @@ -140,22 +140,28 @@ function ThemeCycleButton({ function AccentPicker({ id, ariaLabel }: { id: string; ariaLabel: string }) { const { t } = useTranslation(); - const { isDark, accent, setAccent } = useTheme(); + const { isDark, accent, setAccent, isGlass, setGlass } = useTheme(); const swatches = ( -
- {ACCENT_COLORS.map((color) => ( -
+ ); return ( diff --git a/frontend/src/styles/glass.css b/frontend/src/styles/glass.css new file mode 100644 index 00000000000..02377a1f93f --- /dev/null +++ b/frontend/src/styles/glass.css @@ -0,0 +1,102 @@ +/* Glassy style: only with html[data-glass] (the toggle under the accent + colors). Surfaces are made translucent by the antd tokens in useTheme; this file + paints the glow behind them and adds the frosted blur. */ + +html[data-glass] { + --glass-base: #eef0f7; + --glass-glow: 36%; + --glass-solid: #f7f8fc; + --glass-blur: saturate(160%) blur(18px); + --glass-shadow: 0 16px 40px -24px rgba(15, 23, 42, 0.35); + --glass-highlight: inset 0 1px 0 rgba(255, 255, 255, 0.7); +} + +html[data-glass] body.dark { + --glass-base: #0b0912; + --glass-glow: 58%; + --glass-solid: #17131f; + --glass-shadow: 0 20px 50px -20px rgba(0, 0, 0, 0.6); + --glass-highlight: inset 0 1px 0 rgba(255, 255, 255, 0.06); +} + +html[data-glass][data-theme='ultra-dark'] body.dark { + --glass-base: #000; + --glass-glow: 44%; + --glass-solid: #0c0b11; +} + +html[data-glass] body { + background-color: var(--glass-base); + background-image: + radial-gradient( + 60rem 40rem at 0% 25%, + color-mix(in srgb, var(--xui-accent) var(--glass-glow), transparent), + transparent 70% + ), + radial-gradient(50rem 40rem at 100% 0%, rgba(31, 110, 255, 0.3), transparent 70%), + radial-gradient( + 45rem 32rem at 60% 110%, + color-mix(in srgb, var(--xui-accent) calc(var(--glass-glow) / 2), transparent), + transparent 70% + ); + background-attachment: fixed; +} + +/* page shells set a solid --bg-page per theme (.x-page.is-dark.is-ultra); the + important wins over any of them so the glow shows through */ +html[data-glass] body * { + --bg-page: transparent !important; + --bg-card: var(--ant-color-bg-container, transparent) !important; +} + +html[data-glass] .ant-card, +html[data-glass] .ant-layout-sider, +html[data-glass] .ant-drawer-content, +html[data-glass] .ant-modal-content, +html[data-glass] .ant-modal-container { + -webkit-backdrop-filter: var(--glass-blur); + backdrop-filter: var(--glass-blur); +} + +html[data-glass] .ant-card { + box-shadow: var(--glass-highlight), var(--glass-shadow); +} + +html[data-glass] .ant-popover-inner, +html[data-glass] .ant-popover-container, +html[data-glass] .ant-dropdown-menu, +html[data-glass] .ant-select-dropdown, +html[data-glass] .ant-picker-panel-container, +html[data-glass] .ant-message-notice-content, +html[data-glass] .ant-notification-notice { + background-color: color-mix(in srgb, var(--glass-solid) 97%, transparent); + -webkit-backdrop-filter: blur(18px); + backdrop-filter: blur(18px); +} + +/* sticky headers and fixed columns sit over scrolled rows: keep them solid */ +html[data-glass] .ant-table-thead > tr > th[class*='ant-table-cell-fix'], +html[data-glass] .ant-table-tbody > tr > td[class*='ant-table-cell-fix'], +html[data-glass] .ant-table-sticky-holder, +html[data-glass] .ant-table-header { + background-color: var(--glass-solid); +} + +/* the main button: an accent gradient */ +html[data-glass] .ant-btn-variant-solid.ant-btn-color-primary:not(:disabled) { + background: linear-gradient( + 135deg, + color-mix(in srgb, var(--ant-color-primary) 72%, #fff) 0%, + var(--ant-color-primary) 100% + ); + border-color: transparent; + box-shadow: 0 8px 22px -10px color-mix(in srgb, var(--ant-color-primary) 80%, transparent); +} + +html[data-glass] .ant-btn-variant-solid.ant-btn-color-primary:not(:disabled):hover { + filter: brightness(1.08); +} + +html[data-glass] .ant-menu-item-selected { + box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--ant-color-primary) 35%, transparent); +} diff --git a/internal/web/translation/ar-EG.json b/internal/web/translation/ar-EG.json index b3b3228ae60..fd13a318c44 100644 --- a/internal/web/translation/ar-EG.json +++ b/internal/web/translation/ar-EG.json @@ -118,6 +118,7 @@ "green": "أخضر", "purple": "بنفسجي" }, + "glassTheme": "مظهر زجاجي", "dashboard": "نظرة عامة", "inbounds": "الواردات", "clients": "العملاء", diff --git a/internal/web/translation/en-US.json b/internal/web/translation/en-US.json index b2462cb1f1a..2b4fdcfffaf 100644 --- a/internal/web/translation/en-US.json +++ b/internal/web/translation/en-US.json @@ -118,6 +118,7 @@ "green": "Green", "purple": "Purple" }, + "glassTheme": "Glassy style", "dashboard": "Overview", "inbounds": "Inbounds", "clients": "Clients", diff --git a/internal/web/translation/es-ES.json b/internal/web/translation/es-ES.json index b29c90239b5..10d7a3b06c2 100644 --- a/internal/web/translation/es-ES.json +++ b/internal/web/translation/es-ES.json @@ -118,6 +118,7 @@ "green": "Verde", "purple": "Morado" }, + "glassTheme": "Estilo cristal", "dashboard": "Estado del Sistema", "inbounds": "Entradas", "clients": "Clientes", diff --git a/internal/web/translation/fa-IR.json b/internal/web/translation/fa-IR.json index 93d9c8f900b..890149a297d 100644 --- a/internal/web/translation/fa-IR.json +++ b/internal/web/translation/fa-IR.json @@ -118,6 +118,7 @@ "green": "سبز", "purple": "بنفش" }, + "glassTheme": "ظاهر شیشه‌ای", "dashboard": "نمای کلی", "inbounds": "ورودی‌ها", "clients": "کلاینت‌ها", diff --git a/internal/web/translation/id-ID.json b/internal/web/translation/id-ID.json index 7b674eea495..0d2986c6a28 100644 --- a/internal/web/translation/id-ID.json +++ b/internal/web/translation/id-ID.json @@ -118,6 +118,7 @@ "green": "Hijau", "purple": "Ungu" }, + "glassTheme": "Gaya kaca", "dashboard": "Ikhtisar", "inbounds": "Inbound", "clients": "Klien", diff --git a/internal/web/translation/ja-JP.json b/internal/web/translation/ja-JP.json index e4c8a0a3fbc..c6ccd11a6de 100644 --- a/internal/web/translation/ja-JP.json +++ b/internal/web/translation/ja-JP.json @@ -118,6 +118,7 @@ "green": "グリーン", "purple": "パープル" }, + "glassTheme": "ガラス風スタイル", "dashboard": "ダッシュボード", "inbounds": "インバウンド", "clients": "クライアント", diff --git a/internal/web/translation/pt-BR.json b/internal/web/translation/pt-BR.json index a100ed1c4cb..68d1427ab73 100644 --- a/internal/web/translation/pt-BR.json +++ b/internal/web/translation/pt-BR.json @@ -118,6 +118,7 @@ "green": "Verde", "purple": "Roxo" }, + "glassTheme": "Estilo vidro", "dashboard": "Visão Geral", "inbounds": "Entradas", "clients": "Clientes", diff --git a/internal/web/translation/ru-RU.json b/internal/web/translation/ru-RU.json index 2810cecca6d..0d5c8f7f41e 100644 --- a/internal/web/translation/ru-RU.json +++ b/internal/web/translation/ru-RU.json @@ -118,6 +118,7 @@ "green": "Зелёный", "purple": "Фиолетовый" }, + "glassTheme": "Стеклянный стиль", "dashboard": "Дашборд", "inbounds": "Входящие", "clients": "Клиенты", diff --git a/internal/web/translation/sk-SK.json b/internal/web/translation/sk-SK.json index c4482c5280c..bd9c393117e 100644 --- a/internal/web/translation/sk-SK.json +++ b/internal/web/translation/sk-SK.json @@ -118,6 +118,7 @@ "green": "Zelená", "purple": "Fialová" }, + "glassTheme": "Sklenený štýl", "dashboard": "Prehľad", "inbounds": "Vstupy", "clients": "Klienti", diff --git a/internal/web/translation/tr-TR.json b/internal/web/translation/tr-TR.json index 034ffbadf39..a68b1537a98 100644 --- a/internal/web/translation/tr-TR.json +++ b/internal/web/translation/tr-TR.json @@ -118,6 +118,7 @@ "green": "Yeşil", "purple": "Mor" }, + "glassTheme": "Cam görünüm", "dashboard": "Genel Bakış", "inbounds": "Gelen Bağlantılar", "clients": "Kullanıcılar", diff --git a/internal/web/translation/uk-UA.json b/internal/web/translation/uk-UA.json index 7e4a2167e30..cb9c82ca9cc 100644 --- a/internal/web/translation/uk-UA.json +++ b/internal/web/translation/uk-UA.json @@ -118,6 +118,7 @@ "green": "Зелений", "purple": "Фіолетовий" }, + "glassTheme": "Скляний стиль", "dashboard": "Огляд", "inbounds": "Вхідні", "clients": "Клієнти", diff --git a/internal/web/translation/vi-VN.json b/internal/web/translation/vi-VN.json index ee918b61726..636d84824b3 100644 --- a/internal/web/translation/vi-VN.json +++ b/internal/web/translation/vi-VN.json @@ -118,6 +118,7 @@ "green": "Xanh lá", "purple": "Tím" }, + "glassTheme": "Kiểu kính", "dashboard": "Trạng thái hệ thống", "inbounds": "Inbound", "clients": "Khách hàng", diff --git a/internal/web/translation/zh-CN.json b/internal/web/translation/zh-CN.json index 0d40273399d..0900ed24c7a 100644 --- a/internal/web/translation/zh-CN.json +++ b/internal/web/translation/zh-CN.json @@ -118,6 +118,7 @@ "green": "绿色", "purple": "紫色" }, + "glassTheme": "玻璃风格", "dashboard": "系统状态", "inbounds": "入站", "clients": "客户端", diff --git a/internal/web/translation/zh-TW.json b/internal/web/translation/zh-TW.json index db935276b77..1c223bffa80 100644 --- a/internal/web/translation/zh-TW.json +++ b/internal/web/translation/zh-TW.json @@ -118,6 +118,7 @@ "green": "綠色", "purple": "紫色" }, + "glassTheme": "玻璃風格", "dashboard": "系統狀態", "inbounds": "入站", "clients": "客戶端", From 19b455b7b6f5f4e10c28f505e206299ae1e2f135 Mon Sep 17 00:00:00 2001 From: PluS98 Date: Sun, 25 Oct 2026 16:40:00 +0300 Subject: [PATCH 3/7] fix(frontend): overview sparklines follow the accent color; a wider glassy glow A healthy CPU/RAM/swap/disk tile drew its sparkline in a fixed blue; it now uses the accent color (warning and critical keep yellow and red). The glassy glow is sized to the viewport, so it covers most of the page on big screens too. --- frontend/src/models/status.ts | 3 ++- frontend/src/pages/index/VitalTile.tsx | 5 ++++- frontend/src/styles/glass.css | 12 ++++++------ 3 files changed, 12 insertions(+), 8 deletions(-) diff --git a/frontend/src/models/status.ts b/frontend/src/models/status.ts index 0fe2c8ab1f8..a23b03c65dd 100644 --- a/frontend/src/models/status.ts +++ b/frontend/src/models/status.ts @@ -1,6 +1,7 @@ import { NumberFormatter } from '@/utils'; import type { StatusInput as ParsedStatusInput } from '@/schemas/status'; +export const USAGE_OK_COLOR = '#1677ff'; export const USAGE_WARN_PERCENT = 80; export const USAGE_CRIT_PERCENT = 90; export const USAGE_WARN_COLOR = '#faad14'; @@ -22,7 +23,7 @@ export class CurTotal { get color(): string { const p = this.percent; - if (p < USAGE_WARN_PERCENT) return '#1677ff'; + if (p < USAGE_WARN_PERCENT) return USAGE_OK_COLOR; if (p < USAGE_CRIT_PERCENT) return USAGE_WARN_COLOR; return USAGE_CRIT_COLOR; } diff --git a/frontend/src/pages/index/VitalTile.tsx b/frontend/src/pages/index/VitalTile.tsx index 151abf1c9fc..7ea9c7c3ba7 100644 --- a/frontend/src/pages/index/VitalTile.tsx +++ b/frontend/src/pages/index/VitalTile.tsx @@ -3,6 +3,7 @@ import type { ReactNode } from 'react'; import { Card, theme } from 'antd'; import { Sparkline } from '@/components/viz'; +import { USAGE_OK_COLOR } from '@/models/status'; import { mean, peak } from './useOverviewHistory'; interface VitalTileProps { @@ -32,6 +33,8 @@ export default function VitalTile({ }: VitalTileProps) { const { token } = theme.useToken(); const meanColor = token.colorTextTertiary; + // A healthy reading follows the accent color; warning and critical keep their own. + const stroke = statusColor === USAGE_OK_COLOR ? token.colorPrimary : statusColor; const referenceLines = useMemo( () => (data.length > 1 ? [{ y: mean(data), dash: '3 4', color: meanColor }] : []), @@ -66,7 +69,7 @@ export default function VitalTile({ showGrid={false} showMarker={false} valueMax={peak(data) > 0 ? null : 100} - stroke={statusColor} + stroke={stroke} referenceLines={referenceLines} yFormatter={(v) => `${v.toFixed(0)}%`} name1={label} diff --git a/frontend/src/styles/glass.css b/frontend/src/styles/glass.css index 02377a1f93f..3ef58c085c1 100644 --- a/frontend/src/styles/glass.css +++ b/frontend/src/styles/glass.css @@ -29,15 +29,15 @@ html[data-glass] body { background-color: var(--glass-base); background-image: radial-gradient( - 60rem 40rem at 0% 25%, + 95vw 90vh at 0% 30%, color-mix(in srgb, var(--xui-accent) var(--glass-glow), transparent), - transparent 70% + transparent 75% ), - radial-gradient(50rem 40rem at 100% 0%, rgba(31, 110, 255, 0.3), transparent 70%), + radial-gradient(70vw 70vh at 100% 0%, rgba(31, 110, 255, 0.3), transparent 72%), radial-gradient( - 45rem 32rem at 60% 110%, - color-mix(in srgb, var(--xui-accent) calc(var(--glass-glow) / 2), transparent), - transparent 70% + 90vw 75vh at 55% 112%, + color-mix(in srgb, var(--xui-accent) calc(var(--glass-glow) * 0.8), transparent), + transparent 75% ); background-attachment: fixed; } From 6cb36ae6b7518912375be2951de05e6e98ee8dea Mon Sep 17 00:00:00 2001 From: PluS98 Date: Sun, 25 Oct 2026 16:49:02 +0300 Subject: [PATCH 4/7] fix(frontend): keep the mobile menu's search box full size; brighter dark accents On a short phone screen the drawer's flex column squeezed the search box to a sliver once the page list outgrew the screen. The page list now scrolls on its own and the header, search box and log-out row keep their size. Dark themes use Smart Caddy's brighter accent tones (more readable on the dark cards), with dark button text where white would not read, and a softer glassy glow. --- frontend/src/hooks/useTheme.tsx | 14 +++++++------- frontend/src/layouts/AppSidebar.css | 15 +++++++++++++++ frontend/src/styles/glass.css | 6 +++--- 3 files changed, 25 insertions(+), 10 deletions(-) diff --git a/frontend/src/hooks/useTheme.tsx b/frontend/src/hooks/useTheme.tsx index 1098e1302c3..82470029504 100644 --- a/frontend/src/hooks/useTheme.tsx +++ b/frontend/src/hooks/useTheme.tsx @@ -21,18 +21,18 @@ export const ACCENT_COLORS = [ ] as const; export type AccentColor = (typeof ACCENT_COLORS)[number]; -// light: white text on it stays >= 4.5:1; dark: readable on dark cards, with -// dark text on the button where white text would not be (orange/yellow/green). +// light: white text on it stays >= 4.5:1; dark: Smart Caddy's brighter tones, readable on +// dark cards, with dark text on the button where white would not be. const ACCENT_SHADES: Record< Exclude, { light: string; dark: string; darkInk: boolean } > = { - red: { light: '#dc2626', dark: '#ef4444', darkInk: false }, - pink: { light: '#db2777', dark: '#ec4899', darkInk: false }, - orange: { light: '#c2410c', dark: '#fb923c', darkInk: true }, + red: { light: '#dc2626', dark: '#ff5468', darkInk: false }, + pink: { light: '#db2777', dark: '#ff5fa2', darkInk: true }, + orange: { light: '#c2410c', dark: '#ff8a3d', darkInk: true }, yellow: { light: '#a16207', dark: '#facc15', darkInk: true }, - green: { light: '#047857', dark: '#34d399', darkInk: true }, - purple: { light: '#7c3aed', dark: '#8b5cf6', darkInk: false }, + green: { light: '#047857', dark: '#2fd68f', darkInk: true }, + purple: { light: '#7c3aed', dark: '#8f80ff', darkInk: false }, }; const DEFAULT_PRIMARY = { light: '#0958d9', dark: '#1677ff' }; const DARK_INK = '#141414'; diff --git a/frontend/src/layouts/AppSidebar.css b/frontend/src/layouts/AppSidebar.css index d0fadc33c9a..72b9bc69f13 100644 --- a/frontend/src/layouts/AppSidebar.css +++ b/frontend/src/layouts/AppSidebar.css @@ -270,6 +270,21 @@ border-top: 1px solid var(--ant-color-border-secondary); } +/* On a short phone screen the page list scrolls; the header, the search box and the + log-out row keep their size instead of being squeezed by the flex column. */ +.drawer-nav { + flex: 1 1 auto; + min-height: 0; + overflow-y: auto; +} + +.drawer-header, +.ant-drawer .sidebar-command-trigger, +.drawer-utility, +.drawer-footer { + flex-shrink: 0; +} + .ant-sidebar > .ant-layout-sider .ant-layout-sider-children { display: flex; flex-direction: column; diff --git a/frontend/src/styles/glass.css b/frontend/src/styles/glass.css index 3ef58c085c1..feb987df80c 100644 --- a/frontend/src/styles/glass.css +++ b/frontend/src/styles/glass.css @@ -4,7 +4,7 @@ html[data-glass] { --glass-base: #eef0f7; - --glass-glow: 36%; + --glass-glow: 30%; --glass-solid: #f7f8fc; --glass-blur: saturate(160%) blur(18px); --glass-shadow: 0 16px 40px -24px rgba(15, 23, 42, 0.35); @@ -13,7 +13,7 @@ html[data-glass] { html[data-glass] body.dark { --glass-base: #0b0912; - --glass-glow: 58%; + --glass-glow: 46%; --glass-solid: #17131f; --glass-shadow: 0 20px 50px -20px rgba(0, 0, 0, 0.6); --glass-highlight: inset 0 1px 0 rgba(255, 255, 255, 0.06); @@ -21,7 +21,7 @@ html[data-glass] body.dark { html[data-glass][data-theme='ultra-dark'] body.dark { --glass-base: #000; - --glass-glow: 44%; + --glass-glow: 36%; --glass-solid: #0c0b11; } From aa1badd10d00ebfa37e650f140f3fd4ca0db5351 Mon Sep 17 00:00:00 2001 From: PluS98 Date: Sun, 25 Oct 2026 17:07:35 +0300 Subject: [PATCH 5/7] fix(frontend): keep the accent popover on screen in the mobile menu In the drawer the palette button sits near the middle, so a popover aligned to its right edge ran past the left edge of a phone screen and hid the first swatch. The drawer's popover opens centred under the button, and the swatches wrap on screens too narrow for seven in a row. --- frontend/src/layouts/AppSidebar.css | 4 +++- frontend/src/layouts/AppSidebar.tsx | 18 +++++++++++++++--- 2 files changed, 18 insertions(+), 4 deletions(-) diff --git a/frontend/src/layouts/AppSidebar.css b/frontend/src/layouts/AppSidebar.css index 72b9bc69f13..332fcbffcba 100644 --- a/frontend/src/layouts/AppSidebar.css +++ b/frontend/src/layouts/AppSidebar.css @@ -174,8 +174,10 @@ .accent-swatches { display: grid; - grid-template-columns: repeat(7, 28px); + grid-template-columns: repeat(auto-fill, 28px); gap: 8px; + /* seven in a row, fewer on a phone too narrow for the popover */ + width: min(244px, calc(100vw - 64px)); } .accent-swatch { diff --git a/frontend/src/layouts/AppSidebar.tsx b/frontend/src/layouts/AppSidebar.tsx index 98a740e6ee1..e4be046593a 100644 --- a/frontend/src/layouts/AppSidebar.tsx +++ b/frontend/src/layouts/AppSidebar.tsx @@ -138,7 +138,15 @@ function ThemeCycleButton({ ); } -function AccentPicker({ id, ariaLabel }: { id: string; ariaLabel: string }) { +function AccentPicker({ + id, + ariaLabel, + placement = 'bottomRight', +}: { + id: string; + ariaLabel: string; + placement?: 'bottomRight' | 'bottom'; +}) { const { t } = useTranslation(); const { isDark, accent, setAccent, isGlass, setGlass } = useTheme(); const swatches = ( @@ -165,7 +173,7 @@ function AccentPicker({ id, ariaLabel }: { id: string; ariaLabel: string }) { ); return ( - + ; + } + return render( + + + , + ); +} + +test('--xui-accent is on :root with the glass style off', async () => { + const { getByRole } = await renderWithStoredAccent(null); + expect(document.documentElement.hasAttribute('data-glass')).toBe(false); + expect(document.documentElement.style.getPropertyValue('--xui-accent')).toBe('#1677ff'); + + fireEvent.click(getByRole('button')); + expect(document.documentElement.style.getPropertyValue('--xui-accent')).toBe('#ff5468'); +}); + +test('an unknown stored accent falls back to the default blue', async () => { + await renderWithStoredAccent('teal'); + expect(document.documentElement.style.getPropertyValue('--xui-accent')).toBe('#1677ff'); +});