diff --git a/frontend/src/components/command-palette/CommandPalette.css b/frontend/src/components/command-palette/CommandPalette.css
index 570b71a98d3..7e838268ad4 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(--xui-accent);
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(--xui-accent);
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: color-mix(in srgb, var(--xui-accent) 10%, #fff);
+ color: var(--xui-accent);
}
.command-palette-modal.dark .command-palette-item.active {
- background: rgba(22, 119, 255, 0.2);
- color: #4096ff;
+ background: color-mix(in srgb, var(--xui-accent) 20%, transparent);
+ color: color-mix(in srgb, var(--xui-accent) 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(--xui-accent) 28%, transparent);
+ color: color-mix(in srgb, var(--xui-accent) 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(--xui-accent);
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(--xui-accent) 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(--xui-accent) 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..dbf0970d16e 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..2a17f0f63b1 100644
--- a/frontend/src/hooks/useTheme.tsx
+++ b/frontend/src/hooks/useTheme.tsx
@@ -3,8 +3,44 @@ 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',
+ '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: 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: '#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: '#2fd68f', darkInk: true },
+ purple: { light: '#7c3aed', dark: '#8f80ff', 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 +48,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');
@@ -29,10 +72,25 @@ function applyDom(isDark: boolean, isUltra: boolean) {
}
}
+// --xui-accent lives on :root because antd's --ant-* vars only reach antd roots;
+// the glass glow and the command palette (plain divs) read it.
+function applyAccentDom(isGlass: boolean, accent: AccentColor, isDark: boolean) {
+ const root = document.documentElement;
+ root.style.setProperty('--xui-accent', accentPrimary(accent, isDark));
+ if (isGlass) {
+ root.setAttribute('data-glass', 'on');
+ } else {
+ root.removeAttribute('data-glass');
+ }
+}
+
// 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);
+applyAccentDom(initialGlass, initialAccent, initialDark);
const DARK_TOKENS = {
colorBgBase: '#1a1b1f',
@@ -112,29 +170,104 @@ const SHARED_STYLE_CONFIG = {
cssVar: { key: 'xui' },
} as const;
-export function buildAntdThemeConfig(isDark: boolean, isUltra: boolean): ThemeConfig {
+// 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) {
- return {
+ const accentToken = accent === 'default' ? {} : { colorPrimary: accentPrimary(accent, false) };
+ config = {
...SHARED_STYLE_CONFIG,
algorithm: antdTheme.defaultAlgorithm,
- token: LIGHT_CONTRAST_TOKENS,
+ token: { ...LIGHT_CONTRAST_TOKENS, ...accentToken },
+ components: {
+ Statistic: STATISTIC_TOKENS,
+ 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,
- Button: LIGHT_BUTTON_TOKENS,
+ ...(darkInk ? { Button: { primaryColor: DARK_INK } } : {}),
},
};
}
- return {
- ...SHARED_STYLE_CONFIG,
- algorithm: antdTheme.darkAlgorithm,
- token: isUltra ? ULTRA_DARK_TOKENS : DARK_TOKENS,
- 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,
- },
- };
+ return isGlass ? withGlass(config, isDark, isUltra) : config;
}
export function pauseAnimationsUntilLeave(elementId: string): void {
@@ -155,6 +288,10 @@ interface ThemeContextValue {
isUltra: boolean;
toggleTheme: () => void;
toggleUltra: () => void;
+ accent: AccentColor;
+ setAccent: (accent: AccentColor) => void;
+ isGlass: boolean;
+ setGlass: (isGlass: boolean) => void;
antdThemeConfig: ThemeConfig;
}
@@ -163,6 +300,8 @@ 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);
+ const [isGlass, setGlass] = useState(initialGlass);
useLayoutEffect(() => {
applyDom(isDark, isUltra);
@@ -170,14 +309,33 @@ export function ThemeProvider({ children }: { children: ReactNode }) {
localStorage.setItem(STORAGE_ULTRA, String(isUltra));
}, [isDark, isUltra]);
+ useLayoutEffect(() => {
+ applyAccentDom(isGlass, accent, isDark);
+ localStorage.setItem(STORAGE_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), [isDark, isUltra]);
+ const antdThemeConfig = useMemo(
+ () => buildAntdThemeConfig(isDark, isUltra, accent, isGlass),
+ [isDark, isUltra, accent, isGlass],
+ );
const value = useMemo(
- () => ({ isDark, isUltra, toggleTheme, toggleUltra, antdThemeConfig }),
- [isDark, isUltra, toggleTheme, toggleUltra, 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 ddd1016282c..b3f3ba56591 100644
--- a/frontend/src/layouts/AppSidebar.css
+++ b/frontend/src/layouts/AppSidebar.css
@@ -156,6 +156,54 @@
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(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 {
+ 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;
@@ -185,6 +233,7 @@
display: flex;
align-items: center;
justify-content: space-between;
+ gap: 16px;
padding: 14px 16px;
border-bottom: 1px solid var(--ant-color-border-secondary);
}
@@ -224,6 +273,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/layouts/AppSidebar.tsx b/frontend/src/layouts/AppSidebar.tsx
index e86cc260dbe..9dcf7d35c8b 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, Switch, 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,55 @@ function ThemeCycleButton({
);
}
+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 = (
+
+
+ {ACCENT_COLORS.map((color) => (
+
+
+
+ );
+ return (
+
+
+
+ );
+}
+
function readSidebarPinned() {
try {
return localStorage.getItem(SIDEBAR_PINNED_KEY) === 'true';
@@ -383,6 +438,7 @@ export default function AppSidebar() {
+
+
(data.length > 1 ? [{ y: mean(data), dash: '3 4', color: meanColor }] : []),
@@ -75,7 +78,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
new file mode 100644
index 00000000000..a1a1f24b17b
--- /dev/null
+++ b/frontend/src/styles/glass.css
@@ -0,0 +1,106 @@
+/* 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: 30%;
+ --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: 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);
+}
+
+html[data-glass][data-theme='ultra-dark'] body.dark {
+ --glass-base: #000;
+ --glass-glow: 36%;
+ --glass-solid: #0c0b11;
+}
+
+html[data-glass] body {
+ background-color: var(--glass-base);
+ background-image:
+ radial-gradient(
+ 95vw 90vh at 0% 30%,
+ color-mix(in srgb, var(--xui-accent) var(--glass-glow), transparent),
+ transparent 75%
+ ),
+ radial-gradient(
+ 70vw 70vh at 100% 0%,
+ color-mix(in srgb, var(--xui-accent) calc(var(--glass-glow) * 0.6), transparent),
+ transparent 72%
+ ),
+ radial-gradient(
+ 90vw 75vh at 55% 112%,
+ color-mix(in srgb, var(--xui-accent) calc(var(--glass-glow) * 0.8), transparent),
+ transparent 75%
+ );
+ 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/frontend/src/test/accent-root-var.test.tsx b/frontend/src/test/accent-root-var.test.tsx
new file mode 100644
index 00000000000..f72f9f62711
--- /dev/null
+++ b/frontend/src/test/accent-root-var.test.tsx
@@ -0,0 +1,59 @@
+import { readFileSync } from 'node:fs';
+import { resolve } from 'node:path';
+
+import { fireEvent, render } from '@testing-library/react';
+import { afterEach, expect, test, vi } from 'vitest';
+
+// vitest stubs CSS imports (even ?raw) to '', so read the sources from disk.
+const source = (path: string) => readFileSync(resolve(process.cwd(), 'src', path), 'utf8');
+const paletteCss = source('components/command-palette/CommandPalette.css');
+const paletteTsx = source('components/command-palette/CommandPalette.tsx');
+const glassCss = source('styles/glass.css');
+
+afterEach(() => {
+ localStorage.clear();
+ document.documentElement.removeAttribute('data-glass');
+ document.documentElement.style.removeProperty('--xui-accent');
+});
+
+// The palette is plain divs under a DOM-less ConfigProvider, so antd's --ant-*
+// vars (scoped to the .xui class on antd roots) resolve to nothing there.
+test('command palette colours do not depend on antd-scoped CSS vars', () => {
+ expect(paletteCss).not.toMatch(/var\(--ant-/);
+ expect(paletteTsx).not.toMatch(/var\(--ant-/);
+});
+
+test('every glass glow follows the accent', () => {
+ const gradients = glassCss.split('radial-gradient(').slice(1);
+ expect(gradients).toHaveLength(3);
+ for (const g of gradients) expect(g).toContain('var(--xui-accent)');
+});
+
+async function renderWithStoredAccent(stored: string | null) {
+ if (stored !== null) localStorage.setItem('accentColor', stored);
+ vi.resetModules();
+ const { ThemeProvider, useTheme } = await import('@/hooks/useTheme');
+ function PickRed() {
+ const { setAccent } = useTheme();
+ 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');
+});
diff --git a/internal/web/translation/ar-EG.json b/internal/web/translation/ar-EG.json
index 80f302f5114..a1f718bd220 100644
--- a/internal/web/translation/ar-EG.json
+++ b/internal/web/translation/ar-EG.json
@@ -111,6 +111,17 @@
},
"menu": {
"theme": "الثيم",
+ "accentColor": "لون التمييز",
+ "accentColors": {
+ "default": "افتراضي",
+ "red": "أحمر",
+ "pink": "وردي",
+ "orange": "برتقالي",
+ "yellow": "أصفر",
+ "green": "أخضر",
+ "purple": "بنفسجي"
+ },
+ "glassTheme": "مظهر زجاجي",
"dashboard": "نظرة عامة",
"inbounds": "الواردات",
"clients": "العملاء",
diff --git a/internal/web/translation/en-US.json b/internal/web/translation/en-US.json
index feb00c580d2..f2d95499fd8 100644
--- a/internal/web/translation/en-US.json
+++ b/internal/web/translation/en-US.json
@@ -111,6 +111,17 @@
},
"menu": {
"theme": "Theme",
+ "accentColor": "Accent color",
+ "accentColors": {
+ "default": "Default",
+ "red": "Red",
+ "pink": "Pink",
+ "orange": "Orange",
+ "yellow": "Yellow",
+ "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 445ce182654..a54460fbcf8 100644
--- a/internal/web/translation/es-ES.json
+++ b/internal/web/translation/es-ES.json
@@ -111,6 +111,17 @@
},
"menu": {
"theme": "Tema",
+ "accentColor": "Color de acento",
+ "accentColors": {
+ "default": "Predeterminado",
+ "red": "Rojo",
+ "pink": "Rosa",
+ "orange": "Naranja",
+ "yellow": "Amarillo",
+ "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 8b747b94282..152c723975b 100644
--- a/internal/web/translation/fa-IR.json
+++ b/internal/web/translation/fa-IR.json
@@ -111,6 +111,17 @@
},
"menu": {
"theme": "تم",
+ "accentColor": "رنگ اصلی",
+ "accentColors": {
+ "default": "پیشفرض",
+ "red": "قرمز",
+ "pink": "صورتی",
+ "orange": "نارنجی",
+ "yellow": "زرد",
+ "green": "سبز",
+ "purple": "بنفش"
+ },
+ "glassTheme": "ظاهر شیشهای",
"dashboard": "نمای کلی",
"inbounds": "ورودیها",
"clients": "کلاینتها",
diff --git a/internal/web/translation/id-ID.json b/internal/web/translation/id-ID.json
index 5ecee70c18f..5dee1a900df 100644
--- a/internal/web/translation/id-ID.json
+++ b/internal/web/translation/id-ID.json
@@ -111,6 +111,17 @@
},
"menu": {
"theme": "Tema",
+ "accentColor": "Warna aksen",
+ "accentColors": {
+ "default": "Bawaan",
+ "red": "Merah",
+ "pink": "Merah muda",
+ "orange": "Oranye",
+ "yellow": "Kuning",
+ "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 52bd3c9b2b1..38bf1332798 100644
--- a/internal/web/translation/ja-JP.json
+++ b/internal/web/translation/ja-JP.json
@@ -111,6 +111,17 @@
},
"menu": {
"theme": "テーマ",
+ "accentColor": "アクセントカラー",
+ "accentColors": {
+ "default": "デフォルト",
+ "red": "レッド",
+ "pink": "ピンク",
+ "orange": "オレンジ",
+ "yellow": "イエロー",
+ "green": "グリーン",
+ "purple": "パープル"
+ },
+ "glassTheme": "ガラス風スタイル",
"dashboard": "ダッシュボード",
"inbounds": "インバウンド",
"clients": "クライアント",
diff --git a/internal/web/translation/pt-BR.json b/internal/web/translation/pt-BR.json
index 11e238b90c1..ac5f842857e 100644
--- a/internal/web/translation/pt-BR.json
+++ b/internal/web/translation/pt-BR.json
@@ -111,6 +111,17 @@
},
"menu": {
"theme": "Tema",
+ "accentColor": "Cor de destaque",
+ "accentColors": {
+ "default": "Padrão",
+ "red": "Vermelho",
+ "pink": "Rosa",
+ "orange": "Laranja",
+ "yellow": "Amarelo",
+ "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 5e4bbce3da8..f80119822f8 100644
--- a/internal/web/translation/ru-RU.json
+++ b/internal/web/translation/ru-RU.json
@@ -111,6 +111,17 @@
},
"menu": {
"theme": "Тема",
+ "accentColor": "Цвет акцента",
+ "accentColors": {
+ "default": "По умолчанию",
+ "red": "Красный",
+ "pink": "Розовый",
+ "orange": "Оранжевый",
+ "yellow": "Жёлтый",
+ "green": "Зелёный",
+ "purple": "Фиолетовый"
+ },
+ "glassTheme": "Стеклянный стиль",
"dashboard": "Дашборд",
"inbounds": "Входящие",
"clients": "Клиенты",
diff --git a/internal/web/translation/sk-SK.json b/internal/web/translation/sk-SK.json
index edb961b007d..5fae6da1bf1 100644
--- a/internal/web/translation/sk-SK.json
+++ b/internal/web/translation/sk-SK.json
@@ -111,6 +111,17 @@
},
"menu": {
"theme": "Vzhľad",
+ "accentColor": "Farba zvýraznenia",
+ "accentColors": {
+ "default": "Predvolená",
+ "red": "Červená",
+ "pink": "Ružová",
+ "orange": "Oranžová",
+ "yellow": "Žltá",
+ "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 3bc0cc4d708..4db9dcd974c 100644
--- a/internal/web/translation/tr-TR.json
+++ b/internal/web/translation/tr-TR.json
@@ -111,6 +111,17 @@
},
"menu": {
"theme": "Tema",
+ "accentColor": "Vurgu rengi",
+ "accentColors": {
+ "default": "Varsayılan",
+ "red": "Kırmızı",
+ "pink": "Pembe",
+ "orange": "Turuncu",
+ "yellow": "Sarı",
+ "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 af803fd74ef..b45164bdc3c 100644
--- a/internal/web/translation/uk-UA.json
+++ b/internal/web/translation/uk-UA.json
@@ -111,6 +111,17 @@
},
"menu": {
"theme": "Тема",
+ "accentColor": "Колір акценту",
+ "accentColors": {
+ "default": "Типовий",
+ "red": "Червоний",
+ "pink": "Рожевий",
+ "orange": "Помаранчевий",
+ "yellow": "Жовтий",
+ "green": "Зелений",
+ "purple": "Фіолетовий"
+ },
+ "glassTheme": "Скляний стиль",
"dashboard": "Огляд",
"inbounds": "Вхідні",
"clients": "Клієнти",
diff --git a/internal/web/translation/vi-VN.json b/internal/web/translation/vi-VN.json
index 68a702ad14c..e367aea5f18 100644
--- a/internal/web/translation/vi-VN.json
+++ b/internal/web/translation/vi-VN.json
@@ -111,6 +111,17 @@
},
"menu": {
"theme": "Chủ đề",
+ "accentColor": "Màu nhấn",
+ "accentColors": {
+ "default": "Mặc định",
+ "red": "Đỏ",
+ "pink": "Hồng",
+ "orange": "Cam",
+ "yellow": "Vàng",
+ "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 b59f15ffa1d..f5049a0dd59 100644
--- a/internal/web/translation/zh-CN.json
+++ b/internal/web/translation/zh-CN.json
@@ -111,6 +111,17 @@
},
"menu": {
"theme": "主题",
+ "accentColor": "强调色",
+ "accentColors": {
+ "default": "默认",
+ "red": "红色",
+ "pink": "粉色",
+ "orange": "橙色",
+ "yellow": "黄色",
+ "green": "绿色",
+ "purple": "紫色"
+ },
+ "glassTheme": "玻璃风格",
"dashboard": "系统状态",
"inbounds": "入站",
"clients": "客户端",
diff --git a/internal/web/translation/zh-TW.json b/internal/web/translation/zh-TW.json
index 02251ac9818..a8523db741a 100644
--- a/internal/web/translation/zh-TW.json
+++ b/internal/web/translation/zh-TW.json
@@ -111,6 +111,17 @@
},
"menu": {
"theme": "主題",
+ "accentColor": "強調色",
+ "accentColors": {
+ "default": "預設",
+ "red": "紅色",
+ "pink": "粉紅色",
+ "orange": "橙色",
+ "yellow": "黃色",
+ "green": "綠色",
+ "purple": "紫色"
+ },
+ "glassTheme": "玻璃風格",
"dashboard": "系統狀態",
"inbounds": "入站",
"clients": "客戶端",