Smooth Pulse brand motion

This commit is contained in:
rcourtman 2026-04-23 22:13:48 +01:00
parent 2edc1cba60
commit cf91aa62d3
6 changed files with 45 additions and 99 deletions

View file

@ -418,11 +418,10 @@ function App() {
ref={setAppScrollShellRef}
class={`app-scroll-shell flex-1 min-w-0 overflow-y-scroll bg-base text-base-content font-sans py-4 sm:py-6 transition-all duration-300`}
>
<AppLayout
connectionStatus={runtime.connectionStatus}
dataUpdated={runtime.dataUpdated}
lastUpdateText={runtime.lastUpdateText}
versionInfo={runtime.versionInfo}
<AppLayout
connectionStatus={runtime.connectionStatus}
lastUpdateText={runtime.lastUpdateText}
versionInfo={runtime.versionInfo}
hasAuth={runtime.hasAuth}
needsAuth={runtime.needsAuth}
proxyAuthInfo={runtime.proxyAuthInfo}

View file

@ -47,7 +47,6 @@ const NAV_TAB_ICON_CLASS = 'w-4 h-4 shrink-0';
export interface AppLayoutProps {
connectionStatus: () => AppConnectionStatus;
dataUpdated: () => boolean;
lastUpdateText: () => string;
versionInfo: () => VersionInfo | null;
hasAuth: () => boolean;
@ -138,9 +137,7 @@ export function AppLayout(props: AppLayoutProps) {
const navigate = useNavigate();
const location = useLocation();
const kioskMode = useKioskMode();
const brandMotionActive = createMemo(
() => props.connectionStatus().kind === 'connected' && props.dataUpdated(),
);
const brandMotionActive = createMemo(() => props.connectionStatus().tone === 'healthy');
const [headerVisible, setHeaderVisible] = createSignal(true);
let headerEl: HTMLDivElement | undefined;

View file

@ -1,3 +1,5 @@
import { readFileSync } from 'node:fs';
import { join } from 'node:path';
import { describe, expect, it } from 'vitest';
import appSource from '@/App.tsx?raw';
import appLayoutSource from '@/AppLayout.tsx?raw';
@ -5,6 +7,8 @@ import appRuntimeContextSource from '@/contexts/appRuntime.ts?raw';
import routePreloadSource from '@/routing/routePreload.ts?raw';
import appRuntimeStateSource from '@/useAppRuntimeState.ts?raw';
const appStylesSource = readFileSync(join(process.cwd(), 'src/index.css'), 'utf8');
describe('App architecture', () => {
it('keeps App as the entry shell that delegates runtime and chrome ownership', () => {
expect(appSource).toContain('DASHBOARD_PATH,');
@ -82,13 +86,24 @@ describe('App architecture', () => {
expect(appLayoutSource).toContain('<OrgSwitcher');
expect(appLayoutSource).toContain('const status = () => props.connectionStatus();');
expect(appLayoutSource).toContain("status().kind === 'sync-reconnecting' || status().kind === 'reconnecting'");
expect(appLayoutSource).toContain(
"props.connectionStatus().kind === 'connected' && props.dataUpdated()",
);
expect(appLayoutSource).toContain("props.connectionStatus().tone === 'healthy'");
expect(appLayoutSource).toContain('const brandMotionActive = createMemo(');
expect(appLayoutSource).toContain('pulse-brand-lockup');
expect(appLayoutSource).toContain('animate-pulse-brand');
expect(appLayoutSource).toContain('pulse-brand-wordmark');
expect(appLayoutSource).not.toContain('dataUpdated: () => boolean');
expect(appLayoutSource).not.toContain('animate-pulse-logo');
expect(appRuntimeStateSource).not.toContain('dataUpdated');
expect(appRuntimeStateSource).not.toContain('DATA_FLASH');
expect(appStylesSource).toContain('--pulse-brand-cycle: 4.8s;');
expect(appStylesSource).toContain(
'animation: pulse-brand-logo var(--pulse-brand-cycle) ease-in-out infinite;',
);
expect(appStylesSource).toContain(
'animation: pulse-brand-ring var(--pulse-brand-cycle) ease-in-out infinite;',
);
expect(appStylesSource).not.toContain('@keyframes pulse-brand-wordmark');
expect(appStylesSource).not.toContain('text-shadow');
expect(appLayoutSource).toContain("props.versionInfo()?.channel === 'rc'");
expect(appLayoutSource).toContain('Preview');
expect(appLayoutSource).not.toContain(

View file

@ -15,7 +15,7 @@ describe('AppLayout navigation icons', () => {
cleanup();
});
const renderLayout = (options: { dataUpdated?: boolean } = {}) =>
const renderLayout = () =>
render(() => (
<Router>
<Route
@ -28,7 +28,6 @@ describe('AppLayout navigation icons', () => {
detail: 'Backend and live data stream are connected.',
tone: 'healthy',
})}
dataUpdated={() => options.dataUpdated ?? false}
lastUpdateText={() => ''}
versionInfo={() =>
({
@ -84,13 +83,15 @@ describe('AppLayout navigation icons', () => {
expect(container).toHaveTextContent('Dashboard body');
});
it('animates the full brand lockup when live data refreshes', () => {
const { container } = renderLayout({ dataUpdated: true });
it('keeps connected brand motion on the logo while the wordmark stays static', () => {
const { container } = renderLayout();
const brandLockup = screen.getByTestId('pulse-brand-lockup');
expect(brandLockup).toHaveClass('animate-pulse-brand');
expect(brandLockup.querySelector('.pulse-brand-logo')).toBeTruthy();
expect(brandLockup.querySelector('.pulse-brand-wordmark')).toHaveTextContent('Pulse');
const wordmark = brandLockup.querySelector('.pulse-brand-wordmark');
expect(wordmark).toHaveTextContent('Pulse');
expect(wordmark).not.toHaveClass('animate-pulse-brand');
expect(container.querySelector('.animate-pulse-logo')).toBeNull();
});
});

View file

@ -76,9 +76,6 @@
--color-border-base: theme('colors.slate.200');
--color-text-base: theme('colors.slate.900');
--color-text-muted: theme('colors.slate.500');
--color-brand-primary: theme('colors.blue.600');
--color-brand-primary-strong: theme('colors.blue.700');
--color-brand-primary-muted: rgba(37, 99, 235, 0.2);
--color-summary-row-bg: rgba(14, 165, 233, 0.05);
--color-summary-row-bg-hover: rgba(14, 165, 233, 0.08);
--color-summary-row-border: rgba(14, 165, 233, 0.16);
@ -101,9 +98,6 @@
--color-border-base: theme('colors.slate.700');
--color-text-base: theme('colors.slate.100');
--color-text-muted: theme('colors.slate.400');
--color-brand-primary: theme('colors.blue.500');
--color-brand-primary-strong: theme('colors.blue.300');
--color-brand-primary-muted: rgba(96, 165, 250, 0.26);
--color-summary-row-bg: rgba(56, 189, 248, 0.08);
--color-summary-row-bg-hover: rgba(56, 189, 248, 0.12);
--color-summary-row-border: rgba(56, 189, 248, 0.18);
@ -404,47 +398,30 @@ body,
border-color 150ms ease-in-out;
}
/* Pulse brand motion - a brief live-data acknowledgement, not decorative looping. */
@keyframes pulse-brand-lockup {
0% {
transform: translate3d(0, 0, 0) scale(1);
}
18% {
transform: translate3d(0, -0.5px, 0) scale(1.012);
}
48%,
100% {
transform: translate3d(0, 0, 0) scale(1);
}
}
/* Pulse brand motion - continuous, low-amplitude live-state rhythm. */
@keyframes pulse-brand-logo {
0%,
100% {
filter: drop-shadow(0 0 0 transparent);
transform: scale(1);
}
28% {
filter: drop-shadow(0 1px 5px var(--color-brand-primary-muted));
transform: scale(1.035);
50% {
transform: scale(1.015);
}
}
@keyframes pulse-brand-ring {
0%,
100% {
opacity: 0.92;
opacity: 0.9;
transform: scale(1);
stroke-width: 14;
}
36% {
opacity: 0.64;
transform: scale(1.16);
stroke-width: 10;
50% {
opacity: 0.7;
transform: scale(1.08);
stroke-width: 11;
}
}
@ -454,30 +431,9 @@ body,
transform: scale(1);
}
28% {
transform: scale(0.88);
50% {
transform: scale(0.94);
}
58% {
transform: scale(1.06);
}
}
@keyframes pulse-brand-wordmark {
0%,
100% {
color: var(--color-text-base);
text-shadow: 0 0 0 transparent;
}
24% {
color: var(--color-brand-primary-strong);
text-shadow: 0 0 9px var(--color-brand-primary-muted);
}
}
.pulse-brand-lockup {
transform-origin: left center;
}
.pulse-brand-logo,
@ -488,36 +444,29 @@ body,
}
.animate-pulse-brand {
animation: pulse-brand-lockup 0.9s cubic-bezier(0.16, 1, 0.3, 1);
will-change: transform;
--pulse-brand-cycle: 4.8s;
}
.animate-pulse-brand .pulse-brand-logo {
animation: pulse-brand-logo 0.9s cubic-bezier(0.16, 1, 0.3, 1);
will-change: filter, transform;
animation: pulse-brand-logo var(--pulse-brand-cycle) ease-in-out infinite;
will-change: transform;
}
.animate-pulse-brand .pulse-ring {
animation: pulse-brand-ring 0.9s cubic-bezier(0.16, 1, 0.3, 1);
animation: pulse-brand-ring var(--pulse-brand-cycle) ease-in-out infinite;
will-change: opacity, stroke-width, transform;
}
.animate-pulse-brand .pulse-center {
animation: pulse-brand-center 0.9s cubic-bezier(0.16, 1, 0.3, 1);
animation: pulse-brand-center var(--pulse-brand-cycle) ease-in-out infinite;
will-change: transform;
}
.animate-pulse-brand .pulse-brand-wordmark {
animation: pulse-brand-wordmark 0.9s cubic-bezier(0.16, 1, 0.3, 1);
will-change: color, text-shadow;
}
@media (prefers-reduced-motion: reduce) {
.animate-pulse-brand,
.animate-pulse-brand .pulse-brand-logo,
.animate-pulse-brand .pulse-ring,
.animate-pulse-brand .pulse-center,
.animate-pulse-brand .pulse-brand-wordmark {
.animate-pulse-brand .pulse-center {
animation: none;
}
}

View file

@ -9,7 +9,6 @@ import {
} from 'solid-js';
import { getGlobalWebSocketStore } from '@/stores/websocket-global';
import { logger } from '@/utils/logger';
import { POLLING_INTERVALS } from '@/constants';
import { STORAGE_KEYS } from '@/utils/localStorage';
import type { TimeRange } from '@/api/charts';
import type { VersionInfo } from '@/api/updates';
@ -220,7 +219,6 @@ export const useAppRuntimeState = () => {
const state = (): State => wsStore()?.state || fallbackState;
const connected = () => wsStore()?.connected() || false;
const reconnecting = () => wsStore()?.reconnecting() || false;
const [dataUpdated, setDataUpdated] = createSignal(false);
const [lastUpdateText, setLastUpdateText] = createSignal('');
const [versionInfo, setVersionInfo] = createSignal<VersionInfo | null>(null);
const connectionStatus = createMemo<AppConnectionStatus>(() => {
@ -453,22 +451,10 @@ export const useAppRuntimeState = () => {
});
};
let updateTimeout: number | undefined;
createEffect(() => {
const updateTime = state().lastUpdate;
if (updateTime > 0) {
setDataUpdated(true);
setLastUpdateText(formatLastUpdate(updateTime));
if (updateTimeout !== undefined) {
window.clearTimeout(updateTimeout);
}
updateTimeout = window.setTimeout(() => setDataUpdated(false), POLLING_INTERVALS.DATA_FLASH);
}
});
onCleanup(() => {
if (updateTimeout !== undefined) {
window.clearTimeout(updateTimeout);
}
});
@ -802,7 +788,6 @@ export const useAppRuntimeState = () => {
backendHealthy,
connectionStatus,
reconnecting,
dataUpdated,
lastUpdateText,
versionInfo,
showOrgSwitcher,