mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-04-28 03:20:11 +00:00
Smooth Pulse brand motion
This commit is contained in:
parent
2edc1cba60
commit
cf91aa62d3
6 changed files with 45 additions and 99 deletions
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue