+
+
{!isGateway && (
Локальный VPN
@@ -1245,24 +1193,28 @@ export function ClientOverviewPage({
)}
- {proxyUrls.http.replace(/^https?:\/\//, '')}
+ {isGateway ? gatewayAddress : proxyUrls.http.replace(/^https?:\/\//, '')}
- {[
- ['socks5', 'SOCKS5'],
- ['http', 'HTTP'],
- ].map(([kind, label]) => (
-
- ))}
+ {(isGateway ? [
+ ['gateway', 'GATEWAY'],
+ ['socks5', 'SOCKS5'],
+ ['http', 'HTTP'],
+ ] : [
+ ['socks5', 'SOCKS5'],
+ ['http', 'HTTP'],
+ ]).map(([kind, label]) => (
+
+ ))}
diff --git a/src/web/styles.css b/src/web/styles.css
index 9ee7783..5de27c4 100644
--- a/src/web/styles.css
+++ b/src/web/styles.css
@@ -1682,7 +1682,7 @@ p {
}
.client-state-copy {
- min-height: 54px;
+ min-height: 76px;
}
.client-power {
@@ -1829,7 +1829,7 @@ p {
}
.client-state-detail {
- min-height: 20px;
+ min-height: 44px;
display: grid;
place-items: center;
margin-top: 8px;
@@ -1863,6 +1863,20 @@ p {
place-items: center;
}
+.client-duration-words {
+ display: grid;
+ place-items: center;
+ gap: 3px;
+}
+
+.client-duration-word-row {
+ min-height: 19px;
+ display: flex;
+ align-items: baseline;
+ justify-content: center;
+ white-space: nowrap;
+}
+
.client-duration-unit {
display: inline-flex;
align-items: baseline;
@@ -1908,7 +1922,7 @@ p {
.client-duration-toggle {
width: min(290px, 100%);
- min-height: 24px;
+ min-height: 44px;
display: grid;
place-items: center;
padding: 0;
@@ -1966,6 +1980,19 @@ p {
transition-delay: 20ms, 20ms, 20ms, 0s;
}
+.client-duration-toggle > .client-tooltip {
+ top: 50%;
+ right: calc(100% + 12px);
+ bottom: auto;
+ left: auto;
+ transform: translate(2px, -50%);
+}
+
+.client-duration-toggle:hover > .client-tooltip,
+.client-duration-toggle:focus-visible > .client-tooltip {
+ transform: translate(0, -50%);
+}
+
.client-icon-tooltip {
width: 16px;
height: 16px;
@@ -3071,8 +3098,12 @@ p {
margin-top: 6px;
}
-.client-proxies.has-tabs {
- min-height: 91px;
+.client-proxies.is-gateway {
+ width: 270px;
+}
+
+.client-proxies.is-gateway .client-copy-button {
+ width: 82px;
}
.client-proxy-label {
@@ -3136,47 +3167,11 @@ p {
animation: client-access-reveal 450ms cubic-bezier(0.16, 1, 0.3, 1) both;
}
-.client-access-point[hidden] {
- display: none;
-}
-
@keyframes client-access-reveal {
0% { opacity: 0; filter: blur(4px); }
100% { opacity: 1; filter: blur(0); }
}
-.client-access-tabs {
- display: grid;
- grid-template-columns: 1fr 1fr;
- width: 220px;
- margin-bottom: 8px;
- border-bottom: 1px solid var(--client-border);
-}
-
-.client-access-tabs .client-access-tab {
- width: auto;
- padding: 6px 4px 7px;
- border: 0;
- border-bottom: 1px solid transparent;
- background: transparent;
- color: var(--client-muted);
- font: 700 9px/1.2 'JetBrains Mono', 'SF Mono', ui-monospace, Menlo, monospace;
- letter-spacing: 0.06em;
- cursor: pointer;
- transition: color 220ms ease, border-color 300ms ease, filter 300ms ease;
-}
-
-.client-access-tabs .client-access-tab.is-active {
- border-bottom-color: var(--client-accent);
- color: var(--client-text);
- filter: drop-shadow(0 0 5px color-mix(in oklch, var(--client-accent) 45%, transparent));
-}
-
-.client-access-tab:focus-visible {
- outline: 2px solid var(--client-accent);
- outline-offset: 2px;
-}
-
.client-proxy-actions {
display: flex;
gap: 8px;
@@ -3457,7 +3452,11 @@ p {
right: 8px;
}
- .client-access-tabs .client-access-tab,
+ .client-duration-toggle > .client-tooltip {
+ right: calc(50% + 36px);
+ max-width: 110px;
+ }
+
.client-copy-button {
min-height: 44px;
font-size: 10px;
@@ -3574,7 +3573,6 @@ p {
.client-server-group-toggle,
.client-server-more,
.client-copy-button,
- .client-access-tab,
.client-access-point,
.client-copy-feedback,
.client-duration,
diff --git a/src/web/utils/clientControls.js b/src/web/utils/clientControls.js
index 4a62e65..1adccd9 100644
--- a/src/web/utils/clientControls.js
+++ b/src/web/utils/clientControls.js
@@ -98,15 +98,6 @@ export async function copyText(text, options = {}) {
await clipboard.writeText(text);
}
-export function accessTabForKey(tabs, current, key) {
- const index = tabs.indexOf(current);
- if (key === 'Home') return tabs[0];
- if (key === 'End') return tabs.at(-1);
- if (key === 'ArrowLeft') return tabs[(index - 1 + tabs.length) % tabs.length];
- if (key === 'ArrowRight') return tabs[(index + 1) % tabs.length];
- return current;
-}
-
export function subscriptionUsage(userInfo = {}) {
const upload = Math.max(0, Number(userInfo.upload) || 0);
const download = Math.max(0, Number(userInfo.download) || 0);
diff --git a/test/web/client-controls.test.js b/test/web/client-controls.test.js
index 68fa14c..3762cae 100644
--- a/test/web/client-controls.test.js
+++ b/test/web/client-controls.test.js
@@ -2,7 +2,6 @@ import assert from 'node:assert/strict';
import test from 'node:test';
import {
- accessTabForKey,
connectionAction,
copyText,
formatConnectionDuration,
@@ -94,16 +93,6 @@ test('copy uses the synchronous native path available on gateway HTTP', async ()
assert.equal(textarea.removed, true);
});
-test('Gateway access tabs wrap with arrows and support Home and End', () => {
- const tabs = ['gateway', 'proxy'];
-
- assert.equal(accessTabForKey(tabs, 'gateway', 'ArrowLeft'), 'proxy');
- assert.equal(accessTabForKey(tabs, 'proxy', 'ArrowRight'), 'gateway');
- assert.equal(accessTabForKey(tabs, 'proxy', 'Home'), 'gateway');
- assert.equal(accessTabForKey(tabs, 'gateway', 'End'), 'proxy');
- assert.equal(accessTabForKey(tabs, 'gateway', 'Enter'), 'gateway');
-});
-
test('subscription usage combines traffic and caps progress', () => {
assert.deepEqual(subscriptionUsage({ upload: 30, download: 80, total: 100, expire: 2 }), {
upload: 30,
diff --git a/test/web/responsive-layout-contract.test.js b/test/web/responsive-layout-contract.test.js
index 353c57e..ef01b2a 100644
--- a/test/web/responsive-layout-contract.test.js
+++ b/test/web/responsive-layout-contract.test.js
@@ -90,6 +90,19 @@ test('secondary menus share one right rail and both drawers open from the right'
assert.match(rule('.client-local-rules'), /transform:\s*translateX\(104%\)/);
});
+test('duration and Gateway access keep stable geometry without tabs', () => {
+ assert.match(rule('.client-state-detail'), /min-height:\s*44px/);
+ assert.match(rule('.client-duration-toggle'), /min-height:\s*44px/);
+ assert.match(component, /client-duration-word-row is-calendar/);
+ assert.match(component, /client-duration-word-row is-clock/);
+ assert.match(rule('.client-duration-toggle > .client-tooltip'), /right:\s*calc\(100% \+ 12px\)/);
+ assert.match(component, /\['gateway', 'GATEWAY'\]/);
+ assert.match(component, /\['socks5', 'SOCKS5'\]/);
+ assert.match(component, /\['http', 'HTTP'\]/);
+ assert.doesNotMatch(component, /client-access-tabs|role="tab"|role="tabpanel"/);
+ assert.match(rule('.client-proxies.is-gateway'), /width:\s*270px/);
+});
+
test('responsive motion has a reduced-motion fallback', () => {
const reducedMotion = /@media \(prefers-reduced-motion: reduce\) \{([\s\S]*)\n\}/.exec(styles)?.[1] || '';
diff --git a/test/web/rule-editor-contract.test.js b/test/web/rule-editor-contract.test.js
index e5d51b3..d3b5126 100644
--- a/test/web/rule-editor-contract.test.js
+++ b/test/web/rule-editor-contract.test.js
@@ -50,7 +50,7 @@ test('critical confirmations share one accessible blocking popup', () => {
assert.match(styles, /\.client-confirmation-popup\.is-open[\s\S]*backdrop-filter: blur\(18px\)/);
});
-test('copy feedback, drawers and Gateway tabs expose complete keyboard semantics', () => {
+test('copy feedback, drawers and Gateway access actions expose complete semantics', () => {
assert.match(component, /className="client-live-region" role="status" aria-live="polite" aria-atomic="true"/);
assert.match(component, /Не удалось скопировать/);
assert.match(component, /Скопировано/);
@@ -59,12 +59,8 @@ test('copy feedback, drawers and Gateway tabs expose complete keyboard semantics
assert.match(component, /aria-label="Закрыть локальные правила"/);
assert.match(component, /instructionsCloseRef\.current\?\.focus\(\)/);
assert.match(component, /localRulesCloseRef\.current\?\.focus\(\)/);
- assert.match(component, /client-access-tab-\$\{tab\}/);
- assert.match(component, /aria-controls={`client-access-panel-\$\{tab\}`}/);
- assert.match(component, /tabIndex={accessTab === tab \? 0 : -1}/);
- assert.match(component, /onKeyDown={\(event\) => navigateAccessTabs\(event, tab\)}/);
- assert.match(component, /aria-labelledby="client-access-tab-gateway"/);
- assert.match(component, /aria-labelledby={isGateway \? 'client-access-tab-proxy' : undefined}/);
+ assert.match(component, /aria-label={`Скопировать \$\{label\}: \$\{kind === 'gateway' \? gatewayAddress : proxyUrls\[kind\]\}`}/);
+ assert.doesNotMatch(component, /client-access-tabs|role="tab"|role="tabpanel"/);
assert.match(styles, /\.client-drawer-close \{[\s\S]*width: 44px;[\s\S]*height: 44px/);
assert.match(styles, /@media \(max-width: 560px\)[\s\S]*\.client-copy-button \{[\s\S]*min-height: 44px/);
assert.match(styles, /@media \(max-width: 560px\)[\s\S]*\.client-local-rule-enabled,[\s\S]*\.client-local-rule-delete \{[\s\S]*width: 44px;[\s\S]*height: 44px/);