Explorar o código

feat(sidebar): pin Logout above trigger, inline 3-state theme cycle

The desktop sider stretched to match the page height, so below lg
(992px) where dashboard cards stack into one column the collapse
trigger plus Logout slid off-screen. Pin the sider with
`position: sticky; height: 100vh; align-self: flex-start` so the chrome
stays viewport-tall. Split the menu into `.sider-nav` (flex: 1,
scrollable) and `.sider-utility` so Logout sits directly above the
48px trigger reserved by padding-bottom.

Replace the `<ThemeSwitch>` a-sub-menu with a single inline icon
button next to the '3X-UI' brand (sun / moon / moon+star SVG). One
click cycles Light -> Dark -> Ultra Dark -> Light. ThemeSwitch.vue
removed since it is now inlined.

Override AD-Vue dark Menu selected + hover/active state on the
sider-nav, sider-utility, and drawer menus to use the same light-blue
tint AD-Vue's light theme uses (rgba(64,150,255,0.2) / #4096ff). The
default dark variant was too subtle against #252526, so the current
page and Logout-on-hover barely distinguished themselves.
MHSanaei hai 19 horas
pai
achega
b5479f3f30

+ 77 - 77
frontend/package-lock.json

@@ -1,12 +1,12 @@
 {
   "name": "3x-ui-frontend",
-  "version": "0.0.1",
+  "version": "0.0.2",
   "lockfileVersion": 3,
   "requires": true,
   "packages": {
     "": {
       "name": "3x-ui-frontend",
-      "version": "0.0.1",
+      "version": "0.0.2",
       "dependencies": {
         "@ant-design/icons-vue": "^7.0.1",
         "ant-design-vue": "^4.2.6",
@@ -424,18 +424,18 @@
       }
     },
     "node_modules/@oxc-project/types": {
-      "version": "0.128.0",
-      "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.128.0.tgz",
-      "integrity": "sha512-huv1Y/LzBJkBVHt3OlC7u0zHBW9qXf1FdD7sGmc1rXc2P1mTwHssYv7jyGx5KAACSCH+9B3Bhn6Z9luHRvf7pQ==",
+      "version": "0.129.0",
+      "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.129.0.tgz",
+      "integrity": "sha512-3oz8m3FGdr2nDXVqmFUw7jolKliC4MoyXYIG2c7gpjBnzUWQpUGIYcXYKxTdTi+N2jusvt610ckTMkxdwHkYEg==",
       "dev": true,
       "funding": {
         "url": "https://github.com/sponsors/Boshen"
       }
     },
     "node_modules/@rolldown/binding-android-arm64": {
-      "version": "1.0.0-rc.18",
-      "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.0-rc.18.tgz",
-      "integrity": "sha512-lIDyUAfD7U3+BWKzdxMbJcsYHuqXqmGz40aeRqvuAm3y5TkJSYTBW2RDrn65DJFPQqVjUAUqq5uz8urzQ8aBdQ==",
+      "version": "1.0.0",
+      "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.0.tgz",
+      "integrity": "sha512-TWMZnRLMe63C2Lhyicviu7ZHaU4kxa6PS3rofvc9GmcvptzNN11BcfQ4Sl7MwTOsisQoa2keB/EBdNCAnUo8vA==",
       "cpu": [
         "arm64"
       ],
@@ -449,9 +449,9 @@
       }
     },
     "node_modules/@rolldown/binding-darwin-arm64": {
-      "version": "1.0.0-rc.18",
-      "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.0.0-rc.18.tgz",
-      "integrity": "sha512-apJq2ktnGp27nSInMR5Vcj8kY6xJzDAvfdIFlpDcAK/w4cDO58qVoi1YQsES/SKiFNge/6e4CUzgjfHduYqWpQ==",
+      "version": "1.0.0",
+      "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.0.0.tgz",
+      "integrity": "sha512-6XcD+8k0gPVItNagEw78/qqcBDwKcwDYS8V2hRmVsfUSIrd8cWe/CBvRDI5toqFyPfj+FJr6t8U6Xj2P2prEew==",
       "cpu": [
         "arm64"
       ],
@@ -465,9 +465,9 @@
       }
     },
     "node_modules/@rolldown/binding-darwin-x64": {
-      "version": "1.0.0-rc.18",
-      "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.0.0-rc.18.tgz",
-      "integrity": "sha512-5Ofot8xbs+pxRHJqm9/9N/4sTQOvdrwEsmPE9pdLEEoAbdZtG6F2LMDfO1sp6ZAtXJuJV/21ew2srq3W8NXB5g==",
+      "version": "1.0.0",
+      "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.0.0.tgz",
+      "integrity": "sha512-iN/tWVXRQDWvmZlKdceP1Dwug9GDpEymhb9p4xnEe6zvCg5lFmzVljl+1qR1NVx3yfGpr2Na+CuLmv5IU8uzfQ==",
       "cpu": [
         "x64"
       ],
@@ -481,9 +481,9 @@
       }
     },
     "node_modules/@rolldown/binding-freebsd-x64": {
-      "version": "1.0.0-rc.18",
-      "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.0.0-rc.18.tgz",
-      "integrity": "sha512-7h8eeOTT1eyqJyx64BFCnWZpNm486hGWt2sqeLLgDxA0xI1oGZ9H7gK1S85uNGmBhkdPwa/6reTxfFFKvIsebw==",
+      "version": "1.0.0",
+      "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.0.0.tgz",
+      "integrity": "sha512-jjQMDvvwSOuhOwMszD/klSOjyWMM3zI64hWTj9KT5x4MxRbZAf+7vLQ6qouRhtsLVFHr3f0ILaJAfgENPiQdAQ==",
       "cpu": [
         "x64"
       ],
@@ -497,9 +497,9 @@
       }
     },
     "node_modules/@rolldown/binding-linux-arm-gnueabihf": {
-      "version": "1.0.0-rc.18",
-      "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.0.0-rc.18.tgz",
-      "integrity": "sha512-eRcm/HVt9U/JFu5RKAEKwGQYtDCKWLiaH6wOnsSEp6NMBb/3Os8LgHZlNyzMpFVNmiiMFlfb2zEnebfzJrHFmg==",
+      "version": "1.0.0",
+      "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.0.0.tgz",
+      "integrity": "sha512-d//Dtg2x6/m3mbV64yUGNnDGNZaDGRpDLLNGerHQUVObuNaIQaaDp25yUiqGXtHEXX+NP2d0wAlmKgpYgIAJ2A==",
       "cpu": [
         "arm"
       ],
@@ -513,9 +513,9 @@
       }
     },
     "node_modules/@rolldown/binding-linux-arm64-gnu": {
-      "version": "1.0.0-rc.18",
-      "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.0.0-rc.18.tgz",
-      "integrity": "sha512-SOrT/cT4ukTmgnrEz/Hg3m7LBnuCLW9psDeMKrimRWY4I8DmnO7Lco8W2vtqPmMkbVu8iJ+g4GFLVLLOVjJ9DQ==",
+      "version": "1.0.0",
+      "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.0.0.tgz",
+      "integrity": "sha512-n7Ofp0mx+aB2cC+Sdy5YtMnXtY9lchnHbY+3Yt0uq9JsWQExf4f5Whu0tK0R8Jdc9S6RchTHjIFY7uc92puOVQ==",
       "cpu": [
         "arm64"
       ],
@@ -529,9 +529,9 @@
       }
     },
     "node_modules/@rolldown/binding-linux-arm64-musl": {
-      "version": "1.0.0-rc.18",
-      "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.0.0-rc.18.tgz",
-      "integrity": "sha512-QWjdxN1HJCpBTAcZ5N5F7wju3gVPzRzSpmGzx7na0c/1qpN9CFil+xt+l9lV/1M6/gqHSNXCiqPfwhVJPeLnug==",
+      "version": "1.0.0",
+      "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.0.0.tgz",
+      "integrity": "sha512-EIVjy2cgd7uuMMo94FVkBp7F6DhcZAUwNURkSG3RwUmvAXR6s0ISxM81U+IydcZByPG0pZIHsf1b6kTxoFDgJA==",
       "cpu": [
         "arm64"
       ],
@@ -545,9 +545,9 @@
       }
     },
     "node_modules/@rolldown/binding-linux-ppc64-gnu": {
-      "version": "1.0.0-rc.18",
-      "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.0.0-rc.18.tgz",
-      "integrity": "sha512-ugCOyj7a4d9h3q9B+wXmf6g3a68UsjGh6dob5DHevHGMwDUbhsYNbSPxJsENcIttJZ9jv7qGM2UesLw5jqIhdg==",
+      "version": "1.0.0",
+      "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.0.0.tgz",
+      "integrity": "sha512-JEwwOPcwTLAcpDQlqSmjEmfs63xJnSiUNIGvLcDLUHCWK4XowpS/7c7tUsUH6uT/ct6bMUTdXKfI8967FYj6mg==",
       "cpu": [
         "ppc64"
       ],
@@ -561,9 +561,9 @@
       }
     },
     "node_modules/@rolldown/binding-linux-s390x-gnu": {
-      "version": "1.0.0-rc.18",
-      "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.0.0-rc.18.tgz",
-      "integrity": "sha512-kKWRhbsotpXkGbcd5dllUWg5gEXcDAa8u5YnP9AV5DYNbvJHGzzuwv7dpmhc8NqKMJldl0a+x76IHbspEpEmdA==",
+      "version": "1.0.0",
+      "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.0.0.tgz",
+      "integrity": "sha512-0wjCFhLrihtAubnT9iA0N++0pSV0z5Hg7tNGdNJ4RFaINceHadoF+kiFGyY1qSSNVIAZtLotG8Ju1bgDPkjnFA==",
       "cpu": [
         "s390x"
       ],
@@ -577,9 +577,9 @@
       }
     },
     "node_modules/@rolldown/binding-linux-x64-gnu": {
-      "version": "1.0.0-rc.18",
-      "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.0.0-rc.18.tgz",
-      "integrity": "sha512-uCo8ElcCIAMyYAZyuIZ81oFkhTSIllNvUCHCAlbhlN4ji3uC28h7IIdlXyIvGO7HsuqnV9p3rD/bpH7XhIyhRw==",
+      "version": "1.0.0",
+      "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.0.0.tgz",
+      "integrity": "sha512-Dfn7iak9BcMMePxcoJfpSbWqnEyrp/dRF63/8qW/eHBdOZov6x5aShLLEYGYdIeSJ6vMLK/XCVB+lGIxm41bQA==",
       "cpu": [
         "x64"
       ],
@@ -593,9 +593,9 @@
       }
     },
     "node_modules/@rolldown/binding-linux-x64-musl": {
-      "version": "1.0.0-rc.18",
-      "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.0.0-rc.18.tgz",
-      "integrity": "sha512-XNOQZtuE6yUIvx4rwGemwh8kpL1xvU41FXy/s9K7T/3JVcqGzo3NfKM2HrbrGgfPYGFW42f07Wk++aOC6B9NWA==",
+      "version": "1.0.0",
+      "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.0.0.tgz",
+      "integrity": "sha512-5/utzzDmD/pD/bmuaUcbTf/sZYy0aztwIVlfpoW1fTjCZ0BaPOMVWGZL1zvgxyi7ZIVYWlxKONHmSbHuiOh8Jw==",
       "cpu": [
         "x64"
       ],
@@ -609,9 +609,9 @@
       }
     },
     "node_modules/@rolldown/binding-openharmony-arm64": {
-      "version": "1.0.0-rc.18",
-      "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.0.0-rc.18.tgz",
-      "integrity": "sha512-tSn/kzrfa7tNOXr7sEacDBN4YsIqTyLqh45IO0nHDwtpKIDNDJr+VFojt+4klSpChxB29JLyduSsE0MKEwa65A==",
+      "version": "1.0.0",
+      "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.0.0.tgz",
+      "integrity": "sha512-ouJs8VcUomfLfpbUECqFMRqdV4x6aeAK3MA4m6vTrJJjKyWTV5KnxZx7Jd9G+GlDaQQxubcba00x16OyJ1meig==",
       "cpu": [
         "arm64"
       ],
@@ -625,9 +625,9 @@
       }
     },
     "node_modules/@rolldown/binding-wasm32-wasi": {
-      "version": "1.0.0-rc.18",
-      "resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.0.0-rc.18.tgz",
-      "integrity": "sha512-+J9YGmc+czgqlhYmwun3S3O0FIZhsH8ep2456xwjAdIOmuJxM7xz4P4PtrxU+Bz17a/5bqPA8o3HAAoX0teUdg==",
+      "version": "1.0.0",
+      "resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.0.0.tgz",
+      "integrity": "sha512-E+oHKGiDA+lsKMmFtffDDw91EryDT7uJocrIuCHqhm6bCTM6xFK+3gaCkYOHfPwQr0cCNarSM2xaELoQDz9jJg==",
       "cpu": [
         "wasm32"
       ],
@@ -643,9 +643,9 @@
       }
     },
     "node_modules/@rolldown/binding-win32-arm64-msvc": {
-      "version": "1.0.0-rc.18",
-      "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.0.0-rc.18.tgz",
-      "integrity": "sha512-zsu47DgU0FQzSwi6sU9dZoEdUv7pc1AptSEz/Z8HBg54sV0Pbs3N0+CrIbTsgiu6EyoaNN9CHboqbLaz9lhOyQ==",
+      "version": "1.0.0",
+      "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.0.0.tgz",
+      "integrity": "sha512-yYK02n8Rngo+gbm1y6G0+7jk1sJ/2Wt7K0me0Y7k/ErBpyf+LJ2gFpqWVTcRV1rUepBlQRmpgWkTQCiiwrK0Ow==",
       "cpu": [
         "arm64"
       ],
@@ -659,9 +659,9 @@
       }
     },
     "node_modules/@rolldown/binding-win32-x64-msvc": {
-      "version": "1.0.0-rc.18",
-      "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.0.0-rc.18.tgz",
-      "integrity": "sha512-7H+3yqGgmnlDTRRhw/xpYY9J1kf4GC681nVc4GqKhExZTDrVVrV2tsOR9kso0fvgBdcTCcQShx4SLLoHgaLwhg==",
+      "version": "1.0.0",
+      "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.0.0.tgz",
+      "integrity": "sha512-14bpChMahXRRXiTwahSl+zzHPW6qQTXtkMuJBFlbo+pqSAews2d4BdCSHfrJ/MBsCZtpmTafsY+1QhBzitcmdg==",
       "cpu": [
         "x64"
       ],
@@ -2300,13 +2300,13 @@
       "integrity": "sha512-LwZrotdHOo12nQuZlHEmtuXdqGoOD0OhaxopaNFxWzInpEgaLWoVuAMbTzixuosCx2nEG58ngzW3vxdWoxIgdg=="
     },
     "node_modules/rolldown": {
-      "version": "1.0.0-rc.18",
-      "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.0-rc.18.tgz",
-      "integrity": "sha512-phmyKBpuBdRYDf4hgyynGAYn/rDDe+iZXKVJ7WX5b1zQzpLkP5oJRPGsfJuHdzPMlyyEO/4sPW6yfSx2gf7lVg==",
+      "version": "1.0.0",
+      "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.0.tgz",
+      "integrity": "sha512-yD986aXDESFGS95spT1LAv0jssywP4npMEjmMHyN2/5+eE8qQJUype2AaKkRiLgBgyD0LFlubwAht7VmY8rGoA==",
       "dev": true,
       "dependencies": {
-        "@oxc-project/types": "=0.128.0",
-        "@rolldown/pluginutils": "1.0.0-rc.18"
+        "@oxc-project/types": "=0.129.0",
+        "@rolldown/pluginutils": "1.0.0"
       },
       "bin": {
         "rolldown": "bin/cli.mjs"
@@ -2315,27 +2315,27 @@
         "node": "^20.19.0 || >=22.12.0"
       },
       "optionalDependencies": {
-        "@rolldown/binding-android-arm64": "1.0.0-rc.18",
-        "@rolldown/binding-darwin-arm64": "1.0.0-rc.18",
-        "@rolldown/binding-darwin-x64": "1.0.0-rc.18",
-        "@rolldown/binding-freebsd-x64": "1.0.0-rc.18",
-        "@rolldown/binding-linux-arm-gnueabihf": "1.0.0-rc.18",
-        "@rolldown/binding-linux-arm64-gnu": "1.0.0-rc.18",
-        "@rolldown/binding-linux-arm64-musl": "1.0.0-rc.18",
-        "@rolldown/binding-linux-ppc64-gnu": "1.0.0-rc.18",
-        "@rolldown/binding-linux-s390x-gnu": "1.0.0-rc.18",
-        "@rolldown/binding-linux-x64-gnu": "1.0.0-rc.18",
-        "@rolldown/binding-linux-x64-musl": "1.0.0-rc.18",
-        "@rolldown/binding-openharmony-arm64": "1.0.0-rc.18",
-        "@rolldown/binding-wasm32-wasi": "1.0.0-rc.18",
-        "@rolldown/binding-win32-arm64-msvc": "1.0.0-rc.18",
-        "@rolldown/binding-win32-x64-msvc": "1.0.0-rc.18"
+        "@rolldown/binding-android-arm64": "1.0.0",
+        "@rolldown/binding-darwin-arm64": "1.0.0",
+        "@rolldown/binding-darwin-x64": "1.0.0",
+        "@rolldown/binding-freebsd-x64": "1.0.0",
+        "@rolldown/binding-linux-arm-gnueabihf": "1.0.0",
+        "@rolldown/binding-linux-arm64-gnu": "1.0.0",
+        "@rolldown/binding-linux-arm64-musl": "1.0.0",
+        "@rolldown/binding-linux-ppc64-gnu": "1.0.0",
+        "@rolldown/binding-linux-s390x-gnu": "1.0.0",
+        "@rolldown/binding-linux-x64-gnu": "1.0.0",
+        "@rolldown/binding-linux-x64-musl": "1.0.0",
+        "@rolldown/binding-openharmony-arm64": "1.0.0",
+        "@rolldown/binding-wasm32-wasi": "1.0.0",
+        "@rolldown/binding-win32-arm64-msvc": "1.0.0",
+        "@rolldown/binding-win32-x64-msvc": "1.0.0"
       }
     },
     "node_modules/rolldown/node_modules/@rolldown/pluginutils": {
-      "version": "1.0.0-rc.18",
-      "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.18.tgz",
-      "integrity": "sha512-CUY5Mnhe64xQBGZEEXQ5WyZwsc1JU3vAZLIxtrsBt3LO6UOb+C8GunVKqe9sT8NeWb4lqSaoJtp2xo6GxT1MNw==",
+      "version": "1.0.0",
+      "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0.tgz",
+      "integrity": "sha512-aKs/3GSWyV0mrhNmt/96/Z3yczC3yvrzYATCiCXQebBsGyYzjNdUphRVLeJQ67ySKVXRfMxt2lm12pmXvbPFQQ==",
       "dev": true
     },
     "node_modules/scroll-into-view-if-needed": {
@@ -2524,15 +2524,15 @@
       "dev": true
     },
     "node_modules/vite": {
-      "version": "8.0.11",
-      "resolved": "https://registry.npmjs.org/vite/-/vite-8.0.11.tgz",
-      "integrity": "sha512-Jz1mxtUBR5xTT65VOdJZUUeoyLtqljmFkiUXhPTLZka3RDc9vpi/xXkyrnsdRcm2lIi3l3GPMnAidTsEGIj3Ow==",
+      "version": "8.0.12",
+      "resolved": "https://registry.npmjs.org/vite/-/vite-8.0.12.tgz",
+      "integrity": "sha512-w2dDofOWv2QB09ZITZBsvKTVAlYvPR4IAmrY/v0ir9KvLs0xybR7i48wxhM1/oyBWO34wPns+bPGw5ZrZqDpZg==",
       "dev": true,
       "dependencies": {
         "lightningcss": "^1.32.0",
         "picomatch": "^4.0.4",
         "postcss": "^8.5.14",
-        "rolldown": "1.0.0-rc.18",
+        "rolldown": "1.0.0",
         "tinyglobby": "^0.2.16"
       },
       "bin": {

+ 1 - 1
frontend/package.json

@@ -1,7 +1,7 @@
 {
   "name": "3x-ui-frontend",
   "private": true,
-  "version": "0.0.1",
+  "version": "0.0.2",
   "type": "module",
   "description": "3x-ui panel frontend (Vue 3 + Ant Design Vue 4 + Vite 8).",
   "scripts": {

+ 191 - 14
frontend/src/components/AppSidebar.vue

@@ -12,8 +12,7 @@ import {
   MenuOutlined,
 } from '@ant-design/icons-vue';
 
-import { currentTheme } from '@/composables/useTheme.js';
-import ThemeSwitch from '@/components/ThemeSwitch.vue';
+import { theme, currentTheme, toggleTheme, toggleUltra, pauseAnimationsUntilLeave } from '@/composables/useTheme.js';
 
 const { t } = useI18n();
 
@@ -98,17 +97,60 @@ function toggleDrawer() {
 function closeDrawer() {
   drawerOpen.value = false;
 }
+
+/* 3-state theme cycle driven by the brand-row icon button.
+ *   Light  → Dark   (turn dark on, ensure ultra off)
+ *   Dark   → Ultra  (turn ultra on)
+ *   Ultra  → Light  (turn ultra off, turn dark off)
+ * Using a single button keeps the sider header clean — the old
+ * ThemeSwitch a-sub-menu plus its expandable items lived here. */
+function cycleTheme() {
+  pauseAnimationsUntilLeave('theme-cycle');
+  if (!theme.isDark) {
+    toggleTheme();
+    if (theme.isUltra) toggleUltra();
+  } else if (!theme.isUltra) {
+    toggleUltra();
+  } else {
+    toggleUltra();
+    toggleTheme();
+  }
+}
 </script>
 
 <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' }}
+        <span class="brand-text">{{ collapsed ? '3X' : '3X-UI' }}</span>
+        <button v-if="!collapsed" id="theme-cycle" type="button" class="theme-cycle" :aria-label="t('menu.theme')"
+          :title="t('menu.theme')" @click="cycleTheme">
+          <svg v-if="!theme.isDark" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"
+            stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
+            <circle cx="12" cy="12" r="4" />
+            <path d="M12 2v2M12 20v2M4.93 4.93l1.41 1.41M17.66 17.66l1.41 1.41M2 12h2M20 12h2M6.34 17.66l-1.41 1.41M19.07 4.93l-1.41 1.41" />
+          </svg>
+          <svg v-else-if="!theme.isUltra" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"
+            stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
+            <path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z" />
+          </svg>
+          <svg v-else viewBox="0 0 24 24" fill="currentColor" stroke="currentColor" stroke-width="1.5"
+            stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
+            <path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z" />
+            <path fill="none" d="M19 3l0.7 1.4 1.4 0.7-1.4 0.7L19 7.2l-0.7-1.4-1.4-0.7 1.4-0.7z" />
+          </svg>
+        </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="sider-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="sider-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>
@@ -121,11 +163,29 @@ function closeDrawer() {
       :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 class="drawer-header-actions">
+          <button id="theme-cycle-drawer" type="button" class="theme-cycle" :aria-label="t('menu.theme')"
+            :title="t('menu.theme')" @click="cycleTheme">
+            <svg v-if="!theme.isDark" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"
+              stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
+              <circle cx="12" cy="12" r="4" />
+              <path d="M12 2v2M12 20v2M4.93 4.93l1.41 1.41M17.66 17.66l1.41 1.41M2 12h2M20 12h2M6.34 17.66l-1.41 1.41M19.07 4.93l-1.41 1.41" />
+            </svg>
+            <svg v-else-if="!theme.isUltra" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"
+              stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
+              <path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z" />
+            </svg>
+            <svg v-else viewBox="0 0 24 24" fill="currentColor" stroke="currentColor" stroke-width="1.5"
+              stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
+              <path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z" />
+              <path fill="none" d="M19 3l0.7 1.4 1.4 0.7-1.4 0.7L19 7.2l-0.7-1.4-1.4-0.7 1.4-0.7z" />
+            </svg>
+          </button>
+          <button class="drawer-close" type="button" :aria-label="t('close')" @click="closeDrawer">
+            <CloseOutlined />
+          </button>
+        </div>
       </div>
-      <ThemeSwitch />
       <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">
@@ -150,8 +210,18 @@ function closeDrawer() {
 </template>
 
 <style scoped>
+/* Pin the desktop sider to the viewport. Without this, AD-Vue's
+ * `<a-layout-sider>` stretches to match the flex row's height — which
+ * equals the page height on tall dashboards (cards stack into one
+ * column below `lg` = 992px), so the bottom-anchored
+ * `.ant-layout-sider-trigger` (and Logout right above it) slide off
+ * the screen. Sticky + 100vh keeps the sider exactly viewport-tall;
+ * `align-self: flex-start` stops the flex row from re-stretching it. */
 .ant-sidebar>.ant-layout-sider {
-  height: 100%;
+  position: sticky;
+  top: 0;
+  height: 100vh;
+  align-self: flex-start;
 }
 
 /* `.sider-brand` and `.drawer-brand` share the same light-theme colour
@@ -169,18 +239,65 @@ function closeDrawer() {
 }
 
 .sider-brand {
-  text-align: center;
-  padding: 16px 12px;
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+  gap: 8px;
+  padding: 14px 14px;
   border-bottom: 1px solid rgba(128, 128, 128, 0.15);
   user-select: none;
 }
 
+/* Collapsed sider only has room for the '3X' brand — center it and
+ * hide the theme cycle button (which is `v-if`-ed out in template). */
 .sider-brand-collapsed {
+  justify-content: center;
   font-size: 16px;
-  padding: 16px 4px;
+  padding: 14px 4px;
   letter-spacing: 0;
 }
 
+.brand-text {
+  flex: 1 1 auto;
+}
+
+.sider-brand-collapsed .brand-text {
+  flex: 0 0 auto;
+}
+
+.theme-cycle {
+  background: transparent;
+  border: none;
+  width: 30px;
+  height: 30px;
+  border-radius: 50%;
+  display: inline-flex;
+  align-items: center;
+  justify-content: center;
+  cursor: pointer;
+  color: inherit;
+  padding: 0;
+  flex-shrink: 0;
+  transition: background-color 0.2s, transform 0.15s;
+}
+
+.theme-cycle:hover,
+.theme-cycle:focus-visible {
+  background: rgba(128, 128, 128, 0.18);
+  transform: scale(1.08);
+}
+
+.theme-cycle svg {
+  width: 16px;
+  height: 16px;
+}
+
+.drawer-header-actions {
+  display: inline-flex;
+  align-items: center;
+  gap: 4px;
+}
+
 .drawer-handle {
   position: fixed;
   top: 12px;
@@ -246,6 +363,41 @@ function closeDrawer() {
   border-top: 1px solid rgba(128, 128, 128, 0.15);
 }
 
+/* Pin Logout exactly above AD-Vue's `.ant-layout-sider-trigger` (the
+ * collapse bar at the bottom, position: absolute; height: 48px). The
+ * old `margin-top: auto` approach only pushed the utility down when the
+ * content was shorter than the container — on short viewports the
+ * Logout got hidden behind the trigger. Switching to a flex layout
+ * where `.sider-nav` consumes all spare space (flex: 1) and
+ * `.sider-utility` stays at content height pins it consistently. The
+ * padding-bottom: 48px on the parent reserves the trigger's strip so
+ * Logout sits directly above it.
+ *
+ * The mobile @media rule below still hides the whole sider on phones;
+ * this block only kicks in once that override no longer matches. */
+.ant-sidebar>.ant-layout-sider :deep(.ant-layout-sider-children) {
+  display: flex;
+  flex-direction: column;
+  height: 100%;
+  padding-bottom: 48px;
+}
+
+.sider-brand {
+  flex: 0 0 auto;
+}
+
+.sider-nav {
+  flex: 1 1 auto;
+  overflow-y: auto;
+  overflow-x: hidden;
+  min-height: 0;
+}
+
+.sider-utility {
+  flex: 0 0 auto;
+  border-top: 1px solid rgba(128, 128, 128, 0.15);
+}
+
 @media (max-width: 768px) {
   .drawer-handle {
     display: inline-flex;
@@ -311,4 +463,29 @@ html[data-theme='ultra-dark'] .ant-drawer .ant-drawer-content,
 html[data-theme='ultra-dark'] .ant-drawer .ant-drawer-body {
   background: #0a0a0a !important;
 }
+
+/* Force the same light-blue tint on selected + hover/active across
+ * all three themes. AD-Vue's defaults read too subtle on the dark
+ * sider, and the light-theme variant looked inconsistent vs. dark —
+ * applying the same RGBA tint over all backgrounds gives the active
+ * page the same visual weight everywhere. `!important` is required to
+ * beat AD-Vue's CSS-in-JS specificity; scoped to .sider-nav /
+ * .sider-utility / .drawer-menu so only the navigation menus pick up
+ * the override (other a-menu instances keep AD-Vue defaults). */
+.sider-nav .ant-menu-item-selected,
+.sider-utility .ant-menu-item-selected,
+.drawer-menu .ant-menu-item-selected {
+  background-color: rgba(64, 150, 255, 0.2) !important;
+  color: #4096ff !important;
+}
+
+.sider-nav .ant-menu-item-active:not(.ant-menu-item-selected),
+.sider-utility .ant-menu-item-active:not(.ant-menu-item-selected),
+.drawer-menu .ant-menu-item-active:not(.ant-menu-item-selected),
+.sider-nav .ant-menu-item:not(.ant-menu-item-selected):not(.ant-menu-item-disabled):hover,
+.sider-utility .ant-menu-item:not(.ant-menu-item-selected):not(.ant-menu-item-disabled):hover,
+.drawer-menu .ant-menu-item:not(.ant-menu-item-selected):not(.ant-menu-item-disabled):hover {
+  background-color: rgba(64, 150, 255, 0.1) !important;
+  color: #4096ff !important;
+}
 </style>

+ 0 - 49
frontend/src/components/ThemeSwitch.vue

@@ -1,49 +0,0 @@
-<script setup>
-import { computed } from 'vue';
-import { useI18n } from 'vue-i18n';
-import { BulbFilled, BulbOutlined } from '@ant-design/icons-vue';
-import { theme, currentTheme, toggleTheme, toggleUltra, pauseAnimationsUntilLeave } from '@/composables/useTheme.js';
-
-const { t } = useI18n();
-
-const BulbIcon = computed(() => (theme.isDark ? BulbFilled : BulbOutlined));
-
-function onDarkChange() {
-  pauseAnimationsUntilLeave('change-theme');
-  toggleTheme();
-}
-
-function onUltraClick() {
-  pauseAnimationsUntilLeave('change-theme-ultra');
-  toggleUltra();
-}
-</script>
-
-<template>
-  <a-menu :theme="currentTheme" mode="inline" :selected-keys="[]">
-    <a-sub-menu>
-      <template #title>
-        <span>
-          <component :is="BulbIcon" />
-          <span class="theme-label">{{ t('menu.theme') }}</span>
-        </span>
-      </template>
-
-      <a-menu-item id="change-theme" class="ant-menu-theme-switch">
-        <span>{{ t('menu.dark') }}</span>
-        <a-switch :style="{ marginLeft: '2px' }" size="small" :checked="theme.isDark" @change="onDarkChange" />
-      </a-menu-item>
-
-      <a-menu-item v-if="theme.isDark" id="change-theme-ultra" class="ant-menu-theme-switch">
-        <span>{{ t('menu.ultraDark') }}</span>
-        <a-checkbox :style="{ marginLeft: '2px' }" :checked="theme.isUltra" @click="onUltraClick" />
-      </a-menu-item>
-    </a-sub-menu>
-  </a-menu>
-</template>
-
-<style scoped>
-.theme-label {
-  margin-left: 8px;
-}
-</style>

+ 0 - 25
frontend/src/components/ThemeSwitchLogin.vue

@@ -1,25 +0,0 @@
-<script setup>
-import { theme, toggleTheme, toggleUltra, pauseAnimationsUntilLeave } from '@/composables/useTheme.js';
-
-function onDarkChange() {
-  pauseAnimationsUntilLeave('change-theme');
-  toggleTheme();
-}
-
-function onUltraClick() {
-  toggleUltra();
-}
-</script>
-
-<template>
-  <a-space id="change-theme" direction="vertical" :size="10" :style="{ width: '100%' }">
-    <a-space direction="horizontal" size="small">
-      <a-switch size="small" :checked="theme.isDark" @change="onDarkChange" />
-      <span>Dark</span>
-    </a-space>
-    <a-space v-if="theme.isDark" direction="horizontal" size="small">
-      <a-checkbox :checked="theme.isUltra" @click="onUltraClick" />
-      <span>Ultra dark</span>
-    </a-space>
-  </a-space>
-</template>

+ 70 - 6
frontend/src/pages/login/LoginPage.vue

@@ -8,8 +8,10 @@ import {
   antdThemeConfig,
   currentTheme,
   theme as themeState,
+  toggleTheme,
+  toggleUltra,
+  pauseAnimationsUntilLeave,
 } from '@/composables/useTheme.js';
-import ThemeSwitchLogin from '@/components/ThemeSwitchLogin.vue';
 
 const { t } = useI18n();
 
@@ -61,21 +63,53 @@ const lang = ref(LanguageManager.getLanguage());
 function onLangChange(next) {
   LanguageManager.setLanguage(next);
 }
+
+/* Same Light -> Dark -> Ultra Dark -> Light cycle the sidebar's brand
+ * button uses, so the login chrome offers a one-click theme toggle
+ * without the popover ceremony. */
+function cycleTheme() {
+  pauseAnimationsUntilLeave('login-theme-cycle');
+  if (!themeState.isDark) {
+    toggleTheme();
+    if (themeState.isUltra) toggleUltra();
+  } else if (!themeState.isUltra) {
+    toggleUltra();
+  } else {
+    toggleUltra();
+    toggleTheme();
+  }
+}
 </script>
 
 <template>
   <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">
-        <!-- Floating settings (theme switcher + language picker) sits in
-             the viewport's top-right corner so the card stays uncluttered. -->
+        <!-- Floating chrome at top-right: theme cycle (Light/Dark/Ultra)
+             plus a language picker hidden behind the gear popover. -->
         <div class="login-toolbar">
-          <a-popover :overlay-class-name="currentTheme" :title="t('menu.settings')" placement="bottomRight"
+          <button type="button" class="theme-cycle" :aria-label="t('menu.theme')" :title="t('menu.theme')"
+            @click="cycleTheme">
+            <svg v-if="!themeState.isDark" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"
+              stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
+              <circle cx="12" cy="12" r="4" />
+              <path d="M12 2v2M12 20v2M4.93 4.93l1.41 1.41M17.66 17.66l1.41 1.41M2 12h2M20 12h2M6.34 17.66l-1.41 1.41M19.07 4.93l-1.41 1.41" />
+            </svg>
+            <svg v-else-if="!themeState.isUltra" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"
+              stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
+              <path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z" />
+            </svg>
+            <svg v-else viewBox="0 0 24 24" fill="currentColor" stroke="currentColor" stroke-width="1.5"
+              stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
+              <path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z" />
+              <path fill="none" d="M19 3l0.7 1.4 1.4 0.7-1.4 0.7L19 7.2l-0.7-1.4-1.4-0.7 1.4-0.7z" />
+            </svg>
+          </button>
+
+          <a-popover :overlay-class-name="currentTheme" :title="t('pages.settings.language')" 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>
@@ -286,6 +320,9 @@ function onLangChange(next) {
   top: 16px;
   right: 16px;
   z-index: 10;
+  display: inline-flex;
+  align-items: center;
+  gap: 8px;
 }
 
 .toolbar-btn {
@@ -293,6 +330,33 @@ function onLangChange(next) {
   height: 40px;
 }
 
+.theme-cycle {
+  width: 40px;
+  height: 40px;
+  border-radius: 50%;
+  border: 1px solid var(--color-border);
+  background: var(--bg-card);
+  color: var(--color-text);
+  display: inline-flex;
+  align-items: center;
+  justify-content: center;
+  cursor: pointer;
+  padding: 0;
+  transition: background-color 0.2s, transform 0.15s, color 0.2s;
+}
+
+.theme-cycle:hover,
+.theme-cycle:focus-visible {
+  color: var(--color-accent);
+  transform: scale(1.05);
+  outline: none;
+}
+
+.theme-cycle svg {
+  width: 18px;
+  height: 18px;
+}
+
 .login-wrapper {
   min-height: 100vh;
   display: flex;

+ 93 - 20
frontend/src/pages/sub/SubPage.vue

@@ -14,8 +14,10 @@ import { ClipboardManager, IntlUtil, LanguageManager } from '@/utils';
 import {
   theme as themeState,
   antdThemeConfig,
+  toggleTheme,
+  toggleUltra,
+  pauseAnimationsUntilLeave,
 } from '@/composables/useTheme.js';
-import ThemeSwitchLogin from '@/components/ThemeSwitchLogin.vue';
 
 const { t } = useI18n();
 
@@ -72,6 +74,22 @@ function onLangChange(next) {
   LanguageManager.setLanguage(next);
 }
 
+/* Same Light -> Dark -> Ultra Dark -> Light cycle the panel sidebar
+ * uses, so the standalone subscription page offers a one-click theme
+ * toggle without the popover ceremony. */
+function cycleTheme() {
+  pauseAnimationsUntilLeave('sub-theme-cycle');
+  if (!themeState.isDark) {
+    toggleTheme();
+    if (themeState.isUltra) toggleUltra();
+  } else if (!themeState.isUltra) {
+    toggleUltra();
+  } else {
+    toggleUltra();
+    toggleTheme();
+  }
+}
+
 const QR_SIZE = 240;
 
 // Actions =====================================================
@@ -140,26 +158,44 @@ const themeClass = computed(() => ({
                 </a-space>
               </template>
               <template #extra>
-                <a-popover :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 />
+                <a-space :size="8" align="center">
+                  <button type="button" class="theme-cycle" :aria-label="t('menu.theme')" :title="t('menu.theme')"
+                    @click="cycleTheme">
+                    <svg v-if="!themeState.isDark" viewBox="0 0 24 24" fill="none" stroke="currentColor"
+                      stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
+                      <circle cx="12" cy="12" r="4" />
+                      <path d="M12 2v2M12 20v2M4.93 4.93l1.41 1.41M17.66 17.66l1.41 1.41M2 12h2M20 12h2M6.34 17.66l-1.41 1.41M19.07 4.93l-1.41 1.41" />
+                    </svg>
+                    <svg v-else-if="!themeState.isUltra" viewBox="0 0 24 24" fill="none" stroke="currentColor"
+                      stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
+                      <path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z" />
+                    </svg>
+                    <svg v-else viewBox="0 0 24 24" fill="currentColor" stroke="currentColor" stroke-width="1.5"
+                      stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
+                      <path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z" />
+                      <path fill="none" d="M19 3l0.7 1.4 1.4 0.7-1.4 0.7L19 7.2l-0.7-1.4-1.4-0.7 1.4-0.7z" />
+                    </svg>
+                  </button>
+
+                  <a-popover :title="t('pages.settings.language')" placement="bottomRight" trigger="click">
+                    <template #content>
+                      <a-space direction="vertical" :size="10" class="settings-popover">
+                        <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>
-                </a-popover>
+                    <a-button shape="circle">
+                      <template #icon>
+                        <SettingOutlined />
+                      </template>
+                    </a-button>
+                  </a-popover>
+                </a-space>
               </template>
 
               <!-- ============== QR codes ============== -->
@@ -450,6 +486,43 @@ const themeClass = computed(() => ({
   min-width: 220px;
 }
 
+.theme-cycle {
+  width: 32px;
+  height: 32px;
+  border-radius: 50%;
+  border: 1px solid rgba(0, 0, 0, 0.08);
+  background: var(--bg-card);
+  color: rgba(0, 0, 0, 0.65);
+  display: inline-flex;
+  align-items: center;
+  justify-content: center;
+  cursor: pointer;
+  padding: 0;
+  transition: background-color 0.2s, transform 0.15s, color 0.2s;
+}
+
+.theme-cycle:hover,
+.theme-cycle:focus-visible {
+  color: #1677ff;
+  transform: scale(1.05);
+  outline: none;
+}
+
+.theme-cycle svg {
+  width: 16px;
+  height: 16px;
+}
+
+.is-dark .theme-cycle {
+  border-color: rgba(255, 255, 255, 0.08);
+  color: rgba(255, 255, 255, 0.85);
+}
+
+.is-dark .theme-cycle:hover,
+.is-dark .theme-cycle:focus-visible {
+  color: #4096ff;
+}
+
 .lang-select {
   width: 100%;
 }