diff --git a/src/shared/versions.ts b/src/shared/versions.ts index feb7455..5688697 100644 --- a/src/shared/versions.ts +++ b/src/shared/versions.ts @@ -1,6 +1,6 @@ export const HARBOR_VERSIONS = Object.freeze({ - macClient: '0.26.0', - gatewayClient: '0.27.0', + macClient: '0.26.1', + gatewayClient: '0.27.1', gatewayBackend: '0.27.0', }); diff --git a/src/web/features/routing/RoutingFeature.tsx b/src/web/features/routing/RoutingFeature.tsx index 12e665a..46dc029 100644 --- a/src/web/features/routing/RoutingFeature.tsx +++ b/src/web/features/routing/RoutingFeature.tsx @@ -19,6 +19,7 @@ import { operationBlocked } from '../../state/operations.js'; import { ConfirmationDialog } from '../../ui/ConfirmationDialog.js'; import { Drawer } from '../../ui/Drawer.js'; import { RailAction } from '../../ui/RailAction.js'; +import { Tooltip } from '../../ui/Tooltip.js'; import { beginRuleReorder, crossedRuleIndex, @@ -760,19 +761,26 @@ function RuleOutboundPicker({ disabled?: boolean; onChange: (value: RouteRule['outbound']) => void; }) { - return
- {([['vpn', 'VPN'], ['direct', 'Напрямую']] as const).map(([outbound, label]) => ( - - ))} -
; + const direct = value === 'direct'; + const label = direct ? 'Напрямую' : 'VPN'; + return + + {label} + ; } export function RoutingToggle({ @@ -899,7 +907,11 @@ export function RoutingPanel({ feature, statusSlot }: { feature: RoutingFeature;
Правила -
+
1 ? ' has-order-flow' : ''}`} + role="list" + aria-label="Правила применяются сверху вниз" + > {feature.rules.map((rule, index) => { const [status, statusLabel] = localRuleStatus( rule, @@ -916,6 +928,7 @@ export function RoutingPanel({ feature, statusSlot }: { feature: RoutingFeature; className={`client-local-rule client-deletable-row is-${status}${rule.enabled ? '' : ' is-disabled'}${rule.removing ? ' is-removing' : ''}${lifted ? ' is-dragging' : ''}`} key={rule._key} data-rule-key={rule._key} + role="listitem" style={{ viewTransitionName: rule.removing ? 'none' : rule._key }} inert={rule.removing ? true : undefined} > @@ -974,6 +987,20 @@ export function RoutingPanel({ feature, statusSlot }: { feature: RoutingFeature; value={rule.value} onChange={(event) => feature.change(index, 'value', event.target.value)} /> + feature.change(index, 'outbound', outbound)} + /> + +
); })} diff --git a/src/web/styles/features/routing.css b/src/web/styles/features/routing.css index 9aa1ab9..797651b 100644 --- a/src/web/styles/features/routing.css +++ b/src/web/styles/features/routing.css @@ -72,7 +72,32 @@ } .client-local-rules-list { - gap: 2px; + position: relative; + gap: 0; + padding-left: 14px; +} + +.client-local-rules-list.has-order-flow::before { + content: ''; + position: absolute; + top: 22px; + bottom: 22px; + left: 3px; + width: 1px; + background: linear-gradient(to bottom, transparent, color-mix(in oklch, var(--client-accent) 46%, transparent) 15%, color-mix(in oklch, var(--client-accent) 46%, transparent) 85%, transparent); + box-shadow: 0 0 7px color-mix(in oklch, var(--client-accent) 24%, transparent); +} + +.client-local-rules-list.has-order-flow::after { + content: ''; + position: absolute; + bottom: 18px; + left: 0; + width: 7px; + height: 6px; + background: color-mix(in oklch, var(--client-accent) 64%, transparent); + clip-path: polygon(0 0, 100% 0, 50% 100%); + filter: drop-shadow(0 0 4px color-mix(in oklch, var(--client-accent) 36%, transparent)); } .client-local-rule { @@ -81,36 +106,57 @@ isolation: isolate; min-width: 0; display: grid; - grid-template-columns: 44px 24px 116px minmax(0, 1fr) 28px; + grid-template-columns: 44px 24px 104px minmax(84px, 1fr) 34px 16px 28px; align-items: center; - column-gap: 8px; - row-gap: 2px; - padding: 7px 0; + column-gap: 6px; + padding: 4px 0; animation: client-row-enter 560ms cubic-bezier(0.16, 1, 0.3, 1) both; transition: background-color 180ms ease, box-shadow 180ms ease, opacity 260ms ease, filter 360ms ease; } +.client-local-rule + .client-local-rule::before { + content: ''; + position: absolute; + top: 0; + right: 0; + left: 44px; + height: 1px; + background: linear-gradient(90deg, color-mix(in oklch, var(--client-muted) 24%, transparent), color-mix(in oklch, var(--client-muted) 9%, transparent) 72%, transparent); +} + .client-local-rule:has(.client-rule-type.is-open) { z-index: 5; } .client-local-rule-status { + width: 16px; + height: 44px; + display: grid; + place-items: center; color: var(--client-muted); - font: var(--type-label); - letter-spacing: var(--type-label-tracking); - text-transform: var(--type-label-transform); - text-align: right; - white-space: nowrap; - transition: color 280ms ease, opacity 280ms ease, filter 360ms ease; } -.client-local-rule.is-active .client-local-rule-status { +.client-rule-status-dot { + width: 6px; + height: 6px; + border-radius: 50%; + background: currentColor; + opacity: 0.58; + transition: color 280ms ease, opacity 280ms ease, filter 360ms ease, transform 280ms cubic-bezier(0.16, 1, 0.3, 1); +} + +.client-local-rule.is-active .client-rule-status-dot { color: var(--client-accent); + opacity: 1; + filter: drop-shadow(0 0 5px color-mix(in oklch, var(--client-accent) 52%, transparent)); + transform: scale(1.14); } -.client-local-rule.is-pending .client-local-rule-status, -.client-local-rule.is-unsaved .client-local-rule-status { +.client-local-rule.is-pending .client-rule-status-dot, +.client-local-rule.is-unsaved .client-rule-status-dot { color: var(--client-warning, oklch(0.72 0.12 72)); + opacity: 1; + filter: drop-shadow(0 0 4px color-mix(in oklch, var(--client-warning, oklch(0.72 0.12 72)) 44%, transparent)); } .client-local-rule.is-disabled > :not(.client-rule-handle):not(.client-row-delete):not(.client-delete-strike) { @@ -361,42 +407,39 @@ text-shadow: 0 0 9px color-mix(in oklch, var(--client-accent) 22%, transparent); } -.client-local-rule-meta { - min-width: 0; - grid-column: 2 / -1; - grid-row: 2; - display: flex; - align-items: center; - justify-content: space-between; - gap: 8px; -} - .client-rule-outbound { - width: 128px; - height: 30px; - flex: 0 0 128px; - display: grid; - grid-template-columns: 48px minmax(0, 1fr); - align-items: center; + width: 34px; + height: 34px; + display: block; } .client-rule-outbound button { - height: 30px; - padding: 0 5px; + width: 34px; + height: 34px; + display: grid; + place-items: center; + padding: 0; border: 0; + border-radius: 50%; background: transparent; color: var(--client-muted); - font: var(--type-label); - letter-spacing: var(--type-label-tracking); - text-transform: var(--type-label-transform); cursor: pointer; - transition: color 280ms ease, filter 280ms ease, text-shadow 280ms ease; + transition: color 220ms ease, filter 320ms ease, background 220ms ease; } -.client-rule-outbound button[aria-pressed='true'] { +.client-rule-outbound button.is-direct { color: var(--client-accent); - filter: drop-shadow(0 0 6px color-mix(in oklch, var(--client-accent) 34%, transparent)); - text-shadow: 0 0 9px color-mix(in oklch, var(--client-accent) 28%, transparent); +} + +.client-rule-outbound svg { + width: 20px; + height: 20px; + fill: none; + stroke: currentColor; + stroke-width: 1.7; + stroke-linecap: round; + stroke-linejoin: round; + transition: transform 320ms cubic-bezier(0.16, 1, 0.3, 1); } .client-rule-outbound button:disabled { @@ -405,8 +448,25 @@ .client-rule-outbound button:focus-visible { outline: 0; + background: color-mix(in oklch, var(--client-accent) 10%, transparent); color: var(--client-accent); - text-shadow: 0 0 9px color-mix(in oklch, var(--client-accent) 42%, transparent); + filter: drop-shadow(0 0 5px color-mix(in oklch, var(--client-accent) 36%, transparent)); +} + +.client-rule-outbound button:hover:not(:disabled) { + background: color-mix(in oklch, var(--client-accent) 10%, transparent); + color: var(--client-accent); + filter: drop-shadow(0 0 5px color-mix(in oklch, var(--client-accent) 36%, transparent)); +} + +.client-rule-outbound button.is-direct:hover:not(:disabled) svg, +.client-rule-outbound button.is-direct:focus-visible svg { + transform: translateX(2px); +} + +.client-rule-outbound button.is-vpn:hover:not(:disabled) svg, +.client-rule-outbound button.is-vpn:focus-visible svg { + transform: translateY(-1px) rotate(3deg) scale(1.05); } .client-rule-reorder-instructions, diff --git a/src/web/styles/layout.css b/src/web/styles/layout.css index 21c5f1e..bc90d03 100644 --- a/src/web/styles/layout.css +++ b/src/web/styles/layout.css @@ -704,7 +704,9 @@ } .client-local-rule { - grid-template-columns: 44px 44px minmax(0, 1fr) 44px; + grid-template-columns: 44px 44px minmax(0, 1fr) 44px 44px; + column-gap: 4px; + row-gap: 0; } .client-rule-handle, @@ -721,6 +723,10 @@ height: 44px; } + .client-rule-outbound button { + width: 44px; + } + .client-rule-type-list button { min-height: 44px; } @@ -734,7 +740,11 @@ } .client-local-rule { - gap: 8px; + padding-block: 4px; + } + + .client-local-rules-list { + padding-left: 12px; } .client-rule-handle { @@ -753,18 +763,27 @@ } .client-row-delete { - grid-column: 4; + grid-column: 5; grid-row: 1; } .client-local-rule input { - grid-column: 2 / -1; + grid-column: 2 / 5; grid-row: 2; } - .client-local-rule-meta { - grid-column: 2 / -1; - grid-row: 3; + .client-rule-outbound { + grid-column: 4; + grid-row: 1; + width: 44px; + height: 44px; + } + + .client-local-rule-status { + grid-column: 5; + grid-row: 2; + width: 44px; + height: 44px; } .client-instruction-block:nth-child(n) { @@ -784,21 +803,29 @@ } @media (max-width: 360px) { - .client-local-rule-meta { - flex-wrap: wrap; - justify-content: flex-start; + .client-local-rule { + grid-template-columns: 44px 44px minmax(0, 1fr) 44px; } .client-rule-outbound { - width: 100%; - flex-basis: 100%; - grid-template-columns: repeat(2, minmax(0, 1fr)); + grid-column: 2; + grid-row: 3; + width: 44px; + height: 44px; } .client-local-rule-status { - width: 100%; - min-height: 1em; - text-align: left; - white-space: normal; + grid-column: 4; + grid-row: 3; + width: 44px; + height: 44px; + } + + .client-row-delete { + grid-column: 4; + } + + .client-local-rule input { + grid-column: 2 / -1; } } diff --git a/src/web/styles/themes.css b/src/web/styles/themes.css index e5bd9ad..7a26c66 100644 --- a/src/web/styles/themes.css +++ b/src/web/styles/themes.css @@ -132,6 +132,8 @@ .client-rule-type-list button, .client-local-rule input, .client-rule-outbound button, + .client-rule-outbound svg, + .client-rule-status-dot, .client-row-delete, .client-row-add, .client-row-add-slot > span, diff --git a/test/web/responsive-layout-contract.test.js b/test/web/responsive-layout-contract.test.js index 2b49567..26c4c5f 100644 --- a/test/web/responsive-layout-contract.test.js +++ b/test/web/responsive-layout-contract.test.js @@ -193,20 +193,23 @@ test('secondary menus share one right rail and switch equal drawers as a vertica assert.doesNotMatch(component, /instructionsFeature\.close\(\)[\s\S]{0,180}devicesFeature\.close\(\)[\s\S]{0,180}subscriptionFeature\.toggle\(\)/); }); -test('routing rules keep two desktop rows and three narrow rows without shrinking drag targets', () => { - assert.match(rule('.client-local-rule'), /grid-template-columns:\s*44px 24px 116px minmax\(0, 1fr\) 28px/); - assert.match(rule('.client-local-rule-meta'), /grid-column:\s*2 \/ -1[\s\S]*grid-row:\s*2/); +test('routing rules stay in one desktop row and reflow without shrinking drag targets', () => { + assert.match(rule('.client-local-rule'), /grid-template-columns:\s*44px 24px 104px minmax\(84px, 1fr\) 34px 16px 28px/); + assert.doesNotMatch(routing, /client-local-rule-meta/); + assert.match(styles, /\.client-local-rules-list \{[\s\S]*position:\s*relative[\s\S]*padding-left:\s*14px/); + assert.match(styles, /\.client-local-rules-list\.has-order-flow::after[\s\S]*clip-path:\s*polygon\(0 0, 100% 0, 50% 100%\)/); const mobile = /@media \(max-width: 560px\) \{([\s\S]*?)\n\}\s*$/.exec(layoutStyles)?.[1] || ''; - assert.match(mobile, /grid-template-columns:\s*44px 44px minmax\(0, 1fr\) 44px/); + assert.match(mobile, /grid-template-columns:\s*44px 44px minmax\(0, 1fr\) 44px 44px/); assert.match(mobile, /\.client-local-rule input \{[\s\S]*grid-row:\s*2/); - assert.match(mobile, /\.client-local-rule-meta \{[\s\S]*grid-row:\s*3/); + assert.match(mobile, /\.client-rule-outbound \{[\s\S]*grid-column:\s*4;[\s\S]*grid-row:\s*1/); + assert.match(mobile, /\.client-local-rule-status \{[\s\S]*grid-column:\s*5;[\s\S]*grid-row:\s*2/); assert.match(mobile, /\.client-rule-handle,[\s\S]*width:\s*44px;[\s\S]*height:\s*44px/); assert.match(mobile, /\.client-rule-type-trigger,[\s\S]*\.client-rule-outbound button \{[\s\S]*height:\s*44px/); assert.match(mobile, /\.client-rule-type-list button \{[\s\S]*min-height:\s*44px/); const compact = layoutStyles.slice(layoutStyles.indexOf('@media (max-width: 360px)')); - assert.match(compact, /\.client-local-rule-meta \{[\s\S]*flex-wrap:\s*wrap/); - assert.match(compact, /\.client-rule-outbound \{[\s\S]*width:\s*100%[\s\S]*flex-basis:\s*100%/); - assert.match(compact, /\.client-local-rule-status \{[\s\S]*width:\s*100%[\s\S]*white-space:\s*normal/); + assert.match(compact, /grid-template-columns:\s*44px 44px minmax\(0, 1fr\) 44px/); + assert.match(compact, /\.client-rule-outbound \{[\s\S]*grid-column:\s*2;[\s\S]*grid-row:\s*3/); + assert.match(compact, /\.client-local-rule-status \{[\s\S]*grid-column:\s*4;[\s\S]*grid-row:\s*3/); }); test('connectivity diagnostics render stable compact tables before the first run', () => { diff --git a/test/web/rule-editor-contract.test.js b/test/web/rule-editor-contract.test.js index 4e7588b..2dc7cb5 100644 --- a/test/web/rule-editor-contract.test.js +++ b/test/web/rule-editor-contract.test.js @@ -99,7 +99,7 @@ test('copy feedback, drawers and Gateway access actions expose complete semantic assert.match(styles, /@media \(max-width: 560px\)[\s\S]*\.client-rule-handle,[\s\S]*\.client-local-rule-enabled,[\s\S]*\.client-row-delete \{[\s\S]*width: 44px;[\s\S]*height: 44px/); }); -test('ordered rules use one accessible drag handle and a fixed two-target control', () => { +test('ordered rules use one accessible drag handle and a compact icon route control', () => { assert.match(routing, /className="client-rule-handle"[\s\S]*type="button"[\s\S]*aria-label=\{`Переместить правило, позиция/); assert.match(routing, /aria-describedby="client-rule-reorder-instructions"[\s\S]*aria-pressed=\{lifted\}/); assert.match(routing, /onPointerDown=\{\(event\) => feature\.startPointerReorder\(event, rule\._key\)\}/); @@ -108,8 +108,16 @@ test('ordered rules use one accessible drag handle and a fixed two-target contro assert.match(routing, /