-
- Правила применяются только к трафику, который вошёл в VPN-маршрутизацию Harbor. Устройство Gateway в режиме «Напрямую» и Connect при активном Harbor Gateway обходят локальный список; «Напрямую» внутри правила — результат уже найденного совпадения.
-
-
- Можно вставить полный URL: Harbor сохранит только домен. Путь и параметры HTTPS недоступны для маршрутизации.
-
{statusSlot}
Отмена
diff --git a/src/web/styles/features/routing.css b/src/web/styles/features/routing.css
index 797651b..374e676 100644
--- a/src/web/styles/features/routing.css
+++ b/src/web/styles/features/routing.css
@@ -102,6 +102,7 @@
.client-local-rule {
--client-rule-drag-y: 0px;
+ --client-rule-route-color: var(--client-accent);
position: relative;
isolation: isolate;
min-width: 0;
@@ -110,8 +111,28 @@
align-items: center;
column-gap: 6px;
padding: 4px 0;
+ background: linear-gradient(90deg, color-mix(in oklch, var(--client-rule-route-color) 8%, transparent), transparent 58%);
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;
+ transition: background 320ms ease, box-shadow 180ms ease, opacity 260ms ease, filter 360ms ease;
+}
+
+.client-local-rule[data-route='direct'] {
+ --client-rule-route-color: light-dark(oklch(0.52 0.11 240), oklch(0.76 0.1 240));
+}
+
+.client-local-rule::after {
+ content: '';
+ position: absolute;
+ top: 10px;
+ bottom: 10px;
+ left: 0;
+ width: 2px;
+ border-radius: 999px;
+ background: var(--client-rule-route-color);
+ box-shadow: 0 0 7px color-mix(in oklch, var(--client-rule-route-color) 44%, transparent);
+ opacity: 0.78;
+ pointer-events: none;
+ transition: background 320ms ease, box-shadow 320ms ease, opacity 260ms ease;
}
.client-local-rule + .client-local-rule::before {
@@ -140,23 +161,46 @@
width: 6px;
height: 6px;
border-radius: 50%;
- background: currentColor;
+ border: 1px solid currentColor;
+ background: transparent;
opacity: 0.58;
- transition: color 280ms ease, opacity 280ms ease, filter 360ms ease, transform 280ms cubic-bezier(0.16, 1, 0.3, 1);
+ transition: color 280ms ease, border 280ms ease, border-radius 280ms ease, background 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);
+ border: 0;
+ background: currentColor;
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-rule-status-dot,
.client-local-rule.is-unsaved .client-rule-status-dot {
+ width: 7px;
+ height: 7px;
+ border: 1.5px solid currentColor;
+ border-radius: 1px;
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));
+ transform: rotate(45deg);
+}
+
+.client-local-rule.is-pending .client-rule-status-dot {
+ width: 8px;
+ height: 8px;
+ border: 1.5px dashed currentColor;
+ color: var(--client-warning, oklch(0.72 0.12 72));
+ opacity: 1;
+}
+
+.client-local-rule.is-disabled .client-rule-status-dot {
+ width: 8px;
+ height: 2px;
+ border: 0;
+ border-radius: 999px;
+ background: var(--client-muted);
+ transform: none;
}
.client-local-rule.is-disabled > :not(.client-rule-handle):not(.client-row-delete):not(.client-delete-strike) {
@@ -164,11 +208,16 @@
filter: saturate(0);
}
+.client-local-rule.is-disabled::after {
+ opacity: 0.22;
+ filter: saturate(0);
+}
+
.client-local-rule.is-dragging {
z-index: 6;
animation: none;
- background: color-mix(in oklch, var(--client-accent) 5%, transparent);
- box-shadow: 0 10px 28px color-mix(in oklch, var(--client-accent) 12%, transparent);
+ background: linear-gradient(90deg, color-mix(in oklch, var(--client-rule-route-color) 13%, transparent), transparent 72%);
+ box-shadow: 0 10px 28px color-mix(in oklch, var(--client-rule-route-color) 16%, transparent);
transform: translateY(var(--client-rule-drag-y));
}
@@ -177,6 +226,13 @@
animation: client-row-leave 820ms cubic-bezier(0.7, 0, 0.84, 0) both;
}
+.client-local-rule-enabled-wrap {
+ width: 24px;
+ height: 44px;
+ display: grid;
+ place-items: center;
+}
+
.client-local-rule-enabled {
width: 24px;
height: 32px;
@@ -228,43 +284,42 @@
}
.client-local-rule-enabled svg {
- width: 18px;
- height: 18px;
+ width: 20px;
+ height: 20px;
overflow: visible;
fill: none;
- stroke: currentColor;
- stroke-linecap: round;
- stroke-linejoin: round;
transition: color 260ms ease, filter 360ms ease, transform 420ms cubic-bezier(0.16, 1, 0.3, 1);
}
-.client-local-rule-enabled circle {
- stroke-width: 1.4;
- transition: fill 320ms ease, stroke 260ms ease;
+.client-rule-switch-track {
+ fill: color-mix(in oklch, var(--client-muted) 16%, transparent);
+ stroke: currentColor;
+ stroke-width: 1;
+ transition: fill 260ms ease, stroke 260ms ease;
}
-.client-rule-check {
- stroke-width: 1.8;
- stroke-dasharray: 12;
- stroke-dashoffset: 12;
- transition: opacity 160ms ease, stroke-dashoffset 360ms cubic-bezier(0.16, 1, 0.3, 1);
+.client-rule-switch-thumb {
+ fill: currentColor;
+ stroke: none;
+ transform-box: fill-box;
+ transform-origin: center;
+ transition: transform 320ms cubic-bezier(0.16, 1, 0.3, 1);
}
.client-local-rule-enabled[aria-checked='true'] {
- color: var(--client-accent);
+ color: var(--client-rule-route-color);
}
.client-local-rule-enabled[aria-checked='true'] svg {
- filter: drop-shadow(0 0 7px color-mix(in oklch, var(--client-accent) 48%, transparent));
- transform: scale(1.08);
+ filter: drop-shadow(0 0 7px color-mix(in oklch, var(--client-rule-route-color) 42%, transparent));
}
-.client-local-rule-enabled[aria-checked='true'] circle {
- fill: color-mix(in oklch, var(--client-accent) 12%, transparent);
+.client-local-rule-enabled[aria-checked='true'] .client-rule-switch-track {
+ fill: color-mix(in oklch, var(--client-rule-route-color) 20%, transparent);
}
-.client-local-rule-enabled[aria-checked='true'] .client-rule-check {
- stroke-dashoffset: 0;
+.client-local-rule-enabled[aria-checked='true'] .client-rule-switch-thumb {
+ transform: translateX(8px);
}
.client-rule-type {
@@ -422,15 +477,11 @@
border: 0;
border-radius: 50%;
background: transparent;
- color: var(--client-muted);
+ color: var(--client-rule-route-color);
cursor: pointer;
transition: color 220ms ease, filter 320ms ease, background 220ms ease;
}
-.client-rule-outbound button.is-direct {
- color: var(--client-accent);
-}
-
.client-rule-outbound svg {
width: 20px;
height: 20px;
@@ -448,15 +499,13 @@
.client-rule-outbound button:focus-visible {
outline: 0;
- 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));
+ background: color-mix(in oklch, var(--client-rule-route-color) 10%, transparent);
+ filter: drop-shadow(0 0 5px color-mix(in oklch, var(--client-rule-route-color) 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));
+ background: color-mix(in oklch, var(--client-rule-route-color) 10%, transparent);
+ filter: drop-shadow(0 0 5px color-mix(in oklch, var(--client-rule-route-color) 36%, transparent));
}
.client-rule-outbound button.is-direct:hover:not(:disabled) svg,
@@ -490,8 +539,42 @@
text-transform: var(--type-control-transform);
}
-.client-local-rules-note {
+.client-local-rules-help {
+ grid-column: 1 / -1;
+ margin-top: 4px;
+}
+
+.client-local-rules-help summary {
+ width: fit-content;
+ color: var(--client-muted);
+ font: var(--type-label);
+ letter-spacing: var(--type-label-tracking);
+ text-transform: var(--type-label-transform);
+ cursor: pointer;
+ transition: color 220ms ease, text-shadow 300ms ease;
+}
+
+.client-local-rules-help summary:hover,
+.client-local-rules-help summary:focus-visible {
+ outline: 0;
+ color: var(--client-accent);
+ text-shadow: 0 0 9px color-mix(in oklch, var(--client-accent) 34%, transparent);
+}
+
+.client-local-rules-help > div {
max-width: 48ch;
+ display: grid;
+ gap: 8px;
+ margin-top: 10px;
+ padding: 10px 12px;
+ border-radius: 10px;
+ background: color-mix(in oklch, var(--client-accent) 5%, transparent);
+ animation: client-local-rules-notice 280ms ease both;
+}
+
+.client-local-rules-help > div p {
+ max-width: 48ch;
+ margin: 0;
color: var(--client-muted);
font: var(--type-label);
letter-spacing: var(--type-label-tracking);
diff --git a/src/web/styles/layout.css b/src/web/styles/layout.css
index bc90d03..e7f960e 100644
--- a/src/web/styles/layout.css
+++ b/src/web/styles/layout.css
@@ -710,7 +710,7 @@
}
.client-rule-handle,
- .client-local-rule-enabled,
+ .client-local-rule-enabled-wrap,
.client-row-delete {
width: 44px;
height: 44px;
@@ -752,7 +752,7 @@
grid-row: 1;
}
- .client-local-rule-enabled {
+ .client-local-rule-enabled-wrap {
grid-column: 2;
grid-row: 1;
}
diff --git a/src/web/styles/primitives.css b/src/web/styles/primitives.css
index e12b46a..7922d76 100644
--- a/src/web/styles/primitives.css
+++ b/src/web/styles/primitives.css
@@ -287,12 +287,11 @@
z-index: 0;
}
-.client-deletable-row.is-removing > :not(.client-delete-strike):not(.client-local-rule-enabled) {
+.client-deletable-row.is-removing > :not(.client-delete-strike) {
animation: client-delete-content-dim 820ms ease-out both;
}
-.client-deletable-row.is-removing .client-local-rule-enabled circle,
-.client-deletable-row.is-removing .client-rule-check {
+.client-deletable-row.is-removing .client-rule-switch-thumb {
opacity: 0;
}
@@ -311,9 +310,33 @@
.client-row-delete:hover {
color: oklch(0.68 0.15 28);
filter: drop-shadow(0 0 7px oklch(0.68 0.15 28 / 0.42));
+}
+
+.client-row-delete:hover:not(:has(svg)) {
transform: rotate(8deg) scale(1.1);
}
+.client-row-delete svg {
+ width: 17px;
+ height: 17px;
+ overflow: visible;
+ fill: none;
+ stroke: currentColor;
+ stroke-width: 1.6;
+ stroke-linecap: round;
+ stroke-linejoin: round;
+}
+
+.client-row-delete-lid {
+ transform-origin: center 7px;
+ transition: transform 260ms cubic-bezier(0.16, 1, 0.3, 1);
+}
+
+.client-row-delete:hover:not(:disabled) .client-row-delete-lid,
+.client-row-delete:focus-visible .client-row-delete-lid {
+ transform: translateY(-2px) rotate(-8deg);
+}
+
.client-rule-handle:focus-visible,
.client-local-rule-enabled:focus-visible,
.client-rule-type-trigger:focus-visible,
diff --git a/src/web/styles/themes.css b/src/web/styles/themes.css
index 7a26c66..6bd4a85 100644
--- a/src/web/styles/themes.css
+++ b/src/web/styles/themes.css
@@ -122,10 +122,11 @@
}
.client-local-rule,
+ .client-local-rule::after,
.client-rule-handle,
.client-local-rule-enabled svg,
- .client-local-rule-enabled circle,
- .client-local-rule-enabled path,
+ .client-rule-switch-track,
+ .client-rule-switch-thumb,
.client-rule-type-trigger,
.client-rule-type-trigger svg,
.client-rule-type-list,
@@ -135,9 +136,13 @@
.client-rule-outbound svg,
.client-rule-status-dot,
.client-row-delete,
+ .client-row-delete svg,
+ .client-row-delete-lid,
.client-row-add,
.client-row-add-slot > span,
.client-local-rules-save,
+ .client-local-rules-help summary,
+ .client-local-rules-help > div,
.client-local-rules-runtime,
.client-local-rules-actions button {
transition: none;
diff --git a/test/web/responsive-layout-contract.test.js b/test/web/responsive-layout-contract.test.js
index 26c4c5f..eadff38 100644
--- a/test/web/responsive-layout-contract.test.js
+++ b/test/web/responsive-layout-contract.test.js
@@ -203,7 +203,8 @@ test('routing rules stay in one desktop row and reflow without shrinking drag ta
assert.match(mobile, /\.client-local-rule input \{[\s\S]*grid-row:\s*2/);
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-handle,[\s\S]*\.client-local-rule-enabled-wrap,[\s\S]*width:\s*44px;[\s\S]*height:\s*44px/);
+ assert.match(mobile, /\.client-local-rule-enabled-wrap \{[\s\S]*grid-column:\s*2;[\s\S]*grid-row:\s*1/);
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)'));
diff --git a/test/web/routing-feature-contract.test.js b/test/web/routing-feature-contract.test.js
index 59a13da..2883238 100644
--- a/test/web/routing-feature-contract.test.js
+++ b/test/web/routing-feature-contract.test.js
@@ -46,6 +46,8 @@ test('routing controller owns ordered outbound drafts, capability gating and dra
assert.match(feature, /onLostPointerCapture=\{feature\.losePointerReorder\}/);
assert.match(feature, /keyboardEvent\.preventDefault\(\);[\s\S]*cancelReorder\(\)/);
assert.match(feature, /stopAutoScroll\(session\)[\s\S]*Перемещение отменено/);
+ assert.match(feature, /const slotCenter = \(element: HTMLElement\) => listTop \+ element\.offsetTop \+ element\.offsetHeight \/ 2/);
+ assert.match(feature, /const centers = rulesRef\.current\.map[\s\S]*slotCenter\(ruleRow\)/);
});
test('routing lifecycle and Page orchestration keep the existing guards and blocking scopes', () => {
diff --git a/test/web/rule-editor-contract.test.js b/test/web/rule-editor-contract.test.js
index 2dc7cb5..1180d56 100644
--- a/test/web/rule-editor-contract.test.js
+++ b/test/web/rule-editor-contract.test.js
@@ -32,7 +32,9 @@ test('rule editor add latency stays constant and dirty exits are guarded', () =>
assert.match(routing, /client-deletable-row/);
assert.match(routing, /className="client-delete-strike"[\s\S]*onAnimationEnd/);
assert.doesNotMatch(routing, /client-rule-delete-cross/);
- assert.match(routing, /className="client-row-delete"/);
+ assert.match(routing, /className="client-row-delete"[\s\S]*client-row-delete-lid/);
+ assert.match(styles, /\.client-row-delete-lid \{[\s\S]*transition: transform/);
+ assert.match(styles, /\.client-row-delete:hover:not\(:disabled\) \.client-row-delete-lid,[\s\S]*translateY\(-2px\) rotate\(-8deg\)/);
assert.match(styles, /\.client-deletable-row\.is-removing > \.client-delete-strike[\s\S]*client-delete-strike/);
assert.match(styles, /\.client-delete-strike \{[\s\S]*z-index: 100[\s\S]*background: transparent/);
assert.match(styles, /\.client-deletable-row\.is-removing > :not\(\.client-delete-strike\)[\s\S]*z-index: 0/);
@@ -96,7 +98,7 @@ test('copy feedback, drawers and Gateway access actions expose complete semantic
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-rule-handle,[\s\S]*\.client-local-rule-enabled,[\s\S]*\.client-row-delete \{[\s\S]*width: 44px;[\s\S]*height: 44px/);
+ assert.match(styles, /@media \(max-width: 560px\)[\s\S]*\.client-rule-handle,[\s\S]*\.client-local-rule-enabled-wrap,[\s\S]*\.client-row-delete \{[\s\S]*width: 44px;[\s\S]*height: 44px/);
});
test('ordered rules use one accessible drag handle and a compact icon route control', () => {
@@ -114,6 +116,14 @@ test('ordered rules use one accessible drag handle and a compact icon route cont
assert.doesNotMatch(routing, /client-rule-outbound" role="group"/);
assert.match(routing, /className=\{`client-local-rules-list\$\{draftRules\.length > 1 \? ' has-order-flow' : ''\}`\}[\s\S]*aria-label="Правила применяются сверху вниз"/);
assert.match(routing, /className="client-rule-status-dot"/);
+ assert.match(routing, /role="switch"[\s\S]*aria-checked=\{rule\.enabled\}[\s\S]*client-rule-switch-track[\s\S]*client-rule-switch-thumb/);
+ assert.match(routing, /Отключить правило'[\s\S]*Включить правило'[\s\S]*/);
+ assert.match(routing, /data-route=\{rule\.outbound\}/);
+ assert.match(styles, /\.client-local-rule\[data-route='direct'\] \{[\s\S]*--client-rule-route-color/);
+ assert.match(styles, /\.client-local-rule::after \{[\s\S]*background: var\(--client-rule-route-color\)/);
+ assert.match(styles, /\.client-local-rule\.is-active \.client-rule-status-dot \{[\s\S]*background: currentColor/);
+ assert.match(styles, /\.client-local-rule\.is-unsaved \.client-rule-status-dot \{[\s\S]*border-radius: 1px[\s\S]*rotate\(45deg\)/);
+ assert.match(styles, /\.client-local-rule\.is-pending \.client-rule-status-dot \{[\s\S]*border: 1\.5px dashed/);
assert.doesNotMatch(routing, />\s*[↑↓]\s*);
assert.match(styles, /\.client-rule-handle \{[\s\S]*width: 44px;[\s\S]*height: 44px;[\s\S]*touch-action: none/);
assert.match(styles, /\.client-rule-outbound \{[\s\S]*width: 34px/);
@@ -121,3 +131,12 @@ test('ordered rules use one accessible drag handle and a compact icon route cont
assert.match(styles, /\.client-local-rules-list\.has-order-flow::before[\s\S]*linear-gradient\(to bottom/);
assert.match(styles, /\.client-local-rule \+ \.client-local-rule::before[\s\S]*linear-gradient\(90deg/);
});
+
+test('rule values normalize on paste and help stays collapsed above the list', () => {
+ assert.match(routing, /normalizeRouteRules\(\[\{ \.\.\.rule, value \}\], \{ strict: true \}\)/);
+ assert.match(routing, /onPaste=\{\(event\) => \{[\s\S]*event\.preventDefault\(\)[\s\S]*normalizeDraftRuleValue\(rule, event\.clipboardData\.getData\('text'\)\)/);
+ assert.match(routing, /onBlur=\{\(\) => feature\.change\(index, 'value', normalizeDraftRuleValue\(rule, rule\.value\)\)\}/);
+ assert.match(routing, /[\s\S]*[\s\S]*Как работают правила[\s\S]*