Refine rail drawer switching and toggle animations
Build and Deploy Gateway / build-and-push (push) Successful in 21s
Build and Deploy Gateway / deploy (push) Successful in 7s

This commit is contained in:
2026-08-14 15:52:56 +03:00
parent a9eca0e9d4
commit 5a21a09b82
11 changed files with 215 additions and 90 deletions
+2 -2
View File
@@ -1,6 +1,6 @@
export const HARBOR_VERSIONS = Object.freeze({ export const HARBOR_VERSIONS = Object.freeze({
macClient: '0.25.11', macClient: '0.25.12',
gatewayClient: '0.26.10', gatewayClient: '0.26.11',
gatewayBackend: '0.26.5', gatewayBackend: '0.26.5',
}); });
+15 -1
View File
@@ -445,6 +445,7 @@ export function ClientOverviewPage({
const [showIntro, setShowIntro] = useState(true); const [showIntro, setShowIntro] = useState(true);
const [copyFeedback, setCopyFeedback] = useState<CopyFeedbackMap>({}); const [copyFeedback, setCopyFeedback] = useState<CopyFeedbackMap>({});
const [copyAnnouncement, setCopyAnnouncement] = useState<CopyAnnouncement>({ text: '', cycle: 0 }); const [copyAnnouncement, setCopyAnnouncement] = useState<CopyAnnouncement>({ text: '', cycle: 0 });
const [drawerSwitchTarget, setDrawerSwitchTarget] = useState<DrawerKey | null>(null);
const copyTimersRef = useRef<Partial<Record<CopyKind, ReturnType<typeof setTimeout>>>>({}); const copyTimersRef = useRef<Partial<Record<CopyKind, ReturnType<typeof setTimeout>>>>({});
const copyAttemptsRef = useRef<Partial<Record<CopyKind, object>>>({}); const copyAttemptsRef = useRef<Partial<Record<CopyKind, object>>>({});
const drawerSwitchRef = useRef<{ const drawerSwitchRef = useRef<{
@@ -567,6 +568,9 @@ export function ClientOverviewPage({
const drawerOrder = isGateway const drawerOrder = isGateway
? DRAWER_ORDER ? DRAWER_ORDER
: DRAWER_ORDER.filter((drawer) => drawer !== 'devices'); : DRAWER_ORDER.filter((drawer) => drawer !== 'devices');
const activeRailDrawer = drawerSwitchTarget && drawerControls[drawerSwitchTarget].isOpen
? drawerSwitchTarget
: drawerOrder.find((drawer) => drawerControls[drawer].isOpen) || null;
useEffect(() => { useEffect(() => {
setNow(Date.now()); setNow(Date.now());
@@ -673,11 +677,13 @@ export function ClientOverviewPage({
return; return;
} }
setDrawerSwitchTarget(target);
flushSync(() => toControl.show()); flushSync(() => toControl.show());
const from = fromControl.panelRef.current; const from = fromControl.panelRef.current;
const to = toControl.panelRef.current; const to = toControl.panelRef.current;
if (!from || !to || typeof from.animate !== 'function' || typeof to.animate !== 'function') { if (!from || !to || typeof from.animate !== 'function' || typeof to.animate !== 'function') {
fromControl.close(); fromControl.close();
setDrawerSwitchTarget(null);
return; return;
} }
@@ -707,7 +713,10 @@ export function ClientOverviewPage({
const finish = () => { const finish = () => {
if (finished) return; if (finished) return;
finished = true; finished = true;
flushSync(() => fromControl.close()); flushSync(() => {
fromControl.close();
setDrawerSwitchTarget(null);
});
cancel(); cancel();
if (drawerSwitchRef.current?.target === target) drawerSwitchRef.current = null; if (drawerSwitchRef.current?.target === target) drawerSwitchRef.current = null;
}; };
@@ -745,22 +754,27 @@ export function ClientOverviewPage({
> >
<SubscriptionToggle <SubscriptionToggle
feature={subscriptionFeature} feature={subscriptionFeature}
open={activeRailDrawer === 'subscription'}
onToggle={() => switchDrawer('subscription')} onToggle={() => switchDrawer('subscription')}
/> />
<InstructionsToggle <InstructionsToggle
feature={instructionsFeature} feature={instructionsFeature}
open={activeRailDrawer === 'instructions'}
onToggle={() => switchDrawer('instructions')} onToggle={() => switchDrawer('instructions')}
/> />
{isGateway && <DevicesToggle {isGateway && <DevicesToggle
feature={devicesFeature} feature={devicesFeature}
open={activeRailDrawer === 'devices'}
onToggle={() => switchDrawer('devices')} onToggle={() => switchDrawer('devices')}
/>} />}
<DiagnosticsToggle <DiagnosticsToggle
feature={diagnosticsFeature} feature={diagnosticsFeature}
open={activeRailDrawer === 'diagnostics'}
onToggle={() => switchDrawer('diagnostics')} onToggle={() => switchDrawer('diagnostics')}
/> />
<RoutingToggle <RoutingToggle
feature={routingFeature} feature={routingFeature}
open={activeRailDrawer === 'routing'}
gatewayDirect={gatewayDirect} gatewayDirect={gatewayDirect}
isGateway={isGateway} isGateway={isGateway}
hasSubscription={hasSubscription} hasSubscription={hasSubscription}
+14 -4
View File
@@ -224,20 +224,30 @@ export function useDevicesFeature({
export type DevicesFeature = ReturnType<typeof useDevicesFeature>; export type DevicesFeature = ReturnType<typeof useDevicesFeature>;
export function DevicesToggle({ feature, onToggle }: { feature: DevicesFeature; onToggle: () => void }) { export function DevicesToggle({
feature,
open,
onToggle,
}: {
feature: DevicesFeature;
open: boolean;
onToggle: () => void;
}) {
return <RailAction return <RailAction
buttonRef={feature.toggleRef} buttonRef={feature.toggleRef}
className="client-instructions-toggle client-devices-toggle" className="client-instructions-toggle client-devices-toggle"
open={feature.isOpen} open={open}
controls="client-devices" controls="client-devices"
ariaLabel={feature.isOpen ? 'Закрыть устройства' : 'Устройства Gateway'} ariaLabel={open ? 'Закрыть устройства' : 'Устройства Gateway'}
label="Устройства" label="Устройства"
onClick={onToggle} onClick={onToggle}
> >
<svg viewBox="0 0 24 24" aria-hidden="true"> <svg viewBox="0 0 24 24" aria-hidden="true">
<rect className="client-rail-device-primary" x="3.5" y="5" width="7" height="10" rx="1.5" /> <rect className="client-rail-device-primary" x="3.5" y="5" width="7" height="10" rx="1.5" />
<rect className="client-rail-device-secondary" x="13.5" y="8" width="7" height="7" rx="1.5" /> <rect className="client-rail-device-secondary" x="13.5" y="8" width="7" height="7" rx="1.5" />
<path className="client-rail-device-link" d="M6 19h12M7 15v4M17 15v4" /> <path className="client-rail-device-link" d="M7 15v4h3.5M17 15v4h-3.5" />
<path className="client-rail-device-bridge" d="M10.5 19h3" />
<path className="client-rail-device-pulse" pathLength="1" d="M7 19h10" />
</svg> </svg>
</RailAction>; </RailAction>;
} }
@@ -43,17 +43,19 @@ export type DiagnosticsFeature = ReturnType<typeof useDiagnosticsFeature>;
export function DiagnosticsToggle({ export function DiagnosticsToggle({
feature, feature,
open,
onToggle, onToggle,
}: { }: {
feature: DiagnosticsFeature; feature: DiagnosticsFeature;
open: boolean;
onToggle: () => void; onToggle: () => void;
}) { }) {
return <RailAction return <RailAction
buttonRef={feature.toggleRef} buttonRef={feature.toggleRef}
className="client-instructions-toggle client-diagnostics-toggle" className="client-instructions-toggle client-diagnostics-toggle"
open={feature.isOpen} open={open}
controls="client-diagnostics" controls="client-diagnostics"
ariaLabel={feature.isOpen ? 'Закрыть диагностику' : 'Проверить маршруты'} ariaLabel={open ? 'Закрыть диагностику' : 'Проверить маршруты'}
label="Диагностика" label="Диагностика"
onClick={onToggle} onClick={onToggle}
> >
@@ -232,23 +232,26 @@ export type InstructionsFeature = ReturnType<typeof useInstructionsFeature>;
export function InstructionsToggle({ export function InstructionsToggle({
feature, feature,
open,
onToggle, onToggle,
}: { }: {
feature: InstructionsFeature; feature: InstructionsFeature;
open: boolean;
onToggle: () => void; onToggle: () => void;
}) { }) {
return <RailAction return <RailAction
buttonRef={feature.toggleRef} buttonRef={feature.toggleRef}
className="client-instructions-toggle" className="client-instructions-toggle"
open={feature.isOpen} open={open}
controls="client-instructions" controls="client-instructions"
ariaLabel={feature.isOpen ? 'Закрыть инструкции' : 'Как использовать'} ariaLabel={open ? 'Закрыть инструкции' : 'Как использовать'}
label="Как использовать" label="Как использовать"
onClick={onToggle} onClick={onToggle}
> >
<svg viewBox="0 0 24 24" aria-hidden="true"> <svg viewBox="0 0 24 24" aria-hidden="true">
<circle className="client-rail-info-ring" cx="12" cy="12" r="8.5" pathLength="1" /> <path className="client-rail-book-page is-left" d="M12 7.5C10.2 6.1 7.7 5.7 5 6v11c2.8-.3 5.2.3 7 1.7z" />
<path d="M12 11v5M12 8h.01" /> <path className="client-rail-book-page is-right" d="M12 7.5c1.8-1.4 4.3-1.8 7-1.5v11c-2.8-.3-5.2.3-7 1.7z" />
<path className="client-rail-book-spine" d="M12 7.5v11.2" />
</svg> </svg>
</RailAction>; </RailAction>;
} }
+8 -6
View File
@@ -381,12 +381,14 @@ function RuleTypePicker({ value, ruleKey, index, disabled, onChange }: RuleTypeP
export function RoutingToggle({ export function RoutingToggle({
feature, feature,
open,
gatewayDirect, gatewayDirect,
isGateway, isGateway,
hasSubscription, hasSubscription,
onOpen, onOpen,
}: { }: {
feature: RoutingFeature; feature: RoutingFeature;
open: boolean;
gatewayDirect: boolean; gatewayDirect: boolean;
isGateway: boolean; isGateway: boolean;
hasSubscription: boolean; hasSubscription: boolean;
@@ -397,25 +399,25 @@ export function RoutingToggle({
<RailAction <RailAction
buttonRef={feature.toggleRef} buttonRef={feature.toggleRef}
className={`client-local-rules-toggle${feature.pendingRestart ? ' has-pending' : ''}`} className={`client-local-rules-toggle${feature.pendingRestart ? ' has-pending' : ''}`}
open={feature.isOpen} open={open}
controls="client-local-rules" controls="client-local-rules"
disabled={disabled} disabled={disabled}
ariaLabel={disabled ariaLabel={disabled
? hasSubscription ? hasSubscription
? 'Локальные правила недоступны: сейчас работают правила Harbor Gateway' ? 'Локальные правила недоступны: сейчас работают правила Harbor Gateway'
: 'Локальные правила недоступны: сначала добавьте подписку' : 'Локальные правила недоступны: сначала добавьте подписку'
: feature.isOpen ? 'Закрыть локальные правила' : 'Настроить локальные правила'} : open ? 'Закрыть локальные правила' : 'Настроить локальные правила'}
label={disabled label={disabled
? hasSubscription ? hasSubscription
? 'Локальные правила недоступны: сейчас работают правила Gateway' ? 'Локальные правила недоступны: сейчас работают правила Gateway'
: 'Сначала добавьте подписку' : 'Сначала добавьте подписку'
: feature.pendingRestart ? 'Правила ждут перезапуска' : 'Локальные правила'} : feature.pendingRestart ? 'Правила ждут перезапуска' : 'Локальные правила'}
onClick={() => feature.isOpen ? feature.requestClose() : onOpen()} onClick={() => open ? feature.requestClose() : onOpen()}
> >
<svg viewBox="0 0 24 24" aria-hidden="true"> <svg viewBox="0 0 24 24" aria-hidden="true">
<path d="M4 7h9M17 7h3M4 17h3M11 17h9" /> <path className="client-rail-rule-track" d="M4 7h16M4 17h16" />
<circle className="client-rail-rule-knob is-top" cx="15" cy="7" r="2" /> <circle className="client-rail-rule-knob is-top" cx="12" cy="7" r="2" />
<circle className="client-rail-rule-knob is-bottom" cx="9" cy="17" r="2" /> <circle className="client-rail-rule-knob is-bottom" cx="12" cy="17" r="2" />
</svg> </svg>
</RailAction> </RailAction>
); );
@@ -301,22 +301,28 @@ type SubscriptionFeatureController = ReturnType<typeof useSubscriptionFeature>;
export function SubscriptionToggle({ export function SubscriptionToggle({
feature, feature,
open,
onToggle, onToggle,
}: { }: {
feature: SubscriptionFeatureController; feature: SubscriptionFeatureController;
open: boolean;
onToggle: () => void; onToggle: () => void;
}) { }) {
return <RailAction return <RailAction
buttonRef={feature.toggleRef} buttonRef={feature.toggleRef}
className="client-instructions-toggle client-subscription-toggle" className="client-instructions-toggle client-subscription-toggle"
open={feature.open} open={open}
controls="client-subscription-drawer" controls="client-subscription-drawer"
ariaLabel={feature.open ? 'Закрыть подписки' : 'Управление подписками'} ariaLabel={open ? 'Закрыть подписки' : 'Управление подписками'}
label="Подписки" label="Подписки"
onClick={onToggle} onClick={onToggle}
> >
<svg viewBox="0 0 24 24" aria-hidden="true"> <svg viewBox="0 0 24 24" aria-hidden="true">
<path d="M5 5h14v14H5zM8 9h8M8 13h5" /> <rect className="client-rail-subscription-back" x="6" y="4.5" width="13" height="15" rx="1.5" />
<g className="client-rail-subscription-front">
<rect className="client-rail-subscription-card" x="4" y="6.5" width="13" height="13" rx="1.5" />
<path className="client-rail-subscription-lines" d="M7.5 10.5h6M7.5 14h4" />
</g>
</svg> </svg>
</RailAction>; </RailAction>;
} }
+115 -43
View File
@@ -68,80 +68,152 @@
filter: drop-shadow(0 0 7px color-mix(in oklch, var(--client-accent) 52%, transparent)); filter: drop-shadow(0 0 7px color-mix(in oklch, var(--client-accent) 52%, transparent));
} }
.client-rail-subscription-back,
.client-rail-subscription-front,
.client-rail-subscription-lines,
.client-rail-book-page,
.client-rail-book-spine,
.client-rail-device-primary, .client-rail-device-primary,
.client-rail-device-secondary, .client-rail-device-secondary,
.client-rail-device-link,
.client-rail-device-bridge,
.client-rail-device-pulse,
.client-rail-diagnostics-base,
.client-rail-diagnostics-pulse,
.client-rail-rule-knob { .client-rail-rule-knob {
transform-box: fill-box; transform-box: fill-box;
transform-origin: center; transform-origin: center;
transition: transform 620ms cubic-bezier(0.16, 1, 0.3, 1), opacity 360ms ease, stroke-dashoffset 620ms cubic-bezier(0.16, 1, 0.3, 1);
} }
.client-rail-info-ring { .client-rail-action .client-rail-subscription-card,
stroke-dasharray: 1; .client-rail-action .client-rail-rule-knob {
fill: var(--client-bg);
} }
.client-rail-subscription-back {
transform: translate(-1.4px, 1.4px);
}
.client-rail-subscription-front {
transform: translateX(1px);
}
.client-rail-subscription-lines {
opacity: 0.52;
transform: scaleX(0.68);
transform-origin: left center;
}
.client-subscription-toggle.is-open .client-rail-subscription-back {
transform: translate(0, 0);
}
.client-subscription-toggle.is-open .client-rail-subscription-front {
transform: translate(-1px, 0.6px);
}
.client-subscription-toggle.is-open .client-rail-subscription-lines {
opacity: 1;
transform: scaleX(1);
}
.client-rail-book-page.is-left {
transform: translateX(1.7px) scaleX(0.58);
transform-origin: right center;
}
.client-rail-book-page.is-right {
transform: translateX(-1.7px) scaleX(0.58);
transform-origin: left center;
}
.client-rail-book-spine {
opacity: 0.55;
transform: scaleY(0.74);
}
.client-instructions-toggle.is-open .client-rail-book-page,
.client-instructions-toggle.is-open .client-rail-book-spine {
opacity: 1;
transform: translate(0, 0) scale(1);
}
.client-rail-device-primary {
transform: translate(-0.9px, -1.8px);
}
.client-rail-device-secondary {
transform: translate(0.9px, -1.8px);
}
.client-rail-device-link {
opacity: 0.55;
}
.client-rail-device-bridge {
opacity: 0;
transform: scaleX(0);
}
.client-rail-device-pulse,
.client-rail-diagnostics-pulse { .client-rail-diagnostics-pulse {
stroke-width: 2.4; stroke-width: 2.4;
opacity: 0; opacity: 0;
stroke-dasharray: 0.16 0.84; stroke-dasharray: 0.16 0.84;
} }
.client-instructions-toggle.is-open:not(.client-devices-toggle):not(.client-diagnostics-toggle) .client-rail-info-ring { .client-devices-toggle.is-open .client-rail-device-primary,
animation: client-rail-info-refill 680ms cubic-bezier(0.16, 1, 0.3, 1);
}
.client-devices-toggle.is-open .client-rail-device-primary {
animation: client-rail-device-left 680ms cubic-bezier(0.16, 1, 0.3, 1);
}
.client-devices-toggle.is-open .client-rail-device-secondary { .client-devices-toggle.is-open .client-rail-device-secondary {
animation: client-rail-device-right 680ms cubic-bezier(0.16, 1, 0.3, 1); transform: translate(0, 0);
}
.client-devices-toggle.is-open .client-rail-device-link {
opacity: 1;
}
.client-devices-toggle.is-open .client-rail-device-bridge {
opacity: 1;
transform: scaleX(1);
}
.client-devices-toggle.is-open .client-rail-device-pulse {
animation: client-rail-device-signal 760ms cubic-bezier(0.16, 1, 0.3, 1);
}
.client-rail-diagnostics-base,
.client-rail-diagnostics-pulse {
vector-effect: non-scaling-stroke;
transform: scaleY(0.22);
}
.client-diagnostics-toggle.is-open .client-rail-diagnostics-base,
.client-diagnostics-toggle.is-open .client-rail-diagnostics-pulse {
transform: scaleY(1);
} }
.client-diagnostics-toggle.is-open .client-rail-diagnostics-pulse { .client-diagnostics-toggle.is-open .client-rail-diagnostics-pulse {
animation: client-rail-diagnostics-pulse 760ms cubic-bezier(0.16, 1, 0.3, 1); animation: client-rail-diagnostics-scan 760ms cubic-bezier(0.16, 1, 0.3, 1);
} }
.client-local-rules-toggle.is-open .client-rail-rule-knob.is-top { .client-local-rules-toggle.is-open .client-rail-rule-knob.is-top {
animation: client-rail-rule-top 680ms cubic-bezier(0.16, 1, 0.3, 1); transform: translateX(3px);
} }
.client-local-rules-toggle.is-open .client-rail-rule-knob.is-bottom { .client-local-rules-toggle.is-open .client-rail-rule-knob.is-bottom {
animation: client-rail-rule-bottom 680ms cubic-bezier(0.16, 1, 0.3, 1); transform: translateX(-3px);
} }
@keyframes client-rail-info-refill { @keyframes client-rail-device-signal {
from { opacity: 0.28; stroke-dashoffset: 1; }
to { opacity: 1; stroke-dashoffset: 0; }
}
@keyframes client-rail-device-left {
0%, 100% { transform: translate(0, 0); }
38%, 58% { transform: translate(-0.6px, -2.25px); }
82% { transform: translate(0.15px, 0.35px); }
}
@keyframes client-rail-device-right {
0%, 100% { transform: translate(0, 0); }
38%, 58% { transform: translate(0.6px, -2.25px); }
82% { transform: translate(-0.15px, 0.35px); }
}
@keyframes client-rail-diagnostics-pulse {
0% { opacity: 0; stroke-dashoffset: 1; } 0% { opacity: 0; stroke-dashoffset: 1; }
16%, 84% { opacity: 1; } 18%, 78% { opacity: 1; }
100% { opacity: 0; stroke-dashoffset: 0; } 100% { opacity: 0; stroke-dashoffset: 0; }
} }
@keyframes client-rail-rule-top { @keyframes client-rail-diagnostics-scan {
0%, 100% { transform: translateX(0); } 0% { opacity: 0; stroke-dashoffset: 1; }
42% { transform: translateX(-3px); } 16%, 84% { opacity: 1; }
72% { transform: translateX(1px); } 100% { opacity: 0; stroke-dashoffset: 0; }
}
@keyframes client-rail-rule-bottom {
0%, 100% { transform: translateX(0); }
42% { transform: translateX(3px); }
72% { transform: translateX(-1px); }
} }
.client-local-rules-toggle:disabled { .client-local-rules-toggle:disabled {
+1
View File
@@ -50,6 +50,7 @@
.client-drawer-close, .client-drawer-close,
.client-rail-action, .client-rail-action,
.client-rail-action svg, .client-rail-action svg,
.client-rail-action g,
.client-rail-action circle, .client-rail-action circle,
.client-rail-action path, .client-rail-action path,
.client-rail-action rect, .client-rail-action rect,
+24 -9
View File
@@ -146,17 +146,26 @@ test('secondary menus share one right rail and switch equal drawers as a vertica
assert.match(disabledRulesLabel, /opacity:\s*0/); assert.match(disabledRulesLabel, /opacity:\s*0/);
assert.match(disabledRulesLabel, /filter:\s*blur\(5px\)/); assert.match(disabledRulesLabel, /filter:\s*blur\(5px\)/);
assert.match(styles, /\.client-local-rules-toggle:disabled:hover span\s*\{[\s\S]*opacity:\s*1/); assert.match(styles, /\.client-local-rules-toggle:disabled:hover span\s*\{[\s\S]*opacity:\s*1/);
assert.match(instructions, /client-rail-info-ring/); assert.match(subscription, /client-rail-subscription-back[\s\S]*client-rail-subscription-front[\s\S]*client-rail-subscription-lines/);
assert.match(instructions, /client-rail-book-page is-left[\s\S]*client-rail-book-page is-right[\s\S]*client-rail-book-spine/);
assert.match(component, /<InstructionsToggle[\s\S]*<DevicesToggle[\s\S]*<DiagnosticsToggle[\s\S]*<RoutingToggle/); assert.match(component, /<InstructionsToggle[\s\S]*<DevicesToggle[\s\S]*<DiagnosticsToggle[\s\S]*<RoutingToggle/);
assert.match(diagnosticsFeature, /client-rail-diagnostics-base[\s\S]*client-rail-diagnostics-pulse/); assert.match(diagnosticsFeature, /client-rail-diagnostics-base[\s\S]*client-rail-diagnostics-pulse/);
assert.match(devices, /client-rail-device-primary[\s\S]*client-rail-device-secondary[\s\S]*client-rail-device-link/); assert.match(devices, /client-rail-device-primary[\s\S]*client-rail-device-secondary[\s\S]*client-rail-device-link[\s\S]*client-rail-device-bridge[\s\S]*client-rail-device-pulse/);
assert.match(routing, /client-rail-rule-knob is-top[\s\S]*client-rail-rule-knob is-bottom/); assert.match(routing, /client-rail-rule-track[\s\S]*client-rail-rule-knob is-top[\s\S]*client-rail-rule-knob is-bottom/);
assert.match(styles, /client-rail-info-refill[\s\S]*client-rail-device-left[\s\S]*client-rail-device-right[\s\S]*client-rail-diagnostics-pulse[\s\S]*client-rail-rule-top[\s\S]*client-rail-rule-bottom/); assert.match(styles, /\.client-rail-subscription-back \{[\s\S]*translate\(-1\.4px, 1\.4px\)/);
assert.match(styles, /\.client-rail-diagnostics-pulse \{[\s\S]*stroke-dasharray: 0\.16 0\.84/); assert.match(styles, /\.client-subscription-toggle\.is-open \.client-rail-subscription-front \{[\s\S]*translate\(-1px, 0\.6px\)/);
assert.match(styles, /@keyframes client-rail-diagnostics-pulse[\s\S]*stroke-dashoffset: 1[\s\S]*stroke-dashoffset: 0/); assert.match(styles, /\.client-rail-book-page\.is-left \{[\s\S]*scaleX\(0\.58\)/);
assert.match(styles, /@keyframes client-rail-device-left[\s\S]*translate\(-0\.6px, -2\.25px\)[\s\S]*@keyframes client-rail-device-right[\s\S]*translate\(0\.6px, -2\.25px\)/); assert.match(styles, /\.client-instructions-toggle\.is-open \.client-rail-book-page,[\s\S]*scale\(1\)/);
assert.match(styles, /@keyframes client-rail-rule-top[\s\S]*translateX\(-3px\)[\s\S]*@keyframes client-rail-rule-bottom[\s\S]*translateX\(3px\)/); assert.match(styles, /\.client-rail-device-primary \{[\s\S]*translate\(-0\.9px, -1\.8px\)/);
assert.match(styles, /@keyframes client-rail-info-refill[\s\S]*stroke-dashoffset: 1[\s\S]*stroke-dashoffset: 0/); assert.match(styles, /\.client-devices-toggle\.is-open \.client-rail-device-bridge \{[\s\S]*scaleX\(1\)/);
assert.match(styles, /\.client-rail-diagnostics-base,[\s\S]*scaleY\(0\.22\)/);
assert.match(styles, /\.client-diagnostics-toggle\.is-open \.client-rail-diagnostics-base,[\s\S]*scaleY\(1\)/);
assert.match(styles, /\.client-local-rules-toggle\.is-open \.client-rail-rule-knob\.is-top \{[\s\S]*translateX\(3px\)/);
assert.match(styles, /\.client-local-rules-toggle\.is-open \.client-rail-rule-knob\.is-bottom \{[\s\S]*translateX\(-3px\)/);
assert.match(styles, /@keyframes client-rail-device-signal[\s\S]*stroke-dashoffset: 1[\s\S]*stroke-dashoffset: 0/);
assert.match(styles, /@keyframes client-rail-diagnostics-scan[\s\S]*stroke-dashoffset: 1[\s\S]*stroke-dashoffset: 0/);
assert.match(styles, /transition: transform 620ms cubic-bezier\(0\.16, 1, 0\.3, 1\)/);
assert.doesNotMatch(styles, /client-rail-info-refill|client-rail-device-left|client-rail-rule-top/);
assert.doesNotMatch(styles, /client-secondary-icon-motion|\.client-local-rules-toggle\.is-open svg\s*\{[^}]*rotate/); assert.doesNotMatch(styles, /client-secondary-icon-motion|\.client-local-rules-toggle\.is-open svg\s*\{[^}]*rotate/);
assert.match(rule('.client-drawer'), /inset:\s*0 0 0 auto/); assert.match(rule('.client-drawer'), /inset:\s*0 0 0 auto/);
assert.match(rule('.client-drawer'), /width:\s*min\(580px, 100vw\)/); assert.match(rule('.client-drawer'), /width:\s*min\(580px, 100vw\)/);
@@ -174,6 +183,11 @@ test('secondary menus share one right rail and switch equal drawers as a vertica
assert.match(subscription, /<Drawer[\s\S]*className="client-subscription-drawer"/); assert.match(subscription, /<Drawer[\s\S]*className="client-subscription-drawer"/);
assert.match(component, /const DRAWER_ORDER = \['subscription', 'instructions', 'devices', 'diagnostics', 'routing'\]/); assert.match(component, /const DRAWER_ORDER = \['subscription', 'instructions', 'devices', 'diagnostics', 'routing'\]/);
assert.match(component, /function switchDrawer\(target: DrawerKey\)[\s\S]*from\.inert = true[\s\S]*translateY\(\$\{direction \* 100\}%\)[\s\S]*translateY\(\$\{-direction \* 100\}%\)/); assert.match(component, /function switchDrawer\(target: DrawerKey\)[\s\S]*from\.inert = true[\s\S]*translateY\(\$\{direction \* 100\}%\)[\s\S]*translateY\(\$\{-direction \* 100\}%\)/);
assert.match(component, /const \[drawerSwitchTarget, setDrawerSwitchTarget\] = useState<DrawerKey \| null>\(null\)/);
assert.match(component, /const activeRailDrawer = drawerSwitchTarget && drawerControls\[drawerSwitchTarget\]\.isOpen[\s\S]*drawerControls\[drawer\]\.isOpen/);
assert.match(component, /setDrawerSwitchTarget\(target\);[\s\S]*flushSync\(\(\) => toControl\.show\(\)\)/);
assert.match(component, /fromControl\.close\(\);[\s\S]*setDrawerSwitchTarget\(null\)/);
assert.match(component, /<SubscriptionToggle[\s\S]*open=\{activeRailDrawer === 'subscription'\}[\s\S]*<RoutingToggle[\s\S]*open=\{activeRailDrawer === 'routing'\}/);
assert.match(component, /const cancel = \(\) => \{[\s\S]*from\.inert = false;[\s\S]*from\.removeAttribute\('aria-hidden'\)/); assert.match(component, /const cancel = \(\) => \{[\s\S]*from\.inert = false;[\s\S]*from\.removeAttribute\('aria-hidden'\)/);
assert.match(component, /DRAWER_SWITCH_MS = 620[\s\S]*prefers-reduced-motion: reduce[\s\S]*flushSync/); assert.match(component, /DRAWER_SWITCH_MS = 620[\s\S]*prefers-reduced-motion: reduce[\s\S]*flushSync/);
assert.match(component, /current === 'routing' && routingFeature\.dirty[\s\S]*routingFeature\.requestClose\(\)/); assert.match(component, /current === 'routing' && routingFeature\.dirty[\s\S]*routingFeature\.requestClose\(\)/);
@@ -230,6 +244,7 @@ test('responsive motion has a reduced-motion fallback', () => {
assert.match(reducedMotion, /\.client-form/); assert.match(reducedMotion, /\.client-form/);
assert.match(reducedMotion, /\.harbor-brand/); assert.match(reducedMotion, /\.harbor-brand/);
assert.match(reducedMotion, /\.client-rail-action circle/); assert.match(reducedMotion, /\.client-rail-action circle/);
assert.match(reducedMotion, /\.client-rail-action g/);
assert.match(reducedMotion, /\.client-rail-action rect/); assert.match(reducedMotion, /\.client-rail-action rect/);
assert.match(reducedMotion, /\.client-drawer/); assert.match(reducedMotion, /\.client-drawer/);
assert.match(reducedMotion, /transition:\s*none/); assert.match(reducedMotion, /transition:\s*none/);
+16 -16
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: 821, cascadeEdges: 822,
customProperties: 103, customProperties: 103,
declarations: 3308, declarations: 3325,
important: 0, important: 0,
keyframes: 56, keyframes: 52,
media: 12, media: 12,
rules: 947, rules: 953,
variableReferences: 810, variableReferences: 811,
}, },
hashes: { hashes: {
cascadeEdges: '54abf37aa617e90947c3e0cfd35f2358f8873bda512992c5628950d828ea7486', cascadeEdges: 'c9c0e3988cea68ecc1c5e06e12b3b12fe9bb4d20482a67abcf838296ccf9740f',
customProperties: 'ef70fb1d9b61b177f1939f811babe1116bee631de26f5eac0aa0d0009ca5a1fb', customProperties: 'ef70fb1d9b61b177f1939f811babe1116bee631de26f5eac0aa0d0009ca5a1fb',
declarations: '97e97ccb50010db1950b2a2ceeb3bdbcc8574aaee5a676724d3332a69fab537d', declarations: '856c8aa1395c435723a52d709a39c9d37e0ede48ddb59f4ce8eff91d695e605d',
duplicateKeyframes: '4f53cda18c2baa0c0354bb5f9a3ecbe5ed12ab4d8e11ba873c2f11161202b945', duplicateKeyframes: '4f53cda18c2baa0c0354bb5f9a3ecbe5ed12ab4d8e11ba873c2f11161202b945',
duplicateSelectors: '0fa66e370695261a2a5c6a189752c07d518d80d68d3e79a3ba4bb34407893c0e', duplicateSelectors: '0fa66e370695261a2a5c6a189752c07d518d80d68d3e79a3ba4bb34407893c0e',
keyframes: 'd4378751f36eccc87923c12540315462055032a5d34978a0f45ecada0a5cc1ce', keyframes: 'd3050d026f6fba3975cf6bb07320d0c3f400c3b3b6eaa3cecbc53b0b137083a3',
ruleDeclarationSequences: '14f4eceda4a1811799b7dc50491c9298c27c5796fa8fc8ce02104bdb268d7675', ruleDeclarationSequences: 'e129c791ddbc3fa3f28fe4fb5565688a7e2e212eff2f8f8e85d76394f9628847',
selectors: '1420d737139ad3e03bbdcf5a4e1573443e500cda572b4461bdc578cd6b3c04de', selectors: '4ab0b3b0944d13512d5ea28255d031b4b2cb7469883057e8f16ab090580fbd51',
variableReferences: 'c8e01fce4a7f3a976040f20938ec8d70403bd003f66ef13b5637c77f9c4cef08', variableReferences: '0ffb99792db35b451fb35d1ced24e1aeef3a6df769cb37bf3d61229b0c970eba',
witnesses: 'b47da99c00da77a5c9716796304fab2163fb21c6bc6753176f008f85a3420695', witnesses: 'c502e252880f18c633b82d09bf349de134c52da4b67a8dfadd327ee01b464692',
}, },
}; };
@@ -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, 811); assert.equal(witnesses.length, 817);
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-0HVoD6xZ.css']); assert.deepEqual(assets, ['index-D5lRz6FV.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, 127874); assert.equal(built.byteLength, 129198);
assert.equal(sha256(built), '97241721abba48f5a120e08c425fb67b8b3ab560e59b479351ea455837838805'); assert.equal(sha256(built), '1108b32f6d493f170b7683a1947c248c5d4e1295dd810bdc0f0935d133c28497');
}); });