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": "客戶端",