Polish routing rules UI and normalize pasted values
Build and Deploy Gateway / build-and-push (push) Successful in 22s
Build and Deploy Gateway / deploy (push) Successful in 6s

This commit is contained in:
2026-08-17 16:27:25 +03:00
parent bc86741397
commit f3be0b2fd0
11 changed files with 254 additions and 97 deletions
+2 -2
View File
@@ -1,6 +1,6 @@
export const HARBOR_VERSIONS = Object.freeze({ export const HARBOR_VERSIONS = Object.freeze({
macClient: '0.26.1', macClient: '0.26.2',
gatewayClient: '0.27.1', gatewayClient: '0.27.2',
gatewayBackend: '0.27.0', gatewayBackend: '0.27.0',
}); });
+41 -17
View File
@@ -12,6 +12,7 @@ import {
import { flushSync } from 'react-dom'; import { flushSync } from 'react-dom';
import { import {
canAppendRouteRule, canAppendRouteRule,
normalizeRouteRules,
ROUTE_RULES_CONTRACT_VERSION, ROUTE_RULES_CONTRACT_VERSION,
} from '../../../shared/routingRules.js'; } from '../../../shared/routingRules.js';
import type { RouteRule } from '../../../shared/contracts/state.js'; import type { RouteRule } from '../../../shared/contracts/state.js';
@@ -112,6 +113,14 @@ const sameRule = (left?: RouteRule, right?: RouteRule) => (
Boolean(left && right && localRuleKey(left) === localRuleKey(right)) Boolean(left && right && localRuleKey(left) === localRuleKey(right))
); );
const normalizeDraftRuleValue = (rule: DraftRule, value: string) => {
try {
return normalizeRouteRules([{ ...rule, value }], { strict: true })[0]?.value || value;
} catch {
return value;
}
};
function localRuleStatus( function localRuleStatus(
rule: DraftRule, rule: DraftRule,
index: number, index: number,
@@ -382,27 +391,28 @@ export function useRoutingFeature({
function positionPointerRule(pointerY: number) { function positionPointerRule(pointerY: number) {
const session = dragRef.current; const session = dragRef.current;
if (!session?.lifted) return; if (!session?.lifted) return;
const list = panelRef.current?.querySelector<HTMLElement>('.client-local-rules-list');
if (!list) return;
const rows = ruleRows(); const rows = ruleRows();
let row = rows.get(session.key); let row = rows.get(session.key);
if (!row) return; if (!row) return;
let rect = row.getBoundingClientRect();
const desiredCenter = pointerY - session.pointerOffsetY; const desiredCenter = pointerY - session.pointerOffsetY;
const baseCenter = rect.top + rect.height / 2 - session.translateY; const listTop = list.getBoundingClientRect().top;
session.translateY = desiredCenter - baseCenter; const slotCenter = (element: HTMLElement) => listTop + element.offsetTop + element.offsetHeight / 2;
session.translateY = desiredCenter - slotCenter(row);
row.style.setProperty('--client-rule-drag-y', `${session.translateY}px`); row.style.setProperty('--client-rule-drag-y', `${session.translateY}px`);
const currentIndex = rulesRef.current.findIndex((rule) => rule._key === session.key); const currentIndex = rulesRef.current.findIndex((rule) => rule._key === session.key);
const centers = rulesRef.current.map((rule) => { const centers = rulesRef.current.map((rule) => {
const ruleRect = rows.get(rule._key)?.getBoundingClientRect(); const ruleRow = rows.get(rule._key);
return ruleRect ? ruleRect.top + ruleRect.height / 2 : desiredCenter; return ruleRow ? slotCenter(ruleRow) : desiredCenter;
}); });
const target = crossedRuleIndex(currentIndex, desiredCenter, centers); const target = crossedRuleIndex(currentIndex, desiredCenter, centers);
if (target === currentIndex) return; if (target === currentIndex) return;
moveRuleTo(session.key, target); moveRuleTo(session.key, target);
row = ruleRows().get(session.key); row = ruleRows().get(session.key);
if (!row) return; if (!row) return;
rect = row.getBoundingClientRect(); session.translateY = desiredCenter - slotCenter(row);
session.translateY = desiredCenter - (rect.top + rect.height / 2 - session.translateY);
row.style.setProperty('--client-rule-drag-y', `${session.translateY}px`); row.style.setProperty('--client-rule-drag-y', `${session.translateY}px`);
setReorderAnnouncement(`Правило перемещено на позицию ${target + 1}`); setReorderAnnouncement(`Правило перемещено на позицию ${target + 1}`);
} }
@@ -896,6 +906,14 @@ export function RoutingPanel({ feature, statusSlot }: { feature: RoutingFeature;
Правила сохранены и начнут работать после запуска Harbor. Правила сохранены и начнут работать после запуска Harbor.
</p> </p>
)} )}
<details className="client-local-rules-help">
<summary>Как работают правила</summary>
<div>
<p>Правила проверяются сверху вниз. Первое совпадение выбирает маршрут: щит означает VPN, стрелка прямое подключение.</p>
<p>Правила применяются только к трафику внутри маршрутизации Harbor. Gateway в режиме «Напрямую» и Connect при активном Harbor Gateway обходят локальный список.</p>
<p>Можно вставить полный URL: Harbor сразу оставит только домен. Путь и параметры HTTPS для маршрутизации недоступны.</p>
</div>
</details>
</header> </header>
<form id="client-local-rules-form" className="client-local-rules-form" onSubmit={feature.save}> <form id="client-local-rules-form" className="client-local-rules-form" onSubmit={feature.save}>
@@ -928,6 +946,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' : ''}`} className={`client-local-rule client-deletable-row is-${status}${rule.enabled ? '' : ' is-disabled'}${rule.removing ? ' is-removing' : ''}${lifted ? ' is-dragging' : ''}`}
key={rule._key} key={rule._key}
data-rule-key={rule._key} data-rule-key={rule._key}
data-route={rule.outbound}
role="listitem" role="listitem"
style={{ viewTransitionName: rule.removing ? 'none' : rule._key }} style={{ viewTransitionName: rule.removing ? 'none' : rule._key }}
inert={rule.removing ? true : undefined} inert={rule.removing ? true : undefined}
@@ -954,20 +973,23 @@ export function RoutingPanel({ feature, statusSlot }: { feature: RoutingFeature;
<circle cx="6" cy="22" r="1.6" /> <circle cx="6" cy="22" r="1.6" />
</svg> </svg>
</button> </button>
<span className="client-local-rule-enabled-wrap client-tooltip-anchor">
<button <button
className="client-local-rule-enabled" className="client-local-rule-enabled"
type="button" type="button"
role="switch" role="switch"
aria-checked={rule.enabled} aria-checked={rule.enabled}
aria-label={`${rule.enabled ? 'Выключить' : 'Включить'} правило ${index + 1}`} aria-label={`${rule.enabled ? 'Отключить' : 'Включить'} правило ${index + 1}`}
disabled={editorDisabled} disabled={editorDisabled}
onClick={() => feature.change(index, 'enabled', !rule.enabled)} onClick={() => feature.change(index, 'enabled', !rule.enabled)}
> >
<svg viewBox="0 0 20 20" aria-hidden="true"> <svg viewBox="0 0 20 20" aria-hidden="true">
<circle cx="10" cy="10" r="6" /> <rect className="client-rule-switch-track" x="2" y="6" width="16" height="8" rx="4" />
<path className="client-rule-check" d="m6.8 10.1 2.1 2.2 4.5-5" /> <circle className="client-rule-switch-thumb" cx="6" cy="10" r="2.5" />
</svg> </svg>
</button> </button>
<Tooltip>{rule.enabled ? 'Отключить правило' : 'Включить правило'}</Tooltip>
</span>
<RuleTypePicker <RuleTypePicker
value={rule.type} value={rule.type}
ruleKey={rule._key} ruleKey={rule._key}
@@ -986,6 +1008,11 @@ export function RoutingPanel({ feature, statusSlot }: { feature: RoutingFeature;
placeholder={ROUTE_RULE_PLACEHOLDERS[rule.type]} placeholder={ROUTE_RULE_PLACEHOLDERS[rule.type]}
value={rule.value} value={rule.value}
onChange={(event) => feature.change(index, 'value', event.target.value)} onChange={(event) => feature.change(index, 'value', event.target.value)}
onPaste={(event) => {
event.preventDefault();
feature.change(index, 'value', normalizeDraftRuleValue(rule, event.clipboardData.getData('text')));
}}
onBlur={() => feature.change(index, 'value', normalizeDraftRuleValue(rule, rule.value))}
/> />
<RuleOutboundPicker <RuleOutboundPicker
value={rule.outbound} value={rule.outbound}
@@ -1008,7 +1035,10 @@ export function RoutingPanel({ feature, statusSlot }: { feature: RoutingFeature;
disabled={editorDisabled} disabled={editorDisabled}
onClick={() => feature.remove(rule._key)} onClick={() => feature.remove(rule._key)}
> >
× <svg viewBox="0 0 24 24" aria-hidden="true">
<path className="client-row-delete-lid" d="M8 7V5h8v2m-11 0h14" />
<path d="M7 7l1 13h8l1-13M10 10v7m4-7v7" />
</svg>
</button> </button>
<span <span
className="client-delete-strike" className="client-delete-strike"
@@ -1028,12 +1058,6 @@ export function RoutingPanel({ feature, statusSlot }: { feature: RoutingFeature;
</div> </div>
</section> </section>
<p className="client-local-rules-note">
Правила применяются только к трафику, который вошёл в VPN-маршрутизацию Harbor. Устройство Gateway в режиме «Напрямую» и Connect при активном Harbor Gateway обходят локальный список; «Напрямую» внутри правила результат уже найденного совпадения.
</p>
<p className="client-local-rules-note">
Можно вставить полный URL: Harbor сохранит только домен. Путь и параметры HTTPS недоступны для маршрутизации.
</p>
{statusSlot} {statusSlot}
<div className="client-local-rules-actions"> <div className="client-local-rules-actions">
<button type="button" onClick={feature.requestClose}>Отмена</button> <button type="button" onClick={feature.requestClose}>Отмена</button>
+122 -39
View File
@@ -102,6 +102,7 @@
.client-local-rule { .client-local-rule {
--client-rule-drag-y: 0px; --client-rule-drag-y: 0px;
--client-rule-route-color: var(--client-accent);
position: relative; position: relative;
isolation: isolate; isolation: isolate;
min-width: 0; min-width: 0;
@@ -110,8 +111,28 @@
align-items: center; align-items: center;
column-gap: 6px; column-gap: 6px;
padding: 4px 0; 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; 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 { .client-local-rule + .client-local-rule::before {
@@ -140,23 +161,46 @@
width: 6px; width: 6px;
height: 6px; height: 6px;
border-radius: 50%; border-radius: 50%;
background: currentColor; border: 1px solid currentColor;
background: transparent;
opacity: 0.58; 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 { .client-local-rule.is-active .client-rule-status-dot {
color: var(--client-accent); color: var(--client-accent);
border: 0;
background: currentColor;
opacity: 1; opacity: 1;
filter: drop-shadow(0 0 5px color-mix(in oklch, var(--client-accent) 52%, transparent)); filter: drop-shadow(0 0 5px color-mix(in oklch, var(--client-accent) 52%, transparent));
transform: scale(1.14); transform: scale(1.14);
} }
.client-local-rule.is-pending .client-rule-status-dot,
.client-local-rule.is-unsaved .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)); color: var(--client-warning, oklch(0.72 0.12 72));
opacity: 1; 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) { .client-local-rule.is-disabled > :not(.client-rule-handle):not(.client-row-delete):not(.client-delete-strike) {
@@ -164,11 +208,16 @@
filter: saturate(0); filter: saturate(0);
} }
.client-local-rule.is-disabled::after {
opacity: 0.22;
filter: saturate(0);
}
.client-local-rule.is-dragging { .client-local-rule.is-dragging {
z-index: 6; z-index: 6;
animation: none; animation: none;
background: color-mix(in oklch, var(--client-accent) 5%, 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-accent) 12%, transparent); box-shadow: 0 10px 28px color-mix(in oklch, var(--client-rule-route-color) 16%, transparent);
transform: translateY(var(--client-rule-drag-y)); 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; 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 { .client-local-rule-enabled {
width: 24px; width: 24px;
height: 32px; height: 32px;
@@ -228,43 +284,42 @@
} }
.client-local-rule-enabled svg { .client-local-rule-enabled svg {
width: 18px; width: 20px;
height: 18px; height: 20px;
overflow: visible; overflow: visible;
fill: none; 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); transition: color 260ms ease, filter 360ms ease, transform 420ms cubic-bezier(0.16, 1, 0.3, 1);
} }
.client-local-rule-enabled circle { .client-rule-switch-track {
stroke-width: 1.4; fill: color-mix(in oklch, var(--client-muted) 16%, transparent);
transition: fill 320ms ease, stroke 260ms ease; stroke: currentColor;
stroke-width: 1;
transition: fill 260ms ease, stroke 260ms ease;
} }
.client-rule-check { .client-rule-switch-thumb {
stroke-width: 1.8; fill: currentColor;
stroke-dasharray: 12; stroke: none;
stroke-dashoffset: 12; transform-box: fill-box;
transition: opacity 160ms ease, stroke-dashoffset 360ms cubic-bezier(0.16, 1, 0.3, 1); transform-origin: center;
transition: transform 320ms cubic-bezier(0.16, 1, 0.3, 1);
} }
.client-local-rule-enabled[aria-checked='true'] { .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 { .client-local-rule-enabled[aria-checked='true'] svg {
filter: drop-shadow(0 0 7px color-mix(in oklch, var(--client-accent) 48%, transparent)); filter: drop-shadow(0 0 7px color-mix(in oklch, var(--client-rule-route-color) 42%, transparent));
transform: scale(1.08);
} }
.client-local-rule-enabled[aria-checked='true'] circle { .client-local-rule-enabled[aria-checked='true'] .client-rule-switch-track {
fill: color-mix(in oklch, var(--client-accent) 12%, transparent); fill: color-mix(in oklch, var(--client-rule-route-color) 20%, transparent);
} }
.client-local-rule-enabled[aria-checked='true'] .client-rule-check { .client-local-rule-enabled[aria-checked='true'] .client-rule-switch-thumb {
stroke-dashoffset: 0; transform: translateX(8px);
} }
.client-rule-type { .client-rule-type {
@@ -422,15 +477,11 @@
border: 0; border: 0;
border-radius: 50%; border-radius: 50%;
background: transparent; background: transparent;
color: var(--client-muted); color: var(--client-rule-route-color);
cursor: pointer; cursor: pointer;
transition: color 220ms ease, filter 320ms ease, background 220ms ease; transition: color 220ms ease, filter 320ms ease, background 220ms ease;
} }
.client-rule-outbound button.is-direct {
color: var(--client-accent);
}
.client-rule-outbound svg { .client-rule-outbound svg {
width: 20px; width: 20px;
height: 20px; height: 20px;
@@ -448,15 +499,13 @@
.client-rule-outbound button:focus-visible { .client-rule-outbound button:focus-visible {
outline: 0; outline: 0;
background: color-mix(in oklch, var(--client-accent) 10%, transparent); background: color-mix(in oklch, var(--client-rule-route-color) 10%, transparent);
color: var(--client-accent); filter: drop-shadow(0 0 5px color-mix(in oklch, var(--client-rule-route-color) 36%, transparent));
filter: drop-shadow(0 0 5px color-mix(in oklch, var(--client-accent) 36%, transparent));
} }
.client-rule-outbound button:hover:not(:disabled) { .client-rule-outbound button:hover:not(:disabled) {
background: color-mix(in oklch, var(--client-accent) 10%, transparent); background: color-mix(in oklch, var(--client-rule-route-color) 10%, transparent);
color: var(--client-accent); filter: drop-shadow(0 0 5px color-mix(in oklch, var(--client-rule-route-color) 36%, transparent));
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:hover:not(:disabled) svg,
@@ -490,8 +539,42 @@
text-transform: var(--type-control-transform); 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; 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); color: var(--client-muted);
font: var(--type-label); font: var(--type-label);
letter-spacing: var(--type-label-tracking); letter-spacing: var(--type-label-tracking);
+2 -2
View File
@@ -710,7 +710,7 @@
} }
.client-rule-handle, .client-rule-handle,
.client-local-rule-enabled, .client-local-rule-enabled-wrap,
.client-row-delete { .client-row-delete {
width: 44px; width: 44px;
height: 44px; height: 44px;
@@ -752,7 +752,7 @@
grid-row: 1; grid-row: 1;
} }
.client-local-rule-enabled { .client-local-rule-enabled-wrap {
grid-column: 2; grid-column: 2;
grid-row: 1; grid-row: 1;
} }
+26 -3
View File
@@ -287,12 +287,11 @@
z-index: 0; 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; 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-switch-thumb {
.client-deletable-row.is-removing .client-rule-check {
opacity: 0; opacity: 0;
} }
@@ -311,9 +310,33 @@
.client-row-delete:hover { .client-row-delete:hover {
color: oklch(0.68 0.15 28); color: oklch(0.68 0.15 28);
filter: drop-shadow(0 0 7px oklch(0.68 0.15 28 / 0.42)); 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); 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-rule-handle:focus-visible,
.client-local-rule-enabled:focus-visible, .client-local-rule-enabled:focus-visible,
.client-rule-type-trigger:focus-visible, .client-rule-type-trigger:focus-visible,
+7 -2
View File
@@ -122,10 +122,11 @@
} }
.client-local-rule, .client-local-rule,
.client-local-rule::after,
.client-rule-handle, .client-rule-handle,
.client-local-rule-enabled svg, .client-local-rule-enabled svg,
.client-local-rule-enabled circle, .client-rule-switch-track,
.client-local-rule-enabled path, .client-rule-switch-thumb,
.client-rule-type-trigger, .client-rule-type-trigger,
.client-rule-type-trigger svg, .client-rule-type-trigger svg,
.client-rule-type-list, .client-rule-type-list,
@@ -135,9 +136,13 @@
.client-rule-outbound svg, .client-rule-outbound svg,
.client-rule-status-dot, .client-rule-status-dot,
.client-row-delete, .client-row-delete,
.client-row-delete svg,
.client-row-delete-lid,
.client-row-add, .client-row-add,
.client-row-add-slot > span, .client-row-add-slot > span,
.client-local-rules-save, .client-local-rules-save,
.client-local-rules-help summary,
.client-local-rules-help > div,
.client-local-rules-runtime, .client-local-rules-runtime,
.client-local-rules-actions button { .client-local-rules-actions button {
transition: none; transition: none;
+2 -1
View File
@@ -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-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-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-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-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/); assert.match(mobile, /\.client-rule-type-list button \{[\s\S]*min-height:\s*44px/);
const compact = layoutStyles.slice(layoutStyles.indexOf('@media (max-width: 360px)')); const compact = layoutStyles.slice(layoutStyles.indexOf('@media (max-width: 360px)'));
@@ -46,6 +46,8 @@ test('routing controller owns ordered outbound drafts, capability gating and dra
assert.match(feature, /onLostPointerCapture=\{feature\.losePointerReorder\}/); assert.match(feature, /onLostPointerCapture=\{feature\.losePointerReorder\}/);
assert.match(feature, /keyboardEvent\.preventDefault\(\);[\s\S]*cancelReorder\(\)/); assert.match(feature, /keyboardEvent\.preventDefault\(\);[\s\S]*cancelReorder\(\)/);
assert.match(feature, /stopAutoScroll\(session\)[\s\S]*Перемещение отменено/); 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', () => { test('routing lifecycle and Page orchestration keep the existing guards and blocking scopes', () => {
+21 -2
View File
@@ -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, /client-deletable-row/);
assert.match(routing, /className="client-delete-strike"[\s\S]*onAnimationEnd/); assert.match(routing, /className="client-delete-strike"[\s\S]*onAnimationEnd/);
assert.doesNotMatch(routing, /client-rule-delete-cross/); 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-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-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/); 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.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, /\.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-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', () => { 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.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-local-rules-list\$\{draftRules\.length > 1 \? ' has-order-flow' : ''\}`\}[\s\S]*aria-label="Правила применяются сверху вниз"/);
assert.match(routing, /className="client-rule-status-dot"/); 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]*<Tooltip>/);
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.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-handle \{[\s\S]*width: 44px;[\s\S]*height: 44px;[\s\S]*touch-action: none/);
assert.match(styles, /\.client-rule-outbound \{[\s\S]*width: 34px/); 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-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/); 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, /<details className="client-local-rules-help">[\s\S]*<summary>[\s\S]*Как работают правила[\s\S]*<form id="client-local-rules-form"/);
assert.doesNotMatch(routing, /client-local-rules-note/);
assert.match(styles, /\.client-local-rules-help > div \{[\s\S]*background: color-mix/);
});
+17 -17
View File
@@ -37,26 +37,26 @@ const expectedImports = [
const sha256 = (value) => crypto.createHash('sha256').update(value).digest('hex'); const sha256 = (value) => crypto.createHash('sha256').update(value).digest('hex');
const acceptedLedger = { const acceptedLedger = {
counts: { counts: {
cascadeEdges: 927, cascadeEdges: 949,
customProperties: 106, customProperties: 108,
declarations: 3436, declarations: 3503,
important: 0, important: 0,
keyframes: 49, keyframes: 49,
media: 13, media: 13,
rules: 971, rules: 984,
variableReferences: 834, variableReferences: 844,
}, },
hashes: { hashes: {
cascadeEdges: 'ea513313fc4cdfa85a5de74c76bdf06a00df1fc8f71825053b05f06684c7d6df', cascadeEdges: '4c0a4c67d60c95181f4267225cbcba7e24eac3355be5e6707db9ed947a4fb54f',
customProperties: '06f39794d72566c2b2f8e4a2354866a54dbdceac0a1fdf9ae2619aae9abf2f36', customProperties: '3b97add4c3d685a532afff5046ddbe2b6eebcc0866c62781348559897f2930fb',
declarations: '0109c86e26e22898aec17aca2cf952edb88748499735c0fd2fd2163d7dd37dda', declarations: 'f630814428eeb4fef56795e2b8323c0fd567bc3d2334c5603973893d2a54c9f7',
duplicateKeyframes: '4f53cda18c2baa0c0354bb5f9a3ecbe5ed12ab4d8e11ba873c2f11161202b945', duplicateKeyframes: '4f53cda18c2baa0c0354bb5f9a3ecbe5ed12ab4d8e11ba873c2f11161202b945',
duplicateSelectors: '8982145dba05b33bf1c93b2d54cfbe76305b276ff3c657aa020144016ada5848', duplicateSelectors: 'e87ccd4a057619f7ae0791564b50867d47473121bf0b0468cf1aceeafb806a1a',
keyframes: '9e78309512ed82b1e9f87c58aeff505dfcdb694fc30c8f54570d01dce13eb51a', keyframes: '9e78309512ed82b1e9f87c58aeff505dfcdb694fc30c8f54570d01dce13eb51a',
ruleDeclarationSequences: '60267f6fbf59602e9028a9be4d8b591e1cd3e999eb5b86ab4e129199fd1853ea', ruleDeclarationSequences: 'b6105ca3e89ca8a4b0f1c3b7e1ed7d1556da7afc856db19d3b36cc48e2de96b0',
selectors: '9970cb8a580a9c57a3f39f7a801bef08e76a905ba8042deae58ac637b9769781', selectors: 'd46d8c4b327f0c7ad2684ee05bf2338271c648fe0548315331c2a8719632e424',
variableReferences: '54bf2dffb86a0c9fc42c3d309cada316f446b17d8e54ce3c89b1108adb6e3db6', variableReferences: '74c781fe61c4ca9734dc76415d4335612e0a0d7c26b3b3731d1bc0bd3c870b5b',
witnesses: '0db2c977cfd7cd86209a972e02bcbca9c22722155a88ba789ccf2a395eae4cfa', witnesses: 'c1248557ba723246417861681ca79c407b3aa47aee1b5e45fb464fb9c135c711',
}, },
}; };
@@ -209,7 +209,7 @@ test('client typography uses the shared semantic scale outside the token owner',
test('accepted stylesheet has pinned declaration, selector, keyframe, variable, and cascade ledgers', () => { test('accepted stylesheet has pinned declaration, selector, keyframe, variable, and cascade ledgers', () => {
const witnesses = readStyleWitnesses(root); const witnesses = readStyleWitnesses(root);
assert.equal(witnesses.length, 845); assert.equal(witnesses.length, 854);
assert.equal(witnesses.filter((witness) => witness.unknown || witness.ancestorUnknown).length, 0); assert.equal(witnesses.filter((witness) => witness.unknown || witness.ancestorUnknown).length, 0);
const ledger = createStyleLedger(readStyleSource(root), { witnesses }); const ledger = createStyleLedger(readStyleSource(root), { witnesses });
assert.deepEqual(ledger.counts, acceptedLedger.counts); assert.deepEqual(ledger.counts, acceptedLedger.counts);
@@ -405,8 +405,8 @@ test('main owns one public stylesheet and the regrouped production CSS is determ
assert.equal((main.match(/import ['"][^'"]+\.css['"]/g) || []).length, 1); assert.equal((main.match(/import ['"][^'"]+\.css['"]/g) || []).length, 1);
const assets = fs.readdirSync(path.join(root, 'dist/assets')).filter((file) => file.endsWith('.css')); const assets = fs.readdirSync(path.join(root, 'dist/assets')).filter((file) => file.endsWith('.css'));
assert.deepEqual(assets, ['index-D1sDzEdb.css']); assert.deepEqual(assets, ['index-Ch0T5A1e.css']);
const built = fs.readFileSync(path.join(root, 'dist/assets', assets[0])); const built = fs.readFileSync(path.join(root, 'dist/assets', assets[0]));
assert.equal(built.byteLength, 133160); assert.equal(built.byteLength, 135594);
assert.equal(sha256(built), '165e6706c69d737a09b9cdde9d8211e94dbea49b3df42c8e5c9d194993e6b618'); assert.equal(sha256(built), '8425efe616036b5b37f0acc7476c1ef01c0c5168bb5e531863e498ca20a8dfa8');
}); });
+1 -1
View File
@@ -32,7 +32,7 @@ test('all repeated client primitive consumers use the shared owners', () => {
.map(({ source }) => source) .map(({ source }) => source)
.join('\n'); .join('\n');
assert.equal((production.match(/<Tooltip\b/g) || []).length, 12); assert.equal((production.match(/<Tooltip\b/g) || []).length, 13);
assert.equal((production.match(/<CopyButton\b/g) || []).length, 2); assert.equal((production.match(/<CopyButton\b/g) || []).length, 2);
assert.equal((production.match(/<RailAction\b/g) || []).length, 5); assert.equal((production.match(/<RailAction\b/g) || []).length, 5);
assert.equal((production.match(/<Drawer\b/g) || []).length, 5); assert.equal((production.match(/<Drawer\b/g) || []).length, 5);