diff --git a/.interface-design/system.md b/.interface-design/system.md new file mode 100644 index 0000000..f1ebcbe --- /dev/null +++ b/.interface-design/system.md @@ -0,0 +1,142 @@ +# Windows Client Interface System + +Дата: 2026-07-08 + +## Направление + +Это максимально простая Windows-утилита управления службами и их состояниями. Она не должна ощущаться как VPN-dashboard, SaaS-панель или маркетинговый клиент. Главные глаголы интерфейса: + +- проверить состояние; +- установить компонент; +- удалить компонент; +- запустить службу; +- остановить службу; +- обновить/перепроверить состояние; +- добавить приложение/путь; +- применить конфигурацию только когда prerequisites готовы. + +Интерфейс должен быть плотным, спокойным, системным и предсказуемым. Пользователь открывает его не для анализа статистики, а чтобы быстро понять "что сейчас установлено, что запущено, что сломано и какую одну кнопку нажать дальше". + +## Домен + +Ключевые понятия: Windows service, installed/running/stopped/missing, install/uninstall, helper/elevation, ProxiFyre, Local sing-box, selected apps, endpoint, generated config, logs, diagnostics, readiness blockers. + +Цветовой мир: темная Windows-оболочка, service console, terminal black, muted slate panels, driver/service green, warning amber, blocked red, focus/navigation blue. + +Сигнатура продукта: `Service Control Row` - компактная строка компонента, где слева состояние службы, в центре человекочитаемый статус и путь/деталь, справа ровно одно главное действие плюс меню дополнительных действий. Эта строка должна быть повторяемым паттерном для ProxiFyre, Local sing-box и будущих компонентов. + +## Визуальная модель + +- Основной layout: fixed header, fixed tabs, scrollable work area, adaptive log/status area. +- Основная композиция: не больше одного главного действия на компонент или экран. +- Summary должен отвечать "готово / не готово / что сделать дальше", а не показывать длинный dashboard. +- Route chain полезен, но он вторичен к service-control модели. Он должен объяснять эффект состояний служб, а не превращать приложение в карту сети. + +## Depth And Surfaces + +Стратегия глубины: borders-only + subtle surface shifts. Без тяжелых shadows. + +- `canvas`: `#101216` - фон приложения. +- `surface`: `#131720` - обычные панели. +- `surface-raised`: `#151923` - service rows и важные панели. +- `surface-control`: `#242a35` - neutral buttons. +- `surface-inset`: `#0d1016` - inputs и read-only inset fields. +- `border`: `#2b3342`. +- `border-strong`: `#343b49`. +- `focus`: `#3b82f6`. + +Радиусы: 4px для buttons, inputs, rows, panels; 999px только для pills/dots. Карточки и панели не должны становиться мягкими/крупно-скругленными. + +Spacing base: 4px. Основные значения: 6, 8, 10, 12, 14, 18. Для плотной утилиты 20+ использовать редко, только между большими группами. + +## Typography + +Шрифт: Inter/system stack как сейчас. + +Плотная шкала: + +- caption: 12px / 500-700 / muted; +- body: 14px / 400-500 / secondary; +- control: 14px / 700 / primary; +- section title: 16px / 700; +- screen title: 18px / 650-750; +- status title: 22px / 700 only for primary readiness state. + +Иерархия должна строиться весом и цветом больше, чем размером. Letter spacing держать `0`. + +## Компонентная база + +### Button + +Все кнопки должны идти через общий компонент и общие variants: + +- `primary`: главное безопасное действие текущего блока. Зеленый использовать только когда действие реально применимо и готово к выполнению. +- `neutral`: refresh, open, cancel, secondary action. +- `add`: добавление процесса, EXE, папки, target. Иконка + tooltip; текстовая кнопка только когда без текста смысл неясен. +- `danger`: stop/uninstall/delete. Не смешивать с neutral. +- `icon`: квадрат 40-44px, только иконка, обязательны `aria-label`, `title` или tooltip. + +Button states обязательны: default, hover, active, focus-visible, disabled, loading. + +Loading state не должен выглядеть как зависание: показывать spinner/progress label, менять текст на действие в процессе ("Проверяю", "Устанавливаю", "Останавливаю"), блокировать повторный запуск. + +### Service Control Row + +Повторяемый компонент для служб: + +- left: status dot/icon (`checking`, `missing`, `installed`, `running`, `stopped`, `error`); +- center: title + short status + optional path/details; +- right: primary action (`Установить`, `Запустить`, `Остановить`, `Обновить`) + overflow menu; +- expanded area: setup checklist, diagnostics, paths, generated config. + +В строке не должно быть двух конкурирующих primary actions. + +### Tabs + +Tabs должны быть единым компонентом: + +- role tablist/tab/tabpanel; +- arrow-key navigation; +- active indicator 3px blue; +- height 46px desktop, не меньше 40px narrow; +- transition 180-240ms только `opacity` + `transform`; +- no layout shift при переключении. + +### Status And Logs + +Status surface должен показывать человеческий текст первым, raw details вторым уровнем. + +- Preview/native command errors: friendly summary in dock, raw error in details. +- `aria-live` объявляет только короткий статус. +- Narrow width: dock collapses into compact toast/details control. + +### Forms And Inputs + +Inputs are inset: + +- background `surface-inset`; +- border `border-strong`; +- focus ring 1px `focus`; +- validation appears directly below or beside the field; +- invalid state disables dependent action and explains exact accepted format. + +### Motion + +Motion должна показывать, что интерфейс живой, но не тормозить повторяющиеся операции. + +- Button press: 100-140ms, `transform: scale(0.98)`. +- Tab switch: 180-240ms, `opacity` + `translateX` 12-24px. +- Popover/menu: 150-180ms, `opacity` + `scale(0.97 -> 1)`. +- Service checking/installing: subtle spinner or border trace, but not decorative glow everywhere. +- Respect `prefers-reduced-motion`. + +Не использовать `transition: all`. + +## Implementation Priorities + +1. Создать shared UI layer: `Button`, `IconButton`, `Tabs`, `ServiceControlRow`, `StatusPill`, `LogDock`, `Field`, `ActionMenu`. +2. Перевести текущие кнопки на variants, чтобы "обновить", "добавить", "установить", "удалить", "применить" выглядели и вели себя стабильно. +3. Ввести readiness blockers для apply actions. +4. Свести все анимации к общим duration/easing tokens. +5. Проверить keyboard flow и responsive snapshots после каждого крупного UI изменения. + diff --git a/apps/windows-client/src/app/App.tsx b/apps/windows-client/src/app/App.tsx index 54039dd..3022f97 100644 --- a/apps/windows-client/src/app/App.tsx +++ b/apps/windows-client/src/app/App.tsx @@ -1,6 +1,6 @@ import { useEffect, useMemo, useRef, useState } from 'react'; import { open } from '@tauri-apps/plugin-dialog'; -import { Cpu, FileCode2, FolderOpen, Gauge, Info, Link2, MoreHorizontal, Trash2, Wand2 } from 'lucide-react'; +import { Cpu, FileCode2, FolderOpen, Gauge, Info, Link2, Trash2, Wand2 } from 'lucide-react'; import { applyProfiles, fetchSingBoxSubscription, @@ -34,6 +34,9 @@ import { type SingBoxSetupStatus, } from '../api/tauriCommands'; import type { ComponentStatus, Profile, ProfileItemInput, ProfileItemType, SubscriptionServer, Target } from '../domain/types'; +import { Button, IconButton, LogDock, ServiceControlRow, Tabs } from '../ui'; +import { getApplyReadiness } from './readiness'; +import { serviceControlState } from './viewModel'; type DraftItemType = Extract; type ProxiFyreAction = 'start' | 'stop' | 'install' | 'uninstall'; @@ -152,12 +155,7 @@ export function App() { () => logEntries.find((entry) => entry.id === activeLogId) ?? null, [activeLogId, logEntries], ); - const finderStateClass = isDetectingComponents ? 'checking' : proxyfier?.installed ? 'found' : 'missing'; - const finderVisualClass = - serviceVisualState === 'active' ? 'working' : serviceVisualState === 'settling' ? 'settling' : ''; const isSingBoxInstalled = Boolean(singbox?.installed); - const singBoxStateClass = isDetectingComponents ? 'checking' : singbox?.installed ? 'found' : 'missing'; - const singBoxVisualClass = singBoxAction ? 'working' : ''; const selectedServerTag = singBoxStatus?.config.selectedServerTag; const currentSnapshot = useMemo( () => configSnapshotFromUi(routeMode, proxyInput, items, selectedServerTag), @@ -882,24 +880,7 @@ export function App() { { id: 'proxy', label: 'VPN / Прокси' }, ]; - return ( -
- {tabs.map((tab) => ( - - ))} -
- ); + return ; } function renderSummaryPanel() { @@ -985,79 +966,59 @@ export function App() {
- - + +
); } function renderProxiFyreCard() { + const state = serviceControlState(proxyfier, isDetectingComponents); + const visualState = serviceVisualState === 'active' ? 'working' : serviceVisualState === 'settling' ? 'settling' : null; + const primaryAction = proxyfier?.installed + ? { + label: proxyfier.running ? 'Остановить' : 'Запустить', + onClick: () => void setProxiFyreServiceRunning(!proxyfier.running), + variant: proxyfier.running ? 'danger' as const : 'neutral' as const, + loading: serviceAction === 'start' || serviceAction === 'stop', + loadingLabel: serviceAction === 'start' ? 'Запускаю' : 'Останавливаю', + disabled: isDetectingComponents || Boolean(serviceAction), + } + : { + label: 'Установить', + onClick: () => void installProxiFyrePackage(), + variant: 'primary' as const, + loading: serviceAction === 'install', + loadingLabel: 'Устанавливаю', + disabled: isDetectingComponents || Boolean(serviceAction), + }; + return ( -
-
+ void uninstallProxiFyrePackage(), + }], + } : undefined} + /> ); } @@ -1109,19 +1070,19 @@ export function App() { spellCheck={false} autoFocus /> - - + ) : (
@@ -1135,38 +1096,31 @@ export function App() {
- - - + loading={pickerAction === 'folder'} + label="Добавить папку" + icon={} + />
)} @@ -1189,9 +1143,9 @@ export function App() { {itemTypeLabel(item.type)} - + )) ) : ( @@ -1207,10 +1161,27 @@ export function App() { options: { showConfigPath?: boolean } = {}, ) { const showConfigPath = options.showConfigPath ?? true; + const externalProxyError = routeMode === 'external' ? safeProxyError(proxyInput) : null; + const readiness = getApplyReadiness({ + routeMode, + appCount: items.length, + proxiFyreInstalled: Boolean(proxyfier?.installed), + singBoxInstalled: isSingBoxInstalled, + selectedServerTag: singBoxStatus?.config.selectedServerTag, + externalProxyValue: proxyInput, + externalProxyError, + busy: isApplying || Boolean(serviceAction) || Boolean(singBoxAction), + }); + const showBlocker = !readiness.ready && !isLoading && !isDetectingComponents; return ( <> - {hasUnappliedChanges ? ( + {showBlocker ? ( +
+ {readiness.title} + {readiness.text} +
+ ) : hasUnappliedChanges ? (
Изменения еще не применены в ProxiFyre {applyStateText(routeMode, isSingBoxInstalled, Boolean(singbox?.running))} @@ -1218,17 +1189,27 @@ export function App() { ) : null}
- - + {applyButtonLabel(context, isApplying, hasUnappliedChanges, singBoxAction)} + +
{showConfigPath && generatedConfigPath ?

{generatedConfigPath}

: null} @@ -1270,35 +1251,68 @@ export function App() { {proxyPing ? pingResultTitle(proxyPing) : proxyValidation ? 'Проверь формат' : 'Проверка не запускалась'} {proxyPing ? pingResultText(proxyPing) : proxyValidation ?? 'TCP check покажет, доступен ли host:port.'}
- + Проверить + ); } function renderSingBoxCard() { + const state = serviceControlState(singbox, isDetectingComponents); + const primaryAction = singbox?.installed + ? { + label: singbox.running ? 'Остановить' : 'Запустить', + onClick: () => void setSingBoxServiceRunning(!singbox.running), + variant: singbox.running ? 'danger' as const : 'neutral' as const, + loading: singBoxAction === 'start' || singBoxAction === 'stop', + loadingLabel: singBoxAction === 'start' ? 'Запускаю' : 'Останавливаю', + disabled: isDetectingComponents || Boolean(singBoxAction), + } + : { + label: 'Установить', + onClick: () => void installSingBoxPackage(), + variant: 'primary' as const, + loading: singBoxAction === 'install', + loadingLabel: 'Устанавливаю', + disabled: isDetectingComponents || Boolean(singBoxAction), + }; + return ( -
-
)} - +
); } @@ -1455,23 +1426,24 @@ export function App() { placeholder={singBoxStatus?.config.subscriptionDisplayUrl ?? 'https://example.com/sub'} spellCheck={false} /> - - + icon={} + /> {singBoxStatus?.cache?.servers.length ? ( @@ -1491,16 +1463,15 @@ export function App() { {displayServerTag(server.tag)} {ping ? {ping.ok ? `${ping.latency ?? 0} ms` : 'fail'} : null} - + icon={} + /> ); })} @@ -1530,22 +1501,22 @@ export function App() {
- - +
{routeMode === 'external' ? renderExternalProxyControls() : renderSingBoxCard()} @@ -1574,14 +1545,16 @@ export function App() {

Proxy для приложений

- + Обновить + {renderTabs()} @@ -1590,45 +1563,13 @@ export function App() {
-
-
- {activeLog ? ( - <> - {activeLog.title} - {activeLog.text} - - ) : ( - Журнал событий - )} -
- - {isLogOpen ? ( -
- {logEntries.length ? ( - logEntries.map((entry) => ( -
- -
- {entry.title} - {entry.text} -
-
- )) - ) : ( -
- -
- Журнал пуст - События появятся после проверок или применения конфигурации. -
-
- )} -
- ) : null} -
+ setIsLogOpen((current) => !current)} + formatTime={formatLogTime} + /> ); } diff --git a/apps/windows-client/src/app/readiness.ts b/apps/windows-client/src/app/readiness.ts new file mode 100644 index 0000000..04b8068 --- /dev/null +++ b/apps/windows-client/src/app/readiness.ts @@ -0,0 +1,83 @@ +export type RouteMode = 'external' | 'local-singbox'; + +export interface ApplyReadinessInput { + routeMode: RouteMode; + appCount: number; + proxiFyreInstalled: boolean; + singBoxInstalled: boolean; + selectedServerTag?: string; + externalProxyValue: string; + externalProxyError?: string | null; + busy: boolean; +} + +export interface ApplyReadiness { + ready: boolean; + title?: string; + text?: string; +} + +export function getApplyReadiness(input: ApplyReadinessInput): ApplyReadiness { + if (input.busy) { + return { + ready: false, + title: 'Операция уже выполняется', + text: 'Дождись завершения текущего действия перед повторным применением.', + }; + } + + if (!input.proxiFyreInstalled) { + return { + ready: false, + title: 'ProxiFyre не установлен', + text: 'Установи ProxiFyre, чтобы маршрутизировать выбранные приложения.', + }; + } + + if (input.appCount < 1) { + return { + ready: false, + title: 'Нет приложений', + text: 'Добавь хотя бы один процесс, EXE-файл или папку.', + }; + } + + if (input.routeMode === 'external') { + if (!input.externalProxyValue.trim()) { + return { + ready: false, + title: 'Прокси не указан', + text: 'Введи адрес SOCKS5 прокси в формате host:port или socks5://host:port.', + }; + } + + if (input.externalProxyError) { + return { + ready: false, + title: 'Проверь формат прокси', + text: input.externalProxyError, + }; + } + } + + if (input.routeMode === 'local-singbox') { + if (!input.singBoxInstalled) { + return { + ready: false, + title: 'Local sing-box не установлен', + text: 'Установи Local sing-box, чтобы применить локальный маршрут.', + }; + } + + if (!input.selectedServerTag) { + return { + ready: false, + title: 'Сервер не выбран', + text: 'Выбери сервер Local sing-box перед применением маршрута.', + }; + } + } + + return { ready: true }; +} + diff --git a/apps/windows-client/src/app/viewModel.ts b/apps/windows-client/src/app/viewModel.ts new file mode 100644 index 0000000..2e337ac --- /dev/null +++ b/apps/windows-client/src/app/viewModel.ts @@ -0,0 +1,15 @@ +import type { ComponentStatus } from '../domain/types'; +import type { ServiceControlState } from '../ui'; + +export function serviceControlState( + component: ComponentStatus | undefined, + checking: boolean, +): ServiceControlState { + if (checking) return 'checking'; + if (!component) return 'missing'; + if (component.state === 'error') return 'error'; + if (component.running) return 'running'; + if (component.installed) return 'stopped'; + return 'missing'; +} + diff --git a/apps/windows-client/src/styles/app.css b/apps/windows-client/src/styles/app.css index bf01762..2c8308e 100644 --- a/apps/windows-client/src/styles/app.css +++ b/apps/windows-client/src/styles/app.css @@ -3,6 +3,19 @@ --app-header-row-height: 50px; --app-tab-height: 46px; --app-header-height: calc(var(--app-header-row-height) + var(--app-tab-height)); + --motion-fast: 120ms; + --motion-standard: 180ms; + --motion-panel: 220ms; + --ease-out: cubic-bezier(0.23, 1, 0.32, 1); + --ease-standard: cubic-bezier(0.22, 0.72, 0.18, 1); + --surface-canvas: #101216; + --surface-panel: #131720; + --surface-raised: #151923; + --surface-control: #242a35; + --surface-inset: #0d1016; + --border-soft: #2b3342; + --border-strong: #343b49; + --focus-ring: #3b82f6; font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; @@ -74,6 +87,487 @@ button:disabled { opacity: 0.56; } +.ui-button, +.ui-icon-button { + appearance: none; + display: inline-flex; + align-items: center; + justify-content: center; + gap: 8px; + border: 1px solid var(--border-strong); + border-radius: 4px; + background: var(--surface-control); + color: #eef2ff; + cursor: pointer; + font-weight: 750; + line-height: 1; + text-decoration: none; + transition: + background-color var(--motion-fast) var(--ease-out), + border-color var(--motion-fast) var(--ease-out), + color var(--motion-fast) var(--ease-out), + opacity var(--motion-fast) var(--ease-out), + transform var(--motion-fast) var(--ease-out); +} + +.ui-button:hover, +.ui-icon-button:hover { + background: #2d3543; +} + +.ui-button:active, +.ui-icon-button:active { + transform: scale(0.98); +} + +.ui-button:focus-visible, +.ui-icon-button:focus-visible, +.ui-tab:focus-visible, +.ui-action-menu-popover button:focus-visible { + outline: 0; + box-shadow: 0 0 0 2px rgba(59, 130, 246, 0.72); +} + +.ui-button:disabled, +.ui-icon-button:disabled { + cursor: not-allowed; + opacity: 0.56; + transform: none; +} + +.ui-button--sm { + min-height: 32px; + padding: 6px 10px; + font-size: 13px; +} + +.ui-button--md { + min-height: 36px; + padding: 8px 12px; + font-size: 14px; +} + +.ui-button--lg { + min-height: 46px; + padding: 10px 14px; + font-size: 14px; +} + +.ui-button--primary { + border-color: #16a34a; + background: #22c55e; + color: #04130a; +} + +.ui-button--primary:hover { + background: #4ade80; +} + +.ui-button--add, +.ui-icon-button--add { + border-color: #2b3342; + background: #202633; + color: #dbeafe; +} + +.ui-button--add:hover, +.ui-icon-button--add:hover { + border-color: var(--focus-ring); + background: #182033; +} + +.ui-button--danger, +.ui-icon-button--danger { + color: #fecaca; +} + +.ui-button--danger:hover, +.ui-icon-button--danger:hover { + border-color: rgba(239, 68, 68, 0.52); + background: rgba(127, 29, 29, 0.34); +} + +.ui-icon-button { + width: 42px; + min-width: 42px; + min-height: 38px; + padding: 0; +} + +.ui-button-icon, +.ui-button-label { + display: inline-flex; + align-items: center; + min-width: 0; +} + +.ui-button-spinner { + width: 14px; + height: 14px; + border: 2px solid currentColor; + border-top-color: transparent; + border-radius: 999px; + animation: spin 0.75s linear infinite; +} + +.ui-tabs { + position: fixed; + top: var(--app-header-row-height); + right: 0; + left: 0; + z-index: 40; + display: flex; + align-items: stretch; + gap: 0; + min-height: var(--app-tab-height); + overflow-x: auto; + border-top: 1px solid #242a35; + border-bottom: 1px solid var(--border-soft); + background: #181b22; + scrollbar-width: none; + margin: 0; + padding: 0 18px; +} + +.ui-tabs::-webkit-scrollbar { + display: none; +} + +.ui-tab { + appearance: none; + position: relative; + flex: 1 1 0; + min-width: 0; + min-height: var(--app-tab-height); + border: 0; + border-radius: 0; + background: transparent; + color: #9aa8bd; + cursor: pointer; + font-weight: 750; + margin: 0; + padding: 10px 12px 12px; + transition: + background-color var(--motion-fast) var(--ease-out), + color var(--motion-fast) var(--ease-out); + white-space: nowrap; +} + +.ui-tab:hover { + background: #1c212b; + color: #eef2ff; +} + +.ui-tab.is-active { + color: #eff6ff; +} + +.ui-tab.is-active::after { + position: absolute; + right: 12px; + bottom: 0; + left: 12px; + height: 3px; + border-radius: 999px 999px 0 0; + background: var(--focus-ring); + box-shadow: 0 -6px 18px rgba(59, 130, 246, 0.34); + content: ""; +} + +.ui-status-pill { + display: inline-flex; + align-items: center; + width: fit-content; + border: 1px solid var(--border-soft); + border-radius: 999px; + background: #1a202b; + color: #cbd5e1; + padding: 5px 9px; + font-size: 12px; + font-weight: 750; +} + +.ui-status-pill--ok { + border-color: rgba(34, 197, 94, 0.38); + color: #86efac; +} + +.ui-status-pill--warning { + border-color: rgba(245, 158, 11, 0.46); + color: #fcd34d; +} + +.ui-status-pill--error { + border-color: rgba(239, 68, 68, 0.46); + color: #fecaca; +} + +.ui-status-pill--checking { + border-color: rgba(59, 130, 246, 0.46); + color: #bfdbfe; +} + +.ui-field { + display: grid; + gap: 7px; +} + +.ui-field-label { + color: #8d99ae; +} + +.ui-field-row { + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + gap: 8px; + align-items: stretch; +} + +.ui-field input { + min-height: 42px; + width: 100%; + border: 1px solid var(--border-strong); + border-radius: 4px; + background: var(--surface-inset); + color: #f8fafc; + outline: none; + padding: 9px 11px; +} + +.ui-field input:focus { + border-color: var(--focus-ring); + box-shadow: 0 0 0 1px var(--focus-ring); +} + +.ui-field-help { + color: #8d99ae; + font-size: 12px; +} + +.ui-field-help.is-error { + color: #fcd34d; +} + +.ui-action-menu { + position: relative; + display: flex; +} + +.ui-action-menu-popover { + position: absolute; + top: calc(100% + 6px); + right: 0; + z-index: 8; + min-width: 172px; + border: 1px solid var(--border-strong); + border-radius: 4px; + background: #171c26; + box-shadow: 0 12px 30px rgba(0, 0, 0, 0.38); + padding: 5px; + transform-origin: top right; + animation: ui-popover-in var(--motion-standard) var(--ease-out); +} + +.ui-action-menu-popover button { + width: 100%; + min-height: 34px; + border: 0; + border-radius: 3px; + background: transparent; + color: #e5e7eb; + cursor: pointer; + font: inherit; + padding: 7px 9px; + text-align: left; +} + +.ui-action-menu-popover button:hover { + background: #242a35; +} + +.ui-action-menu-popover button.is-danger { + color: #fecaca; +} + +.ui-action-menu-popover button.is-danger:hover { + background: rgba(127, 29, 29, 0.42); +} + +.ui-service-row { + position: relative; + isolation: isolate; + display: grid; + grid-template-columns: auto minmax(220px, 1fr) auto; + align-items: center; + gap: 12px; + justify-content: stretch; + min-height: 56px; + overflow: visible; + border: 1px solid var(--border-soft); + border-radius: 4px; + background: var(--surface-raised); + padding: 12px; +} + +.ui-service-row > :not(.ui-service-border-glow) { + position: relative; + z-index: 1; +} + +.ui-service-border-glow { + position: absolute; + display: none; + z-index: 0; + inset: 0; + overflow: hidden; + border-radius: inherit; + opacity: 0; + contain: layout paint; + pointer-events: none; + transition: opacity var(--motion-panel) var(--ease-out); +} + +.ui-service-row--checking .ui-service-border-glow, +.ui-service-row--working .ui-service-border-glow { + display: block; + opacity: 0.78; +} + +.ui-service-row--settling .ui-service-border-glow { + display: block; + opacity: 0; + transition-duration: 0.7s; +} + +.ui-service-border-glow-segment { + position: absolute; + display: block; + background: #93c5fd; + box-shadow: + 0 0 8px rgba(96, 165, 250, 0.95), + 0 0 16px rgba(34, 197, 94, 0.36); + opacity: 0; +} + +.ui-service-border-glow-segment.top, +.ui-service-border-glow-segment.bottom { + width: 108px; + height: 2px; + background: linear-gradient(90deg, transparent, #60a5fa 24%, #bbf7d0 54%, transparent); +} + +.ui-service-border-glow-segment.right, +.ui-service-border-glow-segment.left { + width: 2px; + height: 64px; + background: linear-gradient(180deg, transparent, #60a5fa 24%, #bbf7d0 54%, transparent); +} + +.ui-service-border-glow-segment.top { + top: 0; + animation: finder-border-top 1.6s linear infinite; +} + +.ui-service-border-glow-segment.right { + right: 0; + animation: finder-border-right 1.6s linear infinite; +} + +.ui-service-border-glow-segment.bottom { + bottom: 0; + animation: finder-border-bottom 1.6s linear infinite; +} + +.ui-service-border-glow-segment.left { + left: 0; + animation: finder-border-left 1.6s linear infinite; +} + +.ui-service-dot { + flex: 0 0 auto; + width: 11px; + height: 11px; + border-radius: 999px; + background: #f59e0b; + box-shadow: 0 0 0 4px rgba(245, 158, 11, 0.12); +} + +.ui-service-row--running .ui-service-dot, +.ui-service-row--installed .ui-service-dot { + background: #22c55e; + box-shadow: 0 0 0 4px rgba(34, 197, 94, 0.12); +} + +.ui-service-row--stopped .ui-service-dot, +.ui-service-row--missing .ui-service-dot { + background: #f59e0b; +} + +.ui-service-row--error .ui-service-dot { + background: #ef4444; + box-shadow: 0 0 0 4px rgba(239, 68, 68, 0.12); +} + +.ui-service-row--checking .ui-service-dot { + border: 2px solid var(--focus-ring); + border-top-color: transparent; + background: transparent; + box-shadow: none; + animation: spin 0.75s linear infinite; +} + +.ui-service-text { + min-width: 0; +} + +.ui-service-text > strong, +.ui-service-text > span { + display: block; + overflow-wrap: anywhere; +} + +.ui-service-text > span { + color: #8d99ae; +} + +.ui-service-inline-actions { + display: flex; + align-items: center; + flex-wrap: wrap; + gap: 6px; + margin-top: 6px; +} + +.ui-service-actions { + display: flex; + align-items: center; + gap: 6px; +} + +.ui-service-row > .setup-details, +.ui-service-row > .singbox-info-popover, +.ui-service-row > .singbox-workspace, +.ui-service-row > .singbox-install-note { + grid-column: 1 / -1; +} + +.log-raw-detail { + color: #7d8aa0; + margin-top: 3px; +} + +@keyframes ui-popover-in { + from { + opacity: 0; + transform: scale(0.97); + } + + to { + opacity: 1; + transform: scale(1); + } +} + .simple-shell { display: block; height: 100vh; @@ -743,7 +1237,7 @@ button.summary-card:hover { color: #bfdbfe; } -.setup-toggle span { +.setup-toggle .ui-button-label > span { display: inline-block; border: 1px solid #343b49; border-radius: 4px; @@ -1132,6 +1626,12 @@ button.summary-card:hover { color: #eff6ff; } +.route-switch .ui-button--primary { + border-color: #2563eb; + background: #1d4ed8; + color: #eff6ff; +} + .subscription-line { display: grid; grid-template-columns: 38px minmax(0, 1fr) 112px 42px; @@ -1598,6 +2098,17 @@ button.summary-card:hover { margin-top: 14px; } +.command-row .ui-button, +.proxy-check-line .ui-button, +.subscription-line .ui-button { + width: 100%; +} + +.apps-config-actions .ui-button { + height: 48px; + min-height: 48px; +} + .apply-button { min-height: 46px; width: 100%; @@ -1721,7 +2232,7 @@ button.summary-card:hover { background: #2d3543; } -.log-toggle span { +.log-toggle .log-count { min-width: 22px; border-radius: 4px; background: #1b202b; @@ -1958,6 +2469,11 @@ button.summary-card:hover { padding: 0 10px; } + .ui-tabs { + gap: 1px; + padding: 0 10px; + } + .panel-tabs button { flex: 1 0 110px; min-height: var(--app-tab-height); @@ -1965,6 +2481,13 @@ button.summary-card:hover { font-size: 13px; } + .ui-tab { + flex: 1 0 110px; + min-height: var(--app-tab-height); + padding: 8px 10px 9px; + font-size: 13px; + } + .panel-section-head { align-items: stretch; flex-direction: column; @@ -2057,10 +2580,34 @@ button.summary-card:hover { grid-template-columns: auto minmax(0, 1fr); } + .ui-service-row { + grid-template-columns: auto minmax(0, 1fr); + } + .service-actions { grid-column: 1 / -1; } + .ui-service-actions { + grid-column: 1 / -1; + display: grid; + grid-template-columns: minmax(0, 1fr) 42px; + width: 100%; + } + + .ui-service-actions > .ui-button { + width: 100%; + } + + .ui-service-actions > .ui-button:only-child { + grid-column: 1 / -1; + } + + .setup-strip-items { + flex-wrap: wrap; + overflow: visible; + } + .setup-details { grid-column: 1 / -1; } @@ -2102,11 +2649,14 @@ button.summary-card:hover { padding: 0 4px; } - .log-current strong, - .log-current span { + .log-current strong { white-space: nowrap; } + .log-current span:not(.log-muted) { + display: none; + } + .log-history { max-height: 220px; padding: 7px 10px; diff --git a/apps/windows-client/src/ui/ActionMenu.tsx b/apps/windows-client/src/ui/ActionMenu.tsx new file mode 100644 index 0000000..eab534a --- /dev/null +++ b/apps/windows-client/src/ui/ActionMenu.tsx @@ -0,0 +1,54 @@ +import { MoreHorizontal } from 'lucide-react'; +import { IconButton } from './IconButton'; + +export interface ActionMenuItem { + label: string; + onClick: () => void; + danger?: boolean; + disabled?: boolean; +} + +export interface ActionMenuProps { + open: boolean; + onOpenChange: (open: boolean) => void; + label: string; + items: ActionMenuItem[]; + disabled?: boolean; +} + +export function ActionMenu({ + open, + onOpenChange, + label, + items, + disabled, +}: ActionMenuProps) { + return ( +
+ } + onClick={() => onOpenChange(!open)} + disabled={disabled} + aria-expanded={open} + /> + {open ? ( +
+ {items.map((item) => ( + + ))} +
+ ) : null} +
+ ); +} + diff --git a/apps/windows-client/src/ui/Button.tsx b/apps/windows-client/src/ui/Button.tsx new file mode 100644 index 0000000..10c7311 --- /dev/null +++ b/apps/windows-client/src/ui/Button.tsx @@ -0,0 +1,49 @@ +import type { ButtonHTMLAttributes, ReactNode } from 'react'; + +export type ButtonVariant = 'primary' | 'neutral' | 'add' | 'danger'; +export type ButtonSize = 'sm' | 'md' | 'lg'; + +export interface ButtonProps extends ButtonHTMLAttributes { + variant?: ButtonVariant; + size?: ButtonSize; + loading?: boolean; + loadingLabel?: string; + leftIcon?: ReactNode; + rightIcon?: ReactNode; +} + +export function Button({ + variant = 'neutral', + size = 'md', + loading = false, + loadingLabel, + leftIcon, + rightIcon, + className, + children, + disabled, + ...props +}: ButtonProps) { + const classes = [ + 'ui-button', + `ui-button--${variant}`, + `ui-button--${size}`, + loading ? 'is-loading' : '', + className ?? '', + ].filter(Boolean).join(' '); + + return ( + + ); +} + diff --git a/apps/windows-client/src/ui/Field.tsx b/apps/windows-client/src/ui/Field.tsx new file mode 100644 index 0000000..e2b669a --- /dev/null +++ b/apps/windows-client/src/ui/Field.tsx @@ -0,0 +1,38 @@ +import type { InputHTMLAttributes, ReactNode } from 'react'; + +export interface FieldProps extends InputHTMLAttributes { + label: string; + error?: string | null; + hint?: string; + action?: ReactNode; +} + +export function Field({ + label, + error, + hint, + action, + className, + id, + ...props +}: FieldProps) { + const inputId = id ?? `field-${label.toLowerCase().replace(/\s+/g, '-')}`; + const helpId = `${inputId}-help`; + + return ( + + ); +} + diff --git a/apps/windows-client/src/ui/IconButton.tsx b/apps/windows-client/src/ui/IconButton.tsx new file mode 100644 index 0000000..bd553b7 --- /dev/null +++ b/apps/windows-client/src/ui/IconButton.tsx @@ -0,0 +1,42 @@ +import type { ButtonHTMLAttributes, ReactNode } from 'react'; + +export type IconButtonVariant = 'neutral' | 'add' | 'danger'; + +export interface IconButtonProps extends ButtonHTMLAttributes { + label: string; + icon: ReactNode; + variant?: IconButtonVariant; + loading?: boolean; +} + +export function IconButton({ + label, + icon, + variant = 'neutral', + loading = false, + className, + disabled, + title, + ...props +}: IconButtonProps) { + const classes = [ + 'ui-icon-button', + `ui-icon-button--${variant}`, + loading ? 'is-loading' : '', + className ?? '', + ].filter(Boolean).join(' '); + + return ( + + ); +} + diff --git a/apps/windows-client/src/ui/LogDock.tsx b/apps/windows-client/src/ui/LogDock.tsx new file mode 100644 index 0000000..176796b --- /dev/null +++ b/apps/windows-client/src/ui/LogDock.tsx @@ -0,0 +1,82 @@ +import { Button } from './Button'; + +export interface LogDockEntry { + id: string; + kind: 'success' | 'error' | 'info'; + title: string; + text: string; + at: number; +} + +export interface LogDockProps { + entries: LogDockEntry[]; + activeEntry: LogDockEntry | null; + open: boolean; + onToggle: () => void; + formatTime: (timestamp: number) => string; +} + +function isNativePreviewError(entry: LogDockEntry | null) { + if (!entry) return false; + return entry.text.includes("reading 'invoke'") || entry.text.includes('undefined (reading'); +} + +function displayEntry(entry: LogDockEntry | null) { + if (!entry) return null; + if (!isNativePreviewError(entry)) return entry; + return { + ...entry, + title: 'Desktop-команды недоступны', + text: 'Запусти клиент через Tauri, чтобы управлять службами и применять конфиг.', + }; +} + +export function LogDock({ + entries, + activeEntry, + open, + onToggle, + formatTime, +}: LogDockProps) { + const current = displayEntry(activeEntry); + + return ( +
+
+ {current ? ( + <> + {current.title} + {current.text} + + ) : ( + Журнал событий + )} +
+ + {open ? ( +
+ {entries.length ? entries.map((entry) => { + const friendly = displayEntry(entry); + return ( +
+ +
+ {friendly?.title ?? entry.title} + {friendly?.text ?? entry.text} + {isNativePreviewError(entry) ? Детали: {entry.text} : null} +
+
+ ); + }) : ( +
+ + Событий пока нет. +
+ )} +
+ ) : null} +
+ ); +} diff --git a/apps/windows-client/src/ui/ServiceControlRow.tsx b/apps/windows-client/src/ui/ServiceControlRow.tsx new file mode 100644 index 0000000..b2c3bc8 --- /dev/null +++ b/apps/windows-client/src/ui/ServiceControlRow.tsx @@ -0,0 +1,93 @@ +import type { ReactNode } from 'react'; +import { Button, type ButtonVariant } from './Button'; +import { ActionMenu, type ActionMenuItem } from './ActionMenu'; + +export type ServiceControlState = 'checking' | 'missing' | 'installed' | 'running' | 'stopped' | 'error'; + +export interface ServicePrimaryAction { + label: string; + onClick: () => void; + variant?: ButtonVariant; + loading?: boolean; + loadingLabel?: string; + disabled?: boolean; +} + +export interface ServiceControlRowProps { + state: ServiceControlState; + title: string; + detail: string; + primaryAction?: ServicePrimaryAction; + menu?: { + label: string; + open: boolean; + onOpenChange: (open: boolean) => void; + items: ActionMenuItem[]; + disabled?: boolean; + }; + visualState?: 'working' | 'settling' | null; + className?: string; + inlineActions?: ReactNode; + children?: ReactNode; +} + +export function ServiceControlRow({ + state, + title, + detail, + primaryAction, + menu, + visualState, + className, + inlineActions, + children, +}: ServiceControlRowProps) { + const classes = [ + 'ui-service-row', + `ui-service-row--${state}`, + visualState ? `ui-service-row--${visualState}` : '', + className ?? '', + ].filter(Boolean).join(' '); + + return ( +
+
+ ); +} + diff --git a/apps/windows-client/src/ui/StatusPill.tsx b/apps/windows-client/src/ui/StatusPill.tsx new file mode 100644 index 0000000..3107f50 --- /dev/null +++ b/apps/windows-client/src/ui/StatusPill.tsx @@ -0,0 +1,11 @@ +export type StatusPillTone = 'ok' | 'warning' | 'error' | 'checking' | 'muted'; + +export interface StatusPillProps { + tone?: StatusPillTone; + children: string; +} + +export function StatusPill({ tone = 'muted', children }: StatusPillProps) { + return {children}; +} + diff --git a/apps/windows-client/src/ui/Tabs.tsx b/apps/windows-client/src/ui/Tabs.tsx new file mode 100644 index 0000000..1632bd5 --- /dev/null +++ b/apps/windows-client/src/ui/Tabs.tsx @@ -0,0 +1,81 @@ +import { useRef, type KeyboardEvent } from 'react'; + +export interface TabItem { + id: T; + label: string; +} + +export interface TabsProps { + items: Array>; + activeId: T; + onChange: (id: T) => void; + ariaLabel: string; +} + +export function Tabs({ + items, + activeId, + onChange, + ariaLabel, +}: TabsProps) { + const refs = useRef>([]); + + function moveFocus(currentId: T, direction: 1 | -1) { + const currentIndex = items.findIndex((item) => item.id === currentId); + const nextIndex = (currentIndex + direction + items.length) % items.length; + const next = items[nextIndex]; + if (!next) return; + onChange(next.id); + window.requestAnimationFrame(() => refs.current[nextIndex]?.focus()); + } + + function handleKeyDown(event: KeyboardEvent, id: T) { + if (event.key === 'ArrowRight') { + event.preventDefault(); + moveFocus(id, 1); + } else if (event.key === 'ArrowLeft') { + event.preventDefault(); + moveFocus(id, -1); + } else if (event.key === 'Home') { + event.preventDefault(); + const first = items[0]; + if (!first) return; + onChange(first.id); + window.requestAnimationFrame(() => refs.current[0]?.focus()); + } else if (event.key === 'End') { + event.preventDefault(); + const last = items[items.length - 1]; + if (!last) return; + onChange(last.id); + window.requestAnimationFrame(() => refs.current[items.length - 1]?.focus()); + } + } + + return ( +
+ {items.map((item, index) => { + const active = item.id === activeId; + return ( + + ); + })} +
+ ); +} + diff --git a/apps/windows-client/src/ui/index.ts b/apps/windows-client/src/ui/index.ts new file mode 100644 index 0000000..3c1edc2 --- /dev/null +++ b/apps/windows-client/src/ui/index.ts @@ -0,0 +1,17 @@ +export { ActionMenu } from './ActionMenu'; +export type { ActionMenuItem } from './ActionMenu'; +export { Button } from './Button'; +export type { ButtonProps, ButtonSize, ButtonVariant } from './Button'; +export { Field } from './Field'; +export type { FieldProps } from './Field'; +export { IconButton } from './IconButton'; +export type { IconButtonProps, IconButtonVariant } from './IconButton'; +export { LogDock } from './LogDock'; +export type { LogDockEntry, LogDockProps } from './LogDock'; +export { ServiceControlRow } from './ServiceControlRow'; +export type { ServiceControlRowProps, ServiceControlState } from './ServiceControlRow'; +export { StatusPill } from './StatusPill'; +export type { StatusPillProps, StatusPillTone } from './StatusPill'; +export { Tabs } from './Tabs'; +export type { TabItem, TabsProps } from './Tabs'; + diff --git a/docs/audits/windows-client-ux-2026-07-08/01-summary-desktop.png b/docs/audits/windows-client-ux-2026-07-08/01-summary-desktop.png new file mode 100644 index 0000000..3d6b798 Binary files /dev/null and b/docs/audits/windows-client-ux-2026-07-08/01-summary-desktop.png differ diff --git a/docs/audits/windows-client-ux-2026-07-08/02-proxifyre-desktop.png b/docs/audits/windows-client-ux-2026-07-08/02-proxifyre-desktop.png new file mode 100644 index 0000000..160ccea Binary files /dev/null and b/docs/audits/windows-client-ux-2026-07-08/02-proxifyre-desktop.png differ diff --git a/docs/audits/windows-client-ux-2026-07-08/03-proxy-desktop.png b/docs/audits/windows-client-ux-2026-07-08/03-proxy-desktop.png new file mode 100644 index 0000000..3ea37c3 Binary files /dev/null and b/docs/audits/windows-client-ux-2026-07-08/03-proxy-desktop.png differ diff --git a/docs/audits/windows-client-ux-2026-07-08/04-proxy-validation-desktop.png b/docs/audits/windows-client-ux-2026-07-08/04-proxy-validation-desktop.png new file mode 100644 index 0000000..497bb6d Binary files /dev/null and b/docs/audits/windows-client-ux-2026-07-08/04-proxy-validation-desktop.png differ diff --git a/docs/audits/windows-client-ux-2026-07-08/05-summary-narrow.png b/docs/audits/windows-client-ux-2026-07-08/05-summary-narrow.png new file mode 100644 index 0000000..066b5e2 Binary files /dev/null and b/docs/audits/windows-client-ux-2026-07-08/05-summary-narrow.png differ diff --git a/docs/audits/windows-client-ux-2026-07-08/06-proxifyre-narrow.png b/docs/audits/windows-client-ux-2026-07-08/06-proxifyre-narrow.png new file mode 100644 index 0000000..6521916 Binary files /dev/null and b/docs/audits/windows-client-ux-2026-07-08/06-proxifyre-narrow.png differ diff --git a/docs/audits/windows-client-ux-2026-07-08/AUDIT.md b/docs/audits/windows-client-ux-2026-07-08/AUDIT.md new file mode 100644 index 0000000..e5655ae --- /dev/null +++ b/docs/audits/windows-client-ux-2026-07-08/AUDIT.md @@ -0,0 +1,188 @@ +# UX/UI-аудит Windows-клиента + +Дата: 2026-07-08 + +## Область аудита + +Проверен текущий React UI `apps/windows-client` для Vite/Tauri как компактная Windows-утилита управления proxy-маршрутизацией приложений. Аудит выполнялся в browser-preview на `http://127.0.0.1:5174/`, поэтому нативные Tauri-команды были недоступны, а интерфейс показывал preview/runtime-ошибки. Все выводы ниже привязаны к скриншотам, снятым в этом прогоне. + +## Цель пользователя + +Главная задача пользователя - быстро управлять службами и понять: + +- какие компоненты установлены; +- какие службы запущены, остановлены или отсутствуют; +- какое действие доступно прямо сейчас: установить, удалить, запустить, остановить, обновить, добавить или применить; +- что сейчас маршрутизируется; +- через какую цепочку компонентов идет трафик; +- готовы ли ProxiFyre и опциональный Local sing-box; +- можно ли безопасно применить изменения; +- что делать дальше, если что-то сломано. + +Цель по доступности: интерфейс должен быть управляемым с клавиатуры, с понятными статусами, читаемым восстановлением после ошибок, предсказуемыми focus states и устойчивой адаптацией к узким размерам Windows-окна. + +## Доказательства + +1. `01-summary-desktop.png` - панель "Сводка", desktop-ширина. Состояние: смешанное. Главный статус виден, но примененное состояние еще "загружается", а рядом уже показан бейдж "Совпадает". +2. `02-proxifyre-desktop.png` - панель ProxiFyre, desktop-ширина. Состояние: в целом рабочее. Состав компонента и список приложений компактны, но главное действие apply доступно даже при отсутствующих prerequisites. +3. `03-proxy-desktop.png` - панель "VPN / Прокси", desktop-ширина. Состояние: рабочее. Настройка маршрута понятна, но route path выглядит как вторичный текст, а apply-action выглядит валидным до того, как маршрут может успешно примениться. +4. `04-proxy-validation-desktop.png` - невалидный proxy input. Состояние: хорошая база. Inline-валидация находится рядом с полем и отключает действие проверки. +5. `05-summary-narrow.png` - панель "Сводка", узкая ширина. Состояние: напряженное. Контент перестраивается, но нижний log dock и длинные сообщения обрезают важную информацию. +6. `06-proxifyre-narrow.png` - панель ProxiFyre, узкая ширина. Состояние: напряженное. Контролы складываются неплохо, но setup chips переполняются по горизонтали, а нижний dock конкурирует с основной задачей. + +## Доменное направление + +Ключевые понятия домена: цепочка маршрута, здоровье компонентов, локальный runtime, внешний endpoint, выбранные приложения, сгенерированный конфиг, граница apply/restart, диагностика. + +Цветовой мир: темная Windows-оболочка, терминальный черный, зеленый для driver/service ready, amber для предупреждений, красный для блокеров/firewall, синий для links/actions, slate для config-файлов. + +Сигнатурный элемент, который стоит усилить: `Service Control Row` - повторяемая строка компонента, где слева состояние службы, в центре человекочитаемый статус, справа одно главное действие и меню дополнительных действий. Route-chain readout тоже нужен, но как объяснение эффекта этих служб: `Apps -> ProxiFyre -> target -> VPN/server`. + +Дефолты, которые стоит отбрасывать: + +- generic dashboard cards -> service-control utility; +- большая зеленая apply-кнопка всегда видна -> apply gated by readiness + inline blockers; +- raw logs как главный error surface -> сначала человеческое recovery-сообщение, raw details вторым уровнем. +- разрозненные кнопки и анимации -> shared component base с едиными variants/states/motion tokens. + +## Сильные стороны + +- Приложение уже ощущается как компактная desktop-утилита, а не как сайт. Фиксированный header, tabs, плотные панели и темная системная палитра подходят задаче. +- Продуктовая модель сильная: ProxiFyre и Local sing-box разделены, sing-box не сделан обязательным. +- Inline-валидация SOCKS5-поля расположена рядом с полем и отключает "Проверить", когда формат неверный. +- Зона добавления приложения использует иконки с доступными labels и tooltip, поэтому плотный workflow остается сканируемым. +- Для основных анимаций есть `prefers-reduced-motion`. +- UI использует настоящие `button` и tab roles, а не click-only divs. Это хорошая база для доступности. + +## UX-риски + +1. Apply выглядит доступным, когда маршрут еще не actionable. + - Доказательства: `02-proxifyre-desktop.png`, `03-proxy-desktop.png`. + - ProxiFyre отсутствует и список приложений пуст, но "Обновить конфиг" / "Обновить маршрут" ярко-зеленые. Это провоцирует failed action вместо направленного setup. + +2. Главная цепочка маршрута не является визуальным фокусом. + - Доказательства: `01-summary-desktop.png`, `03-proxy-desktop.png`. + - Самая важная mental model - путь трафика, но сейчас он показан plain text в таблице или вторичном блоке. Пользователь вынужден читать фрагменты статуса вместо того, чтобы сразу увидеть цепочку. + +3. В "Сводке" есть противоречивое состояние. + - Доказательство: `01-summary-desktop.png`. + - "Состояние загружается" показано рядом с "Совпадает", а система одновременно говорит "Не настроено". Это может создать ощущение, что действий не требуется. + +4. Preview/native command failures слишком сырые. + - Доказательства: все скриншоты. + - Dock показывает `Cannot read properties of undefined (reading 'invoke')`. Для разработчика это полезно, но как первичный пользовательский error surface это шум. + +5. Bottom log dock конфликтует с узкой компоновкой. + - Доказательства: `05-summary-narrow.png`, `06-proxifyre-narrow.png`. + - Важные сообщения обрезаются до "Компонен..." и "Cannot read prop..."; dock постоянно занимает вертикальное место в и так коротком окне. + +6. ProxiFyre setup chips плохо перестраиваются. + - Доказательство: `06-proxifyre-narrow.png`. + - Горизонтальная chip-лента скрывает третий dependency и ухудшает диагностическую ясность. + +7. Терминология немного смешана. + - Доказательства: `01-summary-desktop.png`, `03-proxy-desktop.png`. + - `config`, `TCP check`, `VPN сервер`, `Local sing-box`, `ProxiFyre` - все эти термины допустимы, но нужен единый принцип: сначала пользовательский русский, технический идентификатор вторым уровнем. + +8. Компонентная база пока не выражена как система. + - Доказательства: `02-proxifyre-desktop.png`, `03-proxy-desktop.png`, `06-proxifyre-narrow.png`. + - Кнопки "Обновить", "Установить", "Открыть", "Проверить", add-icon buttons и service actions выглядят близко, но пока не читаются как строгая система variants. Из-за этого сложно гарантировать одинаковые hover/loading/disabled/focus states и одинаковую motion-модель. + +## Риски доступности + +- Tablist использует `role="tab"`, но не видно поддержки ожидаемого arrow-key behavior и roving tab index. Пользователю с клавиатурой, вероятно, придется проходить все tabs через Tab вместо Left/Right. +- Focus visibility есть через browser defaults, но визуально она тяжелая и не согласована со стилем приложения. Нужен отдельный `:focus-visible` token для buttons, tabs, inputs и icon controls. +- Status dots сильно полагаются на цвет. Стоит дублировать состояние видимым текстом или `aria-label`/screen-reader text там, где текст рядом не объясняет статус. +- Footer использует `aria-live`, но повторяющиеся raw runtime errors могут быть шумными для assistive tech. Сначала стоит объявлять короткий статус, а подробный error text держать за "Посмотреть". +- На узкой ширине truncation может скрывать actionable-часть сообщений и button labels. +- Скриншоты не доказывают полный keyboard order, screen-reader output или contrast ratios. Для этого нужен интерактивный accessibility pass. + +## План улучшений + +### P0 - Сделать service-control flow заслуживающим доверия + +1. Ввести shared component base: + - `Button` с variants: `primary`, `neutral`, `add`, `danger`, `icon`; + - `Tabs` с единым keyboard behavior и animation contract; + - `ServiceControlRow` для ProxiFyre, Local sing-box и будущих служб; + - `StatusPill`, `Field`, `ActionMenu`, `LogDock`; + - единые states: default, hover, active, focus-visible, disabled, loading. + +2. Нормализовать motion tokens: + - button press: 100-140ms; + - tab switch: 180-240ms, только `opacity` + `transform`; + - popover/menu: 150-180ms; + - service operation: видимый loading state, чтобы интерфейс не ощущался зависшим; + - не использовать `transition: all`. + +3. Заменить raw preview/native invocation failures на дружелюбное состояние: + - "Desktop-команды недоступны в browser preview. Запусти через Tauri для управления службами." + - Raw error оставить только в деталях лога. + +4. Заблокировать primary apply actions до готовности: + - disabled + explanation, когда список приложений пуст; + - disabled + explanation, когда ProxiFyre отсутствует; + - disabled + explanation, когда выбран local route без установленного/запущенного sing-box или выбранного сервера; + - "Открыть конфиг" оставить вторичным действием, не равным apply по весу. + +5. Исправить язык summary-state: + - использовать взаимоисключающие состояния: "Проверяю", "Не готово", "Готово к применению", "Применено", "Есть черновик"; + - не показывать "Совпадает", пока saved/applied state реально не загружен. + +### P1 - Пересобрать главную mental model + +6. Вынести route-chain component как объясняющий элемент: + - узел `Apps` с количеством; + - узел `ProxiFyre` со статусом installed/running; + - узел `Target`: external SOCKS5 или Local sing-box; + - узел `Server`, когда активен local sing-box; + - каждый сломанный узел показывает одно следующее действие. + +7. Переработать "Сводку" в command center: + - сверху: route-chain и readiness системы; + - посередине: applied vs draft diff, только если есть отличие; + - снизу: максимум два next actions; + - длинные config paths и подробный состав компонентов убрать из первого взгляда. + +8. Разделить "настроить маршрут" и "применить маршрут": + - Proxy panel отвечает за endpoint choice и connection check; + - ProxiFyre panel отвечает за app selection и service readiness; + - Summary подтверждает объединенный маршрут и применяет только при готовности. + +### P2 - Усилить responsive и component craft + +7. Превратить setup chips в wrapping checklist на узких ширинах. + +8. Сделать log dock адаптивным: + - desktop: persistent compact dock подходит; + - narrow: collapsed toast/details sheet вместо постоянного full-width footer. + +9. Определить design tokens: + - surfaces: canvas, panel, inset, elevated; + - text: primary, secondary, muted, disabled; + - status: ready, warning, blocked, checking; + - focus ring и border intensities. + +10. Стандартизировать copy: + - последовательно использовать "конфиг" или "конфигурация"; + - заменить "TCP check" на "TCP-проверка"; + - `Local sing-box` оставить техническим именем компонента, но в user-facing headings связывать с "локальный прокси". + +### P3 - Verification pass + +11. Добавить focused interaction QA: + - keyboard tab order; + - arrow-key tab navigation; + - focus return из menus/popovers; + - disabled-action explanations; + - empty, loading, error, missing-component, ready и unapplied-change states. + +12. Добавить responsive visual snapshots: + - 1280x720 desktop; + - 800x600 compact desktop; + - 420x720 narrow window; + - длинные proxy/server names и длинные Windows paths. + +## Рекомендуемый первый redesign slice + +Сначала стоит сделать shared UI foundation: `Button`, `Tabs`, `ServiceControlRow`, `StatusPill`, `Field`, `ActionMenu`, `LogDock` и motion tokens. После этого собрать ProxiFyre и Local sing-box через один `ServiceControlRow`, а route-chain использовать как объясняющий компонент в "Сводке" и "VPN / Прокси". Такой порядок сначала стабилизирует поведение кнопок/табы/анимации, затем улучшит ориентацию и recovery без переписывания всего UI. diff --git a/docs/goals/windows-client-service-control-ui-system/EVIDENCE.md b/docs/goals/windows-client-service-control-ui-system/EVIDENCE.md new file mode 100644 index 0000000..7df1cc6 --- /dev/null +++ b/docs/goals/windows-client-service-control-ui-system/EVIDENCE.md @@ -0,0 +1,82 @@ +# Windows Client Service-Control UI System Evidence + +## Acceptance Evidence + +- Desktop Summary screenshot: `docs/goals/windows-client-service-control-ui-system/evidence/01-summary-desktop.png` + - Shows shared tabs, shared refresh button, and friendly preview error: `Desktop-команды недоступны`. +- Desktop ProxiFyre screenshot: `docs/goals/windows-client-service-control-ui-system/evidence/02-proxifyre-desktop.png` + - Shows ProxiFyre rendered through the shared service-control row pattern. + - Shows apply blocker: `ProxiFyre не установлен`. + - Shows `Обновить конфиг` disabled instead of green/ready. +- Desktop VPN / Proxy screenshot: `docs/goals/windows-client-service-control-ui-system/evidence/03-proxy-desktop.png` + - Shows route mode buttons through shared button variants. + - Shows apply blocker before route apply. +- Narrow ProxiFyre screenshot: `docs/goals/windows-client-service-control-ui-system/evidence/04-proxifyre-narrow.png` + - Shows service row and setup checklist reflow at 420px. + - Shows compact log dock title instead of long raw runtime error text. + +Captured DOM state from Browser preview: + +```json +{ + "proxState": { + "disabledApply": true, + "blockerText": true, + "friendlyError": true, + "rawInvokeCurrent": false + }, + "routeState": { + "externalButtonClass": "ui-button ui-button--primary ui-button--md", + "externalAriaPressed": "true" + }, + "narrowState": { + "width": 420, + "hasHorizontalOverflow": false, + "setupStripOverflow": false + } +} +``` + +Native/elevated install/start/stop/uninstall service actions were not executed in this evidence pass. They are implemented through the existing handlers and shared callbacks, but real elevated service execution remains `implemented but unproven`. + +## Verification + +Build was run with bundled Node because system `npm` is not in PATH in this Codex shell. + +```powershell +& 'C:\Users\PC\.cache\codex-runtimes\codex-primary-runtime\dependencies\node\bin\node.exe' '.\node_modules\typescript\bin\tsc' +& 'C:\Users\PC\.cache\codex-runtimes\codex-primary-runtime\dependencies\node\bin\node.exe' '.\node_modules\vite\bin\vite.js' build +``` + +Relevant output: + +```text +vite v7.3.6 building client environment for production... +✓ 1800 modules transformed. +✓ built in 1.14s +``` + +Cutover check: + +```powershell +rg -n 'transition:\s*all|className=.*(apply-button|service-button|ghost-button|add-tile|open-config-button)' apps\windows-client\src +``` + +Relevant output: + +```text +no legacy className or transition: all matches +``` + +Notes: +- `pnpm run build` was attempted through the bundled runtime, but it began reinstalling `node_modules` because dependencies were originally installed by another package manager. The process was stopped; no package or lockfile changes were recorded by git. +- Browser preview was served from `http://127.0.0.1:5174/`. + +## Review Notes + +- Visual self-review found that active route mode lost its active styling after switching to shared `Button`; CSS was corrected with `.route-switch .ui-button--primary`. +- Visual self-review found setup chips still horizontally scrolled at 420px; narrow CSS was corrected to wrap `.setup-strip-items`. +- Residual product issue from the earlier audit remains: Summary still has `Состояние загружается` beside `Совпадает`. That state language was outside the implemented high-value slice and should be handled in a follow-up Summary view-model cleanup. +- POST plan review: aligned. Implementation stayed inside the approved ownership and contract boundaries: shared UI components are presentational, `App.tsx` still owns orchestration, and Tauri wrappers remain the command boundary. +- Correctness review: no blocker found. Real elevated service actions were not executed, so that lane remains `implemented but unproven`. +- Maintainability review: no blocker found. Old dominant `className` paths for apply/service/ghost/add/open buttons were displaced from `App.tsx`; stale legacy CSS selectors remain in `app.css` as non-dominant compatibility residue and should be removed in a follow-up CSS pruning pass if desired. diff --git a/docs/goals/windows-client-service-control-ui-system/GOAL.md b/docs/goals/windows-client-service-control-ui-system/GOAL.md new file mode 100644 index 0000000..43a3fa0 --- /dev/null +++ b/docs/goals/windows-client-service-control-ui-system/GOAL.md @@ -0,0 +1,11 @@ +# Goal: Windows Client Service-Control UI System + +Use Krypton Execution to execute `docs/goals/windows-client-service-control-ui-system/PLAN.md`. + +Core rules: +- Treat PLAN.md as the source plan. +- Preserve intent, ownership, contract, cutover, evidence, and kill criteria. +- Do not add a new dominant path without deleting, redirecting, demoting, or shimming the displaced path. +- Capture acceptance evidence from the target perspective and record it in EVIDENCE.md. +- Say "implemented but unproven" if that evidence cannot be captured. + diff --git a/docs/goals/windows-client-service-control-ui-system/PLAN.md b/docs/goals/windows-client-service-control-ui-system/PLAN.md new file mode 100644 index 0000000..1c40c98 --- /dev/null +++ b/docs/goals/windows-client-service-control-ui-system/PLAN.md @@ -0,0 +1,503 @@ +# Windows Client Service-Control UI System Implementation Plan + +**Intent:** Превратить текущий Windows-клиент в простую service-control утилиту с единой компонентной базой, предсказуемыми состояниями кнопок/табов/служебных строк, readiness-блокерами и аккуратными motion tokens. +**Current Behavior:** `apps/windows-client/src/app/App.tsx` и `apps/windows-client/src/styles/app.css` остаются крупным монолитом. Кнопки, табы, service cards, add controls, apply/open actions, log dock, validation и анимации задаются ad hoc через локальные JSX-блоки и классы вроде `service-button`, `apply-button`, `ghost-button`, `add-tile`, `route-switch`, `finder-card`, `log-dock`. +**Expected Outcome:** Пользователь видит плотную Windows-утилиту, где ProxiFyre и Local sing-box управляются через один повторяемый service-control паттерн, все кнопки имеют понятные variants/states, apply недоступен до готовности prerequisites, ошибки preview/native команд показываются человечески, а вкладки/меню/loading состояния двигаются одинаково и не создают ощущения зависшего UI. +**Target-Perspective Output:** Пользователь открывает клиент и за несколько секунд понимает, какие компоненты установлены, какие службы запущены/остановлены/отсутствуют, какую одну кнопку можно нажать дальше, почему apply заблокирован, и где посмотреть детали. На desktop и узкой ширине интерфейс не обрезает ключевые действия и не показывает raw runtime errors как основной текст. +**Truth Owner:** Источник правды для профилей, targets, компонентов, Local sing-box, generated config и apply остается в Rust/Tauri командах и JSON-файлах под `C:\ProgramData\VpnProxy`. Источник правды для UI-паттернов и визуальных контрактов после этого плана: `.interface-design/system.md` и shared UI components под `apps/windows-client/src/ui`. +**Contract Boundary:** React UI вызывает typed wrappers из `apps/windows-client/src/api/tauriCommands.ts`. Shared UI components не вызывают Tauri напрямую и не владеют persistent state; они получают props, emit callbacks и отображают states. `App.tsx`/panel containers владеют orchestration, draft state, loading state, readiness blockers и вызовами существующих handlers. +**Cutover:** Старые ad hoc JSX/CSS paths для кнопок, табов, service cards, log dock, fields и menus постепенно заменяются shared components. После cutover в `App.tsx` не должно оставаться нового прямого `