Skip to content
22 changes: 11 additions & 11 deletions frontend/src/components/command-palette/CommandPalette.css
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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;
}

Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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);
}

Expand All @@ -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);
}

Expand All @@ -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);
}

Expand Down
2 changes: 1 addition & 1 deletion frontend/src/components/command-palette/CommandPalette.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -355,7 +355,7 @@ export default function CommandPalette() {
category: 'inbounds',
title: ib.remark || ib.tag || `Inbound #${ib.id}`,
subtitle: `Port ${ib.port || ''}`,
icon: <ImportOutlined aria-hidden style={{ color: '#1677ff' }} />,
icon: <ImportOutlined aria-hidden style={{ color: 'var(--xui-accent)' }} />,
tag:
tags.length > 0 ? (
<div style={{ display: 'inline-flex', gap: 4, flexWrap: 'wrap' }}>{tags}</div>
Expand Down
13 changes: 7 additions & 6 deletions frontend/src/components/form/DateTimePicker.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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<HTMLDivElement>(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.
Expand All @@ -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)) {
Expand Down
194 changes: 176 additions & 18 deletions frontend/src/hooks/useTheme.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,15 +3,58 @@ 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<AccentColor, 'default'>,
{ 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);
if (raw === null) return fallback;
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');
Expand All @@ -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',
Expand Down Expand Up @@ -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 {
Expand All @@ -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;
}

Expand All @@ -163,21 +300,42 @@ const ThemeContext = createContext<ThemeContextValue | null>(null);
export function ThemeProvider({ children }: { children: ReactNode }) {
const [isDark, setIsDark] = useState<boolean>(initialDark);
const [isUltra, setIsUltra] = useState<boolean>(initialUltra);
const [accent, setAccent] = useState<AccentColor>(initialAccent);
const [isGlass, setGlass] = useState<boolean>(initialGlass);

useLayoutEffect(() => {
applyDom(isDark, isUltra);
localStorage.setItem(STORAGE_DARK, String(isDark));
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<ThemeContextValue>(
() => ({ 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 <ThemeContext.Provider value={value}>{children}</ThemeContext.Provider>;
Expand Down
Loading
Loading