From 24fda3e34e2fcc0c2b54e08fb3674445a7f69df2 Mon Sep 17 00:00:00 2001 From: Dmitriy Petrov Date: Sun, 12 Jul 2026 13:24:05 +0300 Subject: [PATCH] Improve client accessibility and bump Harbor versions --- src/shared/versions.js | 4 +- src/web/components/ClientOverviewPage.jsx | 92 +++++++++++++++++--- src/web/styles.css | 95 ++++++++++++++++++--- src/web/utils/clientControls.js | 9 ++ test/web/client-controls.test.js | 11 +++ test/web/responsive-layout-contract.test.js | 14 +++ test/web/rule-editor-contract.test.js | 20 +++++ 7 files changed, 215 insertions(+), 30 deletions(-) diff --git a/src/shared/versions.js b/src/shared/versions.js index 54745ec..5f46cc5 100644 --- a/src/shared/versions.js +++ b/src/shared/versions.js @@ -1,6 +1,6 @@ export const HARBOR_VERSIONS = Object.freeze({ - macClient: '0.7.10', - gatewayClient: '0.7.10', + macClient: '0.7.11', + gatewayClient: '0.7.11', gatewayBackend: '0.7.0', }); diff --git a/src/web/components/ClientOverviewPage.jsx b/src/web/components/ClientOverviewPage.jsx index 36d53e3..6e14f4e 100644 --- a/src/web/components/ClientOverviewPage.jsx +++ b/src/web/components/ClientOverviewPage.jsx @@ -2,6 +2,7 @@ import React, { useEffect, useLayoutEffect, useRef, useState } from 'react'; import { flushSync } from 'react-dom'; import { api } from '../api.js'; import { + accessTabForKey, connectionAction, connectionDurationParts, copyText, @@ -24,6 +25,10 @@ import { const SUBSCRIPTION_REVEAL_DELAY_MS = 1350; const DURATION_MODE_STORAGE_KEY = 'harbor-duration-mode'; +const ACCESS_TABS = [ + ['gateway', 'Gateway'], + ['proxy', 'Gateway Proxy'], +]; function CloudTooltip({ children }) { return {children}; @@ -340,6 +345,7 @@ function LocalRulesPanel({ error, operations, panelRef, + closeRef, onAdd, onChange, onRemove, @@ -361,6 +367,13 @@ function LocalRulesPanel({ inert={!open ? true : undefined} >
+
Маршрутизация ))}
)} - {(isGateway ? accessTab === 'gateway' : false) ? ( -
+ {isGateway && ( + - ) : ( -
+ )} +
- )} @@ -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 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/); +});