소스 검색

feat(frontend): refresh dark theme + redesign login page

- Swap navy dark palette for VS Code Dark+ neutrals (#1e1e1e/#252526/
  #2d2d30) across theme tokens, page backgrounds and DateTimePicker
- Add brand header to the mobile drawer and desktop sider, and recolor
  the drawer body so it reads as one panel with the menu
- Redesign login page with a centered card, cycling Hello/Welcome
  headline and per-theme animated gradient-blob backgrounds
MHSanaei 1 일 전
부모
커밋
c1efc48694

+ 162 - 11
frontend/src/components/AppSidebar.vue

@@ -9,7 +9,7 @@ import {
   ClusterOutlined,
   LogoutOutlined,
   CloseOutlined,
-  MenuFoldOutlined,
+  MenuOutlined,
 } from '@ant-design/icons-vue';
 
 import { currentTheme } from '@/composables/useTheme.js';
@@ -58,11 +58,23 @@ const tabs = computed(() => [
   { key: `${prefix}logout`, icon: 'logout', title: t('logout') },
 ]);
 
+// Logout sits in its own pinned-to-bottom block on the drawer; the
+// remaining items are the navigation proper. The full-height sider on
+// desktop still uses `tabs` as-is so the desktop look is unchanged.
+const navTabs = computed(() => tabs.value.filter((tab) => tab.icon !== 'logout'));
+const utilTabs = computed(() => tabs.value.filter((tab) => tab.icon === 'logout'));
+
 const activeTab = ref([props.requestUri]);
 
 const drawerOpen = ref(false);
 const collapsed = ref(JSON.parse(localStorage.getItem(SIDEBAR_COLLAPSED_KEY) || 'false'));
 
+// Drawer width is capped against the viewport — AD-Vue's default 378px
+// overflows on narrow phones (e.g. 360px portrait), leaving the page
+// hidden behind the mask. `min()` keeps it sane on both phones and
+// tablets while never exceeding 320px on larger displays.
+const drawerWidth = 'min(82vw, 320px)';
+
 function openLink(key) {
   if (key.startsWith('http')) {
     window.open(key);
@@ -91,6 +103,9 @@ function closeDrawer() {
 <template>
   <div class="ant-sidebar">
     <a-layout-sider :theme="currentTheme" collapsible :collapsed="collapsed" breakpoint="md" @collapse="onCollapse">
+      <div class="sider-brand" :class="{ 'sider-brand-collapsed': collapsed }">
+        {{ collapsed ? '3X' : '3X-UI' }}
+      </div>
       <ThemeSwitch />
       <a-menu :theme="currentTheme" mode="inline" :selected-keys="activeTab" @click="({ key }) => openLink(key)">
         <a-menu-item v-for="tab in tabs" :key="tab.key">
@@ -101,19 +116,35 @@ function closeDrawer() {
     </a-layout-sider>
 
     <a-drawer placement="left" :closable="false" :open="drawerOpen" :wrap-class-name="currentTheme"
-      :wrap-style="{ padding: 0 }" :style="{ height: '100%' }" @close="closeDrawer">
+      :wrap-style="{ padding: 0 }" :width="drawerWidth"
+      :body-style="{ padding: 0, display: 'flex', flexDirection: 'column', height: '100%' }"
+      :header-style="{ display: 'none' }" @close="closeDrawer">
+      <div class="drawer-header">
+        <span class="drawer-brand">3X-UI</span>
+        <button class="drawer-close" type="button" :aria-label="t('close')" @click="closeDrawer">
+          <CloseOutlined />
+        </button>
+      </div>
       <ThemeSwitch />
-      <a-menu :theme="currentTheme" mode="inline" :selected-keys="activeTab" @click="({ key }) => openLink(key)">
-        <a-menu-item v-for="tab in tabs" :key="tab.key">
+      <a-menu :theme="currentTheme" mode="inline" :selected-keys="activeTab" class="drawer-menu drawer-nav"
+        @click="({ key }) => openLink(key)">
+        <a-menu-item v-for="tab in navTabs" :key="tab.key">
+          <component :is="iconByName[tab.icon]" />
+          <span>{{ tab.title }}</span>
+        </a-menu-item>
+      </a-menu>
+      <a-menu :theme="currentTheme" mode="inline" :selected-keys="activeTab" class="drawer-menu drawer-utility"
+        @click="({ key }) => openLink(key)">
+        <a-menu-item v-for="tab in utilTabs" :key="tab.key">
           <component :is="iconByName[tab.icon]" />
           <span>{{ tab.title }}</span>
         </a-menu-item>
       </a-menu>
     </a-drawer>
 
-    <button class="drawer-handle" type="button" @click="toggleDrawer">
-      <CloseOutlined v-if="drawerOpen" />
-      <MenuFoldOutlined v-else />
+    <button v-show="!drawerOpen" class="drawer-handle" type="button" :aria-label="t('menu.dashboard')"
+      @click="toggleDrawer">
+      <MenuOutlined />
     </button>
   </div>
 </template>
@@ -123,21 +154,96 @@ function closeDrawer() {
   height: 100%;
 }
 
+/* `.sider-brand` and `.drawer-brand` share the same light-theme colour
+ * but differ in layout — the sider one is centered with its own
+ * top-of-sidebar padding + border, the drawer one sits inside a flex
+ * header next to the close button. Dark/ultra colour overrides live
+ * in the non-scoped block at the bottom (theme classes attach to
+ * body / html). */
+.sider-brand,
+.drawer-brand {
+  font-weight: 600;
+  font-size: 18px;
+  letter-spacing: 0.5px;
+  color: rgba(0, 0, 0, 0.88);
+}
+
+.sider-brand {
+  text-align: center;
+  padding: 16px 12px;
+  border-bottom: 1px solid rgba(128, 128, 128, 0.15);
+  user-select: none;
+}
+
+.sider-brand-collapsed {
+  font-size: 16px;
+  padding: 16px 4px;
+  letter-spacing: 0;
+}
+
 .drawer-handle {
   position: fixed;
-  top: 16px;
-  left: 16px;
+  top: 12px;
+  left: 12px;
   z-index: 1100;
   background: rgba(0, 0, 0, 0.55);
   color: #fff;
   border: none;
-  width: 36px;
-  height: 36px;
+  width: 40px;
+  height: 40px;
   border-radius: 50%;
   cursor: pointer;
   display: none;
   align-items: center;
   justify-content: center;
+  font-size: 18px;
+  box-shadow: 0 2px 8px rgba(0, 0, 0, 0.25);
+}
+
+.drawer-header {
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+  padding: 14px 16px;
+  border-bottom: 1px solid rgba(128, 128, 128, 0.15);
+}
+
+.drawer-close {
+  background: transparent;
+  border: none;
+  width: 32px;
+  height: 32px;
+  border-radius: 50%;
+  display: inline-flex;
+  align-items: center;
+  justify-content: center;
+  cursor: pointer;
+  font-size: 16px;
+  color: rgba(0, 0, 0, 0.65);
+}
+
+.drawer-close:hover,
+.drawer-close:focus-visible {
+  background: rgba(128, 128, 128, 0.18);
+}
+
+.drawer-menu :deep(.ant-menu-item) {
+  height: 48px;
+  line-height: 48px;
+  margin: 0;
+  border-radius: 0;
+}
+
+.drawer-menu :deep(.ant-menu-item .anticon) {
+  font-size: 16px;
+}
+
+/* Push the utility (Logout) block to the bottom of the flex-column
+ * drawer body and separate it from the nav block with a hairline. The
+ * border colour is theme-neutral so it reads on both light and dark. */
+.drawer-utility {
+  margin-top: auto;
+  border-top: 1px solid rgba(128, 128, 128, 0.15);
 }
 
 @media (max-width: 768px) {
@@ -161,3 +267,48 @@ function closeDrawer() {
   }
 }
 </style>
+
+<style>
+/* Non-scoped so the rules survive AD-Vue teleporting the drawer body
+ * outside the AppSidebar element's scope id. Without this the Vue
+ * `:global(body.dark) .drawer-brand` form did not produce the expected
+ * `body.dark .drawer-brand[data-v-xxx]` selector reliably, and the
+ * drawer brand stayed at the light-theme dark colour on the navy
+ * drawer surface. Class names are specific enough that no collision is
+ * expected; AppSidebar owns the only drawer in the app. */
+body.dark .drawer-brand,
+body.dark .sider-brand {
+  color: rgba(255, 255, 255, 0.92);
+}
+
+html[data-theme='ultra-dark'] .drawer-brand,
+html[data-theme='ultra-dark'] .sider-brand {
+  color: #ffffff;
+}
+
+body.dark .drawer-close {
+  color: rgba(255, 255, 255, 0.75);
+}
+
+html[data-theme='ultra-dark'] .drawer-close {
+  color: rgba(255, 255, 255, 0.85);
+}
+
+/* Pin the drawer surface to the same colour the desktop sider uses
+ * (Layout.colorBgHeader / Menu.colorItemBg from useTheme.js) so the
+ * header, empty body region, and menu items read as one continuous
+ * panel. AD-Vue's CSS-in-JS tokens otherwise leave the drawer at
+ * colorBgElevated (#2d2d30 in dark) which clashes with the #252526
+ * menu rows. `!important` is required to beat the CSS-in-JS rule
+ * specificity; AppSidebar owns the only drawer in the app so this
+ * doesn't collide with anything else. */
+body.dark .ant-drawer .ant-drawer-content,
+body.dark .ant-drawer .ant-drawer-body {
+  background: #252526 !important;
+}
+
+html[data-theme='ultra-dark'] .ant-drawer .ant-drawer-content,
+html[data-theme='ultra-dark'] .ant-drawer .ant-drawer-body {
+  background: #0a0a0a !important;
+}
+</style>

+ 6 - 6
frontend/src/components/DateTimePicker.vue

@@ -235,8 +235,8 @@ function onAntChange(next) {
 /* ===== Dark (navy) ======================================================= */
 
 body.dark .persian-datepicker-input {
-  background: #142340;
-  border-color: #1f3358;
+  background: #252526;
+  border-color: #3c3c3c;
   color: rgba(255, 255, 255, 0.88);
 }
 
@@ -251,14 +251,14 @@ body.dark .persian-datepicker-input:focus {
 
 body.dark .vpd-main .vpd-icon-btn {
   background: rgba(255, 255, 255, 0.04) !important;
-  border: 1px solid #1f3358 !important;
+  border: 1px solid #3c3c3c !important;
   border-right: none !important;
   border-radius: 6px 0 0 6px !important;
   color: rgba(255, 255, 255, 0.75) !important;
 }
 
 body.dark .vpd-wrapper .vpd-content {
-  background: #1a2c4d;
+  background: #2d2d30;
   color: rgba(255, 255, 255, 0.88);
   box-shadow: 0 6px 16px 0 rgba(0, 0, 0, 0.32),
     0 3px 6px -4px rgba(0, 0, 0, 0.48),
@@ -266,7 +266,7 @@ body.dark .vpd-wrapper .vpd-content {
 }
 
 body.dark .vpd-wrapper .vpd-body {
-  background: #1a2c4d;
+  background: #2d2d30;
   color: rgba(255, 255, 255, 0.88);
 }
 
@@ -315,7 +315,7 @@ body.dark .vpd-wrapper .vpd-actions button:hover {
 
 body.dark .vpd-wrapper .vpd-addon-list,
 body.dark .vpd-wrapper .vpd-addon-list-content {
-  background: #1a2c4d;
+  background: #2d2d30;
   color: rgba(255, 255, 255, 0.88);
 }
 

+ 16 - 16
frontend/src/composables/useTheme.js

@@ -27,14 +27,15 @@ export const currentTheme = computed(() => (theme.isDark ? 'dark' : 'light'));
 
 // AD-Vue 4 theme config consumed by every page's <a-config-provider>.
 // Three modes — light / dark / ultra-dark — all share AD-Vue's vanilla
-// blue primary. Dark uses a navy palette across page/cards/modals so
-// the sidebar blends with the rest of the surface; ultra-dark stays
-// neutral black on top of darkAlgorithm.
+// blue primary. Dark uses a neutral grey palette modelled on VS Code's
+// Dark+ chrome (`#1e1e1e` editor, `#252526` sidebar, `#2d2d30` panel),
+// so the panel reads as a familiar modern IDE rather than the older
+// navy shade. Ultra-dark stays pure-black on darkAlgorithm.
 const DARK_TOKENS = {
-  colorBgBase: '#0a1426',
-  colorBgLayout: '#0a1426',
-  colorBgContainer: '#142340',
-  colorBgElevated: '#1a2c4d',
+  colorBgBase: '#1e1e1e',
+  colorBgLayout: '#1e1e1e',
+  colorBgContainer: '#252526',
+  colorBgElevated: '#2d2d30',
 };
 const ULTRA_DARK_TOKENS = {
   colorBgBase: '#000',
@@ -47,13 +48,12 @@ const ULTRA_DARK_TOKENS = {
 // + trigger backgrounds and `#001529` / `#000c17` as the dark Menu item
 // backgrounds (see node_modules/ant-design-vue/es/{layout,menu}/style/
 // index.js). Override at the component-token level so the sider blends
-// with darkAlgorithm's neutral surfaces.
-// Dark theme uses a refined navy for the sidebar — distinct from the
-// neutral ultra-dark and warmer than AD-Vue's stock #001529.
+// with darkAlgorithm's neutral surfaces. Sider/trigger use the same
+// `#252526` / `#333333` tones VS Code does for its activity bar.
 const DARK_LAYOUT_TOKENS = {
-  colorBgHeader: '#0d1d33',
-  colorBgTrigger: '#15294a',
-  colorBgBody: '#000',
+  colorBgHeader: '#252526',
+  colorBgTrigger: '#333333',
+  colorBgBody: '#1e1e1e',
 };
 const ULTRA_DARK_LAYOUT_TOKENS = {
   colorBgHeader: '#0a0a0a',
@@ -61,9 +61,9 @@ const ULTRA_DARK_LAYOUT_TOKENS = {
   colorBgBody: '#000',
 };
 const DARK_MENU_TOKENS = {
-  colorItemBg: '#0d1d33',
-  colorSubItemBg: '#08142a',
-  menuSubMenuBg: '#0d1d33',
+  colorItemBg: '#252526',
+  colorSubItemBg: '#1e1e1e',
+  menuSubMenuBg: '#252526',
 };
 const ULTRA_DARK_MENU_TOKENS = {
   colorItemBg: '#0a0a0a',

+ 2 - 2
frontend/src/pages/inbounds/InboundsPage.vue

@@ -650,8 +650,8 @@ function onRowAction({ key, dbInbound }) {
 }
 
 .inbounds-page.is-dark {
-  --bg-page: #0a1222;
-  --bg-card: #151f31;
+  --bg-page: #1e1e1e;
+  --bg-card: #252526;
 }
 
 .inbounds-page.is-dark.is-ultra {

+ 2 - 2
frontend/src/pages/index/IndexPage.vue

@@ -336,8 +336,8 @@ async function openConfig() {
 }
 
 .index-page.is-dark {
-  --bg-page: #0a1222;
-  --bg-card: #151f31;
+  --bg-page: #1e1e1e;
+  --bg-card: #252526;
 }
 
 .index-page.is-dark.is-ultra {

+ 268 - 219
frontend/src/pages/login/LoginPage.vue

@@ -13,6 +13,19 @@ import ThemeSwitchLogin from '@/components/ThemeSwitchLogin.vue';
 
 const { t } = useI18n();
 
+const fetched = ref(false);
+const submitting = ref(false);
+const twoFactorEnable = ref(false);
+const version = computed(() => window.X_UI_CUR_VER || '');
+
+const user = reactive({
+  username: '',
+  password: '',
+  twoFactorCode: '',
+});
+
+const basePath = window.X_UI_BASE_PATH || '';
+
 const headlineWords = computed(() => [t('pages.login.hello'), t('pages.login.title')]);
 const HEADLINE_INTERVAL_MS = 2000;
 const headlineIndex = ref(0);
@@ -28,23 +41,9 @@ onBeforeUnmount(() => {
   if (headlineTimer != null) window.clearInterval(headlineTimer);
 });
 
-const fetched = ref(false);
-const submitting = ref(false);
-const twoFactorEnable = ref(false);
-
-const user = reactive({
-  username: '',
-  password: '',
-  twoFactorCode: '',
-});
-
-const basePath = window.X_UI_BASE_PATH || '';
-
 onMounted(async () => {
   const msg = await HttpUtil.post('/getTwoFactorEnable');
-  if (msg.success) {
-    twoFactorEnable.value = !!msg.obj;
-  }
+  if (msg.success) twoFactorEnable.value = !!msg.obj;
   fetched.value = true;
 });
 
@@ -52,9 +51,7 @@ async function login() {
   submitting.value = true;
   try {
     const msg = await HttpUtil.post('/login', user);
-    if (msg.success) {
-      window.location.href = basePath + 'panel/';
-    }
+    if (msg.success) window.location.href = basePath + 'panel/';
   } finally {
     submitting.value = false;
   }
@@ -70,102 +67,85 @@ function onLangChange(next) {
   <a-config-provider :theme="antdThemeConfig">
     <a-layout class="login-app" :class="{ 'is-dark': themeState.isDark, 'is-ultra': themeState.isUltra }">
       <a-layout-content class="login-content">
-        <div class="waves-header">
-          <div class="waves-inner-header"></div>
-          <svg class="waves" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink"
-            viewBox="0 24 150 28" preserveAspectRatio="none" shape-rendering="auto">
-            <defs>
-              <path id="gentle-wave" d="M-160 44c30 0 58-18 88-18s 58 18 88 18 58-18 88-18 58 18 88 18 v44h-352z" />
-            </defs>
-            <g class="parallax">
-              <use xlink:href="#gentle-wave" x="48" y="0" />
-              <use xlink:href="#gentle-wave" x="48" y="3" />
-              <use xlink:href="#gentle-wave" x="48" y="5" />
-              <use xlink:href="#gentle-wave" x="48" y="7" />
-            </g>
-          </svg>
+        <!-- Floating settings (theme switcher + language picker) sits in
+             the viewport's top-right corner so the card stays uncluttered. -->
+        <div class="login-toolbar">
+          <a-popover :overlay-class-name="currentTheme" :title="t('menu.settings')" placement="bottomRight"
+            trigger="click">
+            <template #content>
+              <a-space direction="vertical" :size="10" class="settings-popover">
+                <ThemeSwitchLogin />
+                <span>{{ t('pages.settings.language') }}</span>
+                <a-select v-model:value="lang" class="lang-select" @change="onLangChange">
+                  <a-select-option v-for="l in LanguageManager.supportedLanguages" :key="l.value" :value="l.value">
+                    <span :aria-label="l.name">{{ l.icon }}</span>
+                    &nbsp;&nbsp;<span>{{ l.name }}</span>
+                  </a-select-option>
+                </a-select>
+              </a-space>
+            </template>
+            <a-button shape="circle" class="toolbar-btn" :aria-label="t('menu.settings')">
+              <template #icon>
+                <SettingOutlined />
+              </template>
+            </a-button>
+          </a-popover>
         </div>
 
-        <a-row type="flex" justify="center" align="middle" class="login-row">
-          <a-col class="login-card">
-            <div v-if="!fetched" class="login-loading">
-              <a-spin size="large" />
-            </div>
+        <div class="login-wrapper">
+          <div v-if="!fetched" class="login-loading">
+            <a-spin size="large" />
+          </div>
 
-            <div v-else>
-              <div class="login-settings">
-                <a-popover :overlay-class-name="currentTheme" :title="t('menu.settings')" placement="bottomRight"
-                  trigger="click">
-                  <template #content>
-                    <a-space direction="vertical" :size="10" class="settings-popover">
-                      <ThemeSwitchLogin />
-                      <span>{{ t('pages.settings.language') }}</span>
-                      <a-select v-model:value="lang" class="lang-select" @change="onLangChange">
-                        <a-select-option v-for="l in LanguageManager.supportedLanguages" :key="l.value"
-                          :value="l.value">
-                          <span :aria-label="l.name">{{ l.icon }}</span>
-                          &nbsp;&nbsp;<span>{{ l.name }}</span>
-                        </a-select-option>
-                      </a-select>
-                    </a-space>
-                  </template>
-                  <a-button shape="circle">
-                    <template #icon>
-                      <SettingOutlined />
-                    </template>
-                  </a-button>
-                </a-popover>
-              </div>
-
-              <a-row justify="center">
-                <a-col :span="24">
-                  <h2 class="login-title">
-                    <Transition name="headline" mode="out-in">
-                      <b :key="headlineIndex">{{ headlineWords[headlineIndex] }}</b>
-                    </Transition>
-                  </h2>
-                </a-col>
-              </a-row>
-
-              <a-form layout="vertical" @submit.prevent="login">
-                <a-form-item>
-                  <a-input v-model:value="user.username" autocomplete="username" name="username"
-                    :placeholder="t('username')" autofocus required>
-                    <template #prefix>
-                      <UserOutlined />
-                    </template>
-                  </a-input>
-                </a-form-item>
-
-                <a-form-item>
-                  <a-input-password v-model:value="user.password" autocomplete="current-password" name="password"
-                    :placeholder="t('password')" required>
-                    <template #prefix>
-                      <LockOutlined />
-                    </template>
-                  </a-input-password>
-                </a-form-item>
-
-                <a-form-item v-if="twoFactorEnable">
-                  <a-input v-model:value="user.twoFactorCode" autocomplete="one-time-code" name="twoFactorCode"
-                    :placeholder="t('twoFactorCode')" required>
-                    <template #prefix>
-                      <KeyOutlined />
-                    </template>
-                  </a-input>
-                </a-form-item>
-
-                <a-form-item>
-                  <a-row justify="center">
-                    <a-button type="primary" html-type="submit" :loading="submitting" block>
-                      {{ submitting ? '' : t('login') }}
-                    </a-button>
-                  </a-row>
-                </a-form-item>
-              </a-form>
+          <div v-else class="login-card">
+            <div class="brand">
+              <span class="brand-name">3X-UI</span>
+              <span class="brand-accent" aria-hidden="true"></span>
             </div>
-          </a-col>
-        </a-row>
+            <h2 class="welcome">
+              <Transition name="headline" mode="out-in">
+                <b :key="headlineIndex">{{ headlineWords[headlineIndex] }}</b>
+              </Transition>
+            </h2>
+
+            <a-form layout="vertical" class="login-form" @submit.prevent="login">
+              <a-form-item :label="t('username')">
+                <a-input v-model:value="user.username" autocomplete="username" name="username" size="large"
+                  :placeholder="t('username')" autofocus required>
+                  <template #prefix>
+                    <UserOutlined />
+                  </template>
+                </a-input>
+              </a-form-item>
+
+              <a-form-item :label="t('password')">
+                <a-input-password v-model:value="user.password" autocomplete="current-password" name="password"
+                  size="large" :placeholder="t('password')" required>
+                  <template #prefix>
+                    <LockOutlined />
+                  </template>
+                </a-input-password>
+              </a-form-item>
+
+              <a-form-item v-if="twoFactorEnable" :label="t('twoFactorCode')">
+                <a-input v-model:value="user.twoFactorCode" autocomplete="one-time-code" name="twoFactorCode"
+                  size="large" :placeholder="t('twoFactorCode')" required>
+                  <template #prefix>
+                    <KeyOutlined />
+                  </template>
+                </a-input>
+              </a-form-item>
+
+              <a-form-item class="submit-row">
+                <a-button type="primary" html-type="submit" :loading="submitting" size="large" block>
+                  {{ submitting ? '' : t('login') }}
+                </a-button>
+              </a-form-item>
+            </a-form>
+
+            <div v-if="version" class="version">v{{ version }}</div>
+          </div>
+        </div>
       </a-layout-content>
     </a-layout>
   </a-config-provider>
@@ -173,178 +153,247 @@ function onLangChange(next) {
 
 <style scoped>
 .login-app {
-  --bg-page: #c7ebe2;
-  --bg-wave-header: #dbf5ed;
+  --bg-page: #f5f7fa;
   --bg-card: #ffffff;
-  --color-title: #008771;
-  --shadow-card: 0 2px 8px rgba(0, 0, 0, 0.09);
-  --wave-fill: rgba(0, 135, 113, 0.12);
-  --wave-fill-bottom: #c7ebe2;
+  --color-text: rgba(0, 0, 0, 0.88);
+  --color-text-subtle: rgba(0, 0, 0, 0.55);
+  --color-accent: #1677ff;
+  --color-border: rgba(0, 0, 0, 0.08);
+  --shadow-card: 0 1px 3px rgba(0, 0, 0, 0.04), 0 8px 24px rgba(0, 0, 0, 0.06);
+  --blob-1: rgba(99, 102, 241, 0.45);
+  --blob-2: rgba(236, 72, 153, 0.38);
+  --blob-3: rgba(20, 184, 166, 0.32);
 
+  position: relative;
   min-height: 100vh;
+  overflow: hidden;
+  background: var(--bg-page);
 }
 
 .login-app.is-dark {
-  --bg-page: #222d42;
-  --bg-wave-header: #0a1222;
-  --bg-card: #151f31;
-  --color-title: rgba(255, 255, 255, 0.92);
-  --shadow-card: 0 4px 16px rgba(0, 0, 0, 0.45);
-  --wave-fill: #222d42;
-  --wave-fill-bottom: #222d42;
+  --bg-page: #1e1e1e;
+  --bg-card: #252526;
+  --color-text: rgba(255, 255, 255, 0.92);
+  --color-text-subtle: rgba(255, 255, 255, 0.55);
+  --color-accent: #4096ff;
+  --color-border: rgba(255, 255, 255, 0.08);
+  --shadow-card: 0 1px 3px rgba(0, 0, 0, 0.3), 0 8px 32px rgba(0, 0, 0, 0.4);
+  --blob-1: rgba(64, 150, 255, 0.40);
+  --blob-2: rgba(168, 85, 247, 0.34);
+  --blob-3: rgba(34, 211, 238, 0.22);
 }
 
 .login-app.is-dark.is-ultra {
-  --bg-page: #0f2d32;
-  --bg-wave-header: #0a2227;
-  --bg-card: #0c0e12;
-  --wave-fill: #1f4d52;
-  --wave-fill-bottom: #0f2d32;
+  --bg-page: #000;
+  --bg-card: #141414;
+  --color-border: rgba(255, 255, 255, 0.06);
+  --blob-1: rgba(64, 150, 255, 0.22);
+  --blob-2: rgba(168, 85, 247, 0.18);
+  --blob-3: rgba(34, 211, 238, 0.12);
 }
 
-.login-app,
-.login-app :deep(.ant-layout-content) {
-  background: transparent;
+/* Three blurred blobs slowly drift across the page; ::before and
+ * ::after carry two of them, the third lives on .login-content::before
+ * so we can animate it independently. */
+.login-app::before,
+.login-app::after {
+  content: '';
+  position: absolute;
+  width: 70vw;
+  height: 70vw;
+  max-width: 900px;
+  max-height: 900px;
+  border-radius: 50%;
+  filter: blur(90px);
+  pointer-events: none;
+  z-index: 0;
+  will-change: transform;
 }
 
-.login-app {
-  background: var(--bg-page);
+.login-app::before {
+  top: -25vw;
+  left: -20vw;
+  background: radial-gradient(circle, var(--blob-1) 0%, transparent 65%);
+  animation: blob-drift-a 24s ease-in-out infinite alternate;
 }
 
-.login-card {
-  background: var(--bg-card);
-  box-shadow: var(--shadow-card);
+.login-app::after {
+  bottom: -25vw;
+  right: -20vw;
+  background: radial-gradient(circle, var(--blob-2) 0%, transparent 65%);
+  animation: blob-drift-b 30s ease-in-out infinite alternate;
 }
 
-.login-title {
-  color: var(--color-title);
+.login-content::before {
+  content: '';
+  position: absolute;
+  top: 30%;
+  left: 50%;
+  width: 50vw;
+  height: 50vw;
+  max-width: 700px;
+  max-height: 700px;
+  border-radius: 50%;
+  background: radial-gradient(circle, var(--blob-3) 0%, transparent 65%);
+  filter: blur(90px);
+  pointer-events: none;
+  z-index: 0;
+  will-change: transform;
+  animation: blob-drift-c 36s ease-in-out infinite alternate;
 }
 
-.login-settings {
-  display: flex;
-  justify-content: flex-end;
-  margin-bottom: 8px;
+@keyframes blob-drift-a {
+  0%   { transform: translate(0, 0) scale(1); }
+  50%  { transform: translate(18vw, 10vh) scale(1.15); }
+  100% { transform: translate(34vw, 22vh) scale(1.25); }
 }
 
-.settings-popover {
-  min-width: 220px;
+@keyframes blob-drift-b {
+  0%   { transform: translate(0, 0) scale(1); }
+  50%  { transform: translate(-16vw, -10vh) scale(1.12); }
+  100% { transform: translate(-30vw, -22vh) scale(1.2); }
 }
 
-.lang-select {
-  width: 100%;
+@keyframes blob-drift-c {
+  0%   { transform: translate(-50%, -50%) scale(1); }
+  50%  { transform: translate(-20%, -20%) scale(1.1); }
+  100% { transform: translate(-80%, -10%) scale(1.05); }
+}
+
+@media (prefers-reduced-motion: reduce) {
+  .login-app::before,
+  .login-app::after,
+  .login-content::before {
+    animation: none;
+  }
+}
+
+.login-app :deep(.ant-layout-content) {
+  background: transparent;
 }
 
 .login-content {
   position: relative;
 }
 
-.login-row {
+.login-content > * {
   position: relative;
   z-index: 1;
-  min-height: 100vh;
-  padding: 24px 0;
 }
 
-.login-card {
-  width: clamp(280px, 90vw, 300px);
-  border-radius: 2rem;
-  padding: clamp(2rem, 5vw, 4rem) 1.5rem;
-  transition: background 0.3s, box-shadow 0.3s;
+.login-toolbar {
+  position: fixed;
+  top: 16px;
+  right: 16px;
+  z-index: 10;
 }
 
-.login-loading {
-  text-align: center;
-  padding: 40px 0;
+.toolbar-btn {
+  width: 40px;
+  height: 40px;
 }
 
-.login-title {
-  text-align: center;
-  margin-bottom: 32px;
-  font-size: 2rem;
-  font-weight: 500;
-  min-height: 2.5rem;
+.login-wrapper {
+  min-height: 100vh;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  padding: 24px 16px;
 }
 
-.login-title b {
-  display: inline-block;
+.login-loading {
+  text-align: center;
 }
 
-.headline-enter-active,
-.headline-leave-active {
-  transition: opacity 0.4s ease, transform 0.4s ease;
+.login-card {
+  width: 100%;
+  max-width: 400px;
+  background: var(--bg-card);
+  border: 1px solid var(--color-border);
+  border-radius: 12px;
+  padding: 40px 32px 28px;
+  box-shadow: var(--shadow-card);
 }
 
-.headline-enter-from {
-  opacity: 0;
-  transform: translateY(-12px);
+@media (max-width: 480px) {
+  .login-card {
+    padding: 32px 20px 24px;
+  }
 }
 
-.headline-leave-to {
-  opacity: 0;
-  transform: translateY(12px);
+.brand {
+  display: flex;
+  flex-direction: column;
+  align-items: center;
+  gap: 10px;
+  margin-bottom: 8px;
 }
 
-.waves-header {
-  position: fixed;
-  inset: 0 0 auto 0;
-  width: 100%;
-  z-index: 0;
-  pointer-events: none;
-  background: var(--bg-wave-header);
+.brand-name {
+  font-size: 28px;
+  font-weight: 700;
+  letter-spacing: 1.5px;
+  color: var(--color-text);
 }
 
-.waves-inner-header {
-  height: 50vh;
-  width: 100%;
+.brand-accent {
+  display: block;
+  width: 40px;
+  height: 3px;
+  border-radius: 2px;
+  background: var(--color-accent);
 }
 
-.waves {
-  position: relative;
-  display: block;
-  width: 100%;
-  height: 15vh;
-  min-height: 100px;
-  max-height: 150px;
-  margin-bottom: -8px;
+.welcome {
+  text-align: center;
+  color: var(--color-text);
+  font-size: 32px;
+  font-weight: 700;
+  line-height: 1.2;
+  min-height: 42px;
+  margin: 12px 0 28px;
+  letter-spacing: 0.3px;
 }
 
-.parallax>use {
-  fill: var(--wave-fill);
-  animation: move-forever 25s cubic-bezier(0.55, 0.5, 0.45, 0.5) infinite;
+.welcome b {
+  display: inline-block;
+  font-weight: inherit;
 }
 
-.parallax>use:nth-child(1) {
-  animation-delay: -2s;
-  animation-duration: 4s;
-  opacity: 0.2;
+.headline-enter-active,
+.headline-leave-active {
+  transition: opacity 280ms ease, transform 280ms ease;
+}
+.headline-enter-from {
+  opacity: 0;
+  transform: translateY(6px);
+}
+.headline-leave-to {
+  opacity: 0;
+  transform: translateY(-6px);
 }
 
-.parallax>use:nth-child(2) {
-  animation-delay: -3s;
-  animation-duration: 7s;
-  opacity: 0.4;
+.login-form :deep(.ant-form-item-label > label) {
+  color: var(--color-text);
+  font-weight: 500;
 }
 
-.parallax>use:nth-child(3) {
-  animation-delay: -4s;
-  animation-duration: 10s;
-  opacity: 0.6;
+.submit-row {
+  margin-bottom: 0;
 }
 
-.parallax>use:nth-child(4) {
-  animation-delay: -5s;
-  animation-duration: 13s;
-  fill: var(--wave-fill-bottom);
-  opacity: 1;
+.version {
+  text-align: center;
+  font-size: 12px;
+  color: var(--color-text-subtle);
+  margin-top: 16px;
 }
 
-@keyframes move-forever {
-  0% {
-    transform: translate3d(-90px, 0, 0);
-  }
+.settings-popover {
+  min-width: 220px;
+}
 
-  100% {
-    transform: translate3d(85px, 0, 0);
-  }
+.lang-select {
+  width: 100%;
 }
 </style>

+ 2 - 2
frontend/src/pages/nodes/NodesPage.vue

@@ -172,8 +172,8 @@ async function onToggleEnable(node, next) {
 }
 
 .nodes-page.is-dark {
-  --bg-page: #0a1222;
-  --bg-card: #151f31;
+  --bg-page: #1e1e1e;
+  --bg-card: #252526;
 }
 
 .nodes-page.is-dark.is-ultra {

+ 2 - 2
frontend/src/pages/settings/SettingsPage.vue

@@ -256,8 +256,8 @@ const alertVisible = ref(true);
 }
 
 .settings-page.is-dark {
-  --bg-page: #0a1222;
-  --bg-card: #151f31;
+  --bg-page: #1e1e1e;
+  --bg-card: #252526;
 }
 
 .settings-page.is-dark.is-ultra {

+ 2 - 2
frontend/src/pages/sub/SubPage.vue

@@ -299,8 +299,8 @@ const themeClass = computed(() => ({
 }
 
 .subscription-page.is-dark {
-  --bg-page: #0a1222;
-  --bg-card: #151f31;
+  --bg-page: #1e1e1e;
+  --bg-card: #252526;
 }
 
 .subscription-page.is-dark.is-ultra {

+ 2 - 2
frontend/src/pages/xray/XrayPage.vue

@@ -339,8 +339,8 @@ function confirmRestart() {
 }
 
 .xray-page.is-dark {
-  --bg-page: #0a1222;
-  --bg-card: #151f31;
+  --bg-page: #1e1e1e;
+  --bg-card: #252526;
 }
 
 .xray-page.is-dark.is-ultra {