Improve startup animation and traffic chart headroom
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
export const HARBOR_VERSIONS = Object.freeze({
|
||||
macClient: '0.17.3',
|
||||
gatewayClient: '0.18.3',
|
||||
macClient: '0.17.5',
|
||||
gatewayClient: '0.18.5',
|
||||
gatewayBackend: '0.18.0',
|
||||
});
|
||||
|
||||
|
||||
@@ -627,7 +627,7 @@ export function ClientOverviewPage({
|
||||
}
|
||||
});
|
||||
const [editingSubscription, setEditingSubscription] = useState(!state?.hasSubscription);
|
||||
const [showIntro, setShowIntro] = useState(!hasSubscription);
|
||||
const [showIntro, setShowIntro] = useState(true);
|
||||
const [subscriptionContentReady, setSubscriptionContentReady] = useState(hasSubscription);
|
||||
const [copyFeedback, setCopyFeedback] = useState(null);
|
||||
const [subscriptionValidation, setSubscriptionValidation] = useState({
|
||||
@@ -1113,7 +1113,7 @@ export function ClientOverviewPage({
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`client-shell${hasSubscription ? '' : ' is-first-run'}${showIntro && !hasSubscription ? ' is-intro' : ''}`}
|
||||
className={`client-shell${hasSubscription ? '' : ' is-first-run'}${showIntro ? ' is-intro' : ''}`}
|
||||
>
|
||||
<VersionDisplay isGateway={isGateway} versionInfo={versionInfo} />
|
||||
<div className="client-live-region" role="status" aria-live="polite" aria-atomic="true">
|
||||
|
||||
@@ -16,6 +16,8 @@ const AUTO_REFRESH_MS = 15_000;
|
||||
const DEVICE_MOVE_MS = 520;
|
||||
const COPY_FEEDBACK_MS = 800;
|
||||
const TRAFFIC_DELTA_MS = 2_200;
|
||||
const TRAFFIC_CHART_HEADROOM = 10;
|
||||
const trafficChartY = (ratio) => 100 - ratio * (100 - TRAFFIC_CHART_HEADROOM);
|
||||
|
||||
function Tooltip({ children }) {
|
||||
return <span className="client-tooltip" role="tooltip">{children}</span>;
|
||||
@@ -62,8 +64,8 @@ function TrafficChart({ samples, scale, capacity, routeLabel, pinned }) {
|
||||
x: (firstSlot + index) * 100 / Math.max(1, capacity - 1),
|
||||
gateway,
|
||||
proxy,
|
||||
gatewayY: 100 - trafficScaleRatio(gateway, max, scale) * 100,
|
||||
proxyY: 100 - trafficScaleRatio(proxy, max, scale) * 100,
|
||||
gatewayY: trafficChartY(trafficScaleRatio(gateway, max, scale)),
|
||||
proxyY: trafficChartY(trafficScaleRatio(proxy, max, scale)),
|
||||
};
|
||||
});
|
||||
const previous = points.slice(0, -1);
|
||||
@@ -108,7 +110,15 @@ function TrafficChart({ samples, scale, capacity, routeLabel, pinned }) {
|
||||
document.body,
|
||||
);
|
||||
|
||||
return <span className="client-device-traffic-chart" role="img" aria-label={`История трафика, шкала ${scale === 'log' ? 'логарифмическая' : 'линейная'}, максимум ${formatByteString(max)}`}>
|
||||
return <span
|
||||
className="client-device-traffic-chart"
|
||||
role="img"
|
||||
aria-label={`История трафика, шкала ${scale === 'log' ? 'логарифмическая' : 'линейная'}, максимум ${formatByteString(max)}`}
|
||||
style={{
|
||||
'--traffic-chart-top': `${TRAFFIC_CHART_HEADROOM}%`,
|
||||
'--traffic-chart-mid': `${(100 + TRAFFIC_CHART_HEADROOM) / 2}%`,
|
||||
}}
|
||||
>
|
||||
{pinned && max > 0n && <span className="client-device-traffic-axis" aria-hidden="true">
|
||||
<span className="is-max">{formatByteString(max)}</span>
|
||||
<span className="is-mid">{formatByteString(mid)}</span>
|
||||
@@ -117,8 +127,8 @@ function TrafficChart({ samples, scale, capacity, routeLabel, pinned }) {
|
||||
<span className="client-device-traffic-plot" onPointerMove={trackPointer} onPointerLeave={() => setHovered(null)}>
|
||||
<svg viewBox="0 0 100 100" preserveAspectRatio="none" aria-hidden="true">
|
||||
{pinned && <g className="client-device-traffic-grid">
|
||||
<line x1="0" x2="100" y1="0" y2="0" />
|
||||
<line x1="0" x2="100" y1="50" y2="50" />
|
||||
<line x1="0" x2="100" y1={TRAFFIC_CHART_HEADROOM} y2={TRAFFIC_CHART_HEADROOM} />
|
||||
<line x1="0" x2="100" y1={(100 + TRAFFIC_CHART_HEADROOM) / 2} y2={(100 + TRAFFIC_CHART_HEADROOM) / 2} />
|
||||
<line x1="0" x2="100" y1="100" y2="100" />
|
||||
</g>}
|
||||
<g className="client-device-traffic-lines" style={{ '--sample-count': Math.max(1, capacity) }}>
|
||||
@@ -165,7 +175,7 @@ function TrafficChart({ samples, scale, capacity, routeLabel, pinned }) {
|
||||
</circle>}
|
||||
</g>
|
||||
{hovered && <g className="client-device-traffic-cursor">
|
||||
<line x1={hovered.x} x2={hovered.x} y1="0" y2="100" />
|
||||
<line x1={hovered.x} x2={hovered.x} y1={TRAFFIC_CHART_HEADROOM} y2="100" />
|
||||
<circle className="is-gateway" cx={hovered.x} cy={hovered.gatewayY} r="2" />
|
||||
{hovered.proxy > 0n && <circle className="is-proxy" cx={hovered.x} cy={hovered.proxyY} r="2" />}
|
||||
</g>}
|
||||
|
||||
+17
-9
@@ -365,19 +365,21 @@ p {
|
||||
}
|
||||
|
||||
.client-shell.is-intro .harbor-brand-content {
|
||||
animation: harbor-first-run 1200ms cubic-bezier(0.16, 1, 0.3, 1);
|
||||
animation: harbor-startup-brand 1200ms cubic-bezier(0.16, 1, 0.3, 1);
|
||||
}
|
||||
|
||||
.client-shell.is-intro .client-form {
|
||||
animation: harbor-first-run-form 650ms 720ms cubic-bezier(0.16, 1, 0.3, 1) both;
|
||||
.client-shell.is-intro .client-panel,
|
||||
.client-shell.is-intro .client-secondary-menu,
|
||||
.client-shell.is-intro .harbor-versions {
|
||||
animation: harbor-startup-content 680ms 520ms cubic-bezier(0.16, 1, 0.3, 1) both;
|
||||
}
|
||||
|
||||
@keyframes harbor-first-run {
|
||||
from { transform: translateY(53px) scale(1.59); }
|
||||
@keyframes harbor-startup-brand {
|
||||
from { opacity: 0.72; filter: blur(5px); transform: translateY(53px); }
|
||||
}
|
||||
|
||||
@keyframes harbor-first-run-form {
|
||||
from { opacity: 0; filter: blur(8px); }
|
||||
@keyframes harbor-startup-content {
|
||||
from { opacity: 0; filter: blur(10px); }
|
||||
}
|
||||
|
||||
.harbor-brand svg {
|
||||
@@ -1406,11 +1408,11 @@ p {
|
||||
}
|
||||
|
||||
.client-device-traffic-axis > .is-max {
|
||||
top: 0;
|
||||
top: var(--traffic-chart-top);
|
||||
}
|
||||
|
||||
.client-device-traffic-axis > .is-mid {
|
||||
top: 50%;
|
||||
top: var(--traffic-chart-mid);
|
||||
transform: translateY(-50%);
|
||||
}
|
||||
|
||||
@@ -4985,6 +4987,12 @@ p {
|
||||
animation: none;
|
||||
}
|
||||
|
||||
.client-shell.is-intro .client-panel,
|
||||
.client-shell.is-intro .client-secondary-menu,
|
||||
.client-shell.is-intro .harbor-versions {
|
||||
animation: none;
|
||||
}
|
||||
|
||||
.harbor-mode-stack em,
|
||||
.harbor-mode-gateway > span,
|
||||
.harbor-mode-swap g,
|
||||
|
||||
@@ -75,6 +75,10 @@ test('Gateway device inventory uses the existing accessible responsive drawer',
|
||||
assert.doesNotMatch(panel, /setTrafficHistory|TRAFFIC_HISTORY_LIMIT/);
|
||||
assert.match(panel, /aria-pressed=\{trafficScale === 'linear'\}[\s\S]*aria-pressed=\{trafficScale === 'log'\}/);
|
||||
assert.match(panel, /previousScale\.current !== scale[\s\S]*attributeName="points"[\s\S]*dur="520ms"/);
|
||||
assert.match(panel, /TRAFFIC_CHART_HEADROOM = 10[\s\S]*trafficChartY = \(ratio\) => 100 - ratio \* \(100 - TRAFFIC_CHART_HEADROOM\)/);
|
||||
assert.match(panel, /gatewayY: trafficChartY\(trafficScaleRatio\(gateway, max, scale\)\)[\s\S]*proxyY: trafficChartY\(trafficScaleRatio\(proxy, max, scale\)\)/);
|
||||
assert.match(panel, /client-device-traffic-grid[\s\S]*y1=\{TRAFFIC_CHART_HEADROOM\}[\s\S]*y1=\{\(100 \+ TRAFFIC_CHART_HEADROOM\) \/ 2\}/);
|
||||
assert.match(panel, /client-device-traffic-cursor[\s\S]*y1=\{TRAFFIC_CHART_HEADROOM\} y2="100"/);
|
||||
assert.doesNotMatch(panel, /key=\{latest\}/);
|
||||
assert.match(panel, /createPortal\([\s\S]*client-device-traffic-point-tooltip[\s\S]*document\.body/);
|
||||
assert.match(panel, /onPointerMove=\{trackPointer\}/);
|
||||
@@ -111,6 +115,8 @@ test('Gateway device inventory uses the existing accessible responsive drawer',
|
||||
assert.match(styles, /\.client-device\.is-pinned \.client-device-traffic-chart \{[\s\S]*grid-template-columns: 52px minmax\(0, 1fr\)/);
|
||||
assert.match(styles, /\.client-device-traffic-grid line \{[\s\S]*vector-effect: non-scaling-stroke/);
|
||||
assert.match(styles, /\.client-device-traffic-lines polyline,[\s\S]*stroke-width: 1\.8/);
|
||||
assert.match(styles, /\.client-device-traffic-axis > \.is-max \{[\s\S]*top: var\(--traffic-chart-top\)/);
|
||||
assert.match(styles, /\.client-device-traffic-axis > \.is-mid \{[\s\S]*top: var\(--traffic-chart-mid\)/);
|
||||
assert.match(styles, /\.client-device-traffic-point-tooltip \{[\s\S]*position: fixed;[\s\S]*z-index: 1002/);
|
||||
assert.match(styles, /@keyframes client-device-traffic-shift[\s\S]*translateX\(calc\(100% \/ var\(--sample-count\)\)\)[\s\S]*translateX\(0\)/);
|
||||
assert.match(styles, /@keyframes client-device-traffic-line-draw[\s\S]*stroke-dashoffset: 0/);
|
||||
|
||||
@@ -32,6 +32,17 @@ test('desktop layout keeps the power control on a symmetric center axis', () =>
|
||||
assert.match(styles, /\.client-panel\.has-subscription \.client-form-content \{[\s\S]*padding-top:\s*var\(--client-power-top\)/);
|
||||
});
|
||||
|
||||
test('page reload plays one stable startup sequence', () => {
|
||||
assert.match(component, /const \[showIntro, setShowIntro\] = useState\(true\)/);
|
||||
assert.match(component, /\$\{showIntro \? ' is-intro' : ''\}/);
|
||||
assert.doesNotMatch(component, /showIntro && !hasSubscription/);
|
||||
assert.match(styles, /\.client-shell\.is-intro \.harbor-brand-content \{[\s\S]*harbor-startup-brand 1200ms/);
|
||||
assert.match(styles, /@keyframes harbor-startup-brand[\s\S]*translateY\(53px\)/);
|
||||
assert.match(styles, /\.client-shell\.is-intro \.client-panel,[\s\S]*\.client-secondary-menu,[\s\S]*\.harbor-versions \{[\s\S]*harbor-startup-content 680ms 520ms/);
|
||||
assert.match(styles, /@keyframes harbor-startup-content[\s\S]*opacity: 0;[\s\S]*filter: blur\(10px\)/);
|
||||
assert.match(styles, /@media \(prefers-reduced-motion: reduce\)[\s\S]*\.client-shell\.is-intro \.client-panel,[\s\S]*animation: none/);
|
||||
});
|
||||
|
||||
test('server rows scroll without moving the subscription column or showing a scrollbar', () => {
|
||||
const scroll = rule('.client-server-scroll');
|
||||
const simpleScroll = rule('.client-server-mode-panel.is-simple .client-server-scroll');
|
||||
|
||||
Reference in New Issue
Block a user