diff --git a/src/app/App.tsx b/src/app/App.tsx index 1a777fa..9ed4bcd 100644 --- a/src/app/App.tsx +++ b/src/app/App.tsx @@ -1,6 +1,6 @@ import {Redirect, Route, Switch} from 'wouter'; -import {AppLayout, PageState} from './components/AppLayout'; +import {AppLayout, HackweekLoader, PageState} from './components/AppLayout'; import {GoogleIcon} from './components/GoogleIcon'; import {AdminAnalyticsPage} from './routes/AdminAnalyticsPage'; import {AdminPage} from './routes/AdminPage'; @@ -17,7 +17,13 @@ export function App() { const session = useSession(); if (session.status === 'loading') { - return ; + return ( + + ); } if (session.status === 'unauthenticated') { const detail = @@ -101,14 +107,21 @@ function AuthState({ title, detail, login = false, + loading = false, }: { title: string; detail: string; login?: boolean; + loading?: boolean; }) { return ( -
-
+
+
+ {loading && }

Sentry #Hackweek

{title}

{detail}

diff --git a/src/app/components/AppLayout.tsx b/src/app/components/AppLayout.tsx index 05111a6..b7a4e90 100644 --- a/src/app/components/AppLayout.tsx +++ b/src/app/components/AppLayout.tsx @@ -92,17 +92,41 @@ export function AppLayout({ ); } +export function HackweekLoader() { + return ( + + ); +} + export function PageState({ title, detail, tone = 'neutral', + loading = false, }: { title: string; detail: string; tone?: 'neutral' | 'error' | 'forbidden'; + loading?: boolean; }) { return ( -
+
+ {loading && }

Hackweek

{title}

{detail}

@@ -121,7 +145,9 @@ export function QueryState({ children: ReactNode; }) { if (loading) { - return ; + return ( + + ); } if (error) { return ( diff --git a/src/app/styles.css b/src/app/styles.css index a2e3236..733e317 100644 --- a/src/app/styles.css +++ b/src/app/styles.css @@ -1496,6 +1496,13 @@ main { min-height: 100vh; background: var(--soft); } +.authShell--loading { + overflow: hidden; + background: + radial-gradient(circle at 20% 15%, rgba(255, 112, 188, 0.14), transparent 28rem), + radial-gradient(circle at 82% 78%, rgba(117, 83, 255, 0.14), transparent 32rem), + var(--soft); +} .pageState, .authState { position: relative; @@ -1530,6 +1537,97 @@ main { color: var(--muted); line-height: 1.7; } +.authState--loading { + animation: loadingStateIn 400ms ease-out both; +} +.pageState--loading { + animation: loadingStateIn 400ms ease-out 200ms both; +} +.authState--loading { + overflow: hidden; + border-color: rgba(117, 83, 255, 0.18); + box-shadow: 0 24px 70px rgba(78, 42, 154, 0.1); +} +.authState--loading::before { + position: absolute; + top: -8rem; + left: 50%; + width: 24rem; + height: 14rem; + content: ''; + background: radial-gradient(circle, rgba(117, 83, 255, 0.1), transparent 70%); + transform: translateX(-50%); + pointer-events: none; +} +.hackweekLoader { + position: relative; + width: min(13rem, 72vw); + margin: 0 auto clamp(2rem, 5vw, 3rem); +} +.hackweekLoaderTiles { + display: flex; + height: 3.4rem; + gap: 0.42rem; + align-items: flex-end; + justify-content: center; +} +.hackweekLoaderTiles span { + --loader-color: var(--blurple); + --loader-tilt: -3deg; + display: block; + width: clamp(1.35rem, 5vw, 1.8rem); + height: clamp(1.75rem, 6vw, 2.3rem); + border: 2px solid rgba(29, 17, 39, 0.12); + border-radius: 0.4rem; + background: var(--loader-color); + box-shadow: 0 0.3rem 0 rgba(29, 17, 39, 0.1); + animation: loadingTile 1.35s cubic-bezier(0.45, 0, 0.2, 1) infinite both; +} +.hackweekLoaderTiles span:nth-child(1) { + --loader-color: var(--dark-blurple); + --loader-tilt: -7deg; + animation-delay: -1.08s; +} +.hackweekLoaderTiles span:nth-child(2) { + --loader-color: var(--pink); + --loader-tilt: 3deg; + animation-delay: -0.81s; +} +.hackweekLoaderTiles span:nth-child(3) { + --loader-color: var(--green); + --loader-tilt: -2deg; + animation-delay: -0.54s; +} +.hackweekLoaderTiles span:nth-child(4) { + --loader-color: var(--yellow); + --loader-tilt: 6deg; + animation-delay: -0.27s; +} +.hackweekLoaderTiles span:nth-child(5) { + --loader-tilt: -4deg; +} +.hackweekLoaderTrack { + position: relative; + overflow: hidden; + height: 0.22rem; + margin-top: 0.7rem; + border-radius: 999px; + background: var(--lavender); +} +.hackweekLoaderTrack span { + position: absolute; + inset: 0; + border-radius: inherit; + background: linear-gradient( + 90deg, + transparent 0%, + var(--pink) 28%, + var(--blurple) 50%, + var(--green) 72%, + transparent 100% + ); + animation: loadingTrack 1.8s ease-in-out infinite; +} .googleLogin { display: inline-flex; gap: 0.65rem; @@ -2474,6 +2572,48 @@ kbd { } } +@keyframes loadingStateIn { + from { + opacity: 0; + transform: translateY(0.6rem) scale(0.985); + } + to { + opacity: 1; + transform: translateY(0) scale(1); + } +} + +@keyframes loadingTile { + 0%, + 100% { + opacity: 0.5; + transform: translateY(0) rotate(var(--loader-tilt)) scale(0.9); + } + 38% { + opacity: 1; + transform: translateY(-0.72rem) rotate(var(--loader-tilt)) scale(1); + } + 68% { + opacity: 0.72; + transform: translateY(0) rotate(var(--loader-tilt)) scale(0.94); + } +} + +@keyframes loadingTrack { + 0% { + opacity: 0; + transform: translateX(-100%); + } + 25%, + 70% { + opacity: 1; + } + 100% { + opacity: 0; + transform: translateX(100%); + } +} + @media (max-width: 900px) { .archiveHero, .projectsHero, diff --git a/test/app/LoadingState.test.tsx b/test/app/LoadingState.test.tsx new file mode 100644 index 0000000..620a730 --- /dev/null +++ b/test/app/LoadingState.test.tsx @@ -0,0 +1,21 @@ +import {render, screen} from '@testing-library/react'; +import {describe, expect, it} from 'vitest'; + +import {PageState} from '../../src/app/components/AppLayout'; + +describe('Hackweek loading state', () => { + it('shows the branded loading indicator and exposes its busy state', () => { + const {container} = render( + , + ); + + const state = screen + .getByRole('heading', {name: 'Loading Hackweek'}) + .closest('section'); + expect(state?.getAttribute('aria-busy')).toBe('true'); + expect(container.querySelectorAll('.hackweekLoaderTiles span')).toHaveLength(5); + expect(container.querySelector('.hackweekLoader')?.getAttribute('aria-hidden')).toBe( + 'true', + ); + }); +});