1
0

6 کامیت‌ها 4e928a1ce0 ... b11ceac18e

نویسنده SHA1 پیام تاریخ
  MHSanaei b11ceac18e fix(ci): install the docs-pinned pnpm instead of floating on 11.x 9 ساعت پیش
  MHSanaei bbc4163768 chore: standardize the toolchain on Node 24 LTS 9 ساعت پیش
  MHSanaei ee9a6067c2 refactor(frontend): migrate off deprecated Ant Design 6 props 9 ساعت پیش
  MHSanaei 60316c831f fix(frontend): resolve every axe accessibility violation in the component library 9 ساعت پیش
  MHSanaei df3ba568d1 feat(docs): publish the component Storybook on the docs site 9 ساعت پیش
  MHSanaei 7078abc14a feat(frontend): make Storybook a validated, fully covered component workbench 9 ساعت پیش
67فایلهای تغییر یافته به همراه2377 افزوده شده و 248 حذف شده
  1. 6 0
      .github/workflows/ci.yml
  2. 2 2
      .github/workflows/docs-ci.yml
  3. 12 2
      .github/workflows/docs-deploy.yml
  4. 1 1
      .nvmrc
  5. 6 4
      CONTRIBUTING.md
  6. 6 2
      Makefile
  7. 7 1
      docs/lib/layout.shared.tsx
  8. 1 1
      docs/package.json
  9. 5 1
      frontend/.storybook/main.ts
  10. 6 0
      frontend/.storybook/preview-head.html
  11. 9 2
      frontend/.storybook/preview.tsx
  12. 4 0
      frontend/CLAUDE.md
  13. 36 3
      frontend/README.md
  14. 370 180
      frontend/package-lock.json
  15. 8 5
      frontend/package.json
  16. 11 0
      frontend/src/components/clients/ClientSpeedTag.stories.tsx
  17. 77 0
      frontend/src/components/clients/ClientTrafficCell.stories.tsx
  18. 2 1
      frontend/src/components/clients/ClientTrafficCell.tsx
  19. 4 0
      frontend/src/components/clients/ConfigBlock.css
  20. 28 1
      frontend/src/components/clients/ConfigBlock.stories.tsx
  21. 1 0
      frontend/src/components/clients/ConfigBlock.tsx
  22. 36 0
      frontend/src/components/feedback/PromptModal.stories.tsx
  23. 2 0
      frontend/src/components/feedback/PromptModal.tsx
  24. 25 0
      frontend/src/components/feedback/TextModal.stories.tsx
  25. 1 0
      frontend/src/components/feedback/TextModal.tsx
  26. 96 0
      frontend/src/components/form/DateTimePicker.stories.tsx
  27. 76 0
      frontend/src/components/form/HeaderMapEditor.stories.tsx
  28. 132 0
      frontend/src/components/form/JsonEditor.stories.tsx
  29. 1 0
      frontend/src/components/form/JsonEditor.tsx
  30. 68 0
      frontend/src/components/form/RemarkTemplateField.stories.tsx
  31. 78 0
      frontend/src/components/form/RemarkVarPicker.stories.tsx
  32. 132 0
      frontend/src/components/form/SelectAllClearButtons.stories.tsx
  33. 210 0
      frontend/src/components/form/rhf/FormField.stories.tsx
  34. 6 1
      frontend/src/components/form/rhf/FormField.tsx
  35. 12 0
      frontend/src/components/ui/InfinityIcon.stories.tsx
  36. 16 1
      frontend/src/components/ui/InputAddon.stories.tsx
  37. 16 1
      frontend/src/components/ui/SettingListItem.stories.tsx
  38. 85 0
      frontend/src/components/ui/notifications/EmailNotifications.stories.tsx
  39. 4 4
      frontend/src/components/ui/notifications/EmailNotifications.tsx
  40. 17 3
      frontend/src/components/ui/notifications/NotificationCard.stories.tsx
  41. 77 0
      frontend/src/components/ui/notifications/NotificationEvent.stories.tsx
  42. 131 0
      frontend/src/components/ui/notifications/NotificationGroup.stories.tsx
  43. 1 0
      frontend/src/components/ui/notifications/NotificationGroup.tsx
  44. 104 0
      frontend/src/components/ui/notifications/NotificationHeader.stories.tsx
  45. 146 0
      frontend/src/components/ui/notifications/NotificationLayout.stories.tsx
  46. 82 0
      frontend/src/components/ui/notifications/TelegramNotifications.stories.tsx
  47. 4 4
      frontend/src/components/ui/notifications/TelegramNotifications.tsx
  48. 1 1
      frontend/src/components/ui/notifications/types.ts
  49. 138 0
      frontend/src/components/utility/LazyMount.stories.tsx
  50. 21 1
      frontend/src/components/viz/Sparkline.stories.tsx
  51. 15 0
      frontend/src/hooks/useTheme.tsx
  52. 1 1
      frontend/src/pages/clients/BulkAttachInboundsModal.tsx
  53. 1 1
      frontend/src/pages/clients/BulkDetachInboundsModal.tsx
  54. 1 1
      frontend/src/pages/clients/ClientsPage.tsx
  55. 4 7
      frontend/src/pages/clients/FilterDrawer.tsx
  56. 2 3
      frontend/src/pages/hosts/HostFormModal.tsx
  57. 1 1
      frontend/src/pages/inbounds/clients/AttachClientsModal.tsx
  58. 1 1
      frontend/src/pages/inbounds/clients/AttachExistingClientsModal.tsx
  59. 1 2
      frontend/src/pages/settings/EmailTab.tsx
  60. 1 2
      frontend/src/pages/settings/TelegramTab.tsx
  61. 1 1
      frontend/src/pages/sub/SubPage.tsx
  62. 1 1
      frontend/src/pages/sub/SubUsageSummary.tsx
  63. 2 2
      frontend/src/pages/xray/balancers/BalancerFormModal.tsx
  64. 1 1
      frontend/src/pages/xray/dns/DnsPresetsModal.tsx
  65. 1 1
      frontend/src/pages/xray/dns/DnsTab.tsx
  66. 22 1
      frontend/vitest.config.ts
  67. 1 0
      frontend/vitest.shims.d.ts

+ 6 - 0
.github/workflows/ci.yml

@@ -168,12 +168,18 @@ jobs:
       - name: Typecheck
         run: npm run typecheck
         working-directory: frontend
+      - name: Install Playwright Chromium (Storybook story tests)
+        run: npx playwright install --with-deps chromium
+        working-directory: frontend
       - name: Test
         run: npm test
         working-directory: frontend
       - name: Build
         run: npm run build
         working-directory: frontend
+      - name: Build Storybook
+        run: npm run build-storybook
+        working-directory: frontend
       - name: Audit
         run: npm audit --audit-level=high
         working-directory: frontend

+ 2 - 2
.github/workflows/docs-ci.yml

@@ -26,11 +26,11 @@ jobs:
 
       - uses: pnpm/action-setup@v6
         with:
-          version: 11
+          package_json_file: docs/package.json
 
       - uses: actions/setup-node@v6
         with:
-          node-version: 22
+          node-version-file: .nvmrc
           cache: pnpm
           cache-dependency-path: docs/pnpm-lock.yaml
 

+ 12 - 2
.github/workflows/docs-deploy.yml

@@ -9,6 +9,9 @@ on:
     branches: [main]
     paths:
       - 'docs/**'
+      - 'frontend/src/components/**'
+      - 'frontend/.storybook/**'
+      - 'frontend/package-lock.json'
       - '.github/workflows/docs-deploy.yml'
   workflow_dispatch:
 
@@ -32,10 +35,10 @@ jobs:
       - uses: actions/checkout@v7
       - uses: pnpm/action-setup@v6
         with:
-          version: 11
+          package_json_file: docs/package.json
       - uses: actions/setup-node@v6
         with:
-          node-version: 22
+          node-version-file: .nvmrc
           cache: pnpm
           cache-dependency-path: docs/pnpm-lock.yaml
       - run: pnpm install --frozen-lockfile
@@ -52,6 +55,13 @@ jobs:
         # in place. That way both /docs/... and /en/docs/... resolve. Other
         # locales stay under /fa, /ru, /zh.
         run: cp -a out/en/. out/
+      - name: Build the component Storybook (frontend/)
+        working-directory: frontend
+        run: |
+          npm ci
+          npm run build-storybook
+      - name: Bundle Storybook at /storybook
+        run: cp -a ../frontend/storybook-static out/storybook
       - uses: actions/upload-pages-artifact@v5
         with:
           path: docs/out

+ 1 - 1
.nvmrc

@@ -1 +1 @@
-22
+24

+ 6 - 4
CONTRIBUTING.md

@@ -5,7 +5,7 @@ Thanks for taking the time to contribute to 3x-ui. This guide gets a development
 ## Prerequisites
 
 - **Go 1.26+** (the version pinned in `go.mod`)
-- **Node.js 22+** and npm 10+ (for the React frontend)
+- **Node.js 24 LTS** (the version pinned in `.nvmrc`) and npm 10+ (for the React frontend)
 - **Git**
 - **A C compiler** — required by the CGo SQLite driver (`github.com/mattn/go-sqlite3`). Linux and macOS already ship one; for Windows see below.
 
@@ -157,12 +157,13 @@ Panel navigation happens client-side through React Router, and per-route code is
 
 Locale strings live in `internal/web/translation/<locale>.json`, **not** under `frontend/`. The Go binary embeds the same JSON and serves it to both backend templates and `react-i18next` (initialized in `src/i18n/react.ts`). When a new English key is added it must also land in **every** non-English locale — missing keys do not break the build, they just render the raw key in the UI.
 
-### Two dev workflows
+### Dev workflows
 
 | Goal | Command |
 |------|---------|
 | Iterate on UI changes with HMR | `cd frontend && npm run dev` (Vite on `:5173`, proxies `/panel/*` and the WebSocket to the Go panel on `:2053`). Start the Go panel first. |
 | Verify what end users actually see | `cd frontend && npm run build`, then `go run .`. The Go binary serves the built bundle — embedded in release mode, off disk in debug mode. |
+| Develop/preview a reusable component in isolation | `cd frontend && npm run storybook` (Storybook workbench + autodocs on `:6006`). |
 
 The Vite dev proxy serves the admin SPA for any `/panel/*` URL — `bypassMigratedRoute` in `vite.config.js` rewrites those requests to `index.html` and lets React Router take over — while forwarding `/panel/api/*`, `/panel/api/setting/*`, `/panel/api/xray/*`, and the WebSocket to the Go panel. Because routing is now client-side, new panel routes need no proxy or allowlist changes.
 
@@ -189,6 +190,7 @@ Only a genuinely **standalone bundle** (like `login` or `subpage`, reachable wit
 - **Document new endpoints.** Every new `g.POST`/`g.GET` in `internal/web/controller/` needs a matching entry in `src/pages/api-docs/endpoints.ts` — it drives both the in-panel API docs and the generated OpenAPI/Zod (`npm run gen:api` / `gen:zod`).
 - **Do not break link generation.** Share-link logic lives in `src/lib/xray/` (`inbound-link.ts`, `outbound-link-parser.ts`, …) and is round-tripped by the golden fixture suite — run `npm run test` after any change to URL generation, defaults, or TLS/Reality handling, and regenerate snapshots (`npx vitest run -u`) only for intentional changes. Two runtime paths consume it: the **inbounds page** and the **clients page** subscription links (`/panel/api/clients/subLinks/:subId` → backend `GetSubs`); exercise both.
 - **Vite is pinned to an exact version** (no `^`) in `frontend/package.json` — read the live version there rather than trusting a number quoted here — so local, CI, and release builds resolve identically. Bump it deliberately and verify both `npm run dev` and `npm run build` afterward.
+- **Reusable components are documented in Storybook.** When you add or change a component in `frontend/src/components/`, add or update its co-located `<Component>.stories.tsx` (`tags: ['autodocs']`), documenting props via `argTypes` / `parameters.docs` string metadata rather than JSDoc. CI compile-checks every story via `npm run build-storybook` and runs each story as a headless-browser test via `@storybook/addon-vitest` (`npm run test`, needs `npx playwright install chromium`); run `npm run storybook` to preview locally.
 
 ### Project layout
 
@@ -277,7 +279,7 @@ CI runs this for you nightly (and on demand) via `.github/workflows/mutation.yml
 
 ### CI
 
-`.github/workflows/ci.yml` runs per PR: `go-test` (with `-shuffle -count=1`), a `race` job (`-race -shuffle -count=1`), a `fuzz-smoke` job on the critical parsers, and the frontend `typecheck`/`lint`/`test`/`build`. Snapshots are regression guards — regenerate them (`npx vitest run -u`) only for intentional output changes, never to make a red test green.
+`.github/workflows/ci.yml` runs per PR: `go-test` (with `-shuffle -count=1`), a `race` job (`-race -shuffle -count=1`), a `fuzz-smoke` job on the critical parsers, and the frontend `typecheck`/`lint`/`test`/`build`/`build-storybook`. Snapshots are regression guards — regenerate them (`npx vitest run -u`) only for intentional output changes, never to make a red test green.
 
 ## Sending a pull request
 
@@ -286,7 +288,7 @@ CI runs this for you nightly (and on demand) via `.github/workflows/mutation.yml
 3. Run the relevant checks before pushing:
    - `go build ./...`
    - `go test ./...` (when Go code changed)
-   - `cd frontend && npm run typecheck && npm run lint && npm run test && npm run build` (when the frontend changed; CI runs this same set on every PR via `.github/workflows/ci.yml`)
+   - `cd frontend && npm run typecheck && npm run lint && npm run test && npm run build && npm run build-storybook` (when the frontend changed; CI runs this same set on every PR via `.github/workflows/ci.yml`)
 4. Commit messages follow the existing pattern in `git log` — `<area>: short imperative summary`, then a body explaining the *why*. Conventional-commit prefixes (`feat`, `fix`, `refactor`, `chore`, `style`, `docs`) are encouraged.
 5. Open the PR against `main` with a brief description of what changed and how to test it.
 

+ 6 - 2
Makefile

@@ -68,8 +68,12 @@ build-fe: ## Build the Vite bundles into internal/web/dist
 build: build-fe ## Build the frontend then the Go binary
 	go build ./...
 
+.PHONY: build-storybook
+build-storybook: ## Build the static Storybook (compile-checks all stories)
+	cd $(FRONTEND) && npm run build-storybook
+
 # The PR gate. Matches ci.yml: codegen freshness, both linters, typecheck,
-# both test suites, and a full build.
+# both test suites, a full build, and the Storybook compile-check.
 .PHONY: verify
-verify: gen-check lint typecheck test build ## Full local gate (mirrors CI)
+verify: gen-check lint typecheck test build build-storybook ## Full local gate (mirrors CI)
 	@echo "verify: OK"

+ 7 - 1
docs/lib/layout.shared.tsx

@@ -2,7 +2,7 @@ import type { BaseLayoutProps } from 'fumadocs-ui/layouts/shared';
 import { Heart } from 'lucide-react';
 import { Logo } from '@/components/logo';
 import { TelegramIcon } from '@/components/icons';
-import { appName, productRepoUrl, telegramChannel, telegramChannelUrl, donateUrl } from './shared';
+import { appName, productRepoUrl, telegramChannel, telegramChannelUrl, donateUrl, siteUrl } from './shared';
 import { getSiteMessages } from './site-i18n';
 
 // Build locale-aware shared layout options. With `hideLocale: 'default-locale'`,
@@ -28,6 +28,12 @@ export function baseOptions(lang: string): BaseLayoutProps {
         url: `${prefix}/docs`,
         active: 'nested-url',
       },
+      // Live component workbench built from frontend/ and published alongside the docs.
+      {
+        text: 'Storybook',
+        url: `${siteUrl}/storybook/`,
+        external: true,
+      },
       {
         type: 'icon',
         label: `Telegram channel (@${telegramChannel})`,

+ 1 - 1
docs/package.json

@@ -9,7 +9,7 @@
     "build": "next build",
     "start": "next start",
     "postinstall": "fumadocs-mdx",
-    "gen:api": "node --experimental-strip-types scripts/gen-openapi.ts",
+    "gen:api": "node scripts/gen-openapi.ts",
     "typecheck": "fumadocs-mdx && next typegen && tsc --noEmit",
     "lint": "eslint .",
     "format": "prettier --write .",

+ 5 - 1
frontend/.storybook/main.ts

@@ -6,7 +6,11 @@ const config: StorybookConfig = {
     options: {},
   },
   stories: ['../src/**/*.stories.@(ts|tsx)'],
-  addons: ['@storybook/addon-docs', '@storybook/addon-a11y'],
+  addons: [
+    '@storybook/addon-docs',
+    '@storybook/addon-a11y',
+    '@storybook/addon-vitest'
+  ],
   viteFinal: (viteConfig) => {
     if (viteConfig.build) {
       viteConfig.build.outDir = undefined;

+ 6 - 0
frontend/.storybook/preview-head.html

@@ -0,0 +1,6 @@
+<script>
+  if (localStorage.getItem('dark-mode') === null) localStorage.setItem('dark-mode', 'false');
+  if (localStorage.getItem('isUltraDarkThemeEnabled') === null) {
+    localStorage.setItem('isUltraDarkThemeEnabled', 'false');
+  }
+</script>

+ 9 - 2
frontend/.storybook/preview.tsx

@@ -1,9 +1,10 @@
 import { useEffect } from 'react';
 import type { Decorator, Preview } from '@storybook/react-vite';
-import { ConfigProvider, theme as antdTheme } from 'antd';
+import { ConfigProvider } from 'antd';
 import i18next from 'i18next';
 import { initReactI18next } from 'react-i18next';
 
+import { buildAntdThemeConfig } from '@/hooks/useTheme';
 import enUS from '../../internal/web/translation/en-US.json';
 
 if (!i18next.isInitialized) {
@@ -19,10 +20,11 @@ if (!i18next.isInitialized) {
 const withTheme: Decorator = (Story, context) => {
   const dark = context.globals.theme === 'dark';
   useEffect(() => {
+    document.body.setAttribute('class', dark ? 'dark' : 'light');
     document.documentElement.setAttribute('data-theme', dark ? 'dark' : 'light');
   }, [dark]);
   return (
-    <ConfigProvider theme={{ algorithm: dark ? antdTheme.darkAlgorithm : antdTheme.defaultAlgorithm }}>
+    <ConfigProvider theme={buildAntdThemeConfig(dark, false)}>
       <div style={{ padding: 24, minWidth: 320 }}>
         <Story />
       </div>
@@ -49,11 +51,16 @@ const preview: Preview = {
   },
   parameters: {
     controls: {
+      expanded: true,
+      sort: 'requiredFirst',
       matchers: {
         color: /(background|color)$/i,
         date: /Date$/i,
       },
     },
+    a11y: {
+      test: 'error',
+    },
   },
 };
 

+ 4 - 0
frontend/CLAUDE.md

@@ -63,5 +63,9 @@ Only standalone bundles (login/subpage) need a new `.html` + `src/entries/*` +
 - `npm run typecheck` / `npm run lint` / `npm run test` / `npm run build`.
 - `npm run gen` = `gen:zod` (Go → `src/generated/`) + `gen:api`
   (`build-openapi.mjs` → `public/openapi.json`).
+- `npm run storybook` (workbench on :6006) / `npm run build-storybook` (CI
+  compile-checks every story). Reusable `src/components/` get a co-located
+  `<Component>.stories.tsx` with `tags: ['autodocs']`; document props via
+  `argTypes` / `parameters.docs` string metadata, never JSDoc.
 - After `npm run build`, RESTART `go run .` (see the XUI_DEBUG gotcha in root
   CLAUDE.md) before checking the panel.

+ 36 - 3
frontend/README.md

@@ -36,11 +36,13 @@ production-style links work without round-tripping through Go.
 | `npm run lint` | ESLint flat config (`@typescript-eslint` + `react-hooks`) |
 | `npm run test` | Vitest single run (schema fixtures, link parsers, …) |
 | `npm run test:watch` | Vitest watch mode |
+| `npm run storybook` | Storybook dev server on `:6006` (component workbench + autodocs) |
+| `npm run build-storybook` | Static Storybook build — CI compile-checks every story |
 | `npm run gen:api` | Build `public/openapi.json` from `pages/api-docs/endpoints.ts` |
 | `npm run gen:zod` | Run the Go-side openapigen tool → `src/generated/{zod,types}.ts` |
 
-CI runs `typecheck`, `lint`, `test`, and `build` on every PR
-(see `../.github/workflows/ci.yml`).
+CI runs `typecheck`, `lint`, `test`, `build`, and `build-storybook` on
+every PR (see `../.github/workflows/ci.yml`).
 
 ### One-off: scan for deprecated APIs
 
@@ -79,6 +81,7 @@ frontend/
 │                                         #   usages of APIs marked with JSDoc @deprecated
 ├── vitest.config.ts
 ├── vite.config.js
+├── .storybook/                           # Storybook config (main.ts, preview.tsx)
 ├── scripts/
 │   └── build-openapi.mjs                 # endpoints.ts → openapi.json
 └── src/
@@ -89,7 +92,7 @@ frontend/
     │   ├── index/, login/, inbounds/, clients/, xray/, nodes/,
     │   ├── settings/, api-docs/, sub/
     ├── layouts/         # AdminLayout (sidebar + header + outlet)
-    ├── components/      # Cross-page React components
+    ├── components/      # Cross-page React components (+ co-located *.stories.tsx)
     ├── hooks/           # useClients, useTheme, useWebSocket, …
     ├── api/             # fetch client + CSRF handling, TanStack Query bridge,
     │                    #   WebSocket client + queryClient.ts
@@ -187,6 +190,36 @@ npx vitest run -u
 Fixtures live in `src/test/golden/fixtures/` and are auto-discovered
 via `import.meta.glob`.
 
+## Storybook
+
+Reusable components in `src/components/` are developed and documented in
+**Storybook** (`@storybook/react-vite`). It is a component workbench, not part
+of the shipped panel — nothing here is embedded into the Go binary. The built
+Storybook is published with the docs site at
+[docs.sanaei.dev/storybook](https://docs.sanaei.dev/storybook/) by
+`.github/workflows/docs-deploy.yml`.
+
+```sh
+npm run storybook        # dev server on http://localhost:6006
+npm run build-storybook  # static build; CI runs this to compile-check every story
+```
+
+Addons: `@storybook/addon-docs` renders an autodocs page per component,
+`@storybook/addon-a11y` flags accessibility issues in the canvas, and
+`@storybook/addon-vitest` runs every story as a headless-browser test under
+`npm run test` (Playwright/Chromium — run `npx playwright install chromium` once
+locally). The `.storybook/preview.tsx` decorator wraps every story in the AntD
+`ConfigProvider` and adds a light/dark theme toggle to the toolbar.
+
+Conventions for a story:
+
+- Co-locate it with its component as `<Component>.stories.tsx`.
+- Set `tags: ['autodocs']` so it gets a generated docs page.
+- Document props via story metadata, not JSDoc (the repo bans `//` comments): a
+  component summary in `parameters.docs.description.component` and per-prop text
+  in `argTypes[prop].description`. `satisfies Meta<typeof Component>` keeps the
+  metadata type-checked.
+
 ## Adding a new page
 
 Most new routes go inside the admin SPA (`index.html`) via

تفاوت فایلی نمایش داده نمی شود زیرا این فایل بسیار بزرگ است
+ 370 - 180
frontend/package-lock.json


+ 8 - 5
frontend/package.json

@@ -5,7 +5,7 @@
   "type": "module",
   "description": "3x-ui panel frontend (React 19 + Ant Design 6 + Vite 8).",
   "engines": {
-    "node": ">=22.0.0",
+    "node": ">=24.0.0",
     "npm": ">=10.0.0"
   },
   "scripts": {
@@ -19,7 +19,7 @@
     "storybook": "storybook dev -p 6006",
     "build-storybook": "storybook build",
     "gen": "npm run gen:zod && npm run gen:api",
-    "gen:api": "node --experimental-strip-types --disable-warning=ExperimentalWarning scripts/build-openapi.mjs",
+    "gen:api": "node scripts/build-openapi.mjs",
     "gen:zod": "cd .. && go run ./tools/openapigen",
     "prepare": "cd .. && husky frontend/.husky || true"
   },
@@ -34,7 +34,7 @@
     "@noble/hashes": "^2.2.0",
     "@tanstack/react-query": "^5.101.2",
     "@tanstack/react-query-devtools": "^5.101.2",
-    "antd": "^6.5.0",
+    "antd": "^6.5.1",
     "codemirror": "^6.0.2",
     "dayjs": "^1.11.21",
     "i18next": "^26.3.6",
@@ -53,6 +53,7 @@
     "@eslint/js": "^10.0.1",
     "@storybook/addon-a11y": "^10.5.0",
     "@storybook/addon-docs": "^10.5.0",
+    "@storybook/addon-vitest": "^10.5.0",
     "@storybook/react-vite": "^10.5.0",
     "@testing-library/dom": "^10.4.1",
     "@testing-library/react": "^16.3.2",
@@ -71,9 +72,11 @@
     "msw": "^2.15.0",
     "storybook": "^10.5.0",
     "typescript": "^6.0.3",
-    "typescript-eslint": "^8.63.0",
+    "typescript-eslint": "^8.64.0",
     "vite": "8.1.4",
-    "vitest": "^4.1.10"
+    "vitest": "^4.1.10",
+    "@vitest/browser-playwright": "4.1.10",
+    "playwright": "^1.61.1"
   },
   "overrides": {
     "eslint-plugin-jsx-a11y": {

+ 11 - 0
frontend/src/components/clients/ClientSpeedTag.stories.tsx

@@ -6,6 +6,17 @@ const meta = {
   title: 'Clients/ClientSpeedTag',
   component: ClientSpeedTag,
   tags: ['autodocs'],
+  parameters: {
+    docs: {
+      description: {
+        component:
+          'Blue tag showing a live upload/download speed for one client, formatted for readability. Shown next to online clients that have active traffic.',
+      },
+    },
+  },
+  argTypes: {
+    speed: { description: 'Live upload/download rate in bytes per second (`{ up, down }`).' },
+  },
 } satisfies Meta<typeof ClientSpeedTag>;
 
 export default meta;

+ 77 - 0
frontend/src/components/clients/ClientTrafficCell.stories.tsx

@@ -0,0 +1,77 @@
+import type { Meta, StoryObj } from '@storybook/react-vite';
+
+import { ThemeProvider } from '@/hooks/useTheme';
+
+import ClientTrafficCell from './ClientTrafficCell';
+
+const GiB = 1024 ** 3;
+
+const meta = {
+  title: 'Clients/ClientTrafficCell',
+  component: ClientTrafficCell,
+  tags: ['autodocs'],
+  decorators: [
+    (Story) => (
+      <ThemeProvider>
+        <Story />
+      </ThemeProvider>
+    ),
+  ],
+  parameters: {
+    layout: 'padded',
+    docs: {
+      description: {
+        component:
+          'Traffic usage cell for the clients table: used bytes, a color-coded progress bar, and the quota (or an infinity icon for unlimited clients), with an upload/download/remaining breakdown in a hover popover.',
+      },
+    },
+  },
+  argTypes: {
+    up: { description: 'Uploaded bytes counted against the client.' },
+    down: { description: 'Downloaded bytes counted against the client.' },
+    total: { description: 'Traffic quota in bytes; 0 or less renders as unlimited.' },
+    enabled: { description: 'Grays the bar out when the client is disabled.' },
+    trafficDiff: { description: 'Headroom in bytes below the quota at which the bar shifts from green to orange.' },
+    compact: { description: 'Smaller bar and tighter layout for dense table rows.' },
+  },
+} satisfies Meta<typeof ClientTrafficCell>;
+
+export default meta;
+
+type Story = StoryObj<typeof meta>;
+
+export const Default: Story = {
+  args: {
+    up: 6 * GiB,
+    down: 35 * GiB,
+    total: 100 * GiB,
+    trafficDiff: 5 * GiB,
+  },
+};
+
+export const Unlimited: Story = {
+  args: {
+    up: 87 * GiB,
+    down: 940 * GiB,
+    total: 0,
+  },
+};
+
+export const Depleted: Story = {
+  args: {
+    up: 9 * GiB,
+    down: 42 * GiB,
+    total: 50 * GiB,
+    trafficDiff: 5 * GiB,
+  },
+};
+
+export const DisabledCompact: Story = {
+  args: {
+    up: 2 * GiB,
+    down: 11 * GiB,
+    total: 40 * GiB,
+    enabled: false,
+    compact: true,
+  },
+};

+ 2 - 1
frontend/src/components/clients/ClientTrafficCell.tsx

@@ -64,6 +64,7 @@ export default function ClientTrafficCell({
         <span className="client-traffic-cell-used">{SizeFormatter.sizeFormat(display.used)}</span>
         <Progress
           className="client-traffic-cell-bar"
+          aria-label={`${SizeFormatter.sizeFormat(display.used)} / ${display.isUnlimited ? t('subscription.unlimited') : SizeFormatter.sizeFormat(total)}`}
           percent={display.percent}
           showInfo={false}
           strokeColor={display.strokeColor}
@@ -72,7 +73,7 @@ export default function ClientTrafficCell({
         />
         <span className="client-traffic-cell-limit">
           {display.isUnlimited ? (
-            <span className="client-traffic-cell-infinity" aria-label={t('subscription.unlimited')}>
+            <span className="client-traffic-cell-infinity" role="img" aria-label={t('subscription.unlimited')}>
               <InfinityIcon />
             </span>
           ) : (

+ 4 - 0
frontend/src/components/clients/ConfigBlock.css

@@ -12,6 +12,10 @@
   min-width: 0;
 }
 
+body.light .config-block .ant-tag.ant-tag-filled.ant-tag-gold {
+  color: #874d00;
+}
+
 .config-block .ant-collapse-extra {
   display: flex;
   align-items: center;

+ 28 - 1
frontend/src/components/clients/ConfigBlock.stories.tsx

@@ -1,4 +1,5 @@
 import type { Meta, StoryObj } from '@storybook/react-vite';
+import { expect, waitFor } from 'storybook/test';
 
 import ConfigBlock from './ConfigBlock';
 
@@ -6,7 +7,24 @@ const meta = {
   title: 'Clients/ConfigBlock',
   component: ConfigBlock,
   tags: ['autodocs'],
-  parameters: { layout: 'padded' },
+  parameters: {
+    layout: 'padded',
+    docs: {
+      description: {
+        component:
+          'Collapsible panel that displays a client config or share link with copy, download, and QR-code actions. Used on the clients and inbounds pages to present generated links.',
+      },
+    },
+  },
+  argTypes: {
+    label: { description: 'Protocol/type badge shown on the panel header (e.g. `vless`, `trojan`).' },
+    text: { description: 'The config or share-link text to display, copy, download, and encode as a QR code.' },
+    fileName: { description: 'File name used when downloading the text.' },
+    qrRemark: { description: 'Optional remark embedded in the QR panel; falls back to `label`.' },
+    showQr: { description: 'Whether to show the QR-code action button.' },
+    tagColor: { description: 'Ant Design tag color for the header badge.' },
+    defaultOpen: { description: 'Whether the panel starts expanded.' },
+  },
 } satisfies Meta<typeof ConfigBlock>;
 
 export default meta;
@@ -18,6 +36,15 @@ const sampleLink = 'vless://[email protected]
 
 export const Collapsed: Story = {
   args: { label: 'vless', text: sampleLink, fileName: 'client-config.txt' },
+  play: async ({ canvas, userEvent }) => {
+    await expect(canvas.queryByText(/vless:\/\/11112222/)).not.toBeInTheDocument();
+    await userEvent.click(canvas.getByText('vless'));
+    const configText = await canvas.findByText(/vless:\/\/11112222/);
+    await waitFor(() => expect(configText).toBeVisible());
+    await expect(canvas.getByRole('button', { name: 'Copy' })).toBeVisible();
+    await expect(canvas.getByRole('button', { name: 'Download' })).toBeVisible();
+    await expect(canvas.getByRole('button', { name: 'QR Code' })).toBeVisible();
+  },
 };
 
 export const Expanded: Story = {

+ 1 - 0
frontend/src/components/clients/ConfigBlock.tsx

@@ -68,6 +68,7 @@ export default function ConfigBlock({
       {messageContextHolder}
       <Collapse
         className="config-block"
+        collapsible="header"
         defaultActiveKey={defaultOpen ? ['cfg'] : []}
         items={[{
           key: 'cfg',

+ 36 - 0
frontend/src/components/feedback/PromptModal.stories.tsx

@@ -1,5 +1,6 @@
 import { useState } from 'react';
 import type { Meta, StoryObj } from '@storybook/react-vite';
+import { expect, waitFor, within } from 'storybook/test';
 import { Button } from 'antd';
 
 import PromptModal from './PromptModal';
@@ -8,6 +9,25 @@ const meta = {
   title: 'Feedback/PromptModal',
   component: PromptModal,
   tags: ['autodocs'],
+  parameters: {
+    docs: {
+      description: {
+        component:
+          'Modal that prompts for a single value — a plain input, a multi-line textarea, or a JSON editor. Confirms on Enter (input) or Ctrl+S (textarea) and returns the entered text.',
+      },
+    },
+  },
+  argTypes: {
+    open: { description: 'Whether the modal is visible.' },
+    title: { description: 'Modal title text.' },
+    okText: { description: 'Confirm button label; defaults to the translated "confirm".' },
+    type: { description: 'Editor variant: single-line `input` or multi-line `textarea`.' },
+    initialValue: { description: 'Value pre-filled when the modal opens.' },
+    loading: { description: 'Shows a loading state on the confirm button.' },
+    json: { description: 'Render a JSON editor instead of a plain field.' },
+    onConfirm: { description: 'Called with the entered value on confirm.' },
+    onClose: { description: 'Called when the modal is dismissed.' },
+  },
 } satisfies Meta<typeof PromptModal>;
 
 export default meta;
@@ -62,9 +82,25 @@ const placeholderArgs = {
 export const Input: Story = {
   args: placeholderArgs,
   render: () => <InputDemo />,
+  play: async ({ canvas, canvasElement, userEvent }) => {
+    const body = within(canvasElement.ownerDocument.body);
+    await userEvent.click(canvas.getByRole('button', { name: 'Rename client' }));
+    const input = await body.findByRole('textbox');
+    await userEvent.type(input, 'new-name');
+    await userEvent.keyboard('{Enter}');
+    await expect(await canvas.findByText(/Last confirmed: new-name/)).toBeVisible();
+  },
 };
 
 export const Textarea: Story = {
   args: placeholderArgs,
   render: () => <TextareaDemo />,
+  play: async ({ canvas, canvasElement, userEvent }) => {
+    const body = within(canvasElement.ownerDocument.body);
+    await userEvent.click(canvas.getByRole('button', { name: 'Edit note' }));
+    const textarea = await body.findByRole('textbox');
+    await expect(textarea).toHaveValue('line one\nline two');
+    await userEvent.click(body.getByRole('button', { name: 'Confirm' }));
+    await waitFor(() => expect(body.queryByRole('dialog')).not.toBeInTheDocument());
+  },
 };

+ 2 - 0
frontend/src/components/feedback/PromptModal.tsx

@@ -72,6 +72,7 @@ export default function PromptModal({
       ) : type === 'textarea' ? (
         <Input.TextArea
           ref={(el) => { textareaRef.current = (el as unknown as { resizableTextArea?: { textArea: HTMLTextAreaElement } })?.resizableTextArea?.textArea ?? null; }}
+          aria-label={title}
           value={value}
           onChange={(e) => setValue(e.target.value)}
           autoSize={{ minRows: 10, maxRows: 20 }}
@@ -80,6 +81,7 @@ export default function PromptModal({
       ) : (
         <Input
           ref={inputRef}
+          aria-label={title}
           value={value}
           onChange={(e) => setValue(e.target.value)}
           onKeyDown={onKeydown}

+ 25 - 0
frontend/src/components/feedback/TextModal.stories.tsx

@@ -1,5 +1,6 @@
 import { useState } from 'react';
 import type { Meta, StoryObj } from '@storybook/react-vite';
+import { expect, waitFor, within } from 'storybook/test';
 import { Button } from 'antd';
 
 import TextModal from './TextModal';
@@ -8,6 +9,23 @@ const meta = {
   title: 'Feedback/TextModal',
   component: TextModal,
   tags: ['autodocs'],
+  parameters: {
+    docs: {
+      description: {
+        component:
+          'Read-only modal for viewing generated text or JSON — a client config, subscription, or exported settings — with copy and optional download actions, plus optional tabs for multiple documents.',
+      },
+    },
+  },
+  argTypes: {
+    open: { description: 'Whether the modal is visible.' },
+    title: { description: 'Modal title text.' },
+    content: { description: 'Text shown when no `tabs` are provided.' },
+    fileName: { description: 'When set, adds a download button that saves the active content under this name.' },
+    json: { description: 'Render the content in a read-only JSON editor with syntax highlighting.' },
+    tabs: { description: 'Optional list of `{ key, label, content }` documents shown as tabs.' },
+    onClose: { description: 'Called when the modal is dismissed.' },
+  },
 } satisfies Meta<typeof TextModal>;
 
 export default meta;
@@ -43,6 +61,13 @@ const placeholderArgs = {
 export const PlainText: Story = {
   args: placeholderArgs,
   render: () => <Demo fileName="client.txt" />,
+  play: async ({ canvas, canvasElement, userEvent }) => {
+    const body = within(canvasElement.ownerDocument.body);
+    await userEvent.click(canvas.getByRole('button', { name: 'Show config' }));
+    const content = await body.findByDisplayValue(/vless:\/\/uuid@example\.com/);
+    await waitFor(() => expect(content).toBeVisible());
+    await expect(body.getByRole('button', { name: /client\.txt/ })).toBeEnabled();
+  },
 };
 
 export const Json: Story = {

+ 1 - 0
frontend/src/components/feedback/TextModal.tsx

@@ -75,6 +75,7 @@ export default function TextModal({ open, onClose, title, content, fileName = ''
         <JsonEditor value={activeContent} readOnly minHeight="240px" maxHeight="60vh" />
       ) : (
         <Input.TextArea
+          aria-label={title}
           value={activeContent}
           readOnly
           autoSize={{ minRows: 10, maxRows: 20 }}

+ 96 - 0
frontend/src/components/form/DateTimePicker.stories.tsx

@@ -0,0 +1,96 @@
+import { useEffect, useState } from 'react';
+import type { Meta, StoryObj } from '@storybook/react-vite';
+import { Typography } from 'antd';
+import dayjs from 'dayjs';
+import type { Dayjs } from 'dayjs';
+
+import { setDatepicker } from '@/hooks/useDatepicker';
+import { ThemeProvider } from '@/hooks/useTheme';
+
+import DateTimePicker from './DateTimePicker';
+
+setDatepicker('gregorian');
+
+function ClientExpiryDemo() {
+  const [value, setValue] = useState<Dayjs | null>(dayjs('2026-12-31 23:59:59'));
+  return (
+    <div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
+      <DateTimePicker value={value} onChange={setValue} placeholder="Expiry date" />
+      <Typography.Text type="secondary">
+        {value ? `user1@node-de expiryTime: ${value.valueOf()}` : 'user1@node-de expiryTime: 0 (never expires)'}
+      </Typography.Text>
+    </div>
+  );
+}
+
+function JalaliDemo() {
+  const [value, setValue] = useState<Dayjs | null>(dayjs('2026-12-31 23:59:59'));
+  useEffect(() => {
+    setDatepicker('jalalian');
+    return () => setDatepicker('gregorian');
+  }, []);
+  return <DateTimePicker value={value} onChange={setValue} placeholder="Expiry date" />;
+}
+
+const meta = {
+  title: 'Form/DateTimePicker',
+  component: DateTimePicker,
+  tags: ['autodocs'],
+  parameters: {
+    layout: 'padded',
+    docs: {
+      description: {
+        component:
+          'Calendar-aware date/time picker used for client and inbound expiry dates. Renders an AntD DatePicker by default and switches to a Persian (Jalali) calendar — with theme-matched colors and an overlaid clear button — when the panel datepicker setting is jalalian.',
+      },
+    },
+  },
+  decorators: [
+    (Story) => (
+      <ThemeProvider>
+        <Story />
+      </ThemeProvider>
+    ),
+  ],
+  argTypes: {
+    value: { description: 'Selected moment as a Dayjs instance, or null when unset.' },
+    onChange: { description: 'Called with the picked Dayjs value, or null when cleared.' },
+    showTime: { description: 'Include an hour/minute/second selector alongside the date.' },
+    format: { description: 'Display format for the Gregorian picker input.' },
+    placeholder: { description: 'Input placeholder shown while no value is set.' },
+    disabled: { description: 'Disables the input and hides the clear button.' },
+  },
+} satisfies Meta<typeof DateTimePicker>;
+
+export default meta;
+
+type Story = StoryObj<typeof meta>;
+
+export const Empty: Story = {
+  args: {
+    value: null,
+    onChange: () => undefined,
+    placeholder: 'Leave blank to never expire',
+  },
+};
+
+export const ClientExpiry: Story = {
+  args: { value: null, onChange: () => undefined },
+  render: () => <ClientExpiryDemo />,
+};
+
+export const DateOnly: Story = {
+  args: {
+    value: dayjs('2026-08-01'),
+    onChange: () => undefined,
+    showTime: false,
+    format: 'YYYY-MM-DD',
+    placeholder: 'Start date',
+  },
+};
+
+export const JalaliCalendar: Story = {
+  args: { value: null, onChange: () => undefined },
+  parameters: { docs: { disable: true } },
+  render: () => <JalaliDemo />,
+};

+ 76 - 0
frontend/src/components/form/HeaderMapEditor.stories.tsx

@@ -0,0 +1,76 @@
+import { useState } from 'react';
+import type { Meta, StoryObj } from '@storybook/react-vite';
+
+import HeaderMapEditor, { type HeaderMapValue } from './HeaderMapEditor';
+
+const meta = {
+  title: 'Form/HeaderMapEditor',
+  component: HeaderMapEditor,
+  tags: ['autodocs'],
+  parameters: {
+    layout: 'padded',
+    docs: {
+      description: {
+        component:
+          'Row-based editor for Xray HTTP header maps, used in the inbound/outbound stream forms. Mode `v1` emits one string per header name (WS / HTTPUpgrade / Hysteria masquerade); mode `v2` emits string arrays so headers can repeat (TCP HTTP camouflage request/response).',
+      },
+    },
+  },
+  argTypes: {
+    mode: { description: 'Wire shape: `v1` = string per name, `v2` = string[] per name (repeatable headers).' },
+    value: { description: 'Header map in the wire shape matching `mode`; converted to editable rows internally.' },
+    onChange: { description: 'Called with the rebuilt wire-shape map after every row edit, add, or remove.' },
+  },
+} satisfies Meta<typeof HeaderMapEditor>;
+
+export default meta;
+
+type Story = StoryObj<typeof meta>;
+
+export const Empty: Story = {
+  args: { mode: 'v1', onChange: () => undefined },
+};
+
+export const WsHostHeaders: Story = {
+  args: {
+    mode: 'v1',
+    value: {
+      Host: 'cdn.example.com',
+      'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36',
+    },
+    onChange: () => undefined,
+  },
+};
+
+export const TcpCamouflageRequest: Story = {
+  args: {
+    mode: 'v2',
+    value: {
+      Accept: ['text/html,application/xhtml+xml', 'application/json'],
+      'Accept-Encoding': ['gzip, deflate'],
+      Connection: ['keep-alive'],
+      Pragma: ['no-cache'],
+    },
+    onChange: () => undefined,
+  },
+};
+
+function WireShapeDemo() {
+  const [value, setValue] = useState<HeaderMapValue>({
+    Accept: ['text/html', 'application/json'],
+    'X-Forwarded-For': ['203.0.113.7'],
+  });
+  return (
+    <div style={{ maxWidth: 560 }}>
+      <HeaderMapEditor mode="v2" value={value} onChange={setValue} />
+      <pre style={{ marginTop: 16, padding: 12, borderRadius: 8, background: 'rgba(128, 128, 128, 0.12)' }}>
+        {JSON.stringify(value ?? {}, null, 2)}
+      </pre>
+    </div>
+  );
+}
+
+export const LiveWireShape: Story = {
+  args: { mode: 'v2', onChange: () => undefined },
+  render: () => <WireShapeDemo />,
+};

+ 132 - 0
frontend/src/components/form/JsonEditor.stories.tsx

@@ -0,0 +1,132 @@
+import { useState } from 'react';
+import type { Meta, StoryObj } from '@storybook/react-vite';
+import { Typography } from 'antd';
+
+import { ThemeProvider } from '@/hooks/useTheme';
+
+import JsonEditor from './JsonEditor';
+
+const warpOutbound = JSON.stringify(
+  {
+    tag: 'warp-out',
+    protocol: 'wireguard',
+    settings: {
+      secretKey: 'yFXfmXX3Zn5tnpNJ7HAcbLvqcMVioqPDGV1GXn2FeV0=',
+      address: ['172.16.0.2/32', '2606:4700:110:8f81::2/128'],
+      peers: [
+        {
+          publicKey: 'bmXOC+F1FxEMF9dyiK2H5/1SUtzH0JuVo51h2wPfgyo=',
+          allowedIPs: ['0.0.0.0/0', '::/0'],
+          endpoint: 'engage.cloudflareclient.com:2408',
+        },
+      ],
+      mtu: 1280,
+    },
+  },
+  null,
+  2,
+);
+
+const realityStreamSettings = JSON.stringify(
+  {
+    network: 'tcp',
+    security: 'reality',
+    realitySettings: {
+      show: false,
+      dest: 'yahoo.com:443',
+      xver: 0,
+      serverNames: ['yahoo.com', 'www.yahoo.com'],
+      privateKey: 'wLc4dpQvRt8mK1nS9jH2fXaU7yEoB3iZ6vNqTgCkW5A',
+      shortIds: ['6ba85179e30d4fc2'],
+    },
+  },
+  null,
+  2,
+);
+
+const brokenInboundSettings = [
+  '{',
+  '  "clients": [',
+  '    {',
+  '      "id": "9f4c3a2b-7d61-4e8a-b5c0-1f2e3d4a5b6c",',
+  '      "email": "user1@node-de",',
+  '      "flow": "xtls-rprx-vision",',
+  '    }',
+  '  ],',
+  '  "decryption": "none"',
+].join('\n');
+
+function ControlledDemo() {
+  const [value, setValue] = useState(warpOutbound);
+  let parseError = '';
+  try {
+    JSON.parse(value);
+  } catch (err) {
+    parseError = err instanceof Error ? err.message : String(err);
+  }
+  return (
+    <div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
+      <JsonEditor value={value} onChange={setValue} minHeight="220px" maxHeight="360px" />
+      <Typography.Text type={parseError ? 'danger' : 'success'}>
+        {parseError ? `Parse error: ${parseError}` : `Valid JSON (${value.length} chars)`}
+      </Typography.Text>
+    </div>
+  );
+}
+
+const meta = {
+  title: 'Form/JsonEditor',
+  component: JsonEditor,
+  tags: ['autodocs'],
+  parameters: {
+    layout: 'padded',
+    docs: {
+      description: {
+        component:
+          'CodeMirror-based JSON editor with syntax highlighting, live parse linting, and theme-aware styling. The panel uses it for raw xray config snippets — inbound settings, stream settings, and outbound JSON in the modals and settings pages.',
+      },
+    },
+  },
+  decorators: [
+    (Story) => (
+      <ThemeProvider>
+        <Story />
+      </ThemeProvider>
+    ),
+  ],
+  argTypes: {
+    value: { description: 'JSON document text; the editor resyncs when this prop changes.' },
+    onChange: { description: 'Called with the full document text on every edit.' },
+    minHeight: { description: 'CSS min-height of the scrollable editor area.' },
+    maxHeight: { description: 'CSS max-height before the editor scrolls internally.' },
+    readOnly: { description: 'Disables editing while keeping selection and scrolling.' },
+  },
+} satisfies Meta<typeof JsonEditor>;
+
+export default meta;
+
+type Story = StoryObj<typeof meta>;
+
+export const Default: Story = {
+  args: { value: warpOutbound },
+};
+
+export const ReadOnly: Story = {
+  args: { value: realityStreamSettings, readOnly: true, minHeight: '200px' },
+};
+
+export const LintErrors: Story = {
+  args: { value: brokenInboundSettings, minHeight: '220px' },
+};
+
+export const Controlled: Story = {
+  args: { value: '' },
+  parameters: {
+    a11y: {
+      config: {
+        rules: [{ id: 'scrollable-region-focusable', enabled: false }],
+      },
+    },
+  },
+  render: () => <ControlledDemo />,
+};

+ 1 - 0
frontend/src/components/form/JsonEditor.tsx

@@ -120,6 +120,7 @@ const JsonEditor = forwardRef<JsonEditorHandle, JsonEditorProps>(function JsonEd
         doc: value,
         extensions: [
           basicSetup,
+          EditorView.contentAttributes.of({ 'aria-label': t('jsonEditor') }),
           keymap.of([indentWithTab]),
           json(),
           linter(jsonParseLinter()),

+ 68 - 0
frontend/src/components/form/RemarkTemplateField.stories.tsx

@@ -0,0 +1,68 @@
+import { useState } from 'react';
+import type { Meta, StoryObj } from '@storybook/react-vite';
+
+import RemarkTemplateField from './RemarkTemplateField';
+
+const meta = {
+  title: 'Form/RemarkTemplateField',
+  component: RemarkTemplateField,
+  tags: ['autodocs'],
+  parameters: {
+    layout: 'padded',
+    docs: {
+      description: {
+        component:
+          'Text input augmented with a {{VAR}} token picker (insert-at-caret) and a live sample-based preview of the expanded remark. The panel uses it in subscription settings for the global Remark Template.',
+      },
+    },
+  },
+  argTypes: {
+    value: { description: 'Current template string; any {{VAR}} token enables the live preview below the input.' },
+    onChange: { description: 'Called with the updated template on typing or token insertion.' },
+    maxLength: { description: 'Maximum template length; picker insertions are clamped to it.' },
+    placeholder: { description: 'Placeholder shown while the template is empty.' },
+  },
+} satisfies Meta<typeof RemarkTemplateField>;
+
+export default meta;
+
+type Story = StoryObj<typeof meta>;
+
+function InteractiveDemo() {
+  const [value, setValue] = useState('{{STATUS_EMOJI}} {{INBOUND}}-{{EMAIL}} | {{TRAFFIC_LEFT}}');
+  return <RemarkTemplateField value={value} onChange={setValue} maxLength={256} placeholder="{{INBOUND}}-{{EMAIL}}" />;
+}
+
+export const Empty: Story = {
+  args: {
+    value: '',
+    onChange: () => undefined,
+    placeholder: '{{INBOUND}}-{{EMAIL}}',
+  },
+};
+
+export const TokenTemplate: Story = {
+  args: {
+    value: '{{INBOUND}}-{{EMAIL}} | {{TRAFFIC_LEFT}} left | {{DAYS_LEFT}}d',
+    onChange: () => undefined,
+    maxLength: 256,
+    placeholder: '{{INBOUND}}-{{EMAIL}}',
+  },
+};
+
+export const PlainRemark: Story = {
+  args: {
+    value: 'Germany CDN node',
+    onChange: () => undefined,
+    maxLength: 256,
+    placeholder: '{{INBOUND}}-{{EMAIL}}',
+  },
+};
+
+export const Interactive: Story = {
+  args: {
+    value: '',
+    onChange: () => undefined,
+  },
+  render: () => <InteractiveDemo />,
+};

+ 78 - 0
frontend/src/components/form/RemarkVarPicker.stories.tsx

@@ -0,0 +1,78 @@
+import { useState } from 'react';
+import type { Meta, StoryObj } from '@storybook/react-vite';
+import { Button, Input, Popover, Typography } from 'antd';
+
+import { previewRemark, wrapToken } from '@/lib/remark/remarkVariables';
+
+import RemarkVarPicker from './RemarkVarPicker';
+
+const meta = {
+  title: 'Form/RemarkVarPicker',
+  component: RemarkVarPicker,
+  tags: ['autodocs'],
+  parameters: {
+    layout: 'padded',
+    docs: {
+      description: {
+        component:
+          'Grouped, tooltipped chip list of the {{VAR}} tokens the backend substitutes per client in subscription remarks. The hosts page shows it in a popover beside the remark-template field so operators can insert placeholders like {{EMAIL}} or {{TRAFFIC_LEFT}}.',
+      },
+    },
+  },
+  argTypes: {
+    onPick: { description: 'Called with the bare token (e.g. "EMAIL") when a chip is clicked or activated via keyboard.' },
+  },
+} satisfies Meta<typeof RemarkVarPicker>;
+
+export default meta;
+
+type Story = StoryObj<typeof meta>;
+
+function TemplateBuilderDemo() {
+  const [template, setTemplate] = useState('{{INBOUND}}-{{EMAIL}} {{STATUS_EMOJI}} {{TRAFFIC_LEFT}} left');
+  return (
+    <div style={{ maxWidth: 520 }}>
+      <Input
+        value={template}
+        onChange={(e) => setTemplate(e.target.value)}
+        aria-label="Remark template"
+        style={{ fontFamily: 'monospace' }}
+      />
+      <Typography.Paragraph type="secondary" style={{ margin: '8px 0 16px' }}>
+        Preview: {previewRemark(template)}
+      </Typography.Paragraph>
+      <RemarkVarPicker onPick={(token) => setTemplate((prev) => `${prev}${wrapToken(token)}`)} />
+    </div>
+  );
+}
+
+function PopoverDemo() {
+  const [lastPicked, setLastPicked] = useState('');
+  return (
+    <>
+      <Popover
+        content={<RemarkVarPicker onPick={(token) => setLastPicked(wrapToken(token))} />}
+        trigger="click"
+        placement="bottomRight"
+        title="Remark variables"
+      >
+        <Button>Insert variable</Button>
+      </Popover>
+      <div style={{ marginTop: 12 }}>Last picked: {lastPicked || '—'}</div>
+    </>
+  );
+}
+
+export const Default: Story = {
+  args: { onPick: () => undefined },
+};
+
+export const TemplateBuilder: Story = {
+  args: { onPick: () => undefined },
+  render: () => <TemplateBuilderDemo />,
+};
+
+export const InsidePopover: Story = {
+  args: { onPick: () => undefined },
+  render: () => <PopoverDemo />,
+};

+ 132 - 0
frontend/src/components/form/SelectAllClearButtons.stories.tsx

@@ -0,0 +1,132 @@
+import { useState } from 'react';
+import type { Meta, StoryObj } from '@storybook/react-vite';
+import { expect } from 'storybook/test';
+import { Select } from 'antd';
+
+import SelectAllClearButtons from './SelectAllClearButtons';
+
+const inboundOptions: Array<{ value: number; label: string }> = [
+  { value: 1, label: 'VLESS Reality — 443' },
+  { value: 2, label: 'VMess WS — 8443' },
+  { value: 3, label: 'Trojan TCP — 2053' },
+  { value: 4, label: 'Shadowsocks — 8388' },
+];
+
+const clientEmailOptions: Array<{ value: string; label: string }> = [
+  { value: '[email protected]', label: '[email protected]' },
+  { value: 'reza.mobile', label: 'reza.mobile' },
+  { value: 'office-tv', label: 'office-tv' },
+  { value: 'guest-42', label: 'guest-42' },
+];
+
+const meta = {
+  title: 'Form/SelectAllClearButtons',
+  component: SelectAllClearButtons,
+  tags: ['autodocs'],
+  parameters: {
+    layout: 'padded',
+    docs: {
+      description: {
+        component:
+          'Small "Select all" / "Clear all" button pair rendered above a multi-select. The panel places it over the attached-inbounds picker in the client form and the bulk attach/detach modals.',
+      },
+    },
+  },
+  argTypes: {
+    options: { description: 'Option list whose values define the "all" set; matches the AntD Select option shape.' },
+    value: { description: 'Currently selected values (controlled).' },
+    onChange: { description: 'Called with the union of the current selection and every option value, or with an empty array on clear.' },
+    selectAllLabel: { description: 'Override for the "Select all" button text; defaults to the translated inbound copy.' },
+    clearLabel: { description: 'Override for the "Clear all" button text; defaults to the translated inbound copy.' },
+  },
+} satisfies Meta<typeof SelectAllClearButtons>;
+
+export default meta;
+
+type Story = StoryObj<typeof meta>;
+
+function InboundPickerDemo() {
+  const [selected, setSelected] = useState<number[]>([1]);
+  return (
+    <div style={{ maxWidth: 360 }}>
+      <SelectAllClearButtons options={inboundOptions} value={selected} onChange={setSelected} />
+      <Select
+        mode="multiple"
+        style={{ width: '100%' }}
+        value={selected}
+        onChange={setSelected}
+        options={inboundOptions}
+        placeholder="Select inbounds"
+        aria-label="Select inbounds"
+        maxTagCount="responsive"
+      />
+    </div>
+  );
+}
+
+function ClientEmailsDemo() {
+  const [selected, setSelected] = useState<string[]>(['[email protected]']);
+  return (
+    <div style={{ maxWidth: 360 }}>
+      <SelectAllClearButtons
+        options={clientEmailOptions}
+        value={selected}
+        onChange={setSelected}
+        selectAllLabel="Select all clients"
+        clearLabel="Deselect clients"
+      />
+      <Select
+        mode="multiple"
+        style={{ width: '100%' }}
+        value={selected}
+        onChange={setSelected}
+        options={clientEmailOptions}
+        placeholder="Select clients"
+        aria-label="Select clients"
+        maxTagCount="responsive"
+      />
+    </div>
+  );
+}
+
+const placeholderArgs = {
+  options: [],
+  value: [],
+  onChange: () => undefined,
+};
+
+export const PartiallySelected: Story = {
+  args: {
+    options: [{ value: 1 }, { value: 2 }, { value: 3 }, { value: 4 }],
+    value: [1, 3],
+    onChange: () => undefined,
+  },
+};
+
+export const AllSelected: Story = {
+  args: {
+    options: [{ value: 1 }, { value: 2 }, { value: 3 }],
+    value: [1, 2, 3],
+    onChange: () => undefined,
+  },
+};
+
+export const WithInboundSelect: Story = {
+  args: placeholderArgs,
+  render: () => <InboundPickerDemo />,
+};
+
+export const CustomLabels: Story = {
+  args: placeholderArgs,
+  render: () => <ClientEmailsDemo />,
+  play: async ({ canvas, userEvent }) => {
+    const selectAll = canvas.getByRole('button', { name: 'Select all clients' });
+    const clearAll = canvas.getByRole('button', { name: 'Deselect clients' });
+    await expect(selectAll).toBeEnabled();
+    await userEvent.click(selectAll);
+    await expect(selectAll).toBeDisabled();
+    await userEvent.click(clearAll);
+    await expect(clearAll).toBeDisabled();
+    await expect(selectAll).toBeEnabled();
+  },
+};

+ 210 - 0
frontend/src/components/form/rhf/FormField.stories.tsx

@@ -0,0 +1,210 @@
+import { useEffect } from 'react';
+import type { Meta, StoryObj } from '@storybook/react-vite';
+import { expect, waitFor } from 'storybook/test';
+import { Button, Form, Input, InputNumber, Select, Switch, Typography } from 'antd';
+import { FormProvider } from 'react-hook-form';
+import { z } from 'zod';
+
+import { FormField } from './FormField';
+import { useZodForm } from './useZodForm';
+
+const GB = 1024 * 1024 * 1024;
+
+const meta = {
+  title: 'Form/RHF/FormField',
+  component: FormField,
+  tags: ['autodocs'],
+  parameters: {
+    layout: 'padded',
+    docs: {
+      description: {
+        component:
+          'Bridges one Ant Design control into react-hook-form: wraps the child in a Controller plus Form.Item, normalizes the onChange payload, and surfaces resolver errors as translated help text. Every RHF panel form (client, inbound, outbound, and host modals) builds its fields with it.',
+      },
+    },
+  },
+  argTypes: {
+    name: { description: 'Field path — a dotted string or an array of segments joined with dots.' },
+    control: { description: 'Optional react-hook-form control; falls back to the surrounding FormProvider.' },
+    label: { description: 'Form.Item label.' },
+    tooltip: { description: 'Form.Item tooltip shown next to the label.' },
+    extra: { description: 'Helper text rendered below the input.' },
+    valueProp: { description: 'Prop the child receives the value on: `value` (default) or `checked` for switches.' },
+    transform: { description: 'Optional input/output mappers, e.g. bytes stored in the form but GB shown in the input.' },
+    onAfterChange: { description: 'Called with the stored value after every change.' },
+    rules: { description: 'Controller-level validation rules applied on top of the form resolver.' },
+    required: { description: 'Marks the label with the required asterisk.' },
+    noStyle: { description: 'Render the bare input without Form.Item chrome.' },
+    children: { description: 'The single Ant Design control to wire up.' },
+  },
+} satisfies Meta<typeof FormField>;
+
+export default meta;
+
+type Story = StoryObj<typeof meta>;
+
+const ClientSchema = z.object({
+  email: z.string(),
+  flow: z.string(),
+  enable: z.boolean(),
+});
+
+function ClientDemo() {
+  const methods = useZodForm(ClientSchema, {
+    defaultValues: { email: '[email protected]', flow: 'xtls-rprx-vision', enable: true },
+  });
+  return (
+    <FormProvider {...methods}>
+      <Form layout="vertical" style={{ maxWidth: 360 }}>
+        <FormField name="email" label="Email" tooltip="Unique identifier used to match client traffic" required>
+          <Input placeholder="[email protected]" />
+        </FormField>
+        <FormField name="flow" label="Flow" extra="Only applies to VLESS over raw TLS">
+          <Select
+            options={[
+              { label: 'none', value: '' },
+              { label: 'xtls-rprx-vision', value: 'xtls-rprx-vision' },
+            ]}
+          />
+        </FormField>
+        <FormField name="enable" label="Enable" valueProp="checked">
+          <Switch />
+        </FormField>
+      </Form>
+    </FormProvider>
+  );
+}
+
+const TrafficSchema = z.object({
+  totalBytes: z.number(),
+});
+
+function TrafficDemo() {
+  const methods = useZodForm(TrafficSchema, { defaultValues: { totalBytes: 50 * GB } });
+  const totalBytes = methods.watch('totalBytes');
+  return (
+    <FormProvider {...methods}>
+      <Form layout="vertical" style={{ maxWidth: 360 }}>
+        <FormField
+          name="totalBytes"
+          label="Total traffic (GB)"
+          extra="Stored on the client as bytes; 0 means unlimited"
+          transform={{
+            input: (value) => (typeof value === 'number' ? value / GB : value),
+            output: (value) => (typeof value === 'number' ? value * GB : 0),
+          }}
+        >
+          <InputNumber min={0} style={{ width: '100%' }} />
+        </FormField>
+        <Typography.Text type="secondary">Form state: {totalBytes.toLocaleString()} bytes</Typography.Text>
+      </Form>
+    </FormProvider>
+  );
+}
+
+const InboundSchema = z.object({
+  remark: z.string().min(1, 'Remark is required'),
+  port: z
+    .number()
+    .min(1, 'Port must be between 1 and 65535')
+    .max(65535, 'Port must be between 1 and 65535'),
+});
+
+function ValidationDemo() {
+  const methods = useZodForm(InboundSchema, { defaultValues: { remark: '', port: 0 } });
+  useEffect(() => {
+    void methods.trigger();
+  }, [methods]);
+  return (
+    <FormProvider {...methods}>
+      <Form layout="vertical" style={{ maxWidth: 360 }}>
+        <FormField name="remark" label="Remark" required>
+          <Input placeholder="vless-reality-443" />
+        </FormField>
+        <FormField name="port" label="Port" required>
+          <InputNumber style={{ width: '100%' }} />
+        </FormField>
+        <Button onClick={() => void methods.trigger()}>Validate</Button>
+      </Form>
+    </FormProvider>
+  );
+}
+
+const RealitySchema = z.object({
+  streamSettings: z.object({
+    realitySettings: z.object({
+      dest: z.string(),
+      serverNames: z.string(),
+    }),
+  }),
+});
+
+function NestedDemo() {
+  const methods = useZodForm(RealitySchema, {
+    defaultValues: {
+      streamSettings: {
+        realitySettings: { dest: 'yahoo.com:443', serverNames: 'yahoo.com,www.yahoo.com' },
+      },
+    },
+  });
+  return (
+    <FormProvider {...methods}>
+      <Form layout="vertical" style={{ maxWidth: 360 }}>
+        <FormField
+          name={['streamSettings', 'realitySettings', 'dest']}
+          label="Dest"
+          tooltip="Camouflage target the REALITY handshake is proxied to"
+        >
+          <Input />
+        </FormField>
+        <FormField
+          name="streamSettings.realitySettings.serverNames"
+          label="Server names"
+          extra="Comma-separated SNI list offered to clients"
+        >
+          <Input />
+        </FormField>
+      </Form>
+    </FormProvider>
+  );
+}
+
+const placeholderArgs = {
+  name: 'email',
+  children: <Input />,
+};
+
+export const ClientFields: Story = {
+  args: placeholderArgs,
+  render: () => <ClientDemo />,
+};
+
+export const TrafficTransform: Story = {
+  args: placeholderArgs,
+  render: () => <TrafficDemo />,
+  play: async ({ canvas, userEvent }) => {
+    const input = canvas.getByRole('spinbutton');
+    await userEvent.clear(input);
+    await userEvent.type(input, '100');
+    await expect(await canvas.findByText(/107,374,182,400 bytes/)).toBeVisible();
+  },
+};
+
+export const ValidationErrors: Story = {
+  args: placeholderArgs,
+  render: () => <ValidationDemo />,
+  play: async ({ canvas, userEvent }) => {
+    const remarkError = await canvas.findByText('Remark is required');
+    await waitFor(() => expect(remarkError).toBeVisible());
+    await waitFor(() => expect(canvas.getByText('Port must be between 1 and 65535')).toBeVisible());
+    await userEvent.type(canvas.getByPlaceholderText('vless-reality-443'), 'vless-reality-443');
+    await userEvent.click(canvas.getByRole('button', { name: 'Validate' }));
+    await waitFor(() => expect(canvas.queryByText('Remark is required')).not.toBeInTheDocument());
+    await expect(canvas.getByText('Port must be between 1 and 65535')).toBeVisible();
+  },
+};
+
+export const NestedNames: Story = {
+  args: placeholderArgs,
+  render: () => <NestedDemo />,
+};

+ 6 - 1
frontend/src/components/form/rhf/FormField.tsx

@@ -1,4 +1,4 @@
-import { cloneElement } from 'react';
+import { cloneElement, useId } from 'react';
 import type { CSSProperties, ReactElement, ReactNode } from 'react';
 import { Controller } from 'react-hook-form';
 import type { Control, ControllerProps, FieldValues, Path } from 'react-hook-form';
@@ -48,6 +48,9 @@ export function FormField<T extends FieldValues = FieldValues>({
 }: FormFieldProps<T>) {
   const { t } = useTranslation();
   const dottedName = toDotted(name) as Path<T>;
+  const generatedId = useId();
+  const explicitId = (children as ReactElement<{ id?: string }>).props.id;
+  const fieldId = explicitId ?? generatedId;
 
   return (
     <Controller
@@ -60,6 +63,7 @@ export function FormField<T extends FieldValues = FieldValues>({
           ? t(fieldState.error.message, { defaultValue: fieldState.error.message })
           : undefined;
         const childProps: Record<string, unknown> = {
+          id: fieldId,
           [valueProp]: displayValue,
           onChange: (...args: unknown[]) => {
             const raw = normalizeAntdOnChange(args, valueProp);
@@ -73,6 +77,7 @@ export function FormField<T extends FieldValues = FieldValues>({
         return (
           <Form.Item
             label={label}
+            htmlFor={label ? fieldId : undefined}
             tooltip={tooltip}
             extra={extra}
             required={required}

+ 12 - 0
frontend/src/components/ui/InfinityIcon.stories.tsx

@@ -6,6 +6,18 @@ const meta = {
   title: 'UI/InfinityIcon',
   component: InfinityIcon,
   tags: ['autodocs'],
+  parameters: {
+    docs: {
+      description: {
+        component:
+          'Inline SVG infinity glyph used to denote an unlimited value (e.g. unlimited traffic or no expiry). Inherits the current text color.',
+      },
+    },
+  },
+  argTypes: {
+    width: { description: 'Icon width in pixels or any CSS length.' },
+    height: { description: 'Icon height in pixels or any CSS length.' },
+  },
 } satisfies Meta<typeof InfinityIcon>;
 
 export default meta;

+ 16 - 1
frontend/src/components/ui/InputAddon.stories.tsx

@@ -7,6 +7,21 @@ const meta = {
   title: 'UI/InputAddon',
   component: InputAddon,
   tags: ['autodocs'],
+  parameters: {
+    docs: {
+      description: {
+        component:
+          'Prefix/suffix addon styled to sit flush against an Ant Design input. Becomes a keyboard-accessible button (role, tabIndex, Enter/Space) when `onClick` is provided.',
+      },
+    },
+  },
+  argTypes: {
+    children: { description: 'Addon content (text or an icon).' },
+    onClick: { description: 'When set, the addon becomes an activatable button.' },
+    ariaLabel: { description: 'Accessible label; used only when `onClick` is set.' },
+    className: { description: 'Extra CSS class appended to the addon.' },
+    style: { description: 'Inline styles for the addon element.' },
+  },
 } satisfies Meta<typeof InputAddon>;
 
 export default meta;
@@ -26,7 +41,7 @@ export const BesideInput: Story = {
   render: () => (
     <Space.Compact>
       <InputAddon>https://</InputAddon>
-      <Input defaultValue="panel.example.com" style={{ width: 220 }} />
+      <Input defaultValue="panel.example.com" aria-label="Panel host" style={{ width: 220 }} />
     </Space.Compact>
   ),
 };

+ 16 - 1
frontend/src/components/ui/SettingListItem.stories.tsx

@@ -7,7 +7,22 @@ const meta = {
   title: 'UI/SettingListItem',
   component: SettingListItem,
   tags: ['autodocs'],
-  parameters: { layout: 'padded' },
+  parameters: {
+    layout: 'padded',
+    docs: {
+      description: {
+        component:
+          'Two-column settings row: a title and description on the left, and a control (Switch, InputNumber, …) on the right. Associates the title with the control via `aria-labelledby` for accessibility.',
+      },
+    },
+  },
+  argTypes: {
+    title: { description: 'Setting name shown on the left.' },
+    description: { description: 'Secondary help text under the title.' },
+    control: { description: 'The control rendered on the right (Switch, InputNumber, …).' },
+    children: { description: 'Alternative to `control`; used when no explicit control is passed.' },
+    paddings: { description: 'Row density: `default` or the tighter `small`.' },
+  },
 } satisfies Meta<typeof SettingListItem>;
 
 export default meta;

+ 85 - 0
frontend/src/components/ui/notifications/EmailNotifications.stories.tsx

@@ -0,0 +1,85 @@
+import { useState } from 'react';
+import type { Meta, StoryObj } from '@storybook/react-vite';
+
+import { AllSetting } from '@/models/setting';
+import { EmailNotifications } from './EmailNotifications';
+
+const meta = {
+  title: 'UI/Notifications/EmailNotifications',
+  component: EmailNotifications,
+  tags: ['autodocs'],
+  parameters: {
+    layout: 'padded',
+    docs: {
+      description: {
+        component:
+          'Grid of grouped event checkboxes on the settings page that picks which panel events (outbound/node health, Xray crashes, CPU/RAM thresholds, login attempts) trigger an SMTP email, stored as a comma-separated list in smtpEnabledEvents.',
+      },
+    },
+  },
+  argTypes: {
+    allSetting: {
+      description:
+        'Panel settings snapshot; smtpEnabledEvents holds the selected event keys and smtpCpu/smtpMemory the alert threshold percentages.',
+    },
+    updateSetting: {
+      description: 'Receives a partial settings patch when an event is toggled or a threshold input changes.',
+    },
+  },
+} satisfies Meta<typeof EmailNotifications>;
+
+export default meta;
+
+type Story = StoryObj<typeof meta>;
+
+function StatefulDemo({ initial }: { initial: AllSetting }) {
+  const [settings, setSettings] = useState(initial);
+  return (
+    <EmailNotifications
+      allSetting={settings}
+      updateSetting={(patch) => setSettings((prev) => new AllSetting({ ...prev, ...patch }))}
+    />
+  );
+}
+
+const placeholderArgs = {
+  allSetting: new AllSetting(),
+  updateSetting: () => undefined,
+};
+
+export const NothingSelected: Story = {
+  args: placeholderArgs,
+  render: () => <StatefulDemo initial={new AllSetting()} />,
+};
+
+export const SystemThresholdAlerts: Story = {
+  args: placeholderArgs,
+  render: () => (
+    <StatefulDemo
+      initial={new AllSetting({ smtpEnabledEvents: 'cpu.high,memory.high', smtpCpu: 85, smtpMemory: 90 })}
+    />
+  ),
+};
+
+export const InfrastructureOnly: Story = {
+  args: placeholderArgs,
+  render: () => (
+    <StatefulDemo initial={new AllSetting({ smtpEnabledEvents: 'outbound.down,node.down,node.up,xray.crash' })} />
+  ),
+};
+
+export const AllEventsEnabled: Story = {
+  args: placeholderArgs,
+  render: () => (
+    <StatefulDemo
+      initial={
+        new AllSetting({
+          smtpEnabledEvents:
+            'outbound.down,outbound.up,xray.crash,node.down,node.up,cpu.high,memory.high,login.attempt',
+          smtpCpu: 80,
+          smtpMemory: 80,
+        })
+      }
+    />
+  ),
+};

+ 4 - 4
frontend/src/components/ui/notifications/EmailNotifications.tsx

@@ -37,16 +37,16 @@ const GROUPS: NotificationGroupConfig[] = [
         key: 'cpu.high',
         label: 'eventCPUHigh',
         settingKey: 'smtpCpu',
-        extra: ({ value, onChange }) => (
-          <InputNumber size="small" min={0} max={100} value={value} onChange={onChange} style={{ width: 80 }} />
+        extra: ({ value, onChange, ariaLabel }) => (
+          <InputNumber size="small" min={0} max={100} value={value} onChange={onChange} aria-label={ariaLabel} style={{ width: 80 }} />
         ),
       },
       {
         key: 'memory.high',
         label: 'eventMemoryHigh',
         settingKey: 'smtpMemory',
-        extra: ({ value, onChange }) => (
-          <InputNumber size="small" min={0} max={100} value={value} onChange={onChange} style={{ width: 80 }} />
+        extra: ({ value, onChange, ariaLabel }) => (
+          <InputNumber size="small" min={0} max={100} value={value} onChange={onChange} aria-label={ariaLabel} style={{ width: 80 }} />
         ),
       },
     ],

+ 17 - 3
frontend/src/components/ui/notifications/NotificationCard.stories.tsx

@@ -8,7 +8,21 @@ const meta = {
   title: 'UI/Notifications/NotificationCard',
   component: NotificationCard,
   tags: ['autodocs'],
-  parameters: { layout: 'padded' },
+  parameters: {
+    layout: 'padded',
+    docs: {
+      description: {
+        component:
+          'Small outlined card that groups a notification channel — an icon and title in the header, a control in the top-right `extra` slot (typically a toggle), and the channel settings as its body.',
+      },
+    },
+  },
+  argTypes: {
+    icon: { description: 'Leading icon shown before the title.' },
+    title: { description: 'Channel name shown in the header.' },
+    extra: { description: 'Top-right slot, typically an enable/disable Switch.' },
+    children: { description: 'Card body — the channel settings.' },
+  },
 } satisfies Meta<typeof NotificationCard>;
 
 export default meta;
@@ -19,7 +33,7 @@ export const Default: Story = {
   args: {
     icon: <BellOutlined />,
     title: 'Telegram',
-    extra: <Switch defaultChecked />,
+    extra: <Switch defaultChecked aria-label="Enable Telegram notifications" />,
     children: <span>Push a message to the configured chat when an event fires.</span>,
   },
 };
@@ -28,7 +42,7 @@ export const Disabled: Story = {
   args: {
     icon: <BellOutlined />,
     title: 'Email',
-    extra: <Switch />,
+    extra: <Switch aria-label="Enable email notifications" />,
     children: <span>Email delivery is turned off for this channel.</span>,
   },
 };

+ 77 - 0
frontend/src/components/ui/notifications/NotificationEvent.stories.tsx

@@ -0,0 +1,77 @@
+import { useState } from 'react';
+import type { Meta, StoryObj } from '@storybook/react-vite';
+import { InputNumber } from 'antd';
+
+import { NotificationEvent } from './NotificationEvent';
+
+const meta = {
+  title: 'UI/Notifications/NotificationEvent',
+  component: NotificationEvent,
+  tags: ['autodocs'],
+  parameters: {
+    layout: 'padded',
+    docs: {
+      description: {
+        component:
+          'Single toggleable notification event row used inside the Telegram and email notification groups on the settings page. Renders a checkbox with a translated label and, when checked, an optional indented extra control such as a threshold input.',
+      },
+    },
+  },
+  argTypes: {
+    label: { description: 'i18n key (or already-translated text) shown next to the checkbox.' },
+    checked: { description: 'Whether the event notification is enabled.' },
+    onToggle: { description: 'Called when the checkbox is clicked.' },
+    children: { description: 'Extra control rendered indented below the label while checked.' },
+  },
+} satisfies Meta<typeof NotificationEvent>;
+
+export default meta;
+
+type Story = StoryObj<typeof meta>;
+
+function CpuThresholdDemo() {
+  const [checked, setChecked] = useState(true);
+  const [threshold, setThreshold] = useState(80);
+  return (
+    <NotificationEvent
+      label="pages.settings.eventCPUHigh"
+      checked={checked}
+      onToggle={() => setChecked((prev) => !prev)}
+    >
+      <InputNumber
+        size="small"
+        min={0}
+        max={100}
+        value={threshold}
+        onChange={(v) => setThreshold(v ?? 0)}
+        aria-label="CPU usage threshold percent"
+        style={{ width: 80 }}
+      />
+    </NotificationEvent>
+  );
+}
+
+export const Unchecked: Story = {
+  args: {
+    label: 'pages.settings.eventLoginAttempt',
+    checked: false,
+    onToggle: () => undefined,
+  },
+};
+
+export const Checked: Story = {
+  args: {
+    label: 'pages.settings.eventXrayCrash',
+    checked: true,
+    onToggle: () => undefined,
+  },
+};
+
+export const CpuThreshold: Story = {
+  args: {
+    label: 'pages.settings.eventCPUHigh',
+    checked: true,
+    onToggle: () => undefined,
+  },
+  render: () => <CpuThresholdDemo />,
+};

+ 131 - 0
frontend/src/components/ui/notifications/NotificationGroup.stories.tsx

@@ -0,0 +1,131 @@
+import { useState } from 'react';
+import type { Meta, StoryObj } from '@storybook/react-vite';
+import { InputNumber } from 'antd';
+import { CloudServerOutlined, DashboardOutlined } from '@ant-design/icons';
+
+import { AllSetting } from '@/models/setting';
+import { NotificationGroup } from './NotificationGroup';
+import type { NotificationGroupConfig } from './types';
+
+const systemGroup: NotificationGroupConfig = {
+  icon: <DashboardOutlined />,
+  title: 'eventGroupSystem',
+  events: [
+    {
+      key: 'cpu.high',
+      label: 'eventCPUHigh',
+      settingKey: 'tgCpu',
+      extra: ({ value, onChange, ariaLabel }) => (
+        <InputNumber size="small" min={0} max={100} value={value} onChange={onChange} aria-label={ariaLabel} style={{ width: 80 }} />
+      ),
+    },
+    {
+      key: 'memory.high',
+      label: 'eventMemoryHigh',
+      settingKey: 'tgMemory',
+      extra: ({ value, onChange, ariaLabel }) => (
+        <InputNumber size="small" min={0} max={100} value={value} onChange={onChange} aria-label={ariaLabel} style={{ width: 80 }} />
+      ),
+    },
+  ],
+};
+
+const outboundGroup: NotificationGroupConfig = {
+  icon: <CloudServerOutlined />,
+  title: 'eventGroupOutbound',
+  events: [
+    { key: 'outbound.down', label: 'eventOutboundDown', settingKey: '' },
+    { key: 'outbound.up', label: 'eventOutboundUp', settingKey: '' },
+  ],
+};
+
+const meta = {
+  title: 'UI/Notifications/NotificationGroup',
+  component: NotificationGroup,
+  tags: ['autodocs'],
+  parameters: {
+    layout: 'padded',
+    docs: {
+      description: {
+        component:
+          'Card for one notification event group (outbound, Xray, node, system, security) with a per-group select-all checkbox, a selected-count tag, and optional per-event threshold inputs. Composed by the Telegram and email notification tabs on the settings page.',
+      },
+    },
+  },
+  argTypes: {
+    config: { description: 'Group definition: icon, `pages.settings` title key, and the event rows to render.' },
+    selected: { description: 'Enabled event keys; drives each checkbox and the header count.' },
+    onToggle: { description: 'Called with the event key when a single checkbox is clicked.' },
+    onToggleAll: { description: 'Called with every event key in the group when the master checkbox is clicked.' },
+    allSetting: { description: 'Panel settings snapshot; threshold values such as `tgCpu` are read from it.' },
+    updateSetting: { description: 'Called with a partial settings patch when a threshold input changes.' },
+  },
+} satisfies Meta<typeof NotificationGroup>;
+
+export default meta;
+
+type Story = StoryObj<typeof meta>;
+
+function Demo() {
+  const [selected, setSelected] = useState<string[]>(['cpu.high']);
+  const [settings, setSettings] = useState(new AllSetting({ tgCpu: 85, tgMemory: 90 }));
+  return (
+    <NotificationGroup
+      config={systemGroup}
+      selected={selected}
+      onToggle={(key) =>
+        setSelected((prev) => (prev.includes(key) ? prev.filter((k) => k !== key) : [...prev, key]))
+      }
+      onToggleAll={(keys) =>
+        setSelected((prev) => (keys.every((k) => prev.includes(k)) ? prev.filter((k) => !keys.includes(k)) : [...new Set([...prev, ...keys])]))
+      }
+      allSetting={settings}
+      updateSetting={(patch) => setSettings((prev) => new AllSetting({ ...prev, ...patch }))}
+    />
+  );
+}
+
+export const AllSelected: Story = {
+  args: {
+    config: systemGroup,
+    selected: ['cpu.high', 'memory.high'],
+    onToggle: () => undefined,
+    onToggleAll: () => undefined,
+    allSetting: new AllSetting({ tgCpu: 85, tgMemory: 90 }),
+    updateSetting: () => undefined,
+  },
+};
+
+export const PartiallySelected: Story = {
+  args: {
+    config: systemGroup,
+    selected: ['cpu.high'],
+    onToggle: () => undefined,
+    onToggleAll: () => undefined,
+    allSetting: new AllSetting(),
+    updateSetting: () => undefined,
+  },
+};
+
+export const NoneSelected: Story = {
+  args: {
+    config: outboundGroup,
+    selected: [],
+    onToggle: () => undefined,
+    onToggleAll: () => undefined,
+    allSetting: new AllSetting(),
+    updateSetting: () => undefined,
+  },
+};
+
+export const Interactive: Story = {
+  args: {
+    config: systemGroup,
+    selected: [],
+    onToggle: () => undefined,
+    onToggleAll: () => undefined,
+    allSetting: new AllSetting(),
+    updateSetting: () => undefined,
+  },
+  render: () => <Demo />,
+};

+ 1 - 0
frontend/src/components/ui/notifications/NotificationGroup.tsx

@@ -51,6 +51,7 @@ export function NotificationGroup({ config, selected, onToggle, onToggleAll, all
             {event.extra?.({
               value: Number((allSetting as unknown as Record<string, unknown>)[event.settingKey]) || 0,
               onChange: (v) => updateSetting({ [event.settingKey]: v }),
+              ariaLabel: t(`pages.settings.${event.label}`),
             })}
           </NotificationEvent>
         ))}

+ 104 - 0
frontend/src/components/ui/notifications/NotificationHeader.stories.tsx

@@ -0,0 +1,104 @@
+import { useState } from 'react';
+import type { Meta, StoryObj } from '@storybook/react-vite';
+import { Checkbox } from 'antd';
+
+import { NotificationHeader } from './NotificationHeader';
+
+const meta = {
+  title: 'UI/Notifications/NotificationHeader',
+  component: NotificationHeader,
+  tags: ['autodocs'],
+  parameters: {
+    layout: 'padded',
+    docs: {
+      description: {
+        component:
+          'Selection summary for a notification group header — a `count/total` tag plus a tri-state master checkbox that selects or clears every event in the group. Rendered in the `extra` slot of the Telegram/email notification cards on the settings page.',
+      },
+    },
+  },
+  argTypes: {
+    count: { description: 'Number of events currently selected in the group.' },
+    total: { description: 'Total number of events the group offers.' },
+    allSelected: { description: 'Checks the master checkbox when every event is selected.' },
+    indeterminate: { description: 'Shows the dash state when only some events are selected.' },
+    onToggleAll: { description: 'Called when the master checkbox is clicked to select or clear all events.' },
+  },
+} satisfies Meta<typeof NotificationHeader>;
+
+export default meta;
+
+type Story = StoryObj<typeof meta>;
+
+export const NoneSelected: Story = {
+  args: {
+    count: 0,
+    total: 6,
+    allSelected: false,
+    indeterminate: false,
+    onToggleAll: () => undefined,
+  },
+};
+
+export const PartialSelection: Story = {
+  args: {
+    count: 3,
+    total: 6,
+    allSelected: false,
+    indeterminate: true,
+    onToggleAll: () => undefined,
+  },
+};
+
+export const AllSelected: Story = {
+  args: {
+    count: 6,
+    total: 6,
+    allSelected: true,
+    indeterminate: false,
+    onToggleAll: () => undefined,
+  },
+};
+
+const events = ['Panel login', 'Xray crashed', 'CPU high', 'Client depleted'];
+
+function GroupDemo() {
+  const [selected, setSelected] = useState<string[]>(['Panel login', 'CPU high']);
+  const count = selected.length;
+  const total = events.length;
+  function toggleAll() {
+    setSelected(count === total ? [] : [...events]);
+  }
+  function toggle(name: string) {
+    setSelected((prev) => (prev.includes(name) ? prev.filter((e) => e !== name) : [...prev, name]));
+  }
+  return (
+    <div style={{ display: 'flex', flexDirection: 'column', gap: 8, maxWidth: 260 }}>
+      <NotificationHeader
+        count={count}
+        total={total}
+        allSelected={count === total}
+        indeterminate={count > 0 && count < total}
+        onToggleAll={toggleAll}
+      />
+      {events.map((name) => (
+        <Checkbox key={name} checked={selected.includes(name)} onChange={() => toggle(name)}>
+          {name}
+        </Checkbox>
+      ))}
+    </div>
+  );
+}
+
+const placeholderArgs = {
+  count: 0,
+  total: 0,
+  allSelected: false,
+  indeterminate: false,
+  onToggleAll: () => undefined,
+};
+
+export const Interactive: Story = {
+  args: placeholderArgs,
+  render: () => <GroupDemo />,
+};

+ 146 - 0
frontend/src/components/ui/notifications/NotificationLayout.stories.tsx

@@ -0,0 +1,146 @@
+import type { Meta, StoryObj } from '@storybook/react-vite';
+import { InputNumber, Space } from 'antd';
+import {
+  CloudServerOutlined,
+  DashboardOutlined,
+  DesktopOutlined,
+  SafetyOutlined,
+  ThunderboltOutlined,
+} from '@ant-design/icons';
+
+import { NotificationLayout } from './NotificationLayout';
+import { NotificationCard } from './NotificationCard';
+import { NotificationEvent } from './NotificationEvent';
+import { NotificationHeader } from './NotificationHeader';
+
+const noop = () => undefined;
+
+function OutboundGroup() {
+  return (
+    <NotificationCard
+      icon={<CloudServerOutlined />}
+      title="Outbound"
+      extra={<NotificationHeader count={1} total={2} allSelected={false} indeterminate onToggleAll={noop} />}
+    >
+      <Space orientation="vertical" size={8} style={{ width: '100%' }}>
+        <NotificationEvent label="Outbound went down" checked onToggle={noop} />
+        <NotificationEvent label="Outbound recovered" checked={false} onToggle={noop} />
+      </Space>
+    </NotificationCard>
+  );
+}
+
+function XrayGroup() {
+  return (
+    <NotificationCard
+      icon={<ThunderboltOutlined />}
+      title="Xray"
+      extra={<NotificationHeader count={1} total={1} allSelected indeterminate={false} onToggleAll={noop} />}
+    >
+      <Space orientation="vertical" size={8} style={{ width: '100%' }}>
+        <NotificationEvent label="Xray crashed" checked onToggle={noop} />
+      </Space>
+    </NotificationCard>
+  );
+}
+
+function NodeGroup() {
+  return (
+    <NotificationCard
+      icon={<DesktopOutlined />}
+      title="Nodes"
+      extra={<NotificationHeader count={0} total={2} allSelected={false} indeterminate={false} onToggleAll={noop} />}
+    >
+      <Space orientation="vertical" size={8} style={{ width: '100%' }}>
+        <NotificationEvent label="Node went offline" checked={false} onToggle={noop} />
+        <NotificationEvent label="Node back online" checked={false} onToggle={noop} />
+      </Space>
+    </NotificationCard>
+  );
+}
+
+function SystemGroup() {
+  return (
+    <NotificationCard
+      icon={<DashboardOutlined />}
+      title="System"
+      extra={<NotificationHeader count={2} total={2} allSelected indeterminate={false} onToggleAll={noop} />}
+    >
+      <Space orientation="vertical" size={8} style={{ width: '100%' }}>
+        <NotificationEvent label="CPU usage above threshold (%)" checked onToggle={noop}>
+          <InputNumber size="small" min={0} max={100} defaultValue={80} aria-label="CPU usage threshold percent" style={{ width: 80 }} />
+        </NotificationEvent>
+        <NotificationEvent label="Memory usage above threshold (%)" checked onToggle={noop}>
+          <InputNumber size="small" min={0} max={100} defaultValue={90} aria-label="Memory usage threshold percent" style={{ width: 80 }} />
+        </NotificationEvent>
+      </Space>
+    </NotificationCard>
+  );
+}
+
+function SecurityGroup() {
+  return (
+    <NotificationCard
+      icon={<SafetyOutlined />}
+      title="Security"
+      extra={<NotificationHeader count={1} total={1} allSelected indeterminate={false} onToggleAll={noop} />}
+    >
+      <Space orientation="vertical" size={8} style={{ width: '100%' }}>
+        <NotificationEvent label="Panel login attempt" checked onToggle={noop} />
+      </Space>
+    </NotificationCard>
+  );
+}
+
+const meta = {
+  title: 'UI/Notifications/NotificationLayout',
+  component: NotificationLayout,
+  tags: ['autodocs'],
+  parameters: {
+    layout: 'padded',
+    docs: {
+      description: {
+        component:
+          'Responsive auto-fit grid (min 260px columns) that arranges notification event-group cards; the Telegram and email notification tabs on the settings page render their groups inside it.',
+      },
+    },
+  },
+  argTypes: {
+    children: { description: 'Grid items, typically one NotificationCard per event group.' },
+  },
+} satisfies Meta<typeof NotificationLayout>;
+
+export default meta;
+
+type Story = StoryObj<typeof meta>;
+
+export const AllEventGroups: Story = {
+  args: {
+    children: (
+      <>
+        <OutboundGroup />
+        <XrayGroup />
+        <NodeGroup />
+        <SystemGroup />
+        <SecurityGroup />
+      </>
+    ),
+  },
+};
+
+export const TwoGroups: Story = {
+  args: {
+    children: (
+      <>
+        <SystemGroup />
+        <SecurityGroup />
+      </>
+    ),
+  },
+};
+
+export const SingleGroup: Story = {
+  args: {
+    children: <OutboundGroup />,
+  },
+};

+ 82 - 0
frontend/src/components/ui/notifications/TelegramNotifications.stories.tsx

@@ -0,0 +1,82 @@
+import { useState } from 'react';
+import type { Meta, StoryObj } from '@storybook/react-vite';
+
+import { AllSetting } from '@/models/setting';
+import { TelegramNotifications } from './TelegramNotifications';
+
+const meta = {
+  title: 'UI/Notifications/TelegramNotifications',
+  component: TelegramNotifications,
+  tags: ['autodocs'],
+  parameters: {
+    layout: 'padded',
+    docs: {
+      description: {
+        component:
+          'Grid of event-group cards (outbound, Xray, node, system, security) that pick which panel events the Telegram bot reports, with per-group select-all and CPU/RAM threshold inputs. Used on the settings page Telegram tab to edit `tgEnabledEvents`.',
+      },
+    },
+  },
+  argTypes: {
+    allSetting: { description: 'Panel settings snapshot; reads `tgEnabledEvents` plus the `tgCpu`/`tgMemory` thresholds.' },
+    updateSetting: { description: 'Called with a partial settings patch when an event toggle or threshold changes.' },
+  },
+} satisfies Meta<typeof TelegramNotifications>;
+
+export default meta;
+
+type Story = StoryObj<typeof meta>;
+
+function Demo({ initial }: { initial: AllSetting }) {
+  const [settings, setSettings] = useState(initial);
+  return (
+    <TelegramNotifications
+      allSetting={settings}
+      updateSetting={(patch) => setSettings((prev) => new AllSetting({ ...prev, ...patch }))}
+    />
+  );
+}
+
+const placeholderArgs = {
+  allSetting: new AllSetting(),
+  updateSetting: () => undefined,
+};
+
+export const NothingSelected: Story = {
+  args: placeholderArgs,
+  render: () => <Demo initial={new AllSetting()} />,
+};
+
+export const TypicalMonitoring: Story = {
+  args: placeholderArgs,
+  render: () => (
+    <Demo
+      initial={
+        new AllSetting({
+          tgBotEnable: true,
+          tgBotChatId: '123456789',
+          tgEnabledEvents: 'xray.crash,node.down,cpu.high,memory.high,login.attempt',
+          tgCpu: 85,
+          tgMemory: 90,
+        })
+      }
+    />
+  ),
+};
+
+export const EverythingEnabled: Story = {
+  args: placeholderArgs,
+  render: () => (
+    <Demo
+      initial={
+        new AllSetting({
+          tgBotEnable: true,
+          tgEnabledEvents:
+            'outbound.down,outbound.up,xray.crash,node.down,node.up,cpu.high,memory.high,login.attempt',
+          tgCpu: 70,
+          tgMemory: 75,
+        })
+      }
+    />
+  ),
+};

+ 4 - 4
frontend/src/components/ui/notifications/TelegramNotifications.tsx

@@ -37,16 +37,16 @@ const GROUPS: NotificationGroupConfig[] = [
         key: 'cpu.high',
         label: 'eventCPUHigh',
         settingKey: 'tgCpu',
-        extra: ({ value, onChange }) => (
-          <InputNumber size="small" min={0} max={100} value={value} onChange={onChange} style={{ width: 80 }} />
+        extra: ({ value, onChange, ariaLabel }) => (
+          <InputNumber size="small" min={0} max={100} value={value} onChange={onChange} aria-label={ariaLabel} style={{ width: 80 }} />
         ),
       },
       {
         key: 'memory.high',
         label: 'eventMemoryHigh',
         settingKey: 'tgMemory',
-        extra: ({ value, onChange }) => (
-          <InputNumber size="small" min={0} max={100} value={value} onChange={onChange} style={{ width: 80 }} />
+        extra: ({ value, onChange, ariaLabel }) => (
+          <InputNumber size="small" min={0} max={100} value={value} onChange={onChange} aria-label={ariaLabel} style={{ width: 80 }} />
         ),
       },
     ],

+ 1 - 1
frontend/src/components/ui/notifications/types.ts

@@ -4,7 +4,7 @@ export interface NotificationEventConfig {
   key: string;
   label: string;
   settingKey: string;
-  extra?: (props: { value: number; onChange: (v: number | null) => void }) => ReactNode;
+  extra?: (props: { value: number; onChange: (v: number | null) => void; ariaLabel: string }) => ReactNode;
 }
 
 export interface NotificationGroupConfig {

+ 138 - 0
frontend/src/components/utility/LazyMount.stories.tsx

@@ -0,0 +1,138 @@
+import { lazy, useState } from 'react';
+import type { Meta, StoryObj } from '@storybook/react-vite';
+import { Alert, Button, Card, Skeleton, Space, Switch, Tag, Typography } from 'antd';
+
+import LazyMount from './LazyMount';
+
+const meta = {
+  title: 'Utility/LazyMount',
+  component: LazyMount,
+  tags: ['autodocs'],
+  parameters: {
+    layout: 'padded',
+    docs: {
+      description: {
+        component:
+          'Mounts its children the first time `when` becomes true and keeps them mounted afterwards, wrapped in Suspense. The panel pairs it with React.lazy modal imports on heavy list pages so modals load on demand while their close animations still play.',
+      },
+    },
+  },
+  argTypes: {
+    when: { description: 'Children mount the first time this becomes true and stay mounted afterwards.' },
+    fallback: { description: 'Suspense fallback shown while a React.lazy child is still loading.' },
+    children: { description: 'Content to mount on demand, typically a lazily imported modal.' },
+  },
+} satisfies Meta<typeof LazyMount>;
+
+export default meta;
+
+type Story = StoryObj<typeof meta>;
+
+function MountBadge() {
+  const [mountedAt] = useState(() => new Date().toLocaleTimeString());
+  return <Tag color="green">mounted at {mountedAt}</Tag>;
+}
+
+function OnDemandDemo() {
+  const [visible, setVisible] = useState(false);
+  return (
+    <Space orientation="vertical" size="middle" style={{ width: '100%' }}>
+      <Space>
+        <Switch checked={visible} onChange={setVisible} aria-label="Mount the client card" />
+        <Typography.Text>when = {String(visible)}</Typography.Text>
+      </Space>
+      <LazyMount when={visible}>
+        <Card size="small" title="[email protected]" style={{ maxWidth: 480 }}>
+          <Space orientation="vertical" size={4}>
+            <MountBadge />
+            <Typography.Text type="secondary">Traffic: 42.7 GB of 100 GB</Typography.Text>
+            <Typography.Text code copyable style={{ fontSize: 12 }}>
+              vless://[email protected]:443?type=tcp&security=reality&fp=chrome&sni=yahoo.com#alice
+            </Typography.Text>
+          </Space>
+        </Card>
+      </LazyMount>
+      <Typography.Text type="secondary">
+        The card mounts the first time the switch turns on and stays mounted after turning it off; the mount time never changes.
+      </Typography.Text>
+    </Space>
+  );
+}
+
+const xrayConfigSnippet = JSON.stringify(
+  {
+    inbounds: [
+      {
+        tag: 'inbound-443',
+        protocol: 'vless',
+        port: 443,
+        settings: {
+          clients: [{ id: 'b831381d-6324-4d53-ad4f-8cda48b30811', email: '[email protected]', flow: 'xtls-rprx-vision' }],
+          decryption: 'none',
+        },
+        streamSettings: { network: 'tcp', security: 'reality' },
+      },
+    ],
+  },
+  null,
+  2,
+);
+
+function XrayConfigPreview() {
+  return (
+    <Card size="small" title="Generated xray config">
+      <pre style={{ margin: 0, overflowX: 'auto', fontSize: 12 }}>{xrayConfigSnippet}</pre>
+    </Card>
+  );
+}
+
+const SlowXrayConfigPreview = lazy(
+  () =>
+    new Promise<{ default: typeof XrayConfigPreview }>((resolve) => {
+      setTimeout(() => resolve({ default: XrayConfigPreview }), 1200);
+    }),
+);
+
+function LazyChildDemo() {
+  const [open, setOpen] = useState(false);
+  return (
+    <Space orientation="vertical" size="middle" style={{ width: '100%', maxWidth: 560 }}>
+      <Button type="primary" onClick={() => setOpen(true)} disabled={open}>
+        Load config preview
+      </Button>
+      <LazyMount when={open} fallback={<Skeleton active paragraph={{ rows: 4 }} />}>
+        <SlowXrayConfigPreview />
+      </LazyMount>
+    </Space>
+  );
+}
+
+const placeholderArgs = {
+  when: false,
+  children: null,
+};
+
+export const MountOnDemand: Story = {
+  args: placeholderArgs,
+  render: () => <OnDemandDemo />,
+};
+
+export const LazyChildWithFallback: Story = {
+  args: placeholderArgs,
+  render: () => <LazyChildDemo />,
+};
+
+export const MountedImmediately: Story = {
+  args: {
+    when: true,
+    children: (
+      <Alert
+        type="warning"
+        showIcon
+        title="Client depleted"
+        description="[email protected] used 100 GB of 100 GB and was disabled by the traffic job."
+        style={{ maxWidth: 480 }}
+      />
+    ),
+  },
+};

+ 21 - 1
frontend/src/components/viz/Sparkline.stories.tsx

@@ -9,7 +9,27 @@ const meta = {
   title: 'Viz/Sparkline',
   component: Sparkline,
   tags: ['autodocs'],
-  parameters: { layout: 'padded' },
+  parameters: {
+    layout: 'padded',
+    docs: {
+      description: {
+        component:
+          'Compact canvas line chart (uPlot) for CPU, memory, and traffic trends. Supports up to three series, optional axes/grid, a hover tooltip, min/max markers, reference lines, and light/dark theming.',
+      },
+    },
+  },
+  argTypes: {
+    data: { description: 'Primary series values, oldest to newest.' },
+    data2: { description: 'Optional second series (e.g. download vs upload).' },
+    data3: { description: 'Optional third series.' },
+    height: { description: 'Chart height in pixels.' },
+    name1: { description: 'Legend/tooltip label for the primary series.' },
+    name2: { description: 'Legend/tooltip label for the second series.' },
+    showAxes: { description: 'Render x/y axes and tick labels.' },
+    showGrid: { description: 'Draw horizontal grid lines.' },
+    showTooltip: { description: 'Show a value tooltip on hover.' },
+    extrema: { description: 'Highlight the min and max points (single-series only).' },
+  },
 } satisfies Meta<typeof Sparkline>;
 
 export default meta;

+ 15 - 0
frontend/src/hooks/useTheme.tsx

@@ -78,13 +78,28 @@ const STATISTIC_TOKENS = {
   contentFontSize: 17,
   titleFontSize: 11,
 };
+const LIGHT_CONTRAST_TOKENS = {
+  colorTextDescription: 'rgba(0, 0, 0, 0.58)',
+  colorTextTertiary: 'rgba(0, 0, 0, 0.58)',
+  colorTextPlaceholder: '#767676',
+  colorError: '#cf1322',
+  colorErrorText: '#cf1322',
+  colorSuccessText: '#237804',
+};
+const LIGHT_BUTTON_TOKENS = {
+  colorPrimary: '#0958d9',
+  colorPrimaryHover: '#2468e5',
+  colorPrimaryActive: '#073ea8',
+};
 
 export function buildAntdThemeConfig(isDark: boolean, isUltra: boolean): ThemeConfig {
   if (!isDark) {
     return {
       algorithm: antdTheme.defaultAlgorithm,
+      token: LIGHT_CONTRAST_TOKENS,
       components: {
         Statistic: STATISTIC_TOKENS,
+        Button: LIGHT_BUTTON_TOKENS,
       },
     };
   }

+ 1 - 1
frontend/src/pages/clients/BulkAttachInboundsModal.tsx

@@ -96,7 +96,7 @@ export default function BulkAttachInboundsModal({
               onChange={setTargetIds}
               options={targetOptions}
               placeholder={t('pages.clients.attachToInboundsTargets')}
-              optionFilterProp="label"
+              showSearch={{ optionFilterProp: 'label' }}
               autoFocus
             />
           </>

+ 1 - 1
frontend/src/pages/clients/BulkDetachInboundsModal.tsx

@@ -96,7 +96,7 @@ export default function BulkDetachInboundsModal({
               onChange={setTargetIds}
               options={targetOptions}
               placeholder={t('pages.clients.detachFromInboundsTargets')}
-              optionFilterProp="label"
+              showSearch={{ optionFilterProp: 'label' }}
               autoFocus
             />
           </>

+ 1 - 1
frontend/src/pages/clients/ClientsPage.tsx

@@ -1212,7 +1212,7 @@ export default function ClientsPage() {
                           value={sortValueFor(sortColumn, sortOrder)}
                           aria-label={t('sort')}
                           size={isMobile ? 'small' : 'middle'}
-                          suffixIcon={<SortAscendingOutlined />}
+                          suffix={<SortAscendingOutlined />}
                           style={{ minWidth: isMobile ? 130 : 200 }}
                           onChange={(value) => {
                             const opt = SORT_OPTIONS.find((o) => o.value === value);

+ 4 - 7
frontend/src/pages/clients/FilterDrawer.tsx

@@ -89,7 +89,7 @@ export default function FilterDrawer({
       title={t('pages.clients.filterTitle')}
       open={open}
       onClose={() => onOpenChange(false)}
-      width={420}
+      size={420}
       destroyOnHidden
       footer={
         <div style={{ display: 'flex', justifyContent: 'space-between' }}>
@@ -139,8 +139,7 @@ export default function FilterDrawer({
             placeholder={t('inbounds')}
             maxTagCount="responsive"
             allowClear
-            showSearch
-            optionFilterProp="label"
+            showSearch={{ optionFilterProp: 'label' }}
             listHeight={220}
           />
         </Form.Item>
@@ -155,8 +154,7 @@ export default function FilterDrawer({
               placeholder={t('pages.clients.filters.nodes')}
               maxTagCount="responsive"
               allowClear
-              showSearch
-              optionFilterProp="label"
+              showSearch={{ optionFilterProp: 'label' }}
               listHeight={220}
             />
           </Form.Item>
@@ -171,8 +169,7 @@ export default function FilterDrawer({
             placeholder={t('pages.clients.groupPlaceholder')}
             maxTagCount="responsive"
             allowClear
-            showSearch
-            optionFilterProp="label"
+            showSearch={{ optionFilterProp: 'label' }}
             listHeight={220}
           />
         </Form.Item>

+ 2 - 3
frontend/src/pages/hosts/HostFormModal.tsx

@@ -191,8 +191,7 @@ export default function HostFormModal({ open, mode, host, inboundOptions, existi
                       <Select
                         mode="multiple"
                         options={inboundSelectOptions}
-                        showSearch
-                        optionFilterProp="label"
+                        showSearch={{ optionFilterProp: 'label' }}
                         placeholder={t('pages.hosts.selectInbound')}
                       />
                     </FormField>
@@ -211,7 +210,7 @@ export default function HostFormModal({ open, mode, host, inboundOptions, existi
                       <Select mode="tags" allowClear tokenSeparators={[',']} />
                     </FormField>
                     <FormField name="nodeGuids" label={t('pages.hosts.fields.nodeGuids')} tooltip={t('pages.hosts.hints.nodeGuids')}>
-                      <Select mode="multiple" allowClear options={nodeSelectOptions} optionFilterProp="label" />
+                      <Select mode="multiple" allowClear options={nodeSelectOptions} showSearch={{ optionFilterProp: 'label' }} />
                     </FormField>
                     <FormField name="enable" label={t('pages.hosts.fields.enable')} valueProp="checked">
                       <Switch />

+ 1 - 1
frontend/src/pages/inbounds/clients/AttachClientsModal.tsx

@@ -203,7 +203,7 @@ export default function AttachClientsModal({
           onChange={setTargetIds}
           options={targetOptions}
           placeholder={t('pages.inbounds.attachClientsTargets')}
-          optionFilterProp="label"
+          showSearch={{ optionFilterProp: 'label' }}
         />
       )}
     </Modal>

+ 1 - 1
frontend/src/pages/inbounds/clients/AttachExistingClientsModal.tsx

@@ -203,7 +203,7 @@ export default function AttachExistingClientsModal({
                     options={groupOptions}
                     placeholder={t('pages.clients.group')}
                     style={{ minWidth: 160 }}
-                    optionFilterProp="label"
+                    showSearch={{ optionFilterProp: 'label' }}
                   />
                 )}
               </Space>

+ 1 - 2
frontend/src/pages/settings/EmailTab.tsx

@@ -113,8 +113,7 @@ export default function EmailTab({ allSetting, updateSetting }: EmailTabProps) {
                       : <span><b>{stageLabel[testResult.stage || ''] || testResult.stage}:</b> {t('pages.settings.' + testResult.msg)}</span>
                   }
                   showIcon
-                  closable
-                  onClose={() => setTestResult(null)}
+                  closable={{ onClose: () => setTestResult(null) }}
                 />
               )}
             </Space>

+ 1 - 2
frontend/src/pages/settings/TelegramTab.tsx

@@ -233,8 +233,7 @@ export default function TelegramTab({ allSetting, updateSetting }: TelegramTabPr
                   type={testResult.success ? 'success' : 'error'}
                   title={testResult.msg}
                   showIcon
-                  closable
-                  onClose={() => setTestResult(null)}
+                  closable={{ onClose: () => setTestResult(null) }}
                 />
               )}
             </Space>

+ 1 - 1
frontend/src/pages/sub/SubPage.tsx

@@ -286,7 +286,7 @@ export default function SubPage() {
             <Col xs={24} sm={22} md={18} lg={14} xl={12}>
               <Card hoverable className="subscription-card" title={cardTitle} extra={cardExtra}>
                 {announce && (
-                  <Alert type="info" showIcon message={announce} style={{ marginBottom: 16 }} />
+                  <Alert type="info" showIcon title={announce} style={{ marginBottom: 16 }} />
                 )}
                 <Descriptions
                   bordered

+ 1 - 1
frontend/src/pages/sub/SubUsageSummary.tsx

@@ -78,7 +78,7 @@ export default function SubUsageSummary({
           percent={pct}
           showInfo={false}
           strokeColor={{ '0%': stroke.from, '100%': stroke.to }}
-          trailColor="var(--ant-color-fill-secondary)"
+          railColor="var(--ant-color-fill-secondary)"
           strokeWidth={10}
           className="usage-summary-bar"
         />

+ 2 - 2
frontend/src/pages/xray/balancers/BalancerFormModal.tsx

@@ -257,7 +257,7 @@ export default function BalancerFormModal({
             <Alert
               type="info"
               showIcon
-              message={t('pages.xray.balancer.balancerFallbackInfo')}
+              title={t('pages.xray.balancer.balancerFallbackInfo')}
               style={{ marginBottom: 16 }}
             />
           )}
@@ -265,7 +265,7 @@ export default function BalancerFormModal({
             <Alert
               type="error"
               showIcon
-              message={t('pages.xray.balancer.balancerFallbackCycle')}
+              title={t('pages.xray.balancer.balancerFallbackCycle')}
               style={{ marginBottom: 16 }}
             />
           )}

+ 1 - 1
frontend/src/pages/xray/dns/DnsPresetsModal.tsx

@@ -86,7 +86,7 @@ export default function DnsPresetsModal({ open, onClose, onInstall }: DnsPresets
         type="warning"
         showIcon
         className="preset-warning"
-        message={t('pages.xray.dns.dnsLeakWarning')}
+        title={t('pages.xray.dns.dnsLeakWarning')}
       />
       <div className="preset-list">
         {PRESETS.map((preset) => (

+ 1 - 1
frontend/src/pages/xray/dns/DnsTab.tsx

@@ -240,7 +240,7 @@ export default function DnsTab({ templateSettings, setTemplateSettings }: DnsTab
                 <Alert
                   type="warning"
                   showIcon
-                  message={t('pages.xray.dns.dnsLeakWarning')}
+                  title={t('pages.xray.dns.dnsLeakWarning')}
                   style={{ marginBottom: 12 }}
                 />
                 <SettingListItem

+ 22 - 1
frontend/vitest.config.ts

@@ -1,13 +1,18 @@
 import path from 'node:path';
+import { fileURLToPath } from 'node:url';
 
 import react from '@vitejs/plugin-react';
+import { storybookTest } from '@storybook/addon-vitest/vitest-plugin';
+import { playwright } from '@vitest/browser-playwright';
 import { defineConfig } from 'vitest/config';
 
+const dirname = typeof __dirname !== 'undefined' ? __dirname : path.dirname(fileURLToPath(import.meta.url));
+
 export default defineConfig({
   plugins: [react()],
   resolve: {
     alias: {
-      '@': path.resolve(__dirname, 'src'),
+      '@': path.resolve(dirname, 'src'),
     },
   },
   test: {
@@ -31,6 +36,22 @@ export default defineConfig({
           setupFiles: ['./src/test/setup.ts', './src/test/setup.components.ts'],
         },
       },
+      {
+        extends: true,
+        optimizeDeps: {
+          include: ['aria-query', 'lz-string', 'pretty-format', 'dom-accessibility-api'],
+        },
+        plugins: [storybookTest({ configDir: path.join(dirname, '.storybook') })],
+        test: {
+          name: 'storybook',
+          browser: {
+            enabled: true,
+            headless: true,
+            provider: playwright({}),
+            instances: [{ browser: 'chromium' }],
+          },
+        },
+      },
     ],
   },
 });

+ 1 - 0
frontend/vitest.shims.d.ts

@@ -0,0 +1 @@
+/// <reference types="@vitest/browser-playwright" />

برخی فایل ها در این مقایسه diff نمایش داده نمی شوند زیرا تعداد فایل ها بسیار زیاد است