From dd4f82461916b66cc7ce69853140e432e553bd02 Mon Sep 17 00:00:00 2001 From: Daniel Griesser Date: Sun, 16 Aug 2026 01:51:31 +0200 Subject: [PATCH 1/2] feat(app): add branded loading state Replace the plain session loading message with a branded Hackweek loader and use the same indicator for data queries. Expose aria-busy while loading so assistive technology can identify the active state, and cover the rendered indicator with an app test. --- src/app/App.tsx | 21 ++++- src/app/components/AppLayout.tsx | 30 ++++++- src/app/styles.css | 140 +++++++++++++++++++++++++++++++ test/app/LoadingState.test.tsx | 21 +++++ 4 files changed, 206 insertions(+), 6 deletions(-) create mode 100644 test/app/LoadingState.test.tsx diff --git a/src/app/App.tsx b/src/app/App.tsx index 1a777fa..b9ba70a 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', + ); + }); +}); From a8d96541a84e77a13fea768aa2492831808dafc3 Mon Sep 17 00:00:00 2001 From: Daniel Griesser Date: Sun, 16 Aug 2026 02:10:56 +0200 Subject: [PATCH 2/2] fix(app): keep session loading copy accurate Describe the authentication request during the initial session state instead of implying that projects, ideas, and teams are already loading. Those records remain covered by the separate QueryState loading message after authentication succeeds. --- src/app/App.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/app/App.tsx b/src/app/App.tsx index b9ba70a..9ed4bcd 100644 --- a/src/app/App.tsx +++ b/src/app/App.tsx @@ -20,7 +20,7 @@ export function App() { return ( );