Improve startup responsiveness and admin prompt motion
CI / Windows baseline (push) Has been cancelled

This commit is contained in:
2026-07-22 17:50:12 +03:00
parent 90ec4ca086
commit 9c987df6e9
11 changed files with 597 additions and 183 deletions
@@ -47,6 +47,21 @@ src/app/components/SingBoxWorkspace.tsx
- Use `aria-*` for tabs, toggle buttons, popovers, service controls. - Use `aria-*` for tabs, toggle buttons, popovers, service controls.
- Honor reduced motion where relevant. - Honor reduced motion where relevant.
## Animated disclosures
- Keep disclosure content mounted through opening and closing so both directions can animate. Do not conditionally render content directly into its final open state.
- Keep the trigger at one screen position and separate layout placement from hover/active transforms.
- Gate hidden content with `aria-hidden` plus `inert` or `tabIndex`; opacity and `pointer-events` alone do not remove controls from keyboard navigation.
- Keep `aria-expanded` and `aria-controls` on the trigger synchronized with the rendered state.
- Use one motion origin and timeline for background, copy, and actions. Implement and verify the reverse transition at the same time as the entrance.
## Startup responsiveness
- Render the shell and saved/default configuration immediately. Do not gate first paint on network access, subscription refresh, or every component probe.
- Show slow component detection in reserved `checking` geometry and apply partial results as they arrive without replaying page entrance motion.
- Do not serialize independent probes to create a staged UI. When the backend exposes only an aggregate snapshot, animate reserved placeholders and replace their values in place when that snapshot arrives.
- Keep navigation and already-known configuration usable while background detection continues.
## Proxy/routing UI ## Proxy/routing UI
When editing route UI: When editing route UI:
@@ -71,6 +86,7 @@ Recommended for extracted pure logic:
- Unit tests for readiness states. - Unit tests for readiness states.
- Unit tests for snapshot diff/change dock model. - Unit tests for snapshot diff/change dock model.
- UI smoke checks for desktop and narrow layout. - UI smoke checks for desktop and narrow layout.
- For hover, disclosure, or motion changes, exercise first open, close, repeated toggle, hover during transition, keyboard focus, loading copy, and `prefers-reduced-motion`. Build and unit tests do not validate these behaviors.
## Do not ## Do not
@@ -32,6 +32,14 @@ pub async fn some_command(input: SomeInput) -> Result<SomeOutput, CommandError>
Implementation function должна быть тестируемой без Tauri runtime, если возможно. Implementation function должна быть тестируемой без Tauri runtime, если возможно.
## Startup responsiveness
- Keep first paint independent from network access, subscription refresh, and slow component detection.
- Run independent startup probes concurrently and outside the async runtime thread. Do not serialize ProxiFyre, sing-box, service, and admin checks without a dependency between them.
- Give external or process-heavy probes a bounded timeout and return partial status when one probe is slow or unavailable.
- Load saved configuration and other cheap state first. Let the UI render it while detection results update separately or through a partial startup snapshot.
- Do not fail the entire startup snapshot because one optional component cannot be detected. Preserve structured per-component errors or unknown/checking state.
## DTO boundary ## DTO boundary
При добавлении или изменении command: При добавлении или изменении command:
@@ -89,6 +97,7 @@ services/singbox_service.rs
- `cargo clippy --all-targets --all-features -- -D warnings` - `cargo clippy --all-targets --all-features -- -D warnings`
- `cargo test --all-targets` - `cargo test --all-targets`
- Relevant Windows/manual check if touching service/install/elevation. - Relevant Windows/manual check if touching service/install/elevation.
- When changing startup aggregation, verify that one delayed or failed probe does not postpone unrelated saved state or component results.
Если `cargo` недоступен в среде, честно написать, что backend проверен только статически. Не изображать компилятор, у него и так тяжелая жизнь. Если `cargo` недоступен в среде, честно написать, что backend проверен только статически. Не изображать компилятор, у него и так тяжелая жизнь.
+14
View File
@@ -37,6 +37,20 @@ PowerShell plan-only:
& .\scripts\install-singbox.ps1 -PlanOnly & .\scripts\install-singbox.ps1 -PlanOnly
``` ```
## Interaction smoke for UI motion
Build, lint, and unit tests do not validate motion or pointer behavior. For any hover, disclosure, stagger, or hit-target change, verify:
- first open and first close;
- repeated and rapid toggle;
- hover and click before, during, and after transition;
- keyboard focus and hidden-control tab order;
- loading and longest localized labels;
- `prefers-reduced-motion`;
- desktop and narrow window geometry.
Use a controlled mock or preview state when backend status is difficult to reproduce. If no visual interaction smoke is possible, report that evidence as missing and do not claim the motion task is complete.
## CI recommendation ## CI recommendation
Add GitHub Actions with at least: Add GitHub Actions with at least:
+9 -5
View File
@@ -12,10 +12,12 @@ Keep ProxyWarden a compact Windows utility while matching the visual language of
1. Read `AGENTS.md`, `.agent/skills/react-typescript-ui/SKILL.md`, and the complete component and CSS being changed. 1. Read `AGENTS.md`, `.agent/skills/react-typescript-ui/SKILL.md`, and the complete component and CSS being changed.
2. Read [visual-language.md](references/visual-language.md) for composition, typography, color, and surfaces. 2. Read [visual-language.md](references/visual-language.md) for composition, typography, color, and surfaces.
3. Read [motion-and-interaction.md](references/motion-and-interaction.md) for state and interaction animation. 3. Read [motion-and-interaction.md](references/motion-and-interaction.md) for state and interaction animation.
4. Reuse `src/ui/*`, existing state, CSS tokens, and typed Tauri boundaries. Prefer CSS and narrow markup changes over dependencies or new abstractions. 4. Before editing a disclosure or motion-heavy control, write a compact storyboard for `collapsed`, `opening`, `open`, and `closing`: fixed elements, origin, direction, duration, easing, focus, and reduced-motion behavior.
5. Keep geometry stable across loading, success, error, copy, refresh, and route changes. 5. Reuse `src/ui/*`, existing state, CSS tokens, and typed Tauri boundaries. Prefer CSS and narrow markup changes over dependencies or new abstractions.
6. Add `prefers-reduced-motion` behavior with every new animation. 6. Keep geometry stable across loading, success, error, copy, refresh, and route changes.
7. Run `npm test`, `npm run build`, and a visual desktop/narrow smoke when the environment allows it. 7. Add `prefers-reduced-motion` behavior with every new animation.
8. After a second user correction to the same interaction, stop stacking overrides. Re-read its markup and styles, restate the latest behavior, remove superseded assumptions, and rebuild the motion model cleanly.
9. Run `npm test`, `npm run build`, and an interaction smoke for visible motion or hit-target changes. Check desktop and narrow layouts; build and lint never substitute for visual verification.
## Non-negotiable decisions ## Non-negotiable decisions
@@ -25,8 +27,10 @@ Keep ProxyWarden a compact Windows utility while matching the visual language of
- Use the blue-green accent for ready/active routing and orange only for direct/local-route distinction. Keep warnings and errors semantic. - Use the blue-green accent for ready/active routing and orange only for direct/local-route distinction. Keep warnings and errors semantic.
- Prefer open composition, quiet surface shifts, and localized light over dashboard cards, thick borders, and decorative chrome. - Prefer open composition, quiet surface shifts, and localized light over dashboard cards, thick borders, and decorative chrome.
- Animate opacity, blur, glow, color, filter, and transform; never animate layout properties or use `transition: all`. - Animate opacity, blur, glow, color, filter, and transform; never animate layout properties or use `transition: all`.
- Keep interactive triggers at one screen position throughout disclosure motion. Use layout for resting placement, never a transform that hover or active feedback can overwrite.
- Make transient prompts independent overlays; they must not add shell height or move the main workspace.
- Keep labels, paths, status copy, spinners, and feedback in reserved geometry so neighboring content does not move. - Keep labels, paths, status copy, spinners, and feedback in reserved geometry so neighboring content does not move.
- Keep tooltips independent from transformed, rotating, glowing, or filtered controls. - Keep tooltips independent from transformed, rotating, glowing, or filtered controls.
- Keep secrets and credential-bearing URLs redacted in every visual state. - Keep secrets and credential-bearing URLs redacted in every visual state.
- Keep narrow layouts single-column and keyboard focus visible. - Keep narrow layouts single-column and keyboard focus visible.
- Do not call a motion task complete without checking open, close, repeated toggle, hover during transition, keyboard focus, and reduced motion. If the state cannot be reproduced, report the missing visual evidence explicitly.
@@ -28,12 +28,22 @@ Use `cubic-bezier(0.16, 1, 0.3, 1)` for arrivals and interaction feedback.
- Keep mode selectors outside the keyed content they replace. Let the new content enter with a short directional fade and blur while focus remains on the selected mode. - Keep mode selectors outside the keyed content they replace. Let the new content enter with a short directional fade and blur while focus remains on the selected mode.
- For user-triggered sorting, fade and lightly blur the reordered list as one surface; row stagger stays bounded and saved data order does not change. - For user-triggered sorting, fade and lightly blur the reordered list as one surface; row stagger stays bounded and saved data order does not change.
## Anchored disclosures
- Keep the trigger fixed while its surface opens and closes. Position its resting hit area with grid, flex, or logical inset properties; never rely on a placement `transform` that hover or active feedback can replace.
- Give the surface, background, copy, and actions one origin and one timeline. They should emerge from the trigger together; do not make the background pop before the trigger or appear after the content.
- Keep animated disclosure content mounted through entry and exit. Gate pointer and keyboard access separately; conditional rendering directly into the final state is not an entrance animation.
- Design opening and closing together. Preserve visible reverse motion long enough before fading opacity, and keep both directions interruptible under repeated clicks.
- Let explicit product feedback override the default easing. When a component calls for a slow start followed by acceleration, define a local curve instead of forcing the global ease-out.
- Compose hover and active feedback without changing the resting position. If transform composition is unavoidable, use separate wrappers, individual transform properties, or shared custom properties and verify every state.
- Keep decorative sweeps subordinate to state motion, low-opacity, bounded to the surface, and finished cleanly. The disclosure must remain legible without the effect.
## Lists and disclosures ## Lists and disclosures
- Reveal dynamic rows with opacity, light blur, and a small transform. - Reveal dynamic rows with opacity, light blur, and a small transform.
- On hover, let a row lift one or two pixels and reveal a restrained local surface/light; keep resting rows visually flat. - On hover, let a row lift one or two pixels and reveal a restrained local surface/light; keep resting rows visually flat.
- Animate status dots through color, light, and a small scale change instead of animating a surrounding badge or border. - Animate status dots through color, light, and a small scale change instead of animating a surrounding badge or border.
- Keep departing rows mounted until their exit animation completes; remove immediately under reduced motion. - Keep departing rows and disclosures mounted until their exit animation completes; remove immediately under reduced motion.
- Bound list stagger to 60-100ms and never make interaction latency grow with list length. - Bound list stagger to 60-100ms and never make interaction latency grow with list length.
- Tooltips appear quickly above the trigger as independent translucent surfaces and never inherit trigger transforms or filters. - Tooltips appear quickly above the trigger as independent translucent surfaces and never inherit trigger transforms or filters.
@@ -40,6 +40,13 @@ Design for a Windows user opening a small control surface to check routing, reco
- Prefer a short luminous underline or localized glow for selection and keyboard focus over a rectangular focus frame. - Prefer a short luminous underline or localized glow for selection and keyboard focus over a rectangular focus frame.
- Use shared `src/ui` primitives and preserve their default, hover, active, focus, disabled, loading, empty, and error states. - Use shared `src/ui` primitives and preserve their default, hover, active, focus, disabled, loading, empty, and error states.
## Emphasis and border budget
- Give each compact surface one dominant accent at most. A transient warning action must not outshine the primary system state or its trigger.
- Do not stack borders on the container, trigger, and action. Start with tonal background, spacing, and text hierarchy; keep persistent outlines for keyboard focus, destructive confirmation, or an otherwise ambiguous hit target.
- Treat warm warning color as a restrained semantic tint, not decorative fill or a large glow. Adapt a shared `primary` button locally when its default emphasis conflicts with the surrounding prompt.
- Validate the complete component, not isolated controls: resting, hover, focus, active, disabled, loading, open, and closed states must share one radius and emphasis language.
## Route checks ## Route checks
- Keep the route description, endpoint, and check action in a stable three-part row. Reserve the action width so mode changes and endpoint length never move the button. - Keep the route description, endpoint, and check action in a stable three-part row. Reserve the action width so mode changes and endpoint length never move the button.
@@ -48,6 +55,26 @@ Design for a Windows user opening a small control surface to check routing, reco
- Keep the summary short. Put verbose URLs, request methods, status codes, and errors in a calm structured detail cloud opened by hovering or focusing the result surface. - Keep the summary short. Put verbose URLs, request methods, status codes, and errors in a calm structured detail cloud opened by hovering or focusing the result surface.
- Animate result arrival and status light, while preserving the same geometry and honoring reduced motion. - Animate result arrival and status light, while preserving the same geometry and honoring reduced motion.
## Route chain semantics
- Show only stages with distinct user-facing responsibilities. Never render both `Выход` and `SOCKS5 endpoint` when they describe the same destination.
- Use `Приложения → ProxiFyre → SOCKS5` for the external-proxy route. End a direct route with `Интернет: напрямую` instead of an implementation-stage label.
- Explain each stage in plain Russian for a non-technical user. Omit filesystem paths, ports, service names, and generated-config details unless the user explicitly asks for diagnostics.
- Reserve the final chain height before revealing nodes. Progressive arrival may change opacity, blur, or transform, but must not reflow neighboring content.
- Treat progressive arrival as a presentation sequence over reserved slots. Do not serialize independent backend probes just to match the animation; if the API returns one aggregate snapshot, show calm `checking` placeholders and replace them in place.
- Reveal the initial chain in a short, legible sequence and do not replay it for background polling or quiet status refreshes.
## Admin elevation prompt
- Render the prompt as a fixed bottom-right overlay that never changes shell height or shifts the workspace. Offset it above persistent bottom docks instead of covering them.
- Keep the collapsed trigger as a stationary 44px warm shield. Show a concise hint once per application session after admin status is known, then dismiss it automatically.
- On click, expand the surface leftward from the shield while the shield stays in the same screen position. Keep the full row height tied to the trigger.
- Reveal background, copy, and action from the same origin and timeline. Use a roughly 520-560ms slow-start opening and a visible 380-420ms reverse close; never delay the background until the end.
- Keep any light pass subtle, local, and optional. It must not replace the actual surface/content motion.
- Use the concise title `Нужны права администратора`, the reason `Для управления ProxiFyre и правилами Windows.`, and the action `Перезапустить`. Do not show paths or elevation internals.
- Keep the surface and action borderless by default. Use a muted warm tint; the action must remain quieter than the shield and main system state.
- Verify the Russian copy, `Открываю UAC`, hover, focus, repeated toggle, narrow width, and Windows text scaling without clipping or layout movement.
## Route modes and managed lists ## Route modes and managed lists
- Present external and local proxy routes as two peer choices above the content they replace. Keep the chooser mounted while the mode body crossfades in from the selected direction. - Present external and local proxy routes as two peer choices above the content they replace. Keep the chooser mounted while the mode body crossfades in from the selected direction.
+4 -1
View File
@@ -122,8 +122,11 @@ pub fn read_components(storage: &JsonStorage) -> Result<Vec<ComponentStatusDto>,
pub fn read_startup_snapshot( pub fn read_startup_snapshot(
storage: &JsonStorage, storage: &JsonStorage,
) -> Result<StartupSnapshotResponse, CommandError> { ) -> Result<StartupSnapshotResponse, CommandError> {
let detected_proxyfier = detect_proxyfier_install(); // Both detectors query Windows independently. Run them together so the
// startup snapshot is bounded by the slower check instead of their sum.
let proxyfier_detection = std::thread::spawn(detect_proxyfier_install);
let detected_singbox = detect_singbox_install(); let detected_singbox = detect_singbox_install();
let detected_proxyfier = proxyfier_detection.join().ok().flatten();
let saved_state = read_saved_state_with_proxifyre_config( let saved_state = read_saved_state_with_proxifyre_config(
storage, storage,
detected_proxyfier detected_proxyfier
+73 -11
View File
@@ -220,6 +220,11 @@ export function App() {
const [serviceVisualState, setServiceVisualState] = const [serviceVisualState, setServiceVisualState] =
useState<ServiceVisualState>(null); useState<ServiceVisualState>(null);
const serviceVisualTimerRef = useRef<number | null>(null); const serviceVisualTimerRef = useRef<number | null>(null);
const [isAdminPromptOpen, setIsAdminPromptOpen] = useState(false);
const [isAdminPromptHintVisible, setIsAdminPromptHintVisible] =
useState(false);
const adminPromptSeenRef = useRef(false);
const adminPromptTimerRef = useRef<number | null>(null);
const proxyfier = useMemo( const proxyfier = useMemo(
() => components.find((component) => component.id === "proxyfier"), () => components.find((component) => component.id === "proxyfier"),
@@ -278,11 +283,32 @@ export function App() {
void refresh(); void refresh();
}, []); }, []);
useEffect(() => {
if (!hasAdminPrompt || adminPromptSeenRef.current) return undefined;
adminPromptSeenRef.current = true;
setIsAdminPromptHintVisible(true);
adminPromptTimerRef.current = window.setTimeout(() => {
setIsAdminPromptHintVisible(false);
adminPromptTimerRef.current = null;
}, 4600);
return () => {
if (adminPromptTimerRef.current !== null) {
window.clearTimeout(adminPromptTimerRef.current);
adminPromptTimerRef.current = null;
}
};
}, [hasAdminPrompt]);
useEffect(() => { useEffect(() => {
return () => { return () => {
if (serviceVisualTimerRef.current !== null) { if (serviceVisualTimerRef.current !== null) {
window.clearTimeout(serviceVisualTimerRef.current); window.clearTimeout(serviceVisualTimerRef.current);
} }
if (adminPromptTimerRef.current !== null) {
window.clearTimeout(adminPromptTimerRef.current);
}
}; };
}, []); }, []);
@@ -1017,17 +1043,50 @@ export function App() {
}, 700); }, 700);
} }
function toggleAdminPrompt() {
if (adminPromptTimerRef.current !== null) {
window.clearTimeout(adminPromptTimerRef.current);
adminPromptTimerRef.current = null;
}
setIsAdminPromptHintVisible(false);
setIsAdminPromptOpen((open) => !open);
}
function renderAdminPrompt() { function renderAdminPrompt() {
if (!hasAdminPrompt) return null; if (!hasAdminPrompt) return null;
return ( return (
<aside className="admin-prompt" aria-label="Права администратора"> <aside
<span className="admin-prompt-icon" aria-hidden="true"> className={`admin-prompt ${isAdminPromptOpen ? "is-expanded" : "is-collapsed"} ${isAdminPromptHintVisible ? "has-hint" : ""}`}
<ShieldAlert size={15} strokeWidth={1.9} /> aria-label="Права администратора"
>
<button
type="button"
className="admin-prompt-toggle"
onClick={toggleAdminPrompt}
aria-expanded={isAdminPromptOpen}
aria-controls="admin-prompt-details"
aria-label={
isAdminPromptOpen
? "Свернуть информацию о правах администратора"
: "Показать, зачем нужны права администратора"
}
>
<ShieldAlert size={18} strokeWidth={1.9} aria-hidden="true" />
</button>
{isAdminPromptHintVisible && !isAdminPromptOpen ? (
<span className="admin-prompt-hint" role="status">
Для удобной работы нужны права администратора.
</span> </span>
) : null}
<div
className="admin-prompt-details"
id="admin-prompt-details"
aria-hidden={!isAdminPromptOpen}
>
<div className="admin-prompt-copy"> <div className="admin-prompt-copy">
<strong>Права администратора</strong> <strong>Нужны права администратора</strong>
<span>{adminStatus?.message}</span> <span>Для управления ProxiFyre и правилами Windows.</span>
</div> </div>
<Button <Button
type="button" type="button"
@@ -1037,9 +1096,11 @@ export function App() {
onClick={() => void restartApplicationAsAdmin()} onClick={() => void restartApplicationAsAdmin()}
loading={isRestartingAsAdmin} loading={isRestartingAsAdmin}
loadingLabel="Открываю UAC" loadingLabel="Открываю UAC"
tabIndex={isAdminPromptOpen ? undefined : -1}
> >
Перезапустить с правами Перезапустить
</Button> </Button>
</div>
</aside> </aside>
); );
} }
@@ -1690,6 +1751,11 @@ export function App() {
align={isVertical || index >= segments.length - 2 ? "end" : "start"} align={isVertical || index >= segments.length - 2 ? "end" : "start"}
aria-label={`${segment.label}: ${segment.value}. ${segment.details.join(". ")}`} aria-label={`${segment.label}: ${segment.value}. ${segment.details.join(". ")}`}
key={segment.id} key={segment.id}
style={
isVertical
? ({ animationDelay: `${index * 280}ms` } as CSSProperties)
: undefined
}
> >
<span className="route-chain-dot" aria-hidden="true" /> <span className="route-chain-dot" aria-hidden="true" />
<span>{segment.label}</span> <span>{segment.label}</span>
@@ -1713,11 +1779,7 @@ export function App() {
return ( return (
<main <main
className={[ className={["simple-shell", hasUnappliedChanges ? "has-change-dock" : ""]
"simple-shell",
hasUnappliedChanges ? "has-change-dock" : "",
hasAdminPrompt ? "has-admin-prompt" : "",
]
.filter(Boolean) .filter(Boolean)
.join(" ")} .join(" ")}
style={shellStyle} style={shellStyle}
+51 -3
View File
@@ -9,6 +9,7 @@ import {
noticeFromConfigurationApply, noticeFromConfigurationApply,
pingSummary, pingSummary,
routeChainSegments, routeChainSegments,
summaryRouteChainSegments,
} from "./viewModel"; } from "./viewModel";
const runningProxiFyre: ComponentStatus = { const runningProxiFyre: ComponentStatus = {
@@ -39,15 +40,62 @@ describe("App view helpers", () => {
"apps", "apps",
"proxifyre", "proxifyre",
"endpoint", "endpoint",
"exit",
]); ]);
expect(segments[2]).toMatchObject({ expect(segments[2]).toMatchObject({
value: "proxy.example.test:1080", value: "proxy.example.test:1080",
tone: "ok", tone: "ok",
}); });
expect(segments[3].details).toContain( expect(segments[2].details).toContain(
"Local sing-box не нужен для этого маршрута.", "Трафик пойдет через внешний SOCKS5.",
); );
expect(segments[1].details).toEqual([
"Служба запущена и готова к маршрутизации.",
]);
});
it("keeps three summary route slots while components are being detected", () => {
const input = {
routeMode: "external" as const,
proxyInput: "",
proxyfier: undefined,
singbox: undefined,
singBoxStatus: null,
selectedServer: null,
appCount: 0,
isDetectingComponents: true,
};
expect(
summaryRouteChainSegments(input, "idle").map(({ id }) => id),
).toEqual(["apps", "proxifyre", "endpoint"]);
});
it("keeps three summary route slots when traffic bypasses ProxiFyre", () => {
const stoppedProxiFyre: ComponentStatus = {
...runningProxiFyre,
state: "stopped",
running: false,
};
const segments = summaryRouteChainSegments(
{
routeMode: "external",
proxyInput: "proxy.example.test:1080",
proxyfier: stoppedProxiFyre,
singbox: undefined,
singBoxStatus: null,
selectedServer: null,
appCount: 2,
isDetectingComponents: false,
},
"direct",
);
expect(segments.map(({ id }) => id)).toEqual([
"apps",
"proxifyre",
"endpoint",
]);
expect(segments[2]).toMatchObject({ label: "Интернет", value: "напрямую" });
}); });
it("summarizes the fastest successful ping", () => { it("summarizes the fastest successful ping", () => {
+27 -77
View File
@@ -396,47 +396,20 @@ export function summaryRouteChainSegments(
input: RouteChainInput, input: RouteChainInput,
flow: SummaryRouteFlow, flow: SummaryRouteFlow,
): RouteChainSegment[] { ): RouteChainSegment[] {
if (flow === "proxy") return routeChainSegments(input); const configuredSegments = routeChainSegments(input);
if (flow !== "direct") return configuredSegments;
const appSegment: RouteChainSegment = {
id: "apps",
label: "Приложения",
value: appCountCompact(input.appCount),
tone: input.appCount > 0 ? "ok" : "warning",
details: [
input.appCount > 0
? appCountText(input.appCount)
: "Добавь хотя бы одно приложение на вкладке ProxiFyre.",
],
};
if (flow === "idle") {
return [
appSegment,
{
id: "idle",
label: "Трафик",
value: input.isDetectingComponents ? "проверяю" : "ожидает приложения",
tone: input.isDetectingComponents ? "checking" : "warning",
details: input.isDetectingComponents
? ["Проверяю ProxiFyre и текущий маршрут."]
: [
"Пока нет выбранных приложений, ProxyWarden ничего не маршрутизирует.",
],
},
];
}
return [ return [
appSegment, configuredSegments[0],
configuredSegments[1],
{ {
id: "direct", id: "endpoint",
label: "Интернет", label: "Интернет",
value: "напрямую", value: "напрямую",
tone: "warning", tone: "warning",
details: [ details: [
proxyfierBypassReason(input.proxyfier), proxyfierBypassReason(input.proxyfier),
"Пакеты идут обычным системным маршрутом без SOCKS5.", "Пакеты идут обычным системным маршрутом.",
], ],
}, },
]; ];
@@ -449,9 +422,6 @@ export function routeChainSegments(
input.routeMode === "external" && input.proxyInput.trim() input.routeMode === "external" && input.proxyInput.trim()
? safeProxyError(input.proxyInput) ? safeProxyError(input.proxyInput)
: null; : null;
const localServer = input.singBoxStatus?.config.selectedServerTag
? displayServerTag(input.singBoxStatus.config.selectedServerTag)
: "сервер не выбран";
const endpoint = const endpoint =
input.routeMode === "local-singbox" input.routeMode === "local-singbox"
? localSingBoxAddress(input.singBoxStatus) ? localSingBoxAddress(input.singBoxStatus)
@@ -467,15 +437,6 @@ export function routeChainSegments(
: input.singbox?.installed : input.singbox?.installed
? "warning" ? "warning"
: "warning"; : "warning";
const exitTone: StatusTone =
input.routeMode === "external"
? proxyValidation || !input.proxyInput.trim()
? "warning"
: "ok"
: input.singbox?.running && input.selectedServer
? "ok"
: "warning";
return [ return [
{ {
id: "apps", id: "apps",
@@ -484,8 +445,8 @@ export function routeChainSegments(
tone: input.appCount > 0 ? "ok" : "warning", tone: input.appCount > 0 ? "ok" : "warning",
details: [ details: [
input.appCount > 0 input.appCount > 0
? appCountText(input.appCount) ? "Эти приложения будут идти через выбранный маршрут."
: "Добавь хотя бы одно приложение на вкладке ProxiFyre.", : "Добавь приложение на вкладке ProxiFyre.",
], ],
}, },
{ {
@@ -497,8 +458,7 @@ export function routeChainSegments(
), ),
tone: componentChainTone(input.proxyfier, input.isDetectingComponents), tone: componentChainTone(input.proxyfier, input.isDetectingComponents),
details: [ details: [
proxyfierTitle(input.proxyfier, input.isDetectingComponents), routeProxyfierDetails(input.proxyfier, input.isDetectingComponents),
proxyfierDetails(input.proxyfier, input.isDetectingComponents),
], ],
}, },
{ {
@@ -511,35 +471,15 @@ export function routeChainSegments(
tone: endpointTone, tone: endpointTone,
details: details:
input.routeMode === "local-singbox" input.routeMode === "local-singbox"
? singBoxDetailLines( ? input.singbox?.running && input.selectedServer
input.singbox, ? ["Трафик пойдет через выбранный VPN-сервер."]
input.singBoxStatus, : input.singbox?.installed
null, ? ["Local sing-box установлен, но пока не запущен."]
input.singBoxStatus?.config.selectedServerTag, : ["Установи Local sing-box для локального маршрута."]
)
: [ : [
`Endpoint: ${endpoint}`, proxyValidation
proxyValidation ?? "Формат внешнего SOCKS5 корректен.", ? `Проверь адрес SOCKS5: ${proxyValidation}`
"Local sing-box не участвует во внешнем маршруте.", : "Трафик пойдет через внешний SOCKS5.",
],
},
{
id: "exit",
label: input.routeMode === "local-singbox" ? "VPN сервер" : "Выход",
value:
input.routeMode === "local-singbox" ? localServer : "внешний SOCKS5",
tone: exitTone,
details:
input.routeMode === "local-singbox"
? [
input.selectedServer
? serverLabel(input.selectedServer)
: "Сервер Local sing-box не выбран.",
"Применение создаст sing-box config и обновит ProxiFyre.",
]
: [
"Выбранные приложения идут через внешний SOCKS5.",
"Local sing-box не нужен для этого маршрута.",
], ],
}, },
]; ];
@@ -850,6 +790,16 @@ export function proxyfierCompactStatus(
return `служба ${serviceStatusLabel(component.serviceStatus)}`; return `служба ${serviceStatusLabel(component.serviceStatus)}`;
} }
export function routeProxyfierDetails(
component: ComponentStatus | undefined,
checking: boolean,
) {
if (checking) return "Проверяю клиент и службу.";
if (!component?.installed) return "Клиент ProxiFyre не найден.";
if (component.running) return "Служба запущена и готова к маршрутизации.";
return "Служба ProxiFyre остановлена.";
}
export function proxyfierBypassReason(component: ComponentStatus | undefined) { export function proxyfierBypassReason(component: ComponentStatus | undefined) {
if (!component?.installed) { if (!component?.installed) {
return "ProxiFyre не найден, поэтому выбранные приложения не перехватываются."; return "ProxiFyre не найден, поэтому выбранные приложения не перехватываются.";
+345 -74
View File
@@ -2,7 +2,6 @@
:root { :root {
--app-footer-height: 54px; --app-footer-height: 54px;
--app-change-dock-height: 0px; --app-change-dock-height: 0px;
--app-admin-prompt-height: 0px;
--change-row-count: 1; --change-row-count: 1;
--app-header-row-height: 0px; --app-header-row-height: 0px;
--app-tab-height: 46px; --app-tab-height: 46px;
@@ -815,10 +814,7 @@ button:disabled {
), ),
var(--surface-canvas); var(--surface-canvas);
padding: var(--app-header-height) 0 padding: var(--app-header-height) 0
calc( calc(var(--app-footer-height) + var(--app-change-dock-height));
var(--app-footer-height) + var(--app-change-dock-height) +
var(--app-admin-prompt-height)
);
} }
.simple-shell.has-change-dock { .simple-shell.has-change-dock {
@@ -829,10 +825,6 @@ button:disabled {
); );
} }
.simple-shell.has-admin-prompt {
--app-admin-prompt-height: 56px;
}
.simple-panel { .simple-panel {
display: grid; display: grid;
grid-template-rows: minmax(0, 1fr); grid-template-rows: minmax(0, 1fr);
@@ -840,7 +832,7 @@ button:disabled {
min-height: 0; min-height: 0;
height: calc( height: calc(
100vh - var(--app-header-height) - var(--app-footer-height) - 100vh - var(--app-header-height) - var(--app-footer-height) -
var(--app-change-dock-height) - var(--app-admin-prompt-height) var(--app-change-dock-height)
); );
width: 100%; width: 100%;
border: 0; border: 0;
@@ -974,34 +966,162 @@ button:disabled {
} }
.admin-prompt { .admin-prompt {
--admin-prompt-width: min(520px, calc(100vw - 28px));
--admin-prompt-expand-ease: cubic-bezier(0.72, 0, 1, 1);
--admin-prompt-collapse-ease: cubic-bezier(0, 0, 0.28, 1);
position: fixed; position: fixed;
right: 0; right: 14px;
bottom: 0; bottom: calc(var(--app-footer-height) + var(--app-change-dock-height) + 12px);
left: 0; z-index: 50;
z-index: 40; width: var(--admin-prompt-width);
display: grid; height: 44px;
grid-template-columns: auto minmax(0, 1fr) auto; min-height: 44px;
gap: 8px;
align-items: center;
height: var(--app-admin-prompt-height);
min-width: 0;
border-top: 1px solid rgba(245, 158, 11, 0.38);
background: #17150f;
box-shadow: inset 0 1px rgba(255, 255, 255, 0.03);
margin: 0; margin: 0;
padding: 5px 14px; padding: 0;
pointer-events: none;
} }
.admin-prompt-icon { .admin-prompt::before {
position: absolute;
z-index: 0;
inset: 0;
border: 0;
border-radius: 22px;
background: #17150f;
box-shadow: 0 14px 36px oklch(0.08 0.012 145 / 0.32);
content: "";
opacity: 0;
pointer-events: none;
transform-origin: right center;
filter: blur(5px);
transform: translateX(8px) scaleX(0.06);
will-change: filter, opacity, transform;
transition:
opacity 260ms var(--admin-prompt-collapse-ease) 140ms,
filter 320ms var(--admin-prompt-collapse-ease),
transform 420ms var(--admin-prompt-collapse-ease);
}
.admin-prompt.is-expanded {
pointer-events: auto;
}
.admin-prompt.is-expanded::before {
opacity: 1;
filter: blur(0);
transform: translateX(0) scaleX(1);
transition:
opacity 160ms linear,
filter 420ms var(--admin-prompt-expand-ease),
transform 560ms var(--admin-prompt-expand-ease);
}
.admin-prompt::after {
position: absolute;
z-index: 0;
top: 5px;
right: 44px;
bottom: 5px;
width: 140px;
border-radius: 17px;
background: linear-gradient(
90deg,
transparent,
oklch(0.78 0.12 82 / 0.11),
transparent
);
content: "";
opacity: 0;
pointer-events: none;
filter: blur(8px);
transform: translateX(70%) scaleX(0.25);
}
.admin-prompt.is-expanded::after {
animation: admin-prompt-light-pass 560ms var(--admin-prompt-expand-ease) both;
}
.admin-prompt-toggle {
position: absolute;
z-index: 2;
top: 0;
left: 0;
display: grid; display: grid;
place-items: center; place-items: center;
width: 26px; width: 44px;
min-width: 26px; height: 44px;
height: 26px; border: 1px solid oklch(0.71 0.12 72 / 0.34);
border: 1px solid rgba(245, 158, 11, 0.28); border-radius: 50%;
border-radius: 4px; background: color-mix(in oklch, var(--surface-raised) 90%, transparent);
background: rgba(15, 23, 42, 0.42); box-shadow: 0 8px 24px oklch(0.08 0.012 145 / 0.28);
color: #fcd34d; color: oklch(0.86 0.14 86);
cursor: pointer;
pointer-events: auto;
transition:
background-color var(--motion-fast) var(--ease-out),
border-color var(--motion-fast) var(--ease-out),
color var(--motion-fast) var(--ease-out),
filter var(--motion-fast) var(--ease-out),
transform 360ms var(--ease-out);
transform: translateX(calc(var(--admin-prompt-width) - 44px));
}
.admin-prompt-toggle:hover {
border-color: oklch(0.78 0.14 80 / 0.7);
background: color-mix(in oklch, var(--surface-raised) 100%, transparent);
filter: drop-shadow(0 0 12px oklch(0.71 0.12 72 / 0.35));
transform: translateX(calc(var(--admin-prompt-width) - 44px)) translateY(-1px);
}
.admin-prompt-toggle:focus-visible {
outline: 2px solid var(--focus-ring);
outline-offset: 3px;
}
.admin-prompt-details {
position: absolute;
z-index: 1;
top: 0;
bottom: 0;
left: 8px;
right: 52px;
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
gap: 10px;
align-items: center;
min-width: 0;
padding: 0 0 0 8px;
clip-path: inset(0 0 0 100%);
opacity: 0;
pointer-events: none;
filter: blur(4px);
transform: translateX(18px);
will-change: clip-path, filter, opacity, transform;
transition:
clip-path 420ms var(--admin-prompt-collapse-ease),
opacity 220ms var(--admin-prompt-collapse-ease) 100ms,
filter 320ms var(--admin-prompt-collapse-ease),
transform 420ms var(--admin-prompt-collapse-ease);
}
.admin-prompt.is-expanded .admin-prompt-toggle {
border-color: oklch(0.78 0.14 80 / 0.6);
background: color-mix(in oklch, var(--surface-raised) 90%, transparent);
box-shadow: 0 8px 24px oklch(0.08 0.012 145 / 0.28);
transform: translateX(calc(var(--admin-prompt-width) - 44px));
}
.admin-prompt.is-expanded .admin-prompt-details {
clip-path: inset(0 0 0 0);
opacity: 1;
pointer-events: auto;
filter: blur(0);
transform: translateX(0);
transition:
clip-path 560ms var(--admin-prompt-expand-ease),
opacity 180ms linear,
filter 420ms var(--admin-prompt-expand-ease),
transform 560ms var(--admin-prompt-expand-ease);
} }
.admin-prompt-copy { .admin-prompt-copy {
@@ -1012,26 +1132,97 @@ button:disabled {
.admin-prompt-copy strong { .admin-prompt-copy strong {
color: #f8fafc; color: #f8fafc;
font-size: 12px; font-size: 11px;
font-weight: 800; font-weight: 800;
line-height: 1.25; line-height: 1.25;
white-space: nowrap; white-space: normal;
} }
.admin-prompt-copy span { .admin-prompt-copy span {
display: -webkit-box; display: block;
max-width: 330px;
color: #c7b489; color: #c7b489;
font-size: 11px; font-size: 10px;
line-height: 1.25; line-height: 1.1;
overflow: hidden; overflow-wrap: anywhere;
white-space: normal;
-webkit-box-orient: vertical;
-webkit-line-clamp: 2;
} }
.admin-prompt-action { .admin-prompt-action {
min-width: 190px; min-width: 180px;
white-space: nowrap; max-width: 190px;
white-space: normal;
}
.admin-prompt.is-expanded .admin-prompt-toggle:hover {
background: oklch(0.71 0.12 72 / 0.08);
transform: translateX(calc(var(--admin-prompt-width) - 44px)) translateY(-1px);
}
.admin-prompt.is-collapsed .admin-prompt-toggle,
.admin-prompt.has-hint .admin-prompt-toggle {
animation: admin-prompt-pulse 3.6s ease-in-out infinite;
}
.admin-prompt-hint {
position: absolute;
z-index: 1;
top: 0;
right: 52px;
display: grid;
align-items: center;
width: min(310px, calc(100vw - 80px));
height: 44px;
border: 0;
border-radius: 22px;
background: color-mix(in oklch, var(--surface-raised) 92%, transparent);
box-shadow: 0 10px 26px oklch(0.08 0.012 145 / 0.24);
color: #c7b489;
font-size: 10px;
line-height: 1.25;
padding: 0 14px;
pointer-events: none;
animation: admin-prompt-hint-arrive 520ms var(--ease-out) both;
}
@keyframes admin-prompt-hint-arrive {
from {
opacity: 0;
filter: blur(4px);
transform: translateX(10px);
}
to {
opacity: 1;
filter: blur(0);
transform: translateX(0);
}
}
@keyframes admin-prompt-light-pass {
0% {
opacity: 0;
transform: translateX(70%) scaleX(0.25);
}
38% {
opacity: 0.22;
}
100% {
opacity: 0;
transform: translateX(-240%) scaleX(0.9);
}
}
@keyframes admin-prompt-pulse {
0%,
100% {
filter: drop-shadow(0 0 0 oklch(0.71 0.12 72 / 0));
}
50% {
filter: drop-shadow(0 0 12px oklch(0.71 0.12 72 / 0.34));
}
} }
.tab-panel-frame { .tab-panel-frame {
@@ -2094,6 +2285,7 @@ button.summary-card:hover {
gap: 10px; gap: 10px;
width: 100%; width: 100%;
max-width: 288px; max-width: 288px;
min-height: 194px;
margin-top: 0; margin-top: 0;
} }
@@ -2196,6 +2388,7 @@ button.summary-card:hover {
min-height: 58px; min-height: 58px;
background: color-mix(in oklch, var(--surface-panel) 94%, transparent); background: color-mix(in oklch, var(--surface-panel) 94%, transparent);
padding: 9px 10px 9px 9px; padding: 9px 10px 9px 9px;
animation: none;
} }
.route-chain-segment > :not(.ui-busy-ring) { .route-chain-segment > :not(.ui-busy-ring) {
@@ -2304,7 +2497,7 @@ button.summary-card:hover {
.changes-dock { .changes-dock {
position: fixed; position: fixed;
right: 0; right: 0;
bottom: calc(var(--app-footer-height) + var(--app-admin-prompt-height)); bottom: var(--app-footer-height);
left: 0; left: 0;
z-index: 38; z-index: 38;
display: grid; display: grid;
@@ -2740,7 +2933,7 @@ button.summary-card:hover {
.log-dock { .log-dock {
position: fixed; position: fixed;
right: 0; right: 0;
bottom: var(--app-admin-prompt-height); bottom: 0;
left: 0; left: 0;
z-index: 40; z-index: 40;
display: grid; display: grid;
@@ -3220,9 +3413,7 @@ button.summary-card:hover {
.log-toggle, .log-toggle,
.log-count, .log-count,
.log-history, .log-history,
.log-history-row, .log-history-row {
.admin-prompt,
.admin-prompt-icon {
border: 0; border: 0;
box-shadow: none; box-shadow: none;
} }
@@ -4171,6 +4362,7 @@ button.summary-card:hover {
.route-chain--vertical .route-chain-segment { .route-chain--vertical .route-chain-segment {
background: color-mix(in oklch, var(--surface-panel) 34%, transparent); background: color-mix(in oklch, var(--surface-panel) 34%, transparent);
animation: float-item-in 900ms var(--ease-out) both;
} }
.route-chain-segment:hover { .route-chain-segment:hover {
@@ -4340,8 +4532,7 @@ button.summary-card:hover {
} }
.changes-dock, .changes-dock,
.log-dock, .log-dock {
.admin-prompt {
background: color-mix(in oklch, var(--surface-raised) 78%, transparent); background: color-mix(in oklch, var(--surface-raised) 78%, transparent);
box-shadow: 0 -14px 42px oklch(0.08 0.012 145 / 0.2); box-shadow: 0 -14px 42px oklch(0.08 0.012 145 / 0.2);
backdrop-filter: blur(18px); backdrop-filter: blur(18px);
@@ -4404,9 +4595,49 @@ button.summary-card:hover {
color: oklch(0.8 0.12 82); color: oklch(0.8 0.12 82);
} }
.admin-prompt-icon { .admin-prompt-action.ui-button {
background: transparent; justify-self: end;
filter: drop-shadow(0 0 12px oklch(0.71 0.12 72 / 0.28)); min-width: 118px;
max-width: none;
min-height: 30px;
border: 0;
border-radius: 999px;
background: oklch(0.71 0.12 72 / 0.1);
box-shadow: none;
color: oklch(0.82 0.1 84);
font-size: 10px;
font-weight: 750;
letter-spacing: 0.01em;
padding: 6px 12px;
text-shadow: none;
transition:
background-color 180ms var(--ease-out),
border-color 180ms var(--ease-out),
box-shadow 180ms var(--ease-out),
color 180ms var(--ease-out),
transform 180ms var(--ease-out);
}
.admin-prompt-action.ui-button:hover:not(:disabled) {
border: 0;
background: oklch(0.71 0.12 72 / 0.15);
box-shadow: none;
color: oklch(0.87 0.1 84);
filter: none;
transform: none;
}
.admin-prompt-action.ui-button:active:not(:disabled) {
background: oklch(0.71 0.12 72 / 0.18);
filter: none;
transform: scale(0.98);
}
.admin-prompt-action.ui-button:focus-visible {
border: 0;
box-shadow:
0 0 0 2px var(--focus-ring),
0 0 0 4px oklch(0.68 0.11 185 / 0.1);
} }
.ui-icon-button[data-tooltip]::before, .ui-icon-button[data-tooltip]::before,
@@ -4686,15 +4917,45 @@ button.summary-card:hover {
filter: none; filter: none;
transform: none; transform: none;
} }
.admin-prompt::before,
.admin-prompt::after,
.admin-prompt-toggle,
.admin-prompt-details,
.admin-prompt-action.ui-button {
transition: none;
}
.admin-prompt.is-collapsed .admin-prompt-toggle,
.admin-prompt.has-hint .admin-prompt-toggle,
.admin-prompt::after {
animation: none;
}
.admin-prompt::after {
opacity: 0;
}
.admin-prompt.is-collapsed .admin-prompt-toggle:hover {
filter: none;
transform: translateX(calc(var(--admin-prompt-width) - 44px));
}
.admin-prompt.is-expanded .admin-prompt-toggle:hover {
filter: none;
transform: translateX(calc(var(--admin-prompt-width) - 44px));
}
.admin-prompt-action.ui-button:hover:not(:disabled) {
filter: none;
transform: none;
}
} }
@media (max-width: 680px) { @media (max-width: 680px) {
.simple-shell { .simple-shell {
padding: var(--app-header-height) 0 padding: var(--app-header-height) 0
calc( calc(var(--app-footer-height) + var(--app-change-dock-height));
var(--app-footer-height) + var(--app-change-dock-height) +
var(--app-admin-prompt-height)
);
} }
.simple-shell.has-change-dock { .simple-shell.has-change-dock {
@@ -4705,37 +4966,47 @@ button.summary-card:hover {
); );
} }
.simple-shell.has-admin-prompt {
--app-admin-prompt-height: 92px;
}
.simple-panel { .simple-panel {
height: calc( height: calc(
100vh - var(--app-header-height) - var(--app-footer-height) - 100vh - var(--app-header-height) - var(--app-footer-height) -
var(--app-change-dock-height) - var(--app-admin-prompt-height) var(--app-change-dock-height)
); );
padding: 12px 12px 16px; padding: 12px 12px 16px;
} }
.admin-prompt { .admin-prompt {
grid-template-columns: auto minmax(0, 1fr); --admin-prompt-width: calc(100vw - 20px);
align-items: start; right: 10px;
gap: 8px; bottom: calc(
padding: 8px 10px; var(--app-footer-height) + var(--app-change-dock-height) + 10px
);
}
.admin-prompt.is-expanded {
width: var(--admin-prompt-width);
}
.admin-prompt-details {
grid-template-columns: 1fr;
width: auto;
padding: 0 0 0 8px;
}
.admin-prompt-hint {
right: 52px;
width: min(280px, calc(100vw - 80px));
} }
.admin-prompt-copy span { .admin-prompt-copy span {
display: -webkit-box; max-width: none;
overflow: hidden;
white-space: normal; white-space: normal;
-webkit-box-orient: vertical;
-webkit-line-clamp: 2;
} }
.admin-prompt-action { .admin-prompt-action {
grid-column: 1 / -1; width: fit-content;
width: 100%; justify-self: end;
min-width: 0; max-width: none;
min-width: 118px;
} }
.app-row { .app-row {
@@ -5026,7 +5297,7 @@ button.summary-card:hover {
.log-dock { .log-dock {
position: fixed; position: fixed;
right: 0; right: 0;
bottom: var(--app-admin-prompt-height); bottom: 0;
left: 0; left: 0;
grid-template-columns: minmax(0, 1fr) auto; grid-template-columns: minmax(0, 1fr) auto;
margin: 0; margin: 0;