+
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)}
+ />
+
+
+ {statusLabel}
+
);
})}
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, /