- {[
- ['gateway', 'Gateway'],
- ['proxy', 'Gateway Proxy'],
- ].map(([tab, label]) => (
+ {ACCESS_TABS.map(([tab, label]) => (
))}
)}
- {(isGateway ? accessTab === 'gateway' : false) ? (
-
+ {isGateway && (
+
{gatewayAddress}
- ) : (
-
+ )}
+
{!isGateway && (
Локальный VPN
@@ -1169,12 +1226,11 @@ export function ClientOverviewPage({
onClick={() => copyProxy(kind)}
>
{label}
- {copyFeedback?.kind === kind && {copyFeedback.failed ? 'Error' : 'Copied'}}
+ {copyFeedback?.kind === kind && {copyFeedback.failed ? 'ОШИБКА' : 'ГОТОВО'}}
))}
- )}
@@ -1368,6 +1424,13 @@ export function ClientOverviewPage({
inert={!instructionsOpen ? true : undefined}
>
+
Подключение
Как использовать {isGateway ? 'Gateway' : 'прокси'}
@@ -1400,6 +1463,7 @@ export function ClientOverviewPage({
error={error}
operations={operations}
panelRef={localRulesPanelRef}
+ closeRef={localRulesCloseRef}
onAdd={() => setLocalRulesDraft((rules) => [
...rules,
createLocalRuleDraft({ type: 'domain', value: '', enabled: true }),
diff --git a/src/web/styles.css b/src/web/styles.css
index 7016880..b0e281f 100644
--- a/src/web/styles.css
+++ b/src/web/styles.css
@@ -192,6 +192,15 @@ p {
--client-accent: var(--harbor-gateway);
}
+.client-live-region {
+ position: fixed;
+ width: 1px;
+ height: 1px;
+ overflow: hidden;
+ clip-path: inset(50%);
+ white-space: nowrap;
+}
+
.harbor-versions {
position: fixed;
right: max(14px, env(safe-area-inset-right));
@@ -265,10 +274,9 @@ p {
gap: 7px;
padding: 12px 13px;
border-radius: 12px;
- background: color-mix(in oklch, var(--client-panel) 84%, transparent);
+ background: oklch(0.14 0.012 145);
box-shadow: 0 9px 30px oklch(0.08 0.015 145 / 0.16);
- backdrop-filter: blur(12px) saturate(0.9);
- color: var(--client-muted);
+ color: oklch(0.88 0.012 145);
font-size: 11px;
font-weight: 500;
line-height: 1.5;
@@ -282,7 +290,7 @@ p {
}
.harbor-version-tooltip strong {
- color: var(--client-text);
+ color: oklch(0.96 0.008 145);
font-size: 11px;
}
@@ -592,10 +600,9 @@ p {
gap: 2px;
padding: 5px 6px;
border-radius: 5px;
- background: color-mix(in oklch, var(--client-panel) 68%, transparent);
+ background: oklch(0.14 0.012 145);
box-shadow: 0 5px 18px oklch(0.08 0.015 145 / 0.1);
- backdrop-filter: blur(8px) saturate(0.9);
- color: var(--client-muted);
+ color: oklch(0.88 0.012 145);
font-size: 4px;
line-height: 1.5;
letter-spacing: 0;
@@ -609,7 +616,7 @@ p {
}
.harbor-mode-tooltip strong {
- color: var(--client-text);
+ color: oklch(0.96 0.008 145);
font-size: 4.2px;
}
@@ -865,6 +872,7 @@ p {
}
.client-local-rules-sheet {
+ position: relative;
min-height: 100%;
padding: 54px 72px 72px 34px;
font-family: 'JetBrains Mono', 'SF Mono', ui-monospace, Menlo, monospace;
@@ -1393,6 +1401,7 @@ p {
}
.client-instructions-sheet {
+ position: relative;
min-height: 100%;
padding: 54px 34px 72px 72px;
font-family: 'JetBrains Mono', 'SF Mono', ui-monospace, Menlo, monospace;
@@ -1404,6 +1413,31 @@ p {
margin: 0 8px 38px;
}
+.client-drawer-close {
+ position: absolute;
+ top: 10px;
+ right: 10px;
+ width: 44px;
+ height: 44px;
+ padding: 0;
+ border: 0;
+ background: transparent;
+ color: var(--client-muted);
+ font: 400 24px/1 'JetBrains Mono', 'SF Mono', ui-monospace, Menlo, monospace;
+ cursor: pointer;
+ transition: color 220ms ease, transform 300ms cubic-bezier(0.16, 1, 0.3, 1);
+}
+
+.client-drawer-close:hover {
+ color: var(--client-text);
+ transform: scale(1.08);
+}
+
+.client-drawer-close:focus-visible {
+ outline: 2px solid var(--client-accent);
+ outline-offset: 2px;
+}
+
.client-instructions-header span,
.client-instruction-summary > span {
color: var(--client-muted);
@@ -1661,6 +1695,12 @@ p {
animation: client-power-arrive 850ms cubic-bezier(0.16, 1, 0.3, 1) both;
}
+.client-power-section:has(.client-tooltip-anchor:hover),
+.client-power-section:has(.client-tooltip-anchor:focus-visible),
+.client-power-section:has(.client-tooltip-anchor > :focus-visible) {
+ z-index: 90;
+}
+
.client-route-rules-pending {
min-height: 17px;
display: flex;
@@ -1958,15 +1998,14 @@ p {
position: absolute;
bottom: calc(100% + 6px);
left: 50%;
- z-index: 50;
+ z-index: 1000;
width: max-content;
max-width: 220px;
padding: 6px 8px;
border-radius: 8px;
- background: color-mix(in oklch, var(--client-panel) 78%, transparent);
+ background: oklch(0.14 0.012 145);
box-shadow: 0 8px 26px oklch(0.08 0.015 145 / 0.16);
- backdrop-filter: blur(10px) saturate(0.9);
- color: var(--client-text);
+ color: oklch(0.94 0.008 145);
font: 600 10px/1.35 'JetBrains Mono', 'SF Mono', ui-monospace, Menlo, monospace;
letter-spacing: 0;
text-align: center;
@@ -1980,7 +2019,8 @@ p {
}
.client-tooltip-anchor:hover > .client-tooltip,
-.client-tooltip-anchor:focus-within > .client-tooltip {
+.client-tooltip-anchor:focus-visible > .client-tooltip,
+.client-tooltip-anchor:has(> :focus-visible) > .client-tooltip {
opacity: 1;
visibility: visible;
filter: blur(0);
@@ -2752,6 +2792,10 @@ 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); }
@@ -3054,6 +3098,29 @@ p {
transform: translateX(0);
}
+ .client-access-tabs .client-access-tab,
+ .client-copy-button {
+ min-height: 44px;
+ font-size: 10px;
+ }
+
+ .client-local-rule {
+ grid-template-columns: 44px minmax(0, 1fr) 44px;
+ }
+
+ .client-local-rule-enabled,
+ .client-local-rule-delete {
+ width: 44px;
+ height: 44px;
+ }
+
+ .client-local-rules-header > span,
+ .client-local-rules-group > span,
+ .client-instructions-header span,
+ .client-instruction-summary > span {
+ font-size: 10px;
+ }
+
.client-instructions-sheet {
padding: 40px 18px 60px 58px;
}
@@ -3067,7 +3134,6 @@ p {
}
.client-local-rule {
- grid-template-columns: 24px minmax(0, 1fr) 28px;
gap: 8px;
}
@@ -3134,6 +3200,7 @@ p {
.client-duration-seconds-value,
.client-duration-second-digit,
.client-instructions,
+ .client-drawer-close,
.client-instructions-toggle,
.client-instructions-toggle svg,
.client-instructions-toggle path,
diff --git a/src/web/utils/clientControls.js b/src/web/utils/clientControls.js
index 1adccd9..4a62e65 100644
--- a/src/web/utils/clientControls.js
+++ b/src/web/utils/clientControls.js
@@ -98,6 +98,15 @@ 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 3762cae..68fa14c 100644
--- a/test/web/client-controls.test.js
+++ b/test/web/client-controls.test.js
@@ -2,6 +2,7 @@ import assert from 'node:assert/strict';
import test from 'node:test';
import {
+ accessTabForKey,
connectionAction,
copyText,
formatConnectionDuration,
@@ -93,6 +94,16 @@ 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 9b7f5b8..be0ea42 100644
--- a/test/web/responsive-layout-contract.test.js
+++ b/test/web/responsive-layout-contract.test.js
@@ -60,3 +60,17 @@ test('responsive motion has a reduced-motion fallback', () => {
assert.match(reducedMotion, /transition:\s*none/);
assert.match(reducedMotion, /animation:\s*none/);
});
+
+test('tooltips stay opaque, above adjacent content, and do not stick after pointer clicks', () => {
+ const tooltip = rule('.client-tooltip');
+
+ assert.match(tooltip, /z-index:\s*1000/);
+ assert.match(tooltip, /background:\s*oklch\(0\.14 0\.012 145\)/);
+ assert.doesNotMatch(tooltip, /transparent|backdrop-filter/);
+ assert.match(styles, /\.client-power-section:has\(\.client-tooltip-anchor:hover\)[\s\S]*z-index:\s*90/);
+ assert.match(styles, /\.client-tooltip-anchor:focus-visible > \.client-tooltip/);
+ assert.match(styles, /\.client-tooltip-anchor:has\(> :focus-visible\) > \.client-tooltip/);
+ assert.doesNotMatch(styles, /\.client-tooltip-anchor:focus-within > \.client-tooltip/);
+ assert.match(rule('.harbor-version-tooltip'), /background:\s*oklch\(0\.14 0\.012 145\)/);
+ assert.match(rule('.harbor-mode-tooltip'), /background:\s*oklch\(0\.14 0\.012 145\)/);
+});
diff --git a/test/web/rule-editor-contract.test.js b/test/web/rule-editor-contract.test.js
index e8d7c6d..3f38aa4 100644
--- a/test/web/rule-editor-contract.test.js
+++ b/test/web/rule-editor-contract.test.js
@@ -46,3 +46,23 @@ test('critical confirmations share one accessible blocking popup', () => {
assert.match(popup, /element\.inert = true/);
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', () => {
+ assert.match(component, /className="client-live-region" role="status" aria-live="polite" aria-atomic="true"/);
+ assert.match(component, /Не удалось скопировать/);
+ assert.match(component, /Скопировано/);
+ assert.doesNotMatch(component, />Error<|>Copied);
+ assert.match(component, /aria-label="Закрыть инструкции"/);
+ 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(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/);
+});