Refine client setup state and subscription controls

This commit is contained in:
2026-07-11 10:43:28 +03:00
parent 99f7f58fcb
commit e19d33adb9
7 changed files with 266 additions and 13 deletions

View File

@@ -0,0 +1,43 @@
---
name: design-vpn-client-ui
description: Design, implement, review, or refine the client-facing VPN interfaces in this repository using the established calm monospace visual language and smooth state-driven motion. Use for the current macOS client and future end-user gateway client screens, especially power controls, subscriptions, traffic usage, proxy copy controls, server selection, responsive layout, hover feedback, transitions, and animation polish. Do not use for the administrative gateway UI unless the user explicitly asks to apply the client visual language there.
---
# Design VPN Client UI
Preserve the repo's focused one-screen VPN client language: a centered primary action, quiet technical typography, restrained green state color, and motion that feels slow, fluid, and deliberate without moving layout.
## Workflow
1. Read `PRODUCT.md` and the complete client component and styles before editing.
2. Inspect the supplied screenshot or live UI. Trace the real DOM and state change that causes the visual issue.
3. Read [visual-language.md](references/visual-language.md) for layout, hierarchy, color, and typography work.
4. Read [motion-and-interaction.md](references/motion-and-interaction.md) for animation, hover, refresh, input, copy, or state-transition work.
5. Reuse existing React state, CSS variables, formatters, and API paths. Prefer a narrow CSS/markup change over a new abstraction or dependency.
6. Keep geometry stable across every state. Reserve space before animating content.
7. Implement `prefers-reduced-motion` alongside every new animation.
8. Run `npm test`, `npm run build`, and `git diff --check`. Visually inspect when a runnable client is available.
## Non-negotiable decisions
- Keep the power action on the screen's central vertical axis. Place subscription content to its right without shifting that axis.
- Keep the power hit target generous while rendering only the icon, never a large enclosing green circle.
- Use green only for active, selected, successful, or refreshed states. Keep inactive power gray, including hover.
- Never let labels, timers, feedback, icons, progress, or server rows shift neighboring content.
- Animate state, opacity, blur, glow, color, filter, and transform. Do not animate layout properties.
- Let visible cycles finish. Never stop a spinner mid-turn or remount a list before its exit animation completes.
- Prefer one clear value over unsupported detail. Hide subscription fields the provider does not supply.
- Keep client UI compact and calm. Do not introduce dashboard cards, decorative chrome, or admin-console density.
## Acceptance pass
Before handing off, verify:
- Power on/off is unmistakable without reading the label.
- Switching on/off preserves the exact positions of title, timer, and hint.
- Refresh and copy feedback cannot change element width or alignment.
- Server separators are compact and only slightly wider than their content.
- Repeated polling does not replay decorative list animations.
- Manual refresh has an obvious but non-jarring response.
- Keyboard focus remains visible even when the text caret is intentionally hidden.
- Narrow screens return to a simple single-column layout.

View File

@@ -0,0 +1,4 @@
interface:
display_name: "Design VPN Client UI"
short_description: "Design the repo's calm animated VPN client UI."
default_prompt: "Use $design-vpn-client-ui to design or refine the VPN client interface in this repository."

View File

@@ -0,0 +1,76 @@
# Motion and interaction
## Motion character
Aim for fluid, slightly viscous motion: noticeable, calm, and complete. Avoid bounce, elastic easing, abrupt unmounts, decorative page choreography, or tiny effects too weak to communicate feedback.
Use exponential ease-out curves such as `cubic-bezier(0.16, 1, 0.3, 1)` for arrivals. Typical timing:
- hover and press: 180-300 ms;
- state color and glow: 600-900 ms;
- content reveal: 600-850 ms;
- numeric tween and progress: about 900 ms;
- copy feedback: about 800 ms;
- server cascade: 620-760 ms per row with 90-110 ms stagger.
## Power state
- Transition gray to green slowly when connecting and green to gray when disconnecting.
- Animate icon color, localized light, and SVG shadow together.
- Let the light expand and brighten on enable, then contract and fade on disable.
- Keep the hit target and all surrounding geometry fixed.
- Use a short press compression, followed by a slower release.
## Changing text and numbers
- Put alternate labels in fixed-size slots.
- Reveal connection title, timer, and hint with opacity plus light blur, never vertical layout movement.
- Do not reanimate the timer every second; animate only its initial appearance.
- Tween numeric traffic values from old to new with `requestAnimationFrame` or an equivalent stable counter.
- Animate progress width concurrently and add a brief glow that fully fades.
- Never translate changing numbers if the user asked for a fluid morph; use numerical interpolation, opacity, color, blur, and light.
## Refresh
- Use a clean, symmetric SVG refresh icon aligned in the same flex row as its label.
- Spin for at least one full cycle. If the request finishes mid-cycle, continue to the next cycle boundary before stopping.
- Update data immediately when it arrives; finishing the icon cycle must not delay the data.
- Manual refresh may replay meaningful data and server transitions.
- Background polling should update quietly and must not repeatedly replay the server cascade.
- On updated traffic, tween the number, advance the bar, and emit a visible but brief green flare.
## Server cascade
- On initial display, reveal rows from top to bottom with a small negative Y offset, opacity, and blur.
- On manual refresh, animate an explicit exit phase first. Fade rows top to bottom, then remount and enter top to bottom.
- Wait for the last exit delay and duration before starting entry.
- Disable pointer interaction during exit.
- Do not replay on ping updates or unrelated renders.
## Subscription input
- Show the public domain while retaining the full URL internally.
- Disable browser autocomplete suggestions and neutralize autofill backgrounds.
- Hide the blinking caret when the paste-first interaction does not need it, while preserving keyboard input and focus outline.
- When an existing subscription is being edited and the field is idle, use the green underline as a five-second timeout indicator: start bright, fade to quiet, then restore display mode.
- Pause the timeout once the user enters content.
- Close and clear unfinished input on outside click or Escape.
## First-run initialization
- With no subscription, show only the centered subscription input. Hide power, proxy controls, usage, and servers.
- After a valid subscription loads, keep the subscription and server list centered. Require an explicit server choice instead of silently selecting the first server.
- On server choice, slide the subscription column to the right while revealing the power column on the viewport's central axis.
- Preserve the chosen server on later visits, but return to first-run initialization after subscription deletion.
- Deleting a subscription must stop the VPN, clear its cached/configured state, and return the UI to the centered input without leaving stale controls visible.
## Copy feedback
- Keep protocol buttons fixed-size and centered.
- Copy the complete protocol URL while showing a shared address separately.
- Overlay green `Copied` feedback in the same fixed box; do not append text or move the label.
- Make feedback appear immediately, hold briefly, and fade fully before restoring the original label. Keep the whole cycle near 800 ms.
## Reduced motion
Under `prefers-reduced-motion: reduce`, remove transitions and keyframe animations while preserving final state, focus, color contrast, copy wording, and all functionality.

View File

@@ -0,0 +1,49 @@
# Visual language
## Scene and character
Design for a macOS user glancing at a small VPN control surface in a quiet desktop environment. The UI should feel soft, precise, dependable, and slightly terminal-like, not like a network administration dashboard.
## Composition
- Make one primary action dominant: the VPN power icon.
- Keep the power control centered on the viewport's vertical axis, not merely centered inside a left column.
- Build the left flow vertically: power icon, stable connection copy, proxy address, copy actions.
- Place subscription identity, usage, expiry, and servers in a compact column to the right.
- Collapse to one centered column on narrow screens.
- Avoid cards and enclosing frames. Use spacing, type, thin rules, and state color for hierarchy.
- Keep server rows vertical and narrow. Underlines should be only slightly wider than the server label and ping.
## Geometry and alignment
- Reserve identical height for mutually exclusive content such as timer versus connection hint.
- Give copy buttons fixed width. Overlay temporary feedback instead of replacing text in normal flow.
- Align icons and labels in the same flex row. Do not position an icon by guessed absolute offsets.
- Preserve a generous invisible hit area around icon-only controls.
- Center proxy address and protocol actions with the power column.
- Treat one-pixel optical misalignment as a defect when controls sit beside uppercase labels.
## Typography
- Prefer the existing JetBrains Mono / SF Mono stack for the client surface.
- Use uppercase, tracked, muted micro-labels for metadata.
- Use stronger weight and size for the subscription domain and connection state.
- Use tabular numerals for timers and changing numeric data.
- Avoid display fonts, oversized headings, and mixed type families.
## Color and light
- Preserve green-tinted dark and light neutrals through the existing OKLCH variables.
- Inactive power stays neutral gray even on hover; active power becomes green.
- Use green for active VPN, selected server underline, progress, copy success, refresh success, and focus.
- Prefer localized `drop-shadow`, `text-shadow`, or a soft radial light layer over filled green containers.
- Let glow support state recognition. Do not leave every element glowing continuously.
## Data presentation
- Show subscription domain, not the credential-like full URL.
- Show used traffic and total limit as the primary statistic.
- Omit upload/download breakdown when provider support is absent or ambiguous.
- Show expiry as both date and remaining days, with correct Russian forms.
- If there is no total, say `без лимита` and omit the progress bar.
- Hide unavailable rows instead of showing empty placeholders or zeros that imply real measurements.

View File

@@ -123,11 +123,16 @@ function App() {
const data = await api.subscription.fetch(subscriptionUrl || state?.subscriptionHost || ''); const data = await api.subscription.fetch(subscriptionUrl || state?.subscriptionHost || '');
setServers(data.servers || []); setServers(data.servers || []);
if (data.servers?.length) { if (data.servers?.length) {
const nextTag = data.servers.some((server) => server.tag === selectedTag) if (state?.mode === 'client') {
? selectedTag setSelectedTag('');
: data.servers[0].tag; setPendingTag('');
setSelectedTag(nextTag); } else {
setPendingTag(nextTag); const nextTag = data.servers.some((server) => server.tag === selectedTag)
? selectedTag
: data.servers[0].tag;
setSelectedTag(nextTag);
setPendingTag(nextTag);
}
} }
await loadState(); await loadState();
}); });
@@ -433,6 +438,7 @@ function App() {
setPendingTag={setPendingTag} setPendingTag={setPendingTag}
onFetchSubscription={fetchSubscription} onFetchSubscription={fetchSubscription}
onRefreshSubscriptionInfo={refreshSubscriptionInfo} onRefreshSubscriptionInfo={refreshSubscriptionInfo}
onForgetSubscription={forgetSubscription}
onApply={applyServer} onApply={applyServer}
onRestart={restartSingbox} onRestart={restartSingbox}
onStop={() => stopSingbox(false)} onStop={() => stopSingbox(false)}

View File

@@ -20,12 +20,15 @@ export function ClientOverviewPage({
setPendingTag, setPendingTag,
onFetchSubscription, onFetchSubscription,
onRefreshSubscriptionInfo, onRefreshSubscriptionInfo,
onForgetSubscription,
onApply, onApply,
onRestart, onRestart,
onStop, onStop,
}) { }) {
const connected = Boolean(state?.singboxRunning); const connected = Boolean(state?.singboxRunning);
const hasSubscription = Boolean(state?.hasSubscription);
const selectedTag = pendingTag || state?.selectedTag || ''; const selectedTag = pendingTag || state?.selectedTag || '';
const showPower = hasSubscription && Boolean(selectedTag);
const canStart = Boolean(selectedTag || state?.configExists); const canStart = Boolean(selectedTag || state?.configExists);
const [now, setNow] = useState(Date.now()); const [now, setNow] = useState(Date.now());
const [editingSubscription, setEditingSubscription] = useState(!state?.hasSubscription); const [editingSubscription, setEditingSubscription] = useState(!state?.hasSubscription);
@@ -82,6 +85,10 @@ export function ClientOverviewPage({
if (editingSubscription) subscriptionInputRef.current?.focus(); if (editingSubscription) subscriptionInputRef.current?.focus();
}, [editingSubscription]); }, [editingSubscription]);
useEffect(() => {
if (!hasSubscription) setEditingSubscription(true);
}, [hasSubscription]);
useEffect(() => { useEffect(() => {
if (!editingSubscription || !state?.hasSubscription || subscriptionUrl) return undefined; if (!editingSubscription || !state?.hasSubscription || subscriptionUrl) return undefined;
const timer = setTimeout(() => setEditingSubscription(false), 5000); const timer = setTimeout(() => setEditingSubscription(false), 5000);
@@ -89,7 +96,7 @@ export function ClientOverviewPage({
}, [editingSubscription, state?.hasSubscription, subscriptionUrl]); }, [editingSubscription, state?.hasSubscription, subscriptionUrl]);
useEffect(() => { useEffect(() => {
if (!editingSubscription) return undefined; if (!editingSubscription || !hasSubscription) return undefined;
const closeOnOutsideClick = (event) => { const closeOnOutsideClick = (event) => {
if (subscriptionRef.current?.contains(event.target)) return; if (subscriptionRef.current?.contains(event.target)) return;
setSubscriptionUrl(''); setSubscriptionUrl('');
@@ -97,7 +104,7 @@ export function ClientOverviewPage({
}; };
document.addEventListener('pointerdown', closeOnOutsideClick); document.addEventListener('pointerdown', closeOnOutsideClick);
return () => document.removeEventListener('pointerdown', closeOnOutsideClick); return () => document.removeEventListener('pointerdown', closeOnOutsideClick);
}, [editingSubscription, setSubscriptionUrl]); }, [editingSubscription, hasSubscription, setSubscriptionUrl]);
useEffect(() => { useEffect(() => {
if (!state?.hasSubscription) return undefined; if (!state?.hasSubscription) return undefined;
@@ -176,7 +183,8 @@ export function ClientOverviewPage({
return ( return (
<div className="client-shell"> <div className="client-shell">
<main className="client-panel"> <main className={`client-panel${showPower ? '' : ' is-setup'}`}>
{showPower && (
<section className="client-power-section" aria-labelledby="connection-title"> <section className="client-power-section" aria-labelledby="connection-title">
<button <button
className="client-power" className="client-power"
@@ -232,6 +240,7 @@ export function ClientOverviewPage({
</div> </div>
</section> </section>
</section> </section>
)}
<div className="client-form"> <div className="client-form">
<div <div
@@ -257,6 +266,18 @@ export function ClientOverviewPage({
<path d="M21 12a9 9 0 0 0-15.2-6.5L3 8m0-5v5h5M3 12a9 9 0 0 0 15.2 6.5L21 16m0 5v-5h-5" /> <path d="M21 12a9 9 0 0 0-15.2-6.5L3 8m0-5v5h5M3 12a9 9 0 0 0 15.2 6.5L21 16m0 5v-5h-5" />
</svg> </svg>
</button> </button>
<button
className="client-subscription-delete"
type="button"
aria-label="Удалить подписку"
title="Удалить подписку"
disabled={busy}
onClick={onForgetSubscription}
>
<svg viewBox="0 0 24 24" aria-hidden="true">
<path d="M4 7h16M9 7V4h6v3m-9 0 1 13h10l1-13M10 11v5M14 11v5" />
</svg>
</button>
</div> </div>
<button <button
className="client-subscription-domain-button" className="client-subscription-domain-button"
@@ -305,7 +326,7 @@ export function ClientOverviewPage({
</form> </form>
</div> </div>
{hasUsage && ( {hasSubscription && hasUsage && (
<section className={`client-usage${usageUpdated ? ' is-updated' : ''}`} aria-label="Статистика подписки"> <section className={`client-usage${usageUpdated ? ' is-updated' : ''}`} aria-label="Статистика подписки">
<span>Использовано</span> <span>Использовано</span>
<strong> <strong>
@@ -335,7 +356,9 @@ export function ClientOverviewPage({
</section> </section>
)} )}
<section className="client-servers" aria-label="Серверы"> {hasSubscription && (
<section className="client-servers" aria-label="Выберите сервер">
{!showPower && <span className="client-server-prompt">Выберите сервер</span>}
<div <div
className={`client-server-grid${serversLeaving ? ' is-leaving' : ''}`} className={`client-server-grid${serversLeaving ? ' is-leaving' : ''}`}
key={`${serverKey}:${serverRevealVersion}`} key={`${serverKey}:${serverRevealVersion}`}
@@ -367,6 +390,7 @@ export function ClientOverviewPage({
})} })}
</div> </div>
</section> </section>
)}
</div> </div>
</main> </main>

View File

@@ -881,6 +881,11 @@ code, .mono {
padding: 24px 0; padding: 24px 0;
} }
.client-panel.is-setup .client-form {
left: 50%;
transform: translateX(-50%);
}
.client-power-section p { .client-power-section p {
color: var(--client-muted); color: var(--client-muted);
font-size: 11px; font-size: 11px;
@@ -891,6 +896,12 @@ code, .mono {
justify-items: center; justify-items: center;
gap: 18px; gap: 18px;
text-align: center; text-align: center;
animation: client-power-arrive 850ms cubic-bezier(0.16, 1, 0.3, 1) both;
}
@keyframes client-power-arrive {
0% { opacity: 0; filter: blur(8px); transform: scale(0.94); }
100% { opacity: 1; filter: blur(0); transform: scale(1); }
} }
.client-state-copy { .client-state-copy {
@@ -1029,6 +1040,8 @@ code, .mono {
width: min(360px, calc(50% - 154px)); width: min(360px, calc(50% - 154px));
display: grid; display: grid;
gap: 36px; gap: 36px;
transform: translateX(0);
transition: left 850ms cubic-bezier(0.16, 1, 0.3, 1), transform 850ms cubic-bezier(0.16, 1, 0.3, 1), width 850ms cubic-bezier(0.16, 1, 0.3, 1);
} }
.client-subscription { .client-subscription {
@@ -1039,7 +1052,8 @@ code, .mono {
justify-items: center; justify-items: center;
} }
.client-subscription-refresh { .client-subscription-refresh,
.client-subscription-delete {
width: 16px; width: 16px;
height: 16px; height: 16px;
display: grid; display: grid;
@@ -1053,7 +1067,8 @@ code, .mono {
transition: color 250ms ease, filter 500ms ease, opacity 300ms ease, transform 600ms cubic-bezier(0.16, 1, 0.3, 1); transition: color 250ms ease, filter 500ms ease, opacity 300ms ease, transform 600ms cubic-bezier(0.16, 1, 0.3, 1);
} }
.client-subscription-refresh svg { .client-subscription-refresh svg,
.client-subscription-delete svg {
width: 14px; width: 14px;
height: 14px; height: 14px;
fill: none; fill: none;
@@ -1080,7 +1095,23 @@ code, .mono {
outline-offset: 2px; outline-offset: 2px;
} }
.client-subscription.is-editing .client-subscription-refresh { .client-subscription-delete:hover:not(:disabled) {
color: oklch(0.68 0.15 28);
filter: drop-shadow(0 0 6px oklch(0.68 0.15 28 / 0.45));
}
.client-subscription-delete:disabled {
opacity: 0.4;
cursor: wait;
}
.client-subscription-delete:focus-visible {
outline: 2px solid oklch(0.68 0.15 28);
outline-offset: 2px;
}
.client-subscription.is-editing .client-subscription-refresh,
.client-subscription.is-editing .client-subscription-delete {
opacity: 0; opacity: 0;
pointer-events: none; pointer-events: none;
} }
@@ -1346,6 +1377,18 @@ code, .mono {
display: block; display: block;
} }
.client-server-prompt {
display: block;
margin-bottom: 12px;
color: var(--client-muted);
font-size: 9px;
font-weight: 700;
letter-spacing: 0.08em;
text-align: center;
text-transform: uppercase;
animation: client-state-reveal 700ms cubic-bezier(0.16, 1, 0.3, 1) both;
}
.client-server-grid { .client-server-grid {
display: grid; display: grid;
grid-template-columns: 1fr; grid-template-columns: 1fr;
@@ -1569,6 +1612,11 @@ code, .mono {
.client-form { .client-form {
position: static; position: static;
width: 100%; width: 100%;
transform: none;
}
.client-panel.is-setup .client-form {
transform: none;
} }
} }
@@ -1579,6 +1627,9 @@ code, .mono {
.client-power svg, .client-power svg,
.client-usage-bar i, .client-usage-bar i,
.client-subscription-refresh, .client-subscription-refresh,
.client-subscription-delete,
.client-power-section,
.client-form,
.client-usage, .client-usage,
.client-usage > strong, .client-usage > strong,
.client-server, .client-server,