diff --git a/.claude/skills/frontend-cyber/SKILL.md b/.claude/skills/frontend-cyber/SKILL.md new file mode 100644 index 000000000..2d55a27a7 --- /dev/null +++ b/.claude/skills/frontend-cyber/SKILL.md @@ -0,0 +1,717 @@ +# HackUTD Front-Facing Website Design Skill + +## Purpose + +Use this skill when designing or modifying HackUTD's public-facing hacker website. + +The website should visually match the current HackUTD 2026 **ZERODAY** identity: a cinematic cyberpunk world built around a futuristic nighttime city, neon signage, hacker culture, glowing technology, and HackUTD's colorful mascot universe. + +The experience should feel like entering the world of ZERODAY rather than browsing a conventional university or hackathon website. + +--- + +# 1. Core Visual Direction + +The primary aesthetic is: + +**Cyberpunk + futuristic city + underground hacker culture + anime/game-inspired digital world.** + +Think: + +- Midnight futuristic city +- Neon-lit buildings +- Dense urban environments +- Rooftops, alleys, subway stations, billboards, cables, screens +- Glowing cyan, magenta, purple, blue, and acid green +- Dark silhouettes and deep black negative space +- Digital interference and glitch artifacts +- Futuristic HUD details +- Stylized angular typography +- High-contrast lighting +- Cinematic environmental storytelling + +The website should feel intentionally designed and illustrated rather than like a standard SaaS landing page with neon colors added. + +--- + +# 2. Overall Mood + +The world should feel: + +- Mysterious +- Futuristic +- Energetic +- Slightly rebellious +- Technical +- Immersive +- Youthful +- Experimental +- High-tech + +It should NOT feel: + +- Corporate +- Minimalist SaaS +- Generic "hacker terminal" +- Matrix-inspired +- Military +- Dystopian or depressing +- Like a cryptocurrency website +- Like an esports template +- Overly clean or sterile + +The cyberpunk elements should feel playful and distinctly HackUTD. + +--- + +# 3. Primary Color System + +The interface is predominantly **black and extremely dark navy/purple**. + +Use neon colors as accents rather than filling every surface with them. + +## Core dark tones + +Use colors around: + +- Near black +- `#15253C` +- `#362556` +- Deep indigo +- Deep purple-black +- Dark midnight blue + +Black should dominate large backgrounds. + +## Primary neon colors + +### Cyan + +`#21FFF0` + +### Magenta + +`#F62BE8` + +### Purple + +`#5900FF` + +### Electric Blue + +`#2B00FF` + +These should be the dominant glowing accent family. + +## Secondary accents + +Use selectively: + +- `#FF0059` — hot pink/red +- `#FF2736` — red +- `#00FFA1` — electric mint +- `#22C0C8` — teal +- `#4AE9EF` — light cyan +- `#A857FF` — light purple +- `#234C8C` — electric navy/blue +- `#FCFB01` — acid yellow +- `#50FC08` — neon green +- `#FF7843` — orange + +Do not use all accent colors simultaneously in ordinary UI. + +The larger palette mainly exists for illustrations, environmental details, mascots, sponsor areas, track identities, interactive elements, and intentional flashes of color. + +--- + +# 4. Lighting + +Lighting is one of the most important parts of the design. + +Large scenes should generally use: + +**80–90% darkness + 10–20% intense colored light.** + +Examples: + +- Cyan window light +- Purple building glow +- Magenta underlighting +- Neon green signage +- Pink reflections +- Blue rim lighting +- Cyan clouds +- Purple haze + +Colors can bleed softly into surrounding surfaces. + +Avoid evenly illuminated environments. + +Cyberpunk scenes should have areas that disappear almost completely into shadow. + +--- + +# 5. Environmental Design + +Use illustrated or rendered environments as major website sections. + +Examples: + +- Rooftop overlooking HackUTD's city +- Alley filled with wires and signs +- Subway station +- Hacker workstation +- Underground lab +- Neon storefront +- Futuristic campus +- Billboard district +- Server room +- Industrial corridor + +Environmental artwork can span an entire viewport. + +The website should sometimes feel closer to navigating through a game world than scrolling through traditional web sections. + +Important content can be embedded naturally into the environment. + +Examples: + +- HackUTD logo on a billboard +- Statistics displayed on a digital screen +- Sponsor logos on city signage +- Track information displayed through terminals +- Video embedded into a subway advertisement +- Countdown shown on a building screen + +--- + +# 6. Hero Section + +The hero should be highly visual and cinematic. + +Prefer: + +- Full-screen artwork +- Cyberpunk city composition +- Large environmental billboard containing the ZERODAY logo +- Strong depth and perspective +- Foreground silhouettes +- Power cables +- Neon architectural elements +- Buildings receding into the background + +The ZERODAY identity should appear as part of the world rather than floating randomly over it. + +Avoid a conventional: + +"Headline + paragraph + button + illustration" + +SaaS hero layout. + +--- + +# 7. ZERODAY Branding + +The HackUTD ZERODAY logo is one of the strongest visual elements. + +Treat it like an in-world cyberpunk brand. + +Characteristics: + +- White dominant lettering +- Aggressive italic geometry +- Sharp edges +- Magenta outlines +- Cyan glitch marks +- Futuristic technical accents +- High contrast +- Slight Y2K / cyberpunk influence + +Large ZERODAY typography can intentionally exceed normal content widths and even partially disappear beyond the viewport. + +It should feel oversized and expressive. + +--- + +# 8. Typography + +Typography should have two distinct roles. + +## Display typography + +Use angular, italicized, futuristic typography for: + +- Major headings +- ZERODAY branding +- Section titles +- Hero messaging +- Large statements +- Decorative words + +It should evoke: + +- Speed +- Technology +- Racing +- Futuristic interfaces +- Cyberpunk signage + +Large headings can be extremely wide and visually dominant. + +## Interface typography + +Use a highly readable modern sans-serif for: + +- Navigation +- Paragraphs +- FAQ content +- Buttons +- Metadata +- Footer links +- Forms + +Use uppercase lettering frequently for small interface labels. + +Increase letter spacing on: + +- Navigation +- Buttons +- Section labels +- Metadata + +Do NOT use futuristic display fonts for long-form text. + +--- + +# 9. Navigation + +The desktop navigation follows the current visual structure: + +### Left + +Small ZERODAY logo. + +### Center + +Floating navigation container with links such as: + +HOME +TRACKS +SPONSORS +FAQ + +The navigation container should contrast strongly with the scene behind it. + +A white or light floating navigation pill works well against the black environment. + +### Right + +Strong purple **APPLY** CTA. + +Buttons may use clipped or angled corners rather than ordinary rounded rectangles. + +The navigation should remain visually simple because the environment behind it is already complex. + +--- + +# 10. UI Geometry + +Avoid excessive rounded cards. + +Prefer: + +- Sharp edges +- Clipped corners +- Angled corners +- Trapezoids +- Irregular futuristic panels +- Thin borders +- Oversized rectangles +- Screen-like containers + +Cards can resemble: + +- Terminals +- Digital displays +- Access panels +- Subway advertisements +- Billboards +- Computer interfaces + +Small amounts of rounding are acceptable where necessary, but the overall language should remain geometric and sharp. + +--- + +# 11. Decorative HUD Elements + +Use subtle technical UI details around the edges of the viewport. + +Examples: + +- Thin vertical lines +- Tiny horizontal ticks +- Coordinates +- Rotated labels +- Section identifiers +- Technical numbers +- Crosshairs +- Small bars +- Micro typography + +The existing side markers such as: + +`MMXXVI` + +and + +`HACKUTD` + +are good examples. + +These details should be subtle. + +They should contribute atmosphere without competing with content. + +--- + +# 12. Glitch Effects + +Use glitch styling sparingly. + +Good uses: + +- Logo distortion +- Cyan/magenta horizontal streaks +- Brief image displacement +- Screen interference +- Text reveal +- Hover transitions +- Loading sequences + +Colors should often separate into: + +- Cyan +- Magenta +- White + +Do NOT continuously glitch the entire interface. + +Glitches should feel like intentional digital imperfections. + +--- + +# 13. Animation + +Motion should make the city feel alive. + +Prefer subtle environmental animation: + +- Slowly moving clouds +- Flickering windows +- Neon sign flicker +- Moving stars +- Shooting stars +- Screen interference +- Floating particles +- Slight billboard animation +- Electrical pulses +- Small parallax between city layers +- Mascot idle animation + +Animations should usually be slow and atmospheric. + +Interactive UI animations can be faster and sharper. + +Example: + +Button hover: + +- Slight glow +- Small horizontal displacement +- Brief cyan/magenta split +- Border illumination + +Avoid excessive bouncing, scaling, spring animations, or generic Framer Motion effects. + +--- + +# 14. Mascots + +HackUTD's mascots are an important contrast to the darker environment. + +Current characters include colorful creatures using: + +- Cyan +- Teal +- Green +- Orange +- Purple +- Magenta +- Pink + +Mascots should retain their illustrated/cartoon appearance. + +Do NOT recolor every mascot to purple simply to match the site. + +Their distinct color palettes help make the world feel alive. + +Mascots can: + +- Walk through scenes +- Fly across sections +- Sit on buildings +- Appear beside tracks +- Hide inside environmental artwork +- Animate during transitions +- Interact with UI elements + +Mascots should feel like inhabitants of the ZERODAY universe. + +--- + +# 15. Section Design + +Avoid repeating identical rectangular content sections. + +Each major section can represent a new physical location. + +Example progression: + +Hero +→ City rooftop + +About +→ Hacker hideout + +Statistics +→ Subway display + +Tracks +→ Neon district + +Sponsors +→ Corporate city skyline + +FAQ +→ Dark terminal/interface environment + +Footer +→ City shutdown / black void / final transmission + +This gives users the sensation of progressing through a world. + +--- + +# 16. Large Black Spaces + +Do not be afraid of black. + +Some sections should intentionally contain large amounts of nearly empty black space. + +This allows: + +- Typography to breathe +- Neon elements to feel brighter +- Cinematic pacing +- Clear transitions between large visual scenes + +The FAQ and footer are good references. + +--- + +# 17. FAQ Design + +FAQ sections should remain extremely readable. + +Use: + +- Black background +- White primary text +- Muted gray secondary text +- Thin dark separators +- Purple or cyan micro-accents + +Keep the interaction simple. + +Do not place detailed cyberpunk artwork directly underneath dense FAQ text. + +--- + +# 18. Social Cards + +Social links can become large futuristic panels. + +Use: + +- Dark purple gradients +- Black surfaces +- White platform icons +- Sharp/clipped upper corners +- Large whitespace +- Uppercase labels +- Small decorative line patterns + +Cards should feel like physical access panels rather than ordinary social buttons. + +--- + +# 19. Buttons + +Primary buttons should commonly use saturated purple. + +Example: + +`#5900FF` + +or nearby values. + +Button characteristics: + +- Uppercase +- Wide letter spacing +- Sharp geometry +- Slightly clipped/angled corners +- Bright text +- Strong contrast + +Secondary actions can use: + +- White +- Transparent +- Thin cyan borders +- Thin purple borders + +Avoid pill-shaped CTAs unless specifically required by the design. + +--- + +# 20. Background Treatment + +The site should not use a generic gradient as the primary page background. + +Gradients should instead appear as: + +- Light pollution +- Neon illumination +- Sky glow +- Fog +- Screen glow +- Reflections + +When no illustration is present, prefer pure black or extremely dark navy. + +--- + +# 21. Cyberpunk Without Visual Noise + +Cyberpunk does NOT mean putting: + +- Random hexadecimal strings +- Fake source code +- Matrix rain +- Binary numbers +- Random terminal windows +- Neon borders around everything + +Instead, build cyberpunk through: + +**environment + typography + lighting + composition + motion + geometry.** + +Every decorative technical element should appear intentionally placed. + +--- + +# 22. Responsive Design + +Desktop can be highly cinematic. + +Mobile must prioritize usability. + +On smaller screens: + +- Reduce environmental complexity +- Preserve focal subjects +- Reposition artwork rather than simply scaling it down +- Keep typography readable +- Collapse navigation cleanly +- Avoid horizontal overflow from oversized typography +- Reduce HUD decoration +- Reduce parallax +- Reduce animation complexity +- Maintain strong CTA visibility + +Important artwork should use responsive crops rather than shrinking the entire desktop composition. + +--- + +# 23. Accessibility + +Despite the visual style, content must remain readable. + +Ensure: + +- Strong text contrast +- Important text is never dependent on glow +- Interactive elements have clear states +- Neon text remains readable without bloom +- Reduced-motion preferences are respected +- Decorative animations do not interfere with navigation +- Body copy maintains comfortable line height +- Mobile text never becomes excessively small + +Cyberpunk atmosphere should never reduce usability. + +--- + +# 24. Design Hierarchy + +When creating a new section, prioritize in this order: + +1. Clear purpose +2. Strong composition +3. Readable content +4. Environmental storytelling +5. ZERODAY branding +6. Neon lighting +7. Cyberpunk details +8. Animation +9. Decorative glitches + +Do not start by adding glitch effects. + +--- + +# 25. Reference Formula + +When unsure how a HackUTD page should look, use this formula: + +**Black cinematic canvas** + +- **Cyberpunk city/environment** +- **white angular typography** +- **purple / magenta / cyan neon lighting** +- **sharp futuristic UI geometry** +- **small HUD details** +- **colorful HackUTD mascots** +- **subtle atmospheric motion** + +The result should immediately feel like it belongs inside the HackUTD 2026 ZERODAY world. + +--- + +# 26. Final Design Test + +Before finishing any page or component, ask: + +- Could this belong on the current ZERODAY website? +- Does this feel like the same nighttime cyberpunk city? +- Are cyan, magenta, purple, and white being used intentionally? +- Does the page have enough darkness? +- Does the typography feel sharp and futuristic? +- Are the UI shapes consistent with the angular visual language? +- Is the design immersive without becoming difficult to use? +- Does HackUTD still feel playful rather than overly serious? +- Have I avoided generic SaaS/card-grid design? +- Have I avoided generic Matrix/hacker clichés? + +If the answer to any major question is no, revise the design. diff --git a/Dockerfile b/Dockerfile index 9ec718004..af1c7fea6 100644 --- a/Dockerfile +++ b/Dockerfile @@ -30,6 +30,7 @@ COPY --from=builder /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/ COPY --from=builder /app/api ./api COPY --from=frontend /app/client/portal/dist ./static COPY --from=frontend /app/client/portal/public/pwa-192x192.png ./client/portal/public/pwa-192x192.png +COPY --from=frontend /app/client/portal/src/assets/title-login.webp ./static/email-assets/zero-day-title.webp EXPOSE 8080 CMD ["./api"] diff --git a/client/portal/branding/README.md b/client/portal/branding/README.md index 9bbf42636..f18bb8a46 100644 --- a/client/portal/branding/README.md +++ b/client/portal/branding/README.md @@ -17,7 +17,7 @@ redesign completely. | ------------------ | --------------------------------------------------------------------------- | | `index.ts` | Product name, PWA manifest text, SuperTokens app name, browser theme colour | | `theme.css` | The colour palette | -| `assets/logo.webp` | Sign-in page logo and browser favicon | +| `assets/logo.webp` | Sign-in page logo | Change all three and you have rebranded the app's identity in about five minutes. If that is as far as you get this year, it is a reasonable place to @@ -32,8 +32,8 @@ the three do less than their names suggest. tab, PWA install prompt, home-screen label, Android status bar, the fallback title on a push notification, and the sender name on magic-link sign-in emails. -**`assets/logo.webp` — fully wired.** Favicon and sign-in page logo, from one -file. +**`assets/logo.webp` — the sign-in page logo.** The browser favicon comes from +the PWA icons in `public/`, so the two can be changed independently. **`theme.css` — only partly.** It drives the admin and super-admin portal completely; those pages are built from shadcn components that read the tokens. @@ -98,8 +98,9 @@ upstream, since every school will want it. `public/pwa-192x192.png` and `public/pwa-512x512.png` are **replace-in-place**: swap the files, keep the names. They are referenced by absolute URL from the PWA -manifest and the service worker, and the Dockerfile copies the 192px one into -the runtime image for Apple Wallet passes. Renaming them breaks all three. +manifest, browser favicon, and service worker, and the Dockerfile copies the +192px one into the runtime image for Apple Wallet passes. Renaming them breaks +all four. 512×512 and 192×192 PNGs. The 512 also serves as the maskable icon, so keep roughly 20% padding around the artwork or Android will crop into it. diff --git a/client/portal/branding/index.ts b/client/portal/branding/index.ts index 415a80eca..dabd444ec 100644 --- a/client/portal/branding/index.ts +++ b/client/portal/branding/index.ts @@ -20,26 +20,27 @@ export const branding = { * prompts, and as the fallback title on a push notification that arrives * without one. */ - appName: "Harp", + appName: "Zero Day Harp", /** Full name in the PWA manifest — shown on the install prompt. */ - fullName: "Harp Portal", + fullName: "Zero Day Harp", /** * Home-screen label once installed. Kept short deliberately: most launchers * truncate past roughly 12 characters. */ - shortName: "Harp", + shortName: "Zero Day Harp", /** PWA manifest description, shown in some install UIs and app listings. */ - description: "Hacker applications, reviews, and event operations.", + description: + "The official hacker portal for HackUTD 2026 Zero Day—applications, event access, schedules, and updates.", /** * Application name registered with SuperTokens. Appears in the subject and * body of magic-link sign-in emails, so it should read as something a * hacker recognises. */ - authAppName: "Harp", + authAppName: "Zero Day Harp", /** * Browser UI colour — Android's status bar and the PWA splash screen. @@ -50,8 +51,8 @@ export const branding = { * the browser chrome rather than CSS, and support for modern colour * functions here is inconsistent. */ - themeColor: "#ffffff", + themeColor: "#020307", /** Splash-screen background while the installed app starts up. */ - backgroundColor: "#ffffff", + backgroundColor: "#020307", } as const; diff --git a/client/portal/index.html b/client/portal/index.html index 14dd9aacb..3c498e6d1 100644 --- a/client/portal/index.html +++ b/client/portal/index.html @@ -4,10 +4,28 @@ - + + + + + + + + + + + + + + + + %HARP_TITLE% diff --git a/client/portal/public/pwa-192x192.png b/client/portal/public/pwa-192x192.png index 659a0d980..0685f5866 100644 Binary files a/client/portal/public/pwa-192x192.png and b/client/portal/public/pwa-192x192.png differ diff --git a/client/portal/public/pwa-512x512.png b/client/portal/public/pwa-512x512.png index d79ebd75f..a59363895 100644 Binary files a/client/portal/public/pwa-512x512.png and b/client/portal/public/pwa-512x512.png differ diff --git a/client/portal/public/pwa.png b/client/portal/public/pwa.png new file mode 100644 index 000000000..d3acda438 Binary files /dev/null and b/client/portal/public/pwa.png differ diff --git a/client/portal/src/assets/logo_glitch.webp b/client/portal/src/assets/logo_glitch.webp new file mode 100644 index 000000000..175acae5a Binary files /dev/null and b/client/portal/src/assets/logo_glitch.webp differ diff --git a/client/portal/src/assets/mascots/dragonfly.webp b/client/portal/src/assets/mascots/dragonfly.webp new file mode 100644 index 000000000..77c7d1ac1 Binary files /dev/null and b/client/portal/src/assets/mascots/dragonfly.webp differ diff --git a/client/portal/src/assets/mascots/jaguar.webp b/client/portal/src/assets/mascots/jaguar.webp new file mode 100644 index 000000000..1bef41668 Binary files /dev/null and b/client/portal/src/assets/mascots/jaguar.webp differ diff --git a/client/portal/src/assets/mascots/octopus.webp b/client/portal/src/assets/mascots/octopus.webp new file mode 100644 index 000000000..6dd726774 Binary files /dev/null and b/client/portal/src/assets/mascots/octopus.webp differ diff --git a/client/portal/src/assets/mascots/raccoon_walk.webp b/client/portal/src/assets/mascots/raccoon_walk.webp new file mode 100644 index 000000000..07955446b Binary files /dev/null and b/client/portal/src/assets/mascots/raccoon_walk.webp differ diff --git a/client/portal/src/assets/signin.webp b/client/portal/src/assets/signin.webp deleted file mode 100644 index 95991c2c9..000000000 Binary files a/client/portal/src/assets/signin.webp and /dev/null differ diff --git a/client/portal/src/assets/title-login.webp b/client/portal/src/assets/title-login.webp new file mode 100644 index 000000000..087602c2a Binary files /dev/null and b/client/portal/src/assets/title-login.webp differ diff --git a/client/portal/src/assets/title.png b/client/portal/src/assets/title.png new file mode 100644 index 000000000..5c1830ff0 Binary files /dev/null and b/client/portal/src/assets/title.png differ diff --git a/client/portal/src/components/AuthFlowSkeleton.tsx b/client/portal/src/components/AuthFlowSkeleton.tsx index 0497cb7b5..3e2acb8bb 100644 --- a/client/portal/src/components/AuthFlowSkeleton.tsx +++ b/client/portal/src/components/AuthFlowSkeleton.tsx @@ -1,13 +1,72 @@ +import zeroDayTitle from "@/assets/title-login.webp"; import { Skeleton } from "@/components/ui/skeleton"; +/** + * Loading state for the three public auth-flow pages (verify, callback, OAuth + * callback). It deliberately mirrors the Zero Day panel those pages resolve + * into — same canvas, HUD rail, title lockup, and panel geometry — so the + * handoff reads as the panel filling in rather than a new screen. + */ export function AuthFlowSkeleton() { return ( -
-
- - - +
+
+
+ +
+ HackUTD // Secure portal + MMXXVI +
+ +
+
+ HackUTD Zero Day + +
+
+
+

+ Secure channel // Verifying +

+ +
+ +
+ + + +
+ +
+
+
+ +
+ + +
+
+
+ +

+ Zero Day Harp // Establishing session +

+
-
+
); } diff --git a/client/portal/src/components/HackerPageLoader.tsx b/client/portal/src/components/HackerPageLoader.tsx new file mode 100644 index 000000000..3fdc59d70 --- /dev/null +++ b/client/portal/src/components/HackerPageLoader.tsx @@ -0,0 +1,74 @@ +import { + HackerSkeleton, + HackerSkeletonStatus, +} from "@/components/HackerSkeleton"; +import { cn } from "@/shared/lib/utils"; + +interface HackerPageLoaderProps { + /** + * Paint the Zero Day canvas too. Needed when the loader renders outside + * `HackerLayout` — the auth guard and the lazy layout import — where the + * page would otherwise flash the portal's light background. + */ + fullscreen?: boolean; +} + +/** + * Suspense fallback for hacker routes. Shaped like a dormant dashboard — + * status panel, date tiles, notification rows — so the most common landing + * fills in rather than swapping layouts. + */ +export function HackerPageLoader({ + fullscreen = false, +}: HackerPageLoaderProps) { + return ( +
+
+ + +
+ + + + + +
+ +
+ {[...Array(3)].map((_, i) => ( +
+ + + +
+ ))} +
+ +
+ + {[...Array(3)].map((_, i) => ( +
+ +
+ + +
+
+ ))} +
+
+
+ ); +} diff --git a/client/portal/src/components/HackerSkeleton.tsx b/client/portal/src/components/HackerSkeleton.tsx new file mode 100644 index 000000000..75a0817d6 --- /dev/null +++ b/client/portal/src/components/HackerSkeleton.tsx @@ -0,0 +1,92 @@ +import { cn } from "@/shared/lib/utils"; + +type SkeletonCut = "none" | "sm" | "md"; + +// Clipped corners follow the login panel geometry. Thin text bars stay sharp +// because an 8px cut would swallow most of their height. +const CUT_CLASS: Record = { + none: "", + sm: "zero-cut-sm", + md: "zero-cut-button", +}; + +interface HackerSkeletonProps extends React.ComponentProps<"div"> { + cut?: SkeletonCut; +} + +/** + * Zero Day placeholder block for hacker-side loading states: a dim panel + * with a slow cyan sweep. Pair with `HackerSkeletonStatus` so every loading + * screen opens with the same HUD line. + */ +export function HackerSkeleton({ + className, + cut = "none", + ...props +}: HackerSkeletonProps) { + return ( +
+ ); +} + +interface HackerSkeletonStatusProps { + /** What is loading, e.g. "Schedule". Rendered as `Loading // Schedule`. */ + label: string; + className?: string; +} + +/** Mono HUD status line that heads a hacker-side loading state. */ +export function HackerSkeletonStatus({ + label, + className, +}: HackerSkeletonStatusProps) { + return ( +

+ + Loading // {label} +

+ ); +} + +interface HackerSkeletonEmbedProps { + label: string; + className?: string; +} + +/** + * Loading state for a large embedded surface (iframe, PDF, receipt image). + * A single block that big reads as a glitch, so this centres the status line + * and the auth flow's scan track instead. + */ +export function HackerSkeletonEmbed({ + label, + className, +}: HackerSkeletonEmbedProps) { + return ( +
+ +
+
+
+
+ ); +} diff --git a/client/portal/src/index.css b/client/portal/src/index.css index 6b71e813d..addb39bd1 100644 --- a/client/portal/src/index.css +++ b/client/portal/src/index.css @@ -337,3 +337,730 @@ .animate-celebration-fade-in { animation: celebration-fade-in 0.3s ease-out; } + +/* --------------------------------------------------------------------------- + * Login mascots + * + * The mascot webps animate in place on their own (run cycle, wings, walk), so + * these keyframes only move them around the sign-in page. Used by + * `pages/public/components/MascotField.tsx`. + * ------------------------------------------------------------------------ */ + +/* Jaguar: sprints the width of the screen, waits off-screen, turns, sprints + back. The scaleX flip is scheduled while it is off-screen so it never reads + as a pivot in mid-stride. */ +@keyframes mascot-dash { + 0% { + transform: translate3d(115vw, 0, 0) scaleX(1); + } + 20% { + transform: translate3d(-45vw, 0, 0) scaleX(1); + } + 20.01%, + 60% { + transform: translate3d(-45vw, 0, 0) scaleX(-1); + } + 80%, + 100% { + transform: translate3d(115vw, 0, 0) scaleX(-1); + } +} + +/* Timing is driven by custom properties rather than set directly, so a single + mascot can be re-timed from the JSX. These rules are unlayered and would + otherwise win over Tailwind's `[animation-duration:…]` utilities. */ +.mascot-dash { + animation: mascot-dash var(--mascot-duration, 24s) linear + var(--mascot-delay, 0s) infinite; + will-change: transform; +} + +/* A separate, sparse one-way sprint used across the hacker portal. The jaguar + remains off-screen for most of the cycle, then crosses quickly enough to + feel like an occasional sighting instead of permanent page furniture. */ +@keyframes hacker-jaguar-run { + 0%, + 72% { + opacity: 0; + transform: translate3d(-180px, 0, 0) scaleX(-1); + } + 73% { + opacity: 1; + transform: translate3d(-180px, 0, 0) scaleX(-1); + } + 91% { + opacity: 1; + transform: translate3d(calc(100vw + 180px), 0, 0) scaleX(-1); + } + 91.01%, + 100% { + opacity: 0; + transform: translate3d(calc(100vw + 180px), 0, 0) scaleX(-1); + } +} + +.hacker-jaguar-run { + animation: hacker-jaguar-run var(--jaguar-duration, 43s) linear + var(--jaguar-delay, 0s) infinite; + will-change: transform, opacity; +} + +/* Wandering: waypoints inside a --roam-x by --roam-y box rather than a straight + there-and-back, so the path never reads as a pendulum. The box hangs off the + mascot's anchor corner, so the signs of the two vars are what aim it at open + space. Easing applies per segment, which gives the drift-and-pause rhythm of + something that keeps changing its mind. */ +@keyframes mascot-roam { + 0%, + 100% { + transform: translate3d(0, 0, 0); + } + 18% { + transform: translate3d( + calc(var(--roam-x, 0px) * 0.85), + calc(var(--roam-y, 0px) * 0.2), + 0 + ); + } + 35% { + transform: translate3d( + calc(var(--roam-x, 0px) * 0.3), + calc(var(--roam-y, 0px) * 0.85), + 0 + ); + } + 52% { + transform: translate3d(var(--roam-x, 0px), var(--roam-y, 0px), 0); + } + 70% { + transform: translate3d( + calc(var(--roam-x, 0px) * 0.45), + calc(var(--roam-y, 0px) * 0.35), + 0 + ); + } + 86% { + transform: translate3d( + calc(var(--roam-x, 0px) * 0.9), + calc(var(--roam-y, 0px) * 0.75), + 0 + ); + } +} + +.mascot-roam { + animation: mascot-roam var(--mascot-duration, 21s) ease-in-out + var(--mascot-delay, 0s) infinite; + will-change: transform; +} + +/* Hovering: bobs and tips on a shorter period than the drift underneath it, so + the two never settle into an obvious repeat. */ +@keyframes mascot-hover { + 0%, + 100% { + transform: translate3d(0, calc(var(--float, 10px) * -1), 0) rotate(-5deg); + } + 50% { + transform: translate3d(0, var(--float, 10px), 0) rotate(5deg); + } +} + +.mascot-hover { + animation: mascot-hover var(--mascot-bounce-duration, 6.5s) ease-in-out + var(--mascot-bounce-delay, 0s) infinite; +} + +/* A small bounce for the ones whose own animation is a walk in place. */ +@keyframes mascot-waddle { + 0%, + 100% { + transform: translate3d(0, 0, 0) rotate(-2.5deg); + } + 50% { + transform: translate3d(0, calc(var(--waddle, 4px) * -1), 0) rotate(2.5deg); + } +} + +.mascot-waddle { + animation: mascot-waddle var(--mascot-bounce-duration, 2.8s) ease-in-out + var(--mascot-bounce-delay, 0s) infinite; +} + +/* Walks the length of --patrol, turns, doubles back part of the way, turns + again — four legs of uneven length so it reads as pottering about rather than + pacing. scaleX passing through 0 at each end is what sells the turn, and the + keyframes are spaced by distance so the walking speed stays constant. + + The raccoon art faces left, so scaleX(-1) is the one that walks it forwards + to the right: every leg travelling in +x is mirrored, every leg travelling + back to the left is not. */ +@keyframes mascot-patrol { + 0% { + transform: translate3d(0, 0, 0) scaleX(-1); + } + 29% { + transform: translate3d(var(--patrol, 80px), 0, 0) scaleX(-1); + } + 33% { + transform: translate3d(var(--patrol, 80px), 0, 0) scaleX(1); + } + 52% { + transform: translate3d(calc(var(--patrol, 80px) * 0.35), 0, 0) scaleX(1); + } + 56% { + transform: translate3d(calc(var(--patrol, 80px) * 0.35), 0, 0) scaleX(-1); + } + 69% { + transform: translate3d(calc(var(--patrol, 80px) * 0.8), 0, 0) scaleX(-1); + } + 73% { + transform: translate3d(calc(var(--patrol, 80px) * 0.8), 0, 0) scaleX(1); + } + 96% { + transform: translate3d(0, 0, 0) scaleX(1); + } + 100% { + transform: translate3d(0, 0, 0) scaleX(-1); + } +} + +.mascot-patrol { + animation: mascot-patrol var(--mascot-duration, 24s) linear + var(--mascot-delay, 0s) infinite; + will-change: transform; +} + +/* The mascots are decoration, and the webps keep animating even when the layer + stops moving — so drop the whole field rather than freeze it. */ +@media (prefers-reduced-motion: reduce) { + .mascot-field { + display: none; + } +} + +/* On a landscape phone the form fills the viewport top to bottom, leaving the + mascots nowhere to wander that isn't on top of it. */ +@media (max-height: 560px) { + .mascot-field { + display: none; + } +} + +/* --------------------------------------------------------------------------- + * Zero Day login + * + * The login screen is intentionally isolated from the portal's light UI. Its + * gradients behave as sparse neon light pollution over a black canvas, while + * the grid, scanlines, and clipped panels supply the technical geometry. + * ------------------------------------------------------------------------ */ + +.zero-login { + background-color: #020307; + background-image: + radial-gradient(circle at 10% 18%, rgb(89 0 255 / 22%), transparent 28%), + radial-gradient(circle at 88% 8%, rgb(246 43 232 / 14%), transparent 24%), + radial-gradient(circle at 58% 105%, rgb(33 255 240 / 10%), transparent 34%); +} + +.zero-login::before { + position: absolute; + inset: 0; + z-index: -1; + background: linear-gradient( + 115deg, + transparent 0 44%, + rgb(89 0 255 / 6%) 44.1% 44.3%, + transparent 44.4% 100% + ); + content: ""; +} + +.zero-login-grid { + background-image: + linear-gradient(rgb(33 255 240 / 9%) 1px, transparent 1px), + linear-gradient(90deg, rgb(33 255 240 / 9%) 1px, transparent 1px); + background-size: 72px 72px; + mask-image: linear-gradient( + to bottom, + transparent 2%, + black 32%, + transparent + ); + opacity: 0.22; + transform: perspective(720px) rotateX(64deg) scale(1.65) translateY(18%); + transform-origin: bottom; +} + +.zero-login-scanlines { + background-image: repeating-linear-gradient( + to bottom, + transparent 0, + transparent 3px, + rgb(255 255 255 / 2.5%) 4px + ); + opacity: 0.4; +} + +.zero-login-title { + filter: drop-shadow(10px 0 18px rgb(246 43 232 / 14%)) + drop-shadow(-8px 0 18px rgb(33 255 240 / 10%)); +} + +.zero-login-hand { + filter: drop-shadow(18px 0 30px rgb(246 43 232 / 18%)) + drop-shadow(-16px 0 30px rgb(33 255 240 / 16%)); + animation: zero-hand-drift 10s ease-in-out infinite; + will-change: transform; +} + +.zero-login-panel { + background: linear-gradient( + 135deg, + rgb(33 255 240 / 72%), + rgb(255 255 255 / 12%) 30%, + rgb(255 255 255 / 12%) 68%, + rgb(246 43 232 / 72%) + ); + clip-path: polygon( + 0 0, + calc(100% - 24px) 0, + 100% 24px, + 100% 100%, + 24px 100%, + 0 calc(100% - 24px) + ); + filter: drop-shadow(0 28px 60px rgb(0 0 0 / 48%)); +} + +.zero-login-panel::after { + position: absolute; + top: 0; + right: 24px; + width: 72px; + height: 1px; + background: #f62be8; + box-shadow: 0 0 12px #f62be8; + content: ""; +} + +.zero-login-panel-inner { + min-height: 100%; + background: rgb(7 8 15 / 94%); + clip-path: inherit; + backdrop-filter: blur(18px); +} + +.zero-cut-button { + border-radius: 0; + clip-path: polygon( + 0 0, + calc(100% - 11px) 0, + 100% 11px, + 100% 100%, + 11px 100%, + 0 calc(100% - 11px) + ); +} + +.zero-cut-sm, +.zero-login-input { + border-radius: 0; + clip-path: polygon( + 0 0, + calc(100% - 8px) 0, + 100% 8px, + 100% 100%, + 8px 100%, + 0 calc(100% - 8px) + ); +} + +.zero-login-legal-link { + position: relative; + display: inline-block; + text-decoration: none; + transition: + color 280ms ease, + text-shadow 280ms ease; +} + +.zero-login-legal-link::before, +.zero-login-legal-link::after { + position: absolute; + right: 0; + bottom: 1px; + left: 0; + height: 1px; + content: ""; +} + +.zero-login-legal-link::before { + background: rgb(255 255 255 / 38%); +} + +.zero-login-legal-link::after { + background: #21fff0; + box-shadow: 0 0 8px rgb(33 255 240 / 70%); + transform: scaleX(0); + transform-origin: left; + transition: transform 280ms ease; +} + +.zero-login-legal-link:hover, +.zero-login-legal-link:focus-visible { + color: #21fff0; + text-shadow: 0 0 12px rgb(33 255 240 / 55%); + outline: none; +} + +.zero-login-legal-link:hover::after, +.zero-login-legal-link:focus-visible::after { + transform: scaleX(1); +} + +.zero-harp-link { + transition: + filter 240ms ease, + transform 240ms ease; +} + +.zero-harp-link span, +.zero-harp-link svg { + transition: color 240ms ease; +} + +.zero-harp-link:hover, +.zero-harp-link:focus-visible { + filter: drop-shadow(0 0 8px rgb(33 255 240 / 55%)); + outline: none; + transform: translateX(2px); +} + +.zero-harp-link:hover span, +.zero-harp-link:focus-visible span, +.zero-harp-link:hover svg, +.zero-harp-link:focus-visible svg { + color: #21fff0; +} + +/* Zero Day hacker portal. The theme class lives at the shared hacker layout, + so admin and public pages retain their existing palette. A matching body + class covers Radix portals, which render outside the layout subtree. */ +.hacker-zero-theme, +body.hacker-zero-portals { + --background: #080910; + --foreground: #f8f8ff; + --card: #0b0c15; + --card-foreground: #f8f8ff; + --popover: #0d0e18; + --popover-foreground: #f8f8ff; + --primary: #5900ff; + --primary-foreground: #ffffff; + --secondary: #151625; + --secondary-foreground: #f8f8ff; + --muted: #151625; + --muted-foreground: rgb(255 255 255 / 52%); + --accent: rgb(33 255 240 / 10%); + --accent-foreground: #21fff0; + --border: rgb(255 255 255 / 11%); + --input: rgb(255 255 255 / 13%); + --ring: rgb(33 255 240 / 65%); +} + +body.hacker-zero-portals { + background: #030409; + color: #f8f8ff; +} + +.zero-hacker-surface { + background-color: #030409; + background-image: + radial-gradient(circle at 88% 4%, rgb(246 43 232 / 8%), transparent 24%), + radial-gradient(circle at 12% 80%, rgb(89 0 255 / 10%), transparent 30%), + linear-gradient(rgb(33 255 240 / 2.5%) 1px, transparent 1px), + linear-gradient(90deg, rgb(33 255 240 / 2.5%) 1px, transparent 1px); + background-size: + auto, + auto, + 64px 64px, + 64px 64px; +} + +/* Translate the hacker pages' legacy hard-coded light utilities at the theme + boundary. QR and camera surfaces opt out because their contrast is + functional rather than decorative. */ +:where(.hacker-zero-theme, body.hacker-zero-portals) .text-black { + color: #f8f8ff; +} + +:where(.hacker-zero-theme, body.hacker-zero-portals) + :is( + [class~="text-[#8A8A8A]"], + [class~="text-[#6B6B6B]"], + [class~="text-[#9B9B9B]"], + [class~="text-[#B4B4B4]"], + [class~="text-[#B8B8B8]"], + [class~="text-[#C4C4C4]"] + ) { + color: rgb(255 255 255 / 48%); +} + +:where(.hacker-zero-theme, body.hacker-zero-portals) + :is( + [class~="text-[#1A1A1A]"], + [class~="text-[#282828]"], + [class~="text-[#3D3D3D]"] + ) { + color: rgb(255 255 255 / 82%); +} + +:where(.hacker-zero-theme, body.hacker-zero-portals) + .bg-white:not([data-hacker-keep-light]) { + background-color: #0b0c15; +} + +:where(.hacker-zero-theme, body.hacker-zero-portals) + .bg-black:not([data-hacker-keep-black]) { + background-color: #5900ff; +} + +:where(.hacker-zero-theme, body.hacker-zero-portals) + :is( + [class~="bg-[#FAFAFA]"], + [class~="bg-[#F5F5F5]"], + [class~="bg-[#F2F2F2]"], + [class~="bg-[#F0F0F0]"], + [class~="bg-[#EDEDED]"], + [class~="bg-[#E5E5E5]"] + ) { + background-color: #11121e; +} + +:where(.hacker-zero-theme, body.hacker-zero-portals) + :is([class~="bg-white/95"], [class~="bg-white/90"]) { + background-color: rgb(11 12 21 / 96%); +} + +:where(.hacker-zero-theme, body.hacker-zero-portals) + :is( + [class~="border-[#E5E5E5]"], + [class~="border-[#D9D9D9]"], + [class~="border-[#F0F0F0]"], + [class~="border-[#EDEDED]"], + [class~="border-[#C9C9C9]"] + ) { + border-color: rgb(255 255 255 / 12%); +} + +:where(.hacker-zero-theme, body.hacker-zero-portals) + :is([class~="divide-[#F0F0F0]"], [class~="divide-[#F5F5F5]"]) + > :not(:last-child) { + border-color: rgb(255 255 255 / 10%); +} + +:where(.hacker-zero-theme, body.hacker-zero-portals) + :is( + [class~="hover:bg-[#FAFAFA]"], + [class~="hover:bg-[#F5F5F5]"], + [class~="hover:bg-[#F2F2F2]"], + [class~="hover:bg-[#F0F0F0]"], + [class~="hover:bg-[#EDEDED]"] + ):hover { + background-color: rgb(33 255 240 / 8%); +} + +:where(.hacker-zero-theme, body.hacker-zero-portals) + :is([class~="hover:text-black"], [class~="focus-visible:text-black"]):hover, +:where(.hacker-zero-theme, body.hacker-zero-portals) + [class~="focus-visible:text-black"]:focus-visible { + color: #21fff0; +} + +:where(.hacker-zero-theme, body.hacker-zero-portals) + :is( + [class~="hover:border-black"], + [class~="focus-visible:border-black"] + ):hover, +:where(.hacker-zero-theme, body.hacker-zero-portals) + [class~="focus-visible:border-black"]:focus-visible { + border-color: #21fff0; +} + +:where(.hacker-zero-theme, body.hacker-zero-portals) + :is([class~="hover:bg-black/85"], [class~="active:bg-black/80"]):hover, +:where(.hacker-zero-theme, body.hacker-zero-portals) + [class~="active:bg-black/80"]:active { + background-color: #6d1cff; +} + +:where(.hacker-zero-theme, body.hacker-zero-portals) + :is(input, textarea, select) { + color: #f8f8ff; + caret-color: #21fff0; +} + +:where(.hacker-zero-theme, body.hacker-zero-portals) + :is(input, textarea)::placeholder { + color: rgb(255 255 255 / 32%); +} + +body.hacker-zero-portals + :is( + [data-slot="dialog-content"], + [data-slot="alert-dialog-content"], + [data-slot="dropdown-menu-content"], + [data-slot="select-content"], + [data-slot="popover-content"], + [data-slot="sheet-content"], + [data-slot="drawer-content"], + [data-slot="hover-card-content"] + ) { + border-color: rgb(255 255 255 / 12%); + background-color: #0b0c15; + color: #f8f8ff; + box-shadow: 0 24px 70px rgb(0 0 0 / 58%); +} + +body.hacker-zero-portals + :is([data-slot="dialog-overlay"], [data-slot="alert-dialog-overlay"]) { + background-color: rgb(0 0 0 / 72%); +} + +.hacker-zero-sidebar { + --sidebar: #070810; + --sidebar-foreground: rgb(255 255 255 / 72%); + --sidebar-accent: rgb(89 0 255 / 22%); + --sidebar-accent-foreground: #ffffff; + --sidebar-border: rgb(255 255 255 / 10%); + --sidebar-ring: rgb(33 255 240 / 55%); +} + +.hacker-zero-sidebar [data-sidebar="sidebar"] { + background-image: radial-gradient( + circle at 0 100%, + rgb(89 0 255 / 18%), + transparent 42% + ); + box-shadow: inset -1px 0 rgb(33 255 240 / 8%); +} + +.hacker-zero-sidebar [data-sidebar="header"] { + border-bottom: 1px solid rgb(255 255 255 / 8%); + padding-block: 0.75rem; +} + +.hacker-zero-sidebar [data-sidebar="group-label"] { + color: rgb(33 255 240 / 55%); + font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace; + font-size: 0.625rem; + letter-spacing: 0.2em; + text-transform: uppercase; +} + +.hacker-zero-sidebar [data-sidebar="menu-button"] { + color: rgb(255 255 255 / 58%); + transition: + color 180ms ease, + background-color 180ms ease, + box-shadow 180ms ease; +} + +.hacker-zero-sidebar [data-sidebar="menu-button"]:hover { + background: rgb(33 255 240 / 8%); + color: #21fff0; +} + +.hacker-zero-sidebar [data-sidebar="menu-button"][data-active="true"] { + background: rgb(89 0 255 / 30%); + color: #ffffff; + box-shadow: + inset 2px 0 #21fff0, + 0 0 18px rgb(89 0 255 / 12%); +} + +.hacker-zero-sidebar [data-sidebar="menu-button"][data-size="lg"] { + color: rgb(255 255 255 / 72%); +} + +.hacker-zero-sidebar [data-slot="avatar-fallback"] { + background: rgb(33 255 240 / 10%); + color: #21fff0; +} + +.hacker-zero-sidebar [data-sidebar="rail"]:hover::after { + background: #21fff0; +} + +/* Indeterminate sweep for the auth-flow loading panel. Short and sharp rather + than atmospheric — it reports that something is happening. */ +@keyframes zero-scan-sweep { + 0% { + transform: translateX(-100%); + } + 100% { + transform: translateX(400%); + } +} + +.zero-scan-line { + animation: zero-scan-sweep 1.4s cubic-bezier(0.4, 0, 0.2, 1) infinite; + will-change: transform; +} + +@media (prefers-reduced-motion: reduce) { + .zero-scan-line { + width: 100%; + animation: none; + opacity: 0.55; + } +} + +@keyframes zero-hand-drift { + 0%, + 100% { + transform: translate3d(0, 0, 0) rotate(-1deg); + } + 50% { + transform: translate3d(-8px, -10px, 0) rotate(1deg); + } +} + +@media (max-width: 639px) { + .zero-login { + background-image: + radial-gradient(circle at 20% 10%, rgb(89 0 255 / 20%), transparent 30%), + radial-gradient(circle at 96% 42%, rgb(246 43 232 / 10%), transparent 25%); + } + + .zero-login-grid { + background-size: 48px 48px; + opacity: 0.14; + } +} + +/* Below `lg` the sign-in screen is one screenful: the title pins to the top and + the access panel takes whatever height is left, with the mascot hand sitting + behind it. On a short viewport (a small phone, or one in landscape) the + supporting line steps aside rather than pushing the form off-screen. */ +@media (max-width: 1023px) and (max-height: 720px) { + .zero-login-blurb { + display: none; + } +} + +@media (prefers-reduced-motion: reduce) { + .zero-login-hand { + animation: none; + } + + :is(.hacker-route-mascot, .hacker-jaguar-lane) { + display: none; + } +} + +@media (max-height: 560px) { + :is(.hacker-route-mascot, .hacker-jaguar-lane) { + display: none; + } +} diff --git a/client/portal/src/layouts/HackerLayout.tsx b/client/portal/src/layouts/HackerLayout.tsx index 3bff9fe19..97e95e61e 100644 --- a/client/portal/src/layouts/HackerLayout.tsx +++ b/client/portal/src/layouts/HackerLayout.tsx @@ -1,7 +1,12 @@ import type { LucideIcon } from "lucide-react"; import { Bell, CalendarDays, House, ScanLine, User } from "lucide-react"; +import { useLayoutEffect } from "react"; import { NavLink, Outlet, useLocation } from "react-router"; +import dragonfly from "@/assets/mascots/dragonfly.webp"; +import jaguar from "@/assets/mascots/jaguar.webp"; +import octopus from "@/assets/mascots/octopus.webp"; +import raccoon from "@/assets/mascots/raccoon_walk.webp"; import { InstallPromptHost } from "@/components/InstallPromptHost"; import { PushPromptHost } from "@/components/PushPromptHost"; import { @@ -43,6 +48,58 @@ const SIDEBAR_NAV = NAV_ITEMS.map(({ label, to, icon, end }) => ({ // bar's padding (p-[BOTTOM_NAV_PAD]) and the bubble's inset-y. const BOTTOM_NAV_PAD = 0.375; +const ROUTE_MASCOTS = [octopus, dragonfly, raccoon, jaguar] as const; + +function mascotForPath(pathname: string) { + const routeKey = pathname.split("/").filter(Boolean).join("/") || "app"; + const hash = [...routeKey].reduce( + (total, character) => total + character.charCodeAt(0), + 0, + ); + return ROUTE_MASCOTS[hash % ROUTE_MASCOTS.length]; +} + +function HackerRouteMascot({ pathname }: { pathname: string }) { + const mascot = mascotForPath(pathname); + const isFlying = mascot === dragonfly; + + return ( +
+ +
+ ); +} + +function HackerJaguarRun() { + return ( +
+ +
+ ); +} + function activeIndex(pathname: string): number { return NAV_ITEMS.findIndex((item) => item.end @@ -62,7 +119,10 @@ function HackerSidebar() { }; return ( - + @@ -88,17 +148,30 @@ export default function HackerLayout() { // bar is hidden there to avoid overlap. const hideMobileNav = location.pathname.startsWith("/app/apply"); + // Radix dialogs and menus render into document.body rather than inside the + // layout wrapper. Scope the same hacker theme to those portals while this + // layout is mounted, then remove it before entering an admin/public route. + useLayoutEffect(() => { + document.body.classList.add("hacker-zero-portals"); + return () => document.body.classList.remove("hacker-zero-portals"); + }, []); + return ( - + {/* Onboarding prompts live here, not in providers.tsx, so they never appear on the admin portal or the public auth pages. */} + + {/* Page content */}
diff --git a/client/portal/src/pages/admin/all-applicants/components/ApplicationsTable.tsx b/client/portal/src/pages/admin/all-applicants/components/ApplicationsTable.tsx index d8e2fbf29..af170ae81 100644 --- a/client/portal/src/pages/admin/all-applicants/components/ApplicationsTable.tsx +++ b/client/portal/src/pages/admin/all-applicants/components/ApplicationsTable.tsx @@ -94,9 +94,13 @@ export const ApplicationsTable = memo(function ApplicationsTable({ - - {app.travel_rsvp_status} - + {app.travel_status === "not_requested" ? ( + - + ) : ( + + {app.travel_rsvp_status} + + )} {name} {email} diff --git a/client/portal/src/pages/hacker/application/ApplicationDetailPage.tsx b/client/portal/src/pages/hacker/application/ApplicationDetailPage.tsx index 72fc3d52f..eca6b5af4 100644 --- a/client/portal/src/pages/hacker/application/ApplicationDetailPage.tsx +++ b/client/portal/src/pages/hacker/application/ApplicationDetailPage.tsx @@ -2,7 +2,6 @@ import { ChevronLeft, Eye } from "lucide-react"; import { useEffect, useState } from "react"; import { useNavigate } from "react-router"; -import { Skeleton } from "@/components/ui/skeleton"; import { errorAlert, getRequest } from "@/shared/lib/api"; import { resolveResumeSectionId } from "@/shared/lib/schema-utils"; import type { Application, ApplicationStatus } from "@/types"; @@ -14,6 +13,7 @@ import { STATUS_MESSAGES, STATUS_PILL_COLORS, } from "../components/applicationStatus"; +import { StatusDetailSkeleton } from "../components/StatusDetailSkeleton"; // Friendly headline for the result card, mirroring the RSVP pages' // "You're in!" / "All set!" pattern. @@ -56,13 +56,7 @@ export default function ApplicationDetailPage() { }, [navigate]); if (loading) { - return ( -
- - - -
- ); + return ; } if (!application) return null; diff --git a/client/portal/src/pages/hacker/apply/components/ApplicationWizard.tsx b/client/portal/src/pages/hacker/apply/components/ApplicationWizard.tsx index 0b00a3609..e97e106fd 100644 --- a/client/portal/src/pages/hacker/apply/components/ApplicationWizard.tsx +++ b/client/portal/src/pages/hacker/apply/components/ApplicationWizard.tsx @@ -573,10 +573,10 @@ export function ApplicationWizard({ userEmail }: ApplicationWizardProps) { if (!applicationsEnabled) { return (
-

+

Applications closed

-

+

The application portal is not currently accepting submissions. Please check back later. {application && @@ -591,10 +591,10 @@ export function ApplicationWizard({ userEmail }: ApplicationWizardProps) { if (application && application.status !== "draft") { return (

-

+

Application submitted

-

+

{application.status === "submitted" && "Your application is being reviewed."} {application.status === "accepted" && @@ -607,7 +607,7 @@ export function ApplicationWizard({ userEmail }: ApplicationWizardProps) { diff --git a/client/portal/src/pages/hacker/apply/steps/SchemaStepRenderer.tsx b/client/portal/src/pages/hacker/apply/steps/SchemaStepRenderer.tsx index 3786584eb..392f3aab9 100644 --- a/client/portal/src/pages/hacker/apply/steps/SchemaStepRenderer.tsx +++ b/client/portal/src/pages/hacker/apply/steps/SchemaStepRenderer.tsx @@ -49,16 +49,16 @@ type FormContext = ReturnType>; const underlineField = "h-11 rounded-none border-0 border-b border-[#D9D9D9] bg-transparent px-0 pt-3.5 pb-1 text-base font-light shadow-none transition-colors focus-visible:border-black focus-visible:ring-0 dark:bg-transparent"; -const fieldLabel = "text-xs font-light text-[#8A8A8A]"; +const fieldLabel = "text-sm font-light text-white/90"; -// Dark floating dropdown panel (see design screenshot). Slides down out of the -// trigger: the zoom/scale is neutralized (`zoom-*-100`) so the motion reads as -// a slide rather than a fade-into-position, anchored to the top edge. +// Soft charcoal dropdown panel. It is lighter than the portal surface for +// visibility while remaining part of the dark theme. It slides down out of the +// trigger with zoom/scale neutralized so the motion stays directional. const selectContent = - "origin-top overflow-hidden rounded-lg border-0 bg-[#3A3A3A] p-0 text-white shadow-2xl ease-[cubic-bezier(0.16,1,0.3,1)] data-[state=open]:duration-[400ms] data-[state=closed]:duration-200 data-[state=open]:!zoom-in-100 data-[state=closed]:!zoom-out-100 data-[side=bottom]:!slide-in-from-top-3 data-[side=top]:!slide-in-from-bottom-3"; + "origin-top overflow-hidden rounded-lg border border-white/15 !bg-[#292B35] p-0 !text-white shadow-[0_18px_45px_rgba(0,0,0,0.48)] ease-[cubic-bezier(0.16,1,0.3,1)] data-[state=open]:duration-[400ms] data-[state=closed]:duration-200 data-[state=open]:!zoom-in-100 data-[state=closed]:!zoom-out-100 data-[side=bottom]:!slide-in-from-top-3 data-[side=top]:!slide-in-from-bottom-3"; const selectItem = - "flex w-full cursor-pointer items-center justify-between gap-2 border-b border-white/[0.08] px-5 py-3.5 text-left text-sm font-light text-white/90 transition-colors last:border-b-0 hover:bg-white/[0.07] hover:text-white focus-visible:bg-white/[0.07] focus-visible:text-white focus-visible:outline-none"; + "flex w-full cursor-pointer items-center justify-between gap-2 border-b border-white/10 px-5 py-3.5 text-left text-sm font-light text-white/90 transition-colors last:border-b-0 hover:bg-white/10 hover:text-white focus-visible:bg-white/10 focus-visible:text-white focus-visible:outline-none"; interface SchemaStepRendererProps { sectionLabel: string; @@ -418,7 +418,7 @@ function SchemaFormField({

- + {renderLabel(field.label)} {requiredMark} @@ -523,6 +523,7 @@ function SchemaSelect({ {options.map((opt) => ( @@ -633,17 +634,18 @@ function SchemaCombobox({ {/* Close button */} -
+
{field.label} @@ -693,6 +695,7 @@ function SchemaCombobox({ + handleSelect(opt)} - className="cursor-pointer justify-between rounded-none border-b border-white/[0.08] px-5 py-3.5 text-sm font-light text-white/90 data-[selected=true]:bg-white/[0.07] data-[selected=true]:text-white" + className="cursor-pointer justify-between rounded-none border-b border-white/10 px-5 py-3.5 text-sm font-light text-white/90 data-[selected=true]:bg-white/10 data-[selected=true]:text-white" > {opt} - {opt === value && } + {opt === value && ( + + )} ))} @@ -785,7 +790,7 @@ function ComboboxContent({ {/* Outside CommandList so it's never hidden by the search filter. */} @@ -210,30 +210,30 @@ export function ApplicationStatusCards({ )} {application.status === "accepted" && application.rsvp_status === "declined" && ( -
+
Spot declined -

+

You've declined your spot. Sorry you can't make it — we hope to see you next time!

@@ -246,7 +246,7 @@ export function ApplicationStatusCards({ ) : ( -
+
{travelCard.pill} -

+

Travel reimbursement

-

+

{travelCard.message}

{travelCard.amountCents != null && ( -
-

+

+

Approved amount

-

+

{formatUSD(travelCard.amountCents)}

@@ -299,7 +299,7 @@ export function ApplicationStatusCards({ {travelCard.showTravelForm && ( diff --git a/client/portal/src/pages/hacker/components/HackerQR.tsx b/client/portal/src/pages/hacker/components/HackerQR.tsx index 4f415c261..5a5df3b1a 100644 --- a/client/portal/src/pages/hacker/components/HackerQR.tsx +++ b/client/portal/src/pages/hacker/components/HackerQR.tsx @@ -11,6 +11,7 @@ interface HackerQRProps { export function HackerQR({ value, size = 200, className }: HackerQRProps) { return (
+
+ +
+ +
+ + +
+ + + + +
+ +
+ +
+ +
+ {SUMMARY_ROWS.map(([labelWidth, valueWidth], i) => ( +
+ + +
+ ))} +
+
+
+
+
+ ); +} diff --git a/client/portal/src/pages/hacker/dashboard/DashboardPage.tsx b/client/portal/src/pages/hacker/dashboard/DashboardPage.tsx index 81ef60099..61360cd61 100644 --- a/client/portal/src/pages/hacker/dashboard/DashboardPage.tsx +++ b/client/portal/src/pages/hacker/dashboard/DashboardPage.tsx @@ -5,6 +5,7 @@ import { Link, useLocation, useNavigate } from "react-router"; import { toast } from "sonner"; import { CelebrationEffect } from "@/components/CelebrationEffect"; +import { HackerPageLoader } from "@/components/HackerPageLoader"; import { getRequest } from "@/shared/lib/api"; import { parseDateOnly } from "@/shared/lib/datetime"; import { hackerLinkIcon } from "@/shared/lib/hacker-link-icons"; @@ -82,16 +83,26 @@ function dashboardStatus(application: Application | null): { label: string; color: string; } { - if (!application) return { label: "Not started", color: "bg-white/15" }; + if (!application) + return { + label: "Not started", + color: "border border-[#A857FF]/35 bg-[#5900FF]/25 text-[#D8C5FF]", + }; switch (application.status) { case "draft": - return { label: "In progress", color: "bg-gray-100 text-gray-800" }; + return { + label: "In progress", + color: "border border-[#21FFF0]/30 bg-[#21FFF0]/10 text-[#21FFF0]", + }; case "submitted": - return { label: "Under review", color: "bg-white/15" }; + return { + label: "Under review", + color: "border border-[#F62BE8]/30 bg-[#F62BE8]/10 text-[#FF8FF7]", + }; default: return { label: "Decisions are out", - color: "bg-[#7A7973] text-white", + color: "bg-[#5900FF] text-white", }; } } @@ -120,11 +131,14 @@ export default function DashboardPage() { const [hackerPackURL, setHackerPackURL] = useState(""); const [config, setConfig] = useState(null); const [hackerLinks, setHackerLinks] = useState([]); - // null until the flag loads — the closed state only renders once we know - // applications really are closed, so the card never flashes the wrong copy. const [applicationsEnabled, setApplicationsEnabled] = useState< boolean | null >(null); + // The card's copy and its call to action both depend on data that arrives + // after mount, so the skeleton stays up until the first load settles — + // otherwise a closed portal briefly renders "Continue" and a hacker can + // click through to the wizard before the flag lands. + const [loading, setLoading] = useState(true); // Grab the "justSubmitted" ID from navigation state then clear it // so back-navigation doesn't re-trigger the submit celebration. @@ -191,6 +205,7 @@ export default function DashboardPage() { .sort((a, b) => a.display_order - b.display_order), ); } + setLoading(false); }; load(); return () => controller.abort(); @@ -209,7 +224,10 @@ export default function DashboardPage() { // submitted yet — a submitted application keeps showing its review state. const applicationsClosed = applicationsEnabled === false && isDraft; const status = applicationsClosed - ? { label: "Applications closed", color: "bg-white/15" } + ? { + label: "Applications closed", + color: "border border-white/15 bg-white/5 text-white/60", + } : dashboardStatus(application); const statusSubtext = isDraft ? `Application ${percent}% complete` @@ -250,8 +268,14 @@ export default function DashboardPage() { }, ]; + // Continues the route's Suspense fallback so the skeleton doesn't blink out + // between the chunk resolving and the dashboard data arriving. + if (loading) { + return ; + } + return ( -
+
{/* Submit celebration: fires when the user was just redirected from submit */} {application && justSubmittedId === application.id && ( @@ -263,7 +287,7 @@ export default function DashboardPage() { {decided ? ( ) : ( -
+
@@ -301,16 +325,16 @@ export default function DashboardPage() {

)} {isDraft && ( -
+
)} {isDraft ? "Continue" : "View submission"} @@ -323,10 +347,10 @@ export default function DashboardPage() { {/* Important dates */}
0 ? "mt-5" : "hidden"}>
-

Important dates

+

Important dates

See all @@ -335,15 +359,13 @@ export default function DashboardPage() { {dates.map((d) => (
-

+

{d.month}

-

{d.day}

-

- {d.label} -

+

{d.day}

+

{d.label}

))}
@@ -352,10 +374,10 @@ export default function DashboardPage() { {/* Notifications */}
-

Notifications

+

Notifications

See all @@ -364,12 +386,12 @@ export default function DashboardPage() { {notifications.map((n) => (
- +
-

{n.title}

-

+

{n.title}

+

{n.body}

@@ -388,11 +410,11 @@ export default function DashboardPage() { const href = label === "Contact" ? `mailto:${contactEmail}` : undefined; const className = - "flex flex-col items-start gap-2 rounded-lg border border-[#E5E5E5] bg-white p-4 active:scale-[0.98]"; + "flex flex-col items-start gap-2 rounded-lg border border-white/10 bg-[#0B0C15]/80 p-4 transition-colors hover:border-[#21FFF0]/35 hover:bg-[#10121D] active:scale-[0.98]"; const content = ( <> - - {label} + + {label} ); return to ? ( @@ -420,10 +442,10 @@ export default function DashboardPage() { href={link.url} target="_blank" rel="noopener noreferrer" - className="flex flex-col items-start gap-2 rounded-lg border border-[#E5E5E5] bg-white p-4 active:scale-[0.98]" + className="flex flex-col items-start gap-2 rounded-lg border border-white/10 bg-[#0B0C15]/80 p-4 transition-colors hover:border-[#F62BE8]/35 hover:bg-[#10121D] active:scale-[0.98]" > - - + + {link.label} diff --git a/client/portal/src/pages/hacker/hacker-pack/HackerPackPage.tsx b/client/portal/src/pages/hacker/hacker-pack/HackerPackPage.tsx index e594e3ac5..93bf4fdfc 100644 --- a/client/portal/src/pages/hacker/hacker-pack/HackerPackPage.tsx +++ b/client/portal/src/pages/hacker/hacker-pack/HackerPackPage.tsx @@ -66,7 +66,7 @@ export default function HackerPackPage() { src={url} title="Hacker Pack" className="h-full w-full" - style={{ colorScheme: "light" }} + style={{ colorScheme: "dark" }} allowFullScreen />
diff --git a/client/portal/src/pages/hacker/rsvp/RSVPPage.tsx b/client/portal/src/pages/hacker/rsvp/RSVPPage.tsx index 908efbcd7..1a6ec96fb 100644 --- a/client/portal/src/pages/hacker/rsvp/RSVPPage.tsx +++ b/client/portal/src/pages/hacker/rsvp/RSVPPage.tsx @@ -17,7 +17,6 @@ import { AlertDialogTrigger, } from "@/components/ui/alert-dialog"; import { Button } from "@/components/ui/button"; -import { Skeleton } from "@/components/ui/skeleton"; import { errorAlert } from "@/shared/lib/api"; import { collectIncompleteSections, @@ -34,6 +33,7 @@ import type { RSVPStatus } from "@/types"; import { ApplicationSummary } from "../apply/components/ApplicationSummary"; import { SchemaStepRenderer } from "../apply/steps/SchemaStepRenderer"; +import { StatusDetailSkeleton } from "../components/StatusDetailSkeleton"; import { fetchMyRSVP, submitMyRSVP } from "./api"; import type { RSVPInfo } from "./types"; @@ -146,13 +146,7 @@ export default function RSVPPage() { ); if (loading) { - return ( -
- - - -
- ); + return ; } if (!rsvp) return null; diff --git a/client/portal/src/pages/hacker/scan/components/ScannerView.tsx b/client/portal/src/pages/hacker/scan/components/ScannerView.tsx index 2e9f92603..073d642eb 100644 --- a/client/portal/src/pages/hacker/scan/components/ScannerView.tsx +++ b/client/portal/src/pages/hacker/scan/components/ScannerView.tsx @@ -105,7 +105,10 @@ export function ScannerView() { Point the camera at a hacker's QR code

-
+
{error ? (
diff --git a/client/portal/src/pages/hacker/schedule/SchedulePage.tsx b/client/portal/src/pages/hacker/schedule/SchedulePage.tsx index 240a7e1da..7ffbdb8f2 100644 --- a/client/portal/src/pages/hacker/schedule/SchedulePage.tsx +++ b/client/portal/src/pages/hacker/schedule/SchedulePage.tsx @@ -49,7 +49,8 @@ const MIN_EVENT_PX = 24; // the first line lands one hour down instead of at y=0 — the sticky header's // bottom border already provides the top boundary, and drawing over it would // make the very top line read as double-thick. -const HOUR_LINES = `repeating-linear-gradient(to bottom, transparent 0, transparent ${HOUR_PX - 1}px, #F0F0F0 ${HOUR_PX - 1}px, #F0F0F0 ${HOUR_PX}px)`; +const GRID_LINE_COLOR = "rgba(33, 255, 240, 0.1)"; +const HOUR_LINES = `repeating-linear-gradient(to bottom, transparent 0, transparent ${HOUR_PX - 1}px, ${GRID_LINE_COLOR} ${HOUR_PX - 1}px, ${GRID_LINE_COLOR} ${HOUR_PX}px)`; const FILTER_OPTIONS = [ ...Object.entries(TAG_COLORS).map(([key, color]) => ({ key, ...color })), @@ -342,7 +343,7 @@ export default function SchedulePage() { type="button" aria-label="Filter events" className={cn( - "pointer-events-auto flex size-10 items-center justify-center rounded-full bg-[#F5F5F5] text-black shadow-sm transition-all duration-200 hover:bg-[#EDEDED]", + "pointer-events-auto flex size-10 items-center justify-center rounded-full bg-[#101321] text-[#21FFF0] shadow-[0_0_0_1px_rgba(33,255,240,0.18),0_8px_24px_rgba(0,0,0,0.35)] transition-all duration-200 hover:bg-[#171A2C] hover:shadow-[0_0_0_1px_rgba(33,255,240,0.45),0_0_20px_rgba(33,255,240,0.12)]", scrolled && !filterOpen ? "opacity-55 hover:opacity-100" : "opacity-100", @@ -357,7 +358,7 @@ export default function SchedulePage() { {FILTER_OPTIONS.map(({ key, label, color }) => (