diff --git a/.env.example b/.env.example index e07a867d1..a9d338e74 100644 --- a/.env.example +++ b/.env.example @@ -37,6 +37,7 @@ MINIO_SECRET_KEY="minioadmin" BLADE_URL="http://localhost:3000" NEXT_PUBLIC_BLADE_URL="http://localhost:3000" BLOOMKNIGHTS_URL="http://localhost:3006" +KHIX_URL="http://localhost:3007" # Passkit WWDR_CERT_BASE64="wwdr-cert-b64" diff --git a/apps/blade/src/app/_components/admin/hackathon/manage/hackathon-manager.tsx b/apps/blade/src/app/_components/admin/hackathon/manage/hackathon-manager.tsx index 368e14dd1..4e39ef9fa 100644 --- a/apps/blade/src/app/_components/admin/hackathon/manage/hackathon-manager.tsx +++ b/apps/blade/src/app/_components/admin/hackathon/manage/hackathon-manager.tsx @@ -775,7 +775,7 @@ export function HackathonManager() {
Email Template Override - Leave off to use the current Knight Hacks VIII email + Leave off to use the default hackathon email templates.
diff --git a/apps/blade/src/app/auth/khix-return/route.ts b/apps/blade/src/app/auth/khix-return/route.ts new file mode 100644 index 000000000..ff69580dd --- /dev/null +++ b/apps/blade/src/app/auth/khix-return/route.ts @@ -0,0 +1,57 @@ +import { NextResponse } from "next/server"; + +import { env } from "~/env"; + +const DEFAULT_KHIX_RETURN_PATH = "/dashboard"; +const PRODUCTION_KHIX_RETURN_ORIGIN = "https://2026.knighthacks.org"; +const LOCAL_KHIX_RETURN_ORIGIN = "http://localhost:3007"; + +function getAllowedKhixReturnOrigins(configuredKhixOrigin: string) { + const allowedOrigins = new Set([ + configuredKhixOrigin, + PRODUCTION_KHIX_RETURN_ORIGIN, + ]); + + if (env.NODE_ENV !== "production") { + allowedOrigins.add(LOCAL_KHIX_RETURN_ORIGIN); + } + + return allowedOrigins; +} + +function getKhixReturnURL(returnTo: string | null) { + const configuredKhixOrigin = new URL(env.KHIX_URL).origin; + const allowedOrigins = getAllowedKhixReturnOrigins(configuredKhixOrigin); + const defaultReturnURL = new URL( + DEFAULT_KHIX_RETURN_PATH, + configuredKhixOrigin, + ); + + if (!returnTo) return defaultReturnURL; + + try { + const requestedReturnURL = new URL(returnTo, configuredKhixOrigin); + + if ( + !allowedOrigins.has(requestedReturnURL.origin) || + !requestedReturnURL.pathname.startsWith("/") + ) { + return defaultReturnURL; + } + + return requestedReturnURL; + } catch { + return defaultReturnURL; + } +} + +export function GET(request: Request) { + const requestUrl = new URL(request.url); + const returnTo = getKhixReturnURL(requestUrl.searchParams.get("returnTo")); + + if (requestUrl.searchParams.has("authError")) { + returnTo.searchParams.set("authError", "oauth"); + } + + return NextResponse.redirect(returnTo); +} diff --git a/apps/blade/src/env.ts b/apps/blade/src/env.ts index 640e5a5fb..f6263bb04 100644 --- a/apps/blade/src/env.ts +++ b/apps/blade/src/env.ts @@ -10,6 +10,11 @@ const bloomKnightsUrlSchema = ? z.string().url() : z.string().url().default("http://localhost:3006"); +const khixUrlSchema = + process.env.NODE_ENV === "production" + ? z.string().url() + : z.string().url().default("http://localhost:3007"); + export const env = createEnv({ extends: [authEnv, apiEnv, dbEnv], shared: { @@ -24,6 +29,7 @@ export const env = createEnv({ */ server: { BLOOMKNIGHTS_URL: bloomKnightsUrlSchema, + KHIX_URL: khixUrlSchema, }, /** diff --git a/apps/khix/next.config.js b/apps/khix/next.config.js index 722f52089..9e79ff8a7 100644 --- a/apps/khix/next.config.js +++ b/apps/khix/next.config.js @@ -1,13 +1,23 @@ +import { networkInterfaces } from "node:os"; + +const localDevOrigins = Object.values(networkInterfaces()).flatMap( + (addresses) => + (addresses ?? []) + .filter(({ family, internal }) => family === "IPv4" && !internal) + .map(({ address }) => address), +); + /** @type {import("next").NextConfig} */ const config = { reactStrictMode: true, + allowedDevOrigins: ["127.0.0.1", ...localDevOrigins], images: { - formats: ["image/avif", "image/webp"], - minimumCacheTTL: 60 * 60 * 24 * 30, + unoptimized: true, remotePatterns: [ { protocol: "https", hostname: "assets.knighthacks.org", + pathname: "/khix/**", }, ], }, diff --git a/apps/khix/package.json b/apps/khix/package.json index 764488ec8..9a799a06d 100644 --- a/apps/khix/package.json +++ b/apps/khix/package.json @@ -15,12 +15,26 @@ "with-env": "dotenv -e ../../.env --" }, "dependencies": { + "@forge/api": "workspace:*", + "@forge/auth": "workspace:*", + "@forge/consts": "workspace:*", + "@forge/db": "workspace:*", + "@forge/hackathon": "workspace:*", + "@forge/ui": "workspace:*", + "@forge/validators": "workspace:*", + "@gsap/react": "^2.1.2", + "@t3-oss/env-nextjs": "^0.13.10", + "@trpc/client": "catalog:", + "@trpc/server": "catalog:", "framer-motion": "^12.34.3", + "gsap": "^3.14.2", "lucide-react": "^0.575.0", "next": "^16.2.7", "react": "^19.2.4", "react-dom": "^19.2.4", - "react-icons": "^5.5.0" + "react-icons": "^5.5.0", + "superjson": "2.2.6", + "zod": "catalog:" }, "devDependencies": { "@forge/eslint-config": "workspace:*", @@ -35,7 +49,6 @@ "eslint": "catalog:", "prettier": "catalog:", "tailwindcss": "catalog:", - "tw-animate-css": "^1.4.0", "typescript": "catalog:" }, "prettier": "@forge/prettier-config" diff --git a/apps/khix/public/birds.mp3 b/apps/khix/public/birds.mp3 deleted file mode 100644 index bbd85cfcb..000000000 Binary files a/apps/khix/public/birds.mp3 and /dev/null differ diff --git a/apps/khix/public/flower.png b/apps/khix/public/flower.png deleted file mode 100644 index 18a25716d..000000000 Binary files a/apps/khix/public/flower.png and /dev/null differ diff --git a/apps/khix/public/khix-gem-shard-mask.png b/apps/khix/public/khix-gem-shard-mask.png deleted file mode 100644 index d04e30254..000000000 Binary files a/apps/khix/public/khix-gem-shard-mask.png and /dev/null differ diff --git a/apps/khix/public/khix-logo-white.svg b/apps/khix/public/khix-logo-white.svg deleted file mode 100644 index af0cc9ef8..000000000 --- a/apps/khix/public/khix-logo-white.svg +++ /dev/null @@ -1,41 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - \ No newline at end of file diff --git a/apps/khix/public/khix-sigil.svg b/apps/khix/public/khix-sigil.svg deleted file mode 100644 index 460ecf049..000000000 --- a/apps/khix/public/khix-sigil.svg +++ /dev/null @@ -1,32 +0,0 @@ - - - - - - - - - - - - - - - - - - - - \ No newline at end of file diff --git a/apps/khix/public/ladybug.png b/apps/khix/public/ladybug.png deleted file mode 100644 index 7b8bcc7ff..000000000 Binary files a/apps/khix/public/ladybug.png and /dev/null differ diff --git a/apps/khix/public/mushroom.png b/apps/khix/public/mushroom.png deleted file mode 100644 index 38839ac62..000000000 Binary files a/apps/khix/public/mushroom.png and /dev/null differ diff --git a/apps/khix/public/music.mp3 b/apps/khix/public/music.mp3 deleted file mode 100644 index 4c62cc364..000000000 Binary files a/apps/khix/public/music.mp3 and /dev/null differ diff --git a/apps/khix/src/PARALLAX.MD b/apps/khix/src/PARALLAX.MD new file mode 100644 index 000000000..2fc31eb2f --- /dev/null +++ b/apps/khix/src/PARALLAX.MD @@ -0,0 +1,77 @@ +# Parallax Implementation Notes + +Temporary handoff for agents working on KHIX or future Forge parallax sections. +This documents the Chromium fix from Carlos's 39fc6795 commit so it can be +reused without rediscovering the same failure mode. + +## What Broke + +The KHIX hero parallax broke when the art stack was treated like a page +background instead of a normal hero section. The problematic shape was: + +- a long or sticky scroll zone that made the hero behave like a background +- a large transformed `.art` container using `scale(...)` +- per-layer parallax values that did not account for that container scale +- unthrottled pointer writes fighting Chromium's rendering pipeline + +The visible symptoms were rapid inline style churn in DevTools, unstable +Chromium parallax, and the hero reading as the entire page background instead +of a section that can scroll away. + +## Carlos's Fix + +Keep the hero itself as a normal section: + +- `section#home` owns the hero and stays in document flow +- `.stage` is `position: relative`, not sticky, for a normal hero +- the hero is `100vh` / `100svh`; extra page scroll should come from following + sections, not from a fake hero scroll zone + +Keep the artwork full-bleed inside the hero without scaling the whole .art +container: + +- store the old visual scale as `--khix-art-motion-scale: 0.64` +- use a smaller art inset, currently `inset: -6.32svh -3.76vw` +- do not put `transform: scale(...)` on `.art` +- apply `--khix-art-motion-scale` inside each layer's parallax transform math + +Scope motion variables to the stage and throttle writes: + +- write `--khix-hero-pointer-x`, `--khix-hero-pointer-y`, and + `--khix-hero-scroll-progress` on the hero stage +- coalesce pointer movement with `requestAnimationFrame` +- only write motion values when they changed by a meaningful epsilon +- cancel pending pointer frames during cleanup and when reduced motion is + enabled + +## Reusable Pattern + +For future parallax components: + +- make the section normal page flow first; avoid sticky or oversized scroll + zones unless the design explicitly calls for pinned storytelling +- keep the art stack visually oversized with insets, not by scaling the whole + container +- if the art needs a visual scale, put that number in a CSS variable and + multiply the pointer and scroll offsets by it +- put all layer movement on `transform: translate3d(...)` and keep depth values + as CSS vars on each layer +- use one local stage for motion vars; do not inject global `:root` motion state + for a component-specific parallax +- throttle pointer writes with `requestAnimationFrame`; throttle scroll writes + too, and skip writes when the value barely changed +- respect `prefers-reduced-motion` by resetting pointer and scroll progress to + `0` and disabling nonessential animated layers + +## Verification Checklist + +Before handing off a parallax change: + +- the hero/section scrolls away like a normal section when content follows it +- the stage is not sticky unless pinned scroll storytelling is the requirement +- Chrome/Chromium DevTools does not show unrelated title/glow/art nodes + constantly changing inline style attributes +- pointer movement still changes layer transforms differently by depth +- scroll changes `--khix-hero-scroll-progress` without causing layout shifts +- `pnpm --filter=@forge/khix lint` and + `pnpm --filter=@forge/khix typecheck` pass for KHIX changes diff --git a/apps/khix/src/app/(portal)/_components/application/hackbackgrounds/index.ts b/apps/khix/src/app/(portal)/_components/application/hackbackgrounds/index.ts new file mode 100644 index 000000000..531b1ba3a --- /dev/null +++ b/apps/khix/src/app/(portal)/_components/application/hackbackgrounds/index.ts @@ -0,0 +1,48 @@ +import type { ApplicationVisualConfig } from "./types"; +import { khixApplicationBackground } from "./khix"; + +export const DEFAULT_APPLICATION_VISUAL = { + key: "default", + label: "Stock purple", + mode: "dynamic", + showStockEffects: true, +} satisfies ApplicationVisualConfig; + +export type HackerApplicationBackgroundKey = "khix"; + +export const HACKER_APPLICATION_BACKGROUNDS = { + khix: khixApplicationBackground, +} satisfies Record; + +function isHackerApplicationBackgroundKey( + backgroundKey: string, +): backgroundKey is HackerApplicationBackgroundKey { + return backgroundKey === "khix"; +} + +export function getHackerApplicationBackgroundKey( + backgroundKey?: string | null, +): HackerApplicationBackgroundKey | null { + if (!backgroundKey) return null; + if (isHackerApplicationBackgroundKey(backgroundKey)) return backgroundKey; + + if (backgroundKey === "knighthacksix") return "khix"; + if (backgroundKey === "knight-hacks-ix") return "khix"; + + return null; +} + +export function getHackerApplicationBackground( + backgroundKey?: string | null, +): ApplicationVisualConfig { + const applicationBackgroundKey = + getHackerApplicationBackgroundKey(backgroundKey); + + if (!applicationBackgroundKey) { + return DEFAULT_APPLICATION_VISUAL; + } + + return HACKER_APPLICATION_BACKGROUNDS[applicationBackgroundKey]; +} + +export type { ApplicationVisualConfig, ApplicationVisualLayer } from "./types"; diff --git a/apps/khix/src/app/(portal)/_components/application/hackbackgrounds/khix.ts b/apps/khix/src/app/(portal)/_components/application/hackbackgrounds/khix.ts new file mode 100644 index 000000000..e684623ce --- /dev/null +++ b/apps/khix/src/app/(portal)/_components/application/hackbackgrounds/khix.ts @@ -0,0 +1,510 @@ +import type { ApplicationVisualConfig, BackgroundSize } from "./types"; + +const KHIX_SCENE_SIZE = { + height: 2250, + width: 12000, +} satisfies BackgroundSize; + +const KHIX_FLAT_WEBP = "https://assets.knighthacks.org/khix-flat.webp"; +const KHIX_FOREGROUND_WEBP = + "https://assets.knighthacks.org/khix-foreground.webp"; +const KHIX_LENNY_FRAME_WEBPS = [ + "https://assets.knighthacks.org/khix/khix-lenny-walk-frame-01.webp", + "https://assets.knighthacks.org/khix/khix-lenny-walk-frame-02.webp", + "https://assets.knighthacks.org/khix/khix-lenny-walk-frame-03.webp", + "https://assets.knighthacks.org/khix/khix-lenny-walk-frame-04.webp", + "https://assets.knighthacks.org/khix/khix-lenny-walk-frame-05.webp", + "https://assets.knighthacks.org/khix/khix-lenny-walk-frame-06.webp", + "https://assets.knighthacks.org/khix/khix-lenny-walk-frame-07.webp", + "https://assets.knighthacks.org/khix/khix-lenny-walk-frame-08.webp", + "https://assets.knighthacks.org/khix/khix-lenny-walk-frame-09.webp", + "https://assets.knighthacks.org/khix/khix-lenny-walk-frame-10.webp", + "https://assets.knighthacks.org/khix/khix-lenny-walk-frame-11.webp", + "https://assets.knighthacks.org/khix/khix-lenny-walk-frame-12.webp", + "https://assets.knighthacks.org/khix/khix-lenny-walk-frame-13.webp", + "https://assets.knighthacks.org/khix/khix-lenny-walk-frame-14.webp", + "https://assets.knighthacks.org/khix/khix-lenny-walk-frame-15.webp", + "https://assets.knighthacks.org/khix/khix-lenny-walk-frame-16.webp", +] as const; +const KHIX_LENNY_REST_FRAME_INDEX = 9; +const KHIX_LENNY_IDLE_WEBP = + KHIX_LENNY_FRAME_WEBPS[KHIX_LENNY_REST_FRAME_INDEX]; + +export const khixApplicationStyles = ` +@keyframes khixApplicationMistDrift { + 0%, 100% { transform: translate3d(-2%, 1%, 0) scale(1.02); opacity: 0.22; } + 50% { transform: translate3d(2%, -1%, 0) scale(1.08); opacity: 0.34; } +} + +@keyframes khixApplicationGlowPulse { + 0%, 100% { opacity: 0.24; transform: scale(0.98); } + 50% { opacity: 0.42; transform: scale(1.03); } +} + +@keyframes khixApplicationLennyWalkPace { + 0% { + transform: translate3d(0, 0, 0) rotate(0deg) scale(1); + } + + 14% { + transform: translate3d(var(--khix-application-lenny-pace-a), 0.14rem, 0) + rotate(var(--khix-application-lenny-tilt-a)) scale(0.997); + } + + 34% { + transform: translate3d(var(--khix-application-lenny-pace-b), -0.22rem, 0) + rotate(var(--khix-application-lenny-tilt-b)) scale(1.004); + } + + 58% { + transform: translate3d(var(--khix-application-lenny-pace-c), -0.14rem, 0) + rotate(var(--khix-application-lenny-tilt-c)) scale(1.002); + } + + 82% { + transform: translate3d(var(--khix-application-lenny-pace-d), 0.1rem, 0) + rotate(var(--khix-application-lenny-tilt-d)) scale(0.998); + } + + 100% { + transform: translate3d(0, 0, 0) rotate(0deg) scale(1); + } +} + +@keyframes khixApplicationLennyStride { + 0%, 100% { + transform: translate3d(0, 0, 0) rotate(0deg) scaleY(1); + } + + 24% { + transform: translate3d(var(--khix-application-lenny-stride-a), -0.2rem, 0) + rotate(var(--khix-application-lenny-stride-tilt-a)) scaleY(1.006); + } + + 48% { + transform: translate3d(var(--khix-application-lenny-stride-b), 0.1rem, 0) + rotate(var(--khix-application-lenny-stride-tilt-b)) scaleY(0.996); + } + + 74% { + transform: translate3d(var(--khix-application-lenny-stride-c), -0.16rem, 0) + rotate(var(--khix-application-lenny-stride-tilt-c)) scaleY(1.004); + } +} + +@keyframes khixApplicationFireflyFloat { + 0%, 100% { + transform: translate3d(0, 0, 0) scale(0.86); + opacity: 0.18; + } + 38% { + transform: translate3d(1.25rem, -0.55rem, 0) scale(1); + opacity: 0.74; + } + 68% { + transform: translate3d(-0.65rem, 0.75rem, 0) scale(1.14); + opacity: 0.34; + } +} + +@keyframes khixApplicationLeafFall { + 0% { + opacity: 0; + transform: translate3d(0, -8svh, 0) rotate(-18deg) scale(0.86); + } + + 12% { + opacity: var(--khix-application-leaf-opacity); + } + + 58% { + opacity: var(--khix-application-leaf-opacity); + transform: translate3d(var(--khix-application-leaf-drift), 54svh, 0) + rotate(var(--khix-application-leaf-rotate)) scale(1); + } + + 100% { + opacity: 0; + transform: translate3d(var(--khix-application-leaf-drift), 106svh, 0) + rotate(var(--khix-application-leaf-rotate)) scale(0.9); + } +} + +.khix-application-lenny-walk { + --khix-application-lenny-pace-a: -0.16rem; + --khix-application-lenny-pace-b: 0.52rem; + --khix-application-lenny-pace-c: 0.36rem; + --khix-application-lenny-pace-d: -0.08rem; + --khix-application-lenny-tilt-a: -0.35deg; + --khix-application-lenny-tilt-b: 0.65deg; + --khix-application-lenny-tilt-c: 0.35deg; + --khix-application-lenny-tilt-d: -0.18deg; + --khix-application-lenny-stride-a: 0.18rem; + --khix-application-lenny-stride-b: 0.04rem; + --khix-application-lenny-stride-c: -0.16rem; + --khix-application-lenny-stride-tilt-a: 0.35deg; + --khix-application-lenny-stride-tilt-b: -0.18deg; + --khix-application-lenny-stride-tilt-c: -0.3deg; + transform-origin: 50% 86%; + animation: khixApplicationLennyWalkPace + var(--kh-application-transition-duration, 1250ms) + cubic-bezier(0.36, 0, 0.16, 1) both; + will-change: transform; +} + +.khix-application-lenny-stage { + backface-visibility: hidden; + isolation: isolate; + transform: translate3d(0, 0, 0); + will-change: transform; +} + +.khix-application-lenny-walk.kh-application-transition-back { + --khix-application-lenny-pace-a: 0.16rem; + --khix-application-lenny-pace-b: -0.52rem; + --khix-application-lenny-pace-c: -0.36rem; + --khix-application-lenny-pace-d: 0.08rem; + --khix-application-lenny-tilt-a: 0.35deg; + --khix-application-lenny-tilt-b: -0.65deg; + --khix-application-lenny-tilt-c: -0.35deg; + --khix-application-lenny-tilt-d: 0.18deg; + --khix-application-lenny-stride-a: -0.18rem; + --khix-application-lenny-stride-b: -0.04rem; + --khix-application-lenny-stride-c: 0.16rem; + --khix-application-lenny-stride-tilt-a: -0.35deg; + --khix-application-lenny-stride-tilt-b: 0.18deg; + --khix-application-lenny-stride-tilt-c: 0.3deg; +} + +.khix-application-lenny-stride { + height: 100%; + transform-origin: 50% 86%; + animation: khixApplicationLennyStride + var(--kh-application-transition-stride-duration, 640ms) + cubic-bezier(0.4, 0, 0.2, 1) infinite; + will-change: transform; +} + +.khix-application-fireflies { + contain: paint; + overflow: hidden; + opacity: 0.96; + mix-blend-mode: screen; +} + +.khix-application-fireflies::before, +.khix-application-fireflies::after { + content: ""; + position: absolute; + inset: 0; + background-repeat: repeat; + background-size: + 78rem 42rem, + 52rem 36rem, + 68rem 48rem, + 44rem 30rem, + 92rem 54rem; + filter: blur(0.15px) drop-shadow(0 0 0.55rem rgba(226, 255, 151, 0.42)); + transform: translate3d(0, 0, 0); + animation: khixApplicationFireflyFloat 9s ease-in-out infinite; +} + +.khix-application-fireflies::before { + background-image: + radial-gradient(circle, rgba(249, 255, 188, 0.96) 0 2px, rgba(249, 255, 188, 0.42) 3.2px, transparent 7px), + radial-gradient(circle, rgba(137, 255, 211, 0.84) 0 1.8px, rgba(137, 255, 211, 0.34) 3px, transparent 6.2px), + radial-gradient(circle, rgba(255, 237, 139, 0.78) 0 1.65px, rgba(255, 237, 139, 0.3) 2.8px, transparent 5.8px), + radial-gradient(circle, rgba(184, 255, 179, 0.76) 0 1.7px, rgba(184, 255, 179, 0.32) 3px, transparent 6px), + radial-gradient(circle, rgba(226, 255, 151, 0.66) 0 1.45px, rgba(226, 255, 151, 0.26) 2.6px, transparent 5.4px); + background-position: + 7% 16%, + 22% 74%, + 46% 34%, + 71% 64%, + 88% 20%; + box-shadow: inset 0 0 5rem rgba(151, 255, 211, 0.05); +} + +.khix-application-fireflies::after { + background-image: + radial-gradient(circle, rgba(246, 255, 188, 0.82) 0 1.45px, rgba(246, 255, 188, 0.28) 2.5px, transparent 5.2px), + radial-gradient(circle, rgba(104, 248, 203, 0.68) 0 1.55px, rgba(104, 248, 203, 0.24) 2.7px, transparent 5.4px), + radial-gradient(circle, rgba(255, 219, 119, 0.66) 0 1.35px, rgba(255, 219, 119, 0.22) 2.4px, transparent 4.8px), + radial-gradient(circle, rgba(216, 255, 134, 0.68) 0 1.5px, rgba(216, 255, 134, 0.26) 2.7px, transparent 5.2px), + radial-gradient(circle, rgba(181, 255, 224, 0.58) 0 1.25px, rgba(181, 255, 224, 0.2) 2.2px, transparent 4.6px); + background-position: + 14% 46%, + 34% 18%, + 54% 82%, + 76% 28%, + 93% 72%; + animation-duration: 12s; + animation-delay: -5s; +} + +.khix-application-fireflies-soft { + opacity: 0.64; +} + +.khix-application-fireflies-soft::before, +.khix-application-fireflies-soft::after { + background-size: + 106rem 62rem, + 84rem 58rem, + 118rem 74rem, + 72rem 46rem, + 140rem 80rem; + filter: blur(1.7px) drop-shadow(0 0 0.8rem rgba(137, 255, 211, 0.24)); + animation-duration: 16s; +} + +.khix-application-fireflies-soft::after { + animation-delay: -9s; +} + +.khix-application-leaf-field { + position: absolute; + inset: 0; + z-index: 3; + overflow: hidden; + pointer-events: none; + opacity: var(--khix-application-leaf-field-opacity, 0); + transition: opacity 520ms ease; +} + +.khix-application-leaf { + position: absolute; + top: -9svh; + left: var(--khix-application-leaf-x); + width: var(--khix-application-leaf-size); + aspect-ratio: 0.62; + border-radius: 100% 0 100% 0; + background: + linear-gradient(135deg, rgba(239, 217, 255, 0.44), transparent 38%), + var(--khix-application-leaf-fill); + box-shadow: inset -1px -2px 3px rgba(58, 31, 93, 0.22); + opacity: 0; + transform: translate3d(0, -8svh, 0) rotate(-18deg); + animation: khixApplicationLeafFall var(--khix-application-leaf-duration) + linear var(--khix-application-leaf-delay) infinite; +} + +.khix-application-leaf::after { + position: absolute; + inset: 13% 46% 8% auto; + width: 1px; + content: ""; + background: rgba(64, 38, 102, 0.3); + transform: rotate(10deg); + transform-origin: bottom; +} + +.khix-application-asset-credit { + position: absolute; + pointer-events: auto; +} + +.khix-application-asset-credit-target { + position: absolute; + inset: 0; + display: block; + border-radius: 9999px; +} + +.khix-application-background-credit { + top: clamp(1.25rem, 4vh, 3.25rem); + right: clamp(1rem, 3vw, 2.5rem); + width: min(20rem, calc(100vw - 2rem)); + height: 2.75rem; +} + +.khix-application-animation-credit { + top: calc(clamp(1.25rem, 4vh, 3.25rem) + 1.1rem); + right: clamp(1rem, 3vw, 2.5rem); + width: min(20rem, calc(100vw - 2rem)); + height: 2.75rem; +} + +.khix-application-background-credit > span:last-child, +.khix-application-animation-credit > span:last-child { + right: 0; + bottom: 0; + opacity: 0.58 !important; + transform: translate3d(0, 0, 0) !important; + pointer-events: auto !important; +} + +.khix-application-background-credit:hover > span:last-child, +.khix-application-background-credit:focus-within > span:last-child, +.khix-application-animation-credit:hover > span:last-child, +.khix-application-animation-credit:focus-within > span:last-child { + opacity: 1 !important; +} + +@media (prefers-reduced-motion: reduce) { + .khix-application-fireflies::before, + .khix-application-fireflies::after { + animation: none; + } + + .khix-application-leaf { + display: none; + } + + .khix-application-lenny-walk, + .khix-application-lenny-stride { + animation: none; + transform: none; + } +} + +form[data-application-visual="khix"], +.kh-application-shell[data-application-visual="khix"] { + background-color: #07150f; + background-image: + linear-gradient( + to bottom, + rgba(4, 16, 13, 0.18) 0%, + rgba(5, 17, 14, 0.08) 42%, + rgba(5, 14, 12, 0.55) 78%, + rgba(3, 10, 9, 0.82) 100% + ), + url("${KHIX_FLAT_WEBP}"); + background-position: center center; + background-repeat: no-repeat; + background-size: cover; +} + +.kh-application-shell[data-application-visual="khix"] .kh-step-content :is(input, textarea) { + border-color: rgba(255, 255, 255, 0.42); +} + +.kh-application-shell[data-application-visual="khix"] .kh-step-content :is(input, textarea):focus-visible { + border-color: rgba(226, 255, 151, 0.78); + box-shadow: 0 10px 34px rgba(216, 255, 134, 0.16); +} + +.kh-application-shell[data-application-visual="khix"] .kh-resume-info-trigger { + border-color: rgba(226, 255, 151, 0.48); + background: rgba(226, 255, 151, 0.12); + color: rgba(245, 255, 196, 0.9); +} +`; + +export const khixApplicationBackground = { + key: "khix", + label: "KHIX forest walk", + mode: "dynamic", + baseLayerId: "khix-flat", + showStockEffects: false, + styles: khixApplicationStyles, + transitionMs: 1500, + transitionEasing: "cubic-bezier(0.37, 0, 0.63, 1)", + stepTransitionMs: 1500, + questionTransitionMs: 620, + fallingLeavesStartProgress: 0.3, + mobileTimingMaxWidth: 768, + mobileTransitionMs: 2300, + mobileStepTransitionMs: 2300, + mobileQuestionTransitionMs: 860, + overlayClassName: + "bg-[linear-gradient(90deg,rgba(2,8,7,0.46),rgba(4,16,13,0.08)_42%,rgba(3,10,8,0.58)),linear-gradient(to_bottom,rgba(5,18,14,0.08),rgba(3,9,8,0.72))]", + assetCredits: [ + { + id: "khix-application-background-credit", + className: + "khix-application-asset-credit khix-application-background-credit", + label: "Background by", + credits: [ + { + name: "Dalia Z", + href: "https://www.linkedin.com/in/dalia-l-zamora/", + }, + ], + }, + { + id: "khix-application-animation-credit", + className: + "khix-application-asset-credit khix-application-animation-credit", + label: "Animation by", + credits: [ + { + name: "Gabriela Z", + href: "https://www.linkedin.com/in/gabriela-zambrano-7074363b4/", + }, + ], + }, + ], + layers: [ + { + id: "khix-flat", + kind: "image", + src: KHIX_FLAT_WEBP, + alt: "", + nativeSize: KHIX_SCENE_SIZE, + parallax: 1, + zIndex: 0, + }, + { + id: "khix-lenny", + kind: "image", + src: KHIX_LENNY_IDLE_WEBP, + animatedRestFrameIndex: KHIX_LENNY_REST_FRAME_INDEX, + animatedFrameSrcs: KHIX_LENNY_FRAME_WEBPS, + alt: "", + className: + "khix-application-lenny-stage bottom-[18%] left-[-8%] aspect-[37/48] h-[clamp(10rem,min(58vh,76vw),42rem)] md:left-[8%] lg:left-[30%] lg:h-[clamp(9rem,min(52.2vh,68.4vw),37.8rem)]", + motion: { + facesStepDirection: true, + transitionPaceClassName: "khix-application-lenny-walk", + transitionStrideClassName: "khix-application-lenny-stride", + transitionStrideMs: 640, + turnDurationMs: 280, + }, + nativeSize: { + height: 960, + width: 740, + }, + opacity: 1, + space: "viewport", + zIndex: 2, + }, + { + id: "khix-foreground", + kind: "image", + src: KHIX_FOREGROUND_WEBP, + alt: "", + nativeSize: KHIX_SCENE_SIZE, + parallax: 1, + zIndex: 4, + }, + ], + ambientLayers: [ + { + id: "khix-fireflies-a", + className: "khix-application-fireflies", + parallax: 1, + space: "scene", + zIndex: 1, + }, + { + id: "khix-fireflies-b", + className: "khix-application-fireflies khix-application-fireflies-soft", + parallax: 1, + space: "scene", + zIndex: 1, + }, + { + id: "khix-mist", + className: + "bg-[radial-gradient(circle_at_24%_38%,rgba(212,255,158,0.20),transparent_26%),radial-gradient(circle_at_72%_34%,rgba(99,225,199,0.13),transparent_24%)] blur-2xl [animation:khixApplicationMistDrift_10s_ease-in-out_infinite]", + zIndex: 1, + }, + { + id: "khix-glow", + className: + "bg-[radial-gradient(circle_at_50%_58%,rgba(216,255,134,0.16),transparent_32%)] [animation:khixApplicationGlowPulse_7s_ease-in-out_infinite]", + zIndex: 3, + }, + ], +} satisfies ApplicationVisualConfig; diff --git a/apps/khix/src/app/(portal)/_components/application/hackbackgrounds/types.ts b/apps/khix/src/app/(portal)/_components/application/hackbackgrounds/types.ts new file mode 100644 index 000000000..b3a2c3330 --- /dev/null +++ b/apps/khix/src/app/(portal)/_components/application/hackbackgrounds/types.ts @@ -0,0 +1,93 @@ +import type { CSSProperties } from "react"; + +export type ApplicationVisualMode = "static" | "dynamic"; +export type ApplicationVisualLayerKind = "image" | "video"; +export type ApplicationVisualLayerSpace = "scene" | "viewport"; + +export interface BackgroundSize { + height: number; + width: number; +} + +export interface ApplicationVisualLayerSource { + media?: string; + mimeType?: string; + src: string; +} + +export interface ApplicationVisualLayerMotion { + facesStepDirection?: boolean; + transitionPaceClassName?: string; + transitionStrideClassName?: string; + transitionStrideMs?: number; + turnDurationMs?: number; +} + +export interface ApplicationVisualAssetCreditEntry { + name: string; + href?: string; + newTab?: boolean; +} + +export interface ApplicationVisualAssetCredit { + id: string; + className?: string; + credits: readonly ApplicationVisualAssetCreditEntry[]; + label?: string; +} + +export interface ApplicationVisualLayer { + id: string; + kind: ApplicationVisualLayerKind; + src: string; + alt?: string; + animatedRestFrameIndex?: number; + animatedFrameSrcs?: readonly string[]; + animatedSrc?: string; + className?: string; + idleSrc?: string; + mediaClassName?: string; + mediaStyle?: CSSProperties; + mimeType?: string; + motion?: ApplicationVisualLayerMotion; + nativeSize?: BackgroundSize; + opacity?: number; + parallax?: number; + playbackRate?: number; + preload?: "auto" | "metadata" | "none"; + sources?: readonly ApplicationVisualLayerSource[]; + space?: ApplicationVisualLayerSpace; + style?: CSSProperties; + zIndex?: number; +} + +export interface ApplicationVisualAmbientLayer { + id: string; + className: string; + parallax?: number; + space?: ApplicationVisualLayerSpace; + style?: CSSProperties; + zIndex?: number; +} + +export interface ApplicationVisualConfig { + key: string; + label: string; + ambientLayers?: readonly ApplicationVisualAmbientLayer[]; + assetCredits?: readonly ApplicationVisualAssetCredit[]; + baseLayerId?: string; + fallingLeavesStartProgress?: number; + layers?: readonly ApplicationVisualLayer[]; + mobileQuestionTransitionMs?: number; + mobileStepTransitionMs?: number; + mobileTimingMaxWidth?: number; + mobileTransitionMs?: number; + mode: ApplicationVisualMode; + overlayClassName?: string; + questionTransitionMs?: number; + showStockEffects?: boolean; + stepTransitionMs?: number; + transitionEasing?: string; + styles?: string; + transitionMs?: number; +} diff --git a/apps/khix/src/app/(portal)/_components/application/hacker-application-background.tsx b/apps/khix/src/app/(portal)/_components/application/hacker-application-background.tsx new file mode 100644 index 000000000..9a8673fdb --- /dev/null +++ b/apps/khix/src/app/(portal)/_components/application/hacker-application-background.tsx @@ -0,0 +1,954 @@ +"use client"; + +import type { CSSProperties, ReactNode } from "react"; +import { useEffect, useRef, useState } from "react"; + +import { cn } from "@forge/ui"; + +import type { + ApplicationVisualAmbientLayer, + ApplicationVisualAssetCredit, + ApplicationVisualLayer, + ApplicationVisualMode, + BackgroundSize, +} from "./hackbackgrounds/types"; +import { AssetCredit } from "~/app/_components/assets"; +import { getHackerApplicationBackground } from "./hackbackgrounds"; + +type StepDirection = "forward" | "back"; + +interface BackgroundFrame { + endX: number; + height: number; + startX: number; + width: number; +} + +interface LayerState { + failedLayerIds: Set; + layerSizes: Record; + visualKey: string; +} + +const EMPTY_VISUAL_LAYERS: readonly ApplicationVisualLayer[] = []; +const EMPTY_AMBIENT_LAYERS: readonly ApplicationVisualAmbientLayer[] = []; +const EMPTY_ASSET_CREDITS: readonly ApplicationVisualAssetCredit[] = []; + +interface FallingLeaf { + delay: string; + drift: string; + duration: string; + fill: string; + opacity: number; + rotate: string; + size: string; + x: string; +} + +const APPLICATION_FALLING_LEAVES: readonly FallingLeaf[] = [ + { + x: "52%", + size: "22px", + duration: "9.6s", + delay: "-1.8s", + opacity: 0.5, + fill: "#b987ff", + drift: "-38px", + rotate: "280deg", + }, + { + x: "61%", + size: "18px", + duration: "11.2s", + delay: "-6.4s", + opacity: 0.42, + fill: "#9b6dff", + drift: "51px", + rotate: "340deg", + }, + { + x: "70%", + size: "25px", + duration: "10.4s", + delay: "-3.2s", + opacity: 0.46, + fill: "#d19aff", + drift: "-61px", + rotate: "390deg", + }, + { + x: "79%", + size: "20px", + duration: "12.8s", + delay: "-8.5s", + opacity: 0.38, + fill: "#8752f0", + drift: "45px", + rotate: "320deg", + }, + { + x: "86%", + size: "18px", + duration: "8.9s", + delay: "-4.9s", + opacity: 0.44, + fill: "#c08cff", + drift: "-29px", + rotate: "250deg", + }, + { + x: "47%", + size: "19px", + duration: "10.8s", + delay: "-7.2s", + opacity: 0.4, + fill: "#a66cff", + drift: "35px", + rotate: "310deg", + }, + { + x: "57%", + size: "26px", + duration: "13.4s", + delay: "-10.6s", + opacity: 0.34, + fill: "#cf95ff", + drift: "-48px", + rotate: "420deg", + }, + { + x: "66%", + size: "18px", + duration: "9.2s", + delay: "-0.9s", + opacity: 0.46, + fill: "#8f5bf5", + drift: "59px", + rotate: "270deg", + }, + { + x: "74%", + size: "23px", + duration: "12.1s", + delay: "-5.7s", + opacity: 0.37, + fill: "#b47aff", + drift: "-43px", + rotate: "365deg", + }, + { + x: "91%", + size: "21px", + duration: "10s", + delay: "-2.6s", + opacity: 0.43, + fill: "#d3a2ff", + drift: "30px", + rotate: "295deg", + }, +]; + +function isValidBackgroundSize( + size: BackgroundSize | null | undefined, +): size is BackgroundSize { + return ( + !!size && + Number.isFinite(size.width) && + Number.isFinite(size.height) && + size.width > 0 && + size.height > 0 + ); +} + +function getCoverBackgroundFrame({ + image, + viewport, +}: { + image: BackgroundSize; + viewport: BackgroundSize; +}): BackgroundFrame { + const coverScale = Math.max( + viewport.width / image.width, + viewport.height / image.height, + ); + const width = image.width * coverScale; + const height = image.height * coverScale; + const endX = Math.min(0, viewport.width - width); + + return { + endX, + height, + startX: 0, + width, + }; +} + +function getInitialLayerSizes(layers: readonly ApplicationVisualLayer[]) { + const sizes: Record = {}; + + for (const layer of layers) { + if (layer.nativeSize) { + sizes[layer.id] = layer.nativeSize; + } + } + + return sizes; +} + +function getFreshLayerState( + visualKey: string, + layers: readonly ApplicationVisualLayer[], +): LayerState { + return { + failedLayerIds: new Set(), + layerSizes: getInitialLayerSizes(layers), + visualKey, + }; +} + +function clampProgress(progress: number) { + if (!Number.isFinite(progress)) return 0; + return Math.min(Math.max(progress, 0), 1); +} + +function getProgressWindow(progress: number, start: number, distance = 0.1) { + return clampProgress((progress - start) / distance); +} + +function getFrameTranslateX({ + frame, + mode, + progress, +}: { + frame: BackgroundFrame; + mode: ApplicationVisualMode; + progress: number; +}) { + if (mode === "static") { + return frame.endX / 2; + } + + return frame.startX + (frame.endX - frame.startX) * progress; +} + +function shouldUseMobileTiming({ + maxWidth, + viewport, +}: { + maxWidth?: number; + viewport: BackgroundSize | null; +}) { + if (!isValidBackgroundSize(viewport)) return false; + + return viewport.width <= (maxWidth ?? 640); +} + +function getAnimatedRestFrameIndex(layer: ApplicationVisualLayer) { + const frameCount = layer.animatedFrameSrcs?.length ?? 0; + if (frameCount <= 0) return 0; + + const requestedIndex = layer.animatedRestFrameIndex ?? frameCount - 1; + + if (!Number.isFinite(requestedIndex)) return frameCount - 1; + + return Math.min(Math.max(Math.round(requestedIndex), 0), frameCount - 1); +} + +export function HackerApplicationBackground({ + backgroundKey, + isTransitioning = false, + progress, + transitionMsOverride, + transitionDirection = "forward", +}: { + backgroundKey?: string | null; + isTransitioning?: boolean; + progress: number; + transitionMsOverride?: number; + transitionDirection?: StepDirection; +}) { + const viewportRef = useRef(null); + const visualConfig = getHackerApplicationBackground(backgroundKey); + const layers = visualConfig.layers ?? EMPTY_VISUAL_LAYERS; + const ambientLayers = visualConfig.ambientLayers ?? EMPTY_AMBIENT_LAYERS; + const assetCredits = visualConfig.assetCredits ?? EMPTY_ASSET_CREDITS; + const primaryLayer = + layers.find((layer) => layer.id === visualConfig.baseLayerId) ?? + layers.find((layer) => (layer.space ?? "scene") === "scene") ?? + layers[0]; + const [layerState, setLayerState] = useState(() => + getFreshLayerState(visualConfig.key, layers), + ); + const [viewportSize, setViewportSize] = useState(null); + const [animatedFrameIndexes, setAnimatedFrameIndexes] = useState< + Record + >({}); + const activeLayerState = + layerState.visualKey === visualConfig.key + ? layerState + : getFreshLayerState(visualConfig.key, layers); + const { failedLayerIds, layerSizes } = activeLayerState; + + useEffect(() => { + const viewport = viewportRef.current; + if (!viewport) return; + + const updateViewportSize = () => { + const rect = viewport.getBoundingClientRect(); + const width = rect.width || window.innerWidth; + const height = rect.height || window.innerHeight; + + setViewportSize({ + height, + width, + }); + }; + + updateViewportSize(); + + const observer = new ResizeObserver(updateViewportSize); + observer.observe(viewport); + window.addEventListener("resize", updateViewportSize); + window.visualViewport?.addEventListener("resize", updateViewportSize); + + return () => { + observer.disconnect(); + window.removeEventListener("resize", updateViewportSize); + window.visualViewport?.removeEventListener("resize", updateViewportSize); + }; + }, [visualConfig.key]); + + useEffect(() => { + for (const layer of layers) { + if (!layer.animatedFrameSrcs?.length) continue; + + for (const src of layer.animatedFrameSrcs) { + const frame = new window.Image(); + frame.src = src; + void frame.decode().catch(() => undefined); + } + } + }, [layers]); + + const primaryLayerFailed = + !!primaryLayer && failedLayerIds.has(primaryLayer.id); + const hasCustomVisual = layers.length > 0 && !!primaryLayer; + const canRenderCustomVisual = hasCustomVisual && !primaryLayerFailed; + const primaryLayerSize = primaryLayer + ? (layerSizes[primaryLayer.id] ?? primaryLayer.nativeSize) + : null; + const frame = + isValidBackgroundSize(primaryLayerSize) && + isValidBackgroundSize(viewportSize) + ? getCoverBackgroundFrame({ + image: primaryLayerSize, + viewport: viewportSize, + }) + : null; + const safeProgress = clampProgress(progress); + const translateX = frame + ? getFrameTranslateX({ + frame, + mode: visualConfig.mode, + progress: safeProgress, + }) + : 0; + const usesMobileTiming = shouldUseMobileTiming({ + maxWidth: visualConfig.mobileTimingMaxWidth, + viewport: viewportSize, + }); + const transitionMs = usesMobileTiming + ? (visualConfig.mobileTransitionMs ?? visualConfig.transitionMs) + : visualConfig.transitionMs; + const resolvedTransitionMs = transitionMsOverride ?? transitionMs ?? 620; + const transition = `${resolvedTransitionMs}ms ${visualConfig.transitionEasing ?? "cubic-bezier(0.22, 1, 0.36, 1)"}`; + const showStockEffects = + !canRenderCustomVisual || visualConfig.showStockEffects === true; + const fallingLeavesProgress = + visualConfig.fallingLeavesStartProgress === undefined + ? 0 + : getProgressWindow( + safeProgress, + visualConfig.fallingLeavesStartProgress, + ); + + useEffect(() => { + const animatedFrameLayers = layers.filter( + (layer) => layer.animatedFrameSrcs?.length, + ); + + if (animatedFrameLayers.length === 0) return; + + let animationFrameId: number | null = null; + + const setLayerFrameIndexes = ( + getFrameIndex: (layer: ApplicationVisualLayer) => number, + ) => { + setAnimatedFrameIndexes((current) => { + let next = current; + + for (const layer of animatedFrameLayers) { + const frameCount = layer.animatedFrameSrcs?.length ?? 0; + if (frameCount === 0) continue; + + const frameIndex = + ((getFrameIndex(layer) % frameCount) + frameCount) % frameCount; + + if (current[layer.id] !== frameIndex) { + next = next === current ? { ...current } : next; + next[layer.id] = frameIndex; + } + } + + return next; + }); + }; + + if (!isTransitioning) { + animationFrameId = window.requestAnimationFrame(() => { + setLayerFrameIndexes(getAnimatedRestFrameIndex); + }); + + return () => { + if (animationFrameId !== null) { + window.cancelAnimationFrame(animationFrameId); + } + }; + } + + const startedAt = performance.now(); + const durationMs = Math.max(resolvedTransitionMs, 1); + + const updateFrame = (now: number) => { + const transitionProgress = clampProgress((now - startedAt) / durationMs); + + setLayerFrameIndexes((layer) => { + const restFrameIndex = getAnimatedRestFrameIndex(layer); + return Math.min( + restFrameIndex, + Math.floor(transitionProgress * (restFrameIndex + 1)), + ); + }); + + if (transitionProgress < 1) { + animationFrameId = window.requestAnimationFrame(updateFrame); + } + }; + + animationFrameId = window.requestAnimationFrame(updateFrame); + + return () => { + if (animationFrameId !== null) { + window.cancelAnimationFrame(animationFrameId); + } + }; + }, [isTransitioning, layers, resolvedTransitionMs]); + + const setLayerSize = (layerId: string, size: BackgroundSize) => { + setLayerState((current) => { + const baseState = + current.visualKey === visualConfig.key + ? current + : getFreshLayerState(visualConfig.key, layers); + const currentSize = baseState.layerSizes[layerId]; + + if ( + currentSize?.height === size.height && + currentSize.width === size.width + ) { + return current; + } + + return { + ...baseState, + layerSizes: { + ...baseState.layerSizes, + [layerId]: size, + }, + }; + }); + }; + + const markLayerFailed = (layerId: string) => { + setLayerState((current) => { + const baseState = + current.visualKey === visualConfig.key + ? current + : getFreshLayerState(visualConfig.key, layers); + + if (baseState.failedLayerIds.has(layerId)) return current; + + const failedLayerIds = new Set(baseState.failedLayerIds); + failedLayerIds.add(layerId); + return { + ...baseState, + failedLayerIds, + }; + }); + }; + + const getLayerMediaStyle = (layer: ApplicationVisualLayer) => { + const shouldFaceBackward = + isTransitioning && + transitionDirection === "back" && + layer.motion?.facesStepDirection === true; + const turnDurationMs = layer.motion?.turnDurationMs ?? 220; + const transform = [ + layer.mediaStyle?.transform, + shouldFaceBackward ? "scaleX(-1)" : "scaleX(1)", + ] + .filter(Boolean) + .join(" "); + const transition = [ + layer.mediaStyle?.transition, + `transform ${turnDurationMs}ms cubic-bezier(0.22, 1, 0.36, 1)`, + ] + .filter(Boolean) + .join(", "); + + return { + ...layer.mediaStyle, + ...(layer.motion?.facesStepDirection + ? { + transform, + transition, + willChange: "transform", + } + : {}), + }; + }; + + const renderLayerMedia = (layer: ApplicationVisualLayer) => { + const isPrimaryLayer = layer.id === primaryLayer?.id; + const layerSrc = layer.idleSrc ?? layer.src; + const layerMediaStyle = getLayerMediaStyle(layer); + const shouldPaceTransition = + isTransitioning && !!layer.motion?.transitionPaceClassName; + const renderAnimatedLayerContainer = (children: ReactNode) => ( +
+
+ {children} +
+
+ ); + const layerSources = + layerSrc === layer.src && layer.sources + ? layer.sources + : [{ mimeType: layer.mimeType, src: layerSrc }]; + + if (layer.kind === "video") { + return ( + + ); + } + + if (layer.animatedFrameSrcs?.length) { + const frameSources = layer.animatedFrameSrcs; + const restFrameIndex = getAnimatedRestFrameIndex(layer); + const frameIndex = + (animatedFrameIndexes[layer.id] ?? restFrameIndex) % + frameSources.length; + + return renderAnimatedLayerContainer( + <> + {frameSources.map((frameSrc, index) => { + const isVisible = index === frameIndex; + + return ( + // eslint-disable-next-line @next/next/no-img-element -- Keeps locally generated animation frames mounted so Chromium does not flicker between src swaps. + {isVisible { + markLayerFailed(layer.id); + }} + onLoad={(event) => { + if ( + event.currentTarget.naturalWidth <= 0 || + event.currentTarget.naturalHeight <= 0 + ) { + return; + } + + setLayerSize(layer.id, { + height: event.currentTarget.naturalHeight, + width: event.currentTarget.naturalWidth, + }); + }} + /> + ); + })} + , + ); + } + + if (layer.animatedSrc) { + const renderAnimatedImage = ({ + isVisible, + loading, + src, + }: { + isVisible: boolean; + loading: "eager" | "lazy"; + src: string; + }) => ( + // eslint-disable-next-line @next/next/no-img-element -- Supports arbitrary R2 image URLs while keeping animated layers mounted for smooth playback. + {layer.alt { + markLayerFailed(layer.id); + }} + onLoad={(event) => { + if ( + event.currentTarget.naturalWidth <= 0 || + event.currentTarget.naturalHeight <= 0 + ) { + return; + } + + setLayerSize(layer.id, { + height: event.currentTarget.naturalHeight, + width: event.currentTarget.naturalWidth, + }); + }} + /> + ); + + return renderAnimatedLayerContainer( + <> + {renderAnimatedImage({ + isVisible: !isTransitioning, + loading: isPrimaryLayer ? "eager" : "lazy", + src: layerSrc, + })} + {renderAnimatedImage({ + isVisible: isTransitioning, + loading: "eager", + src: layer.animatedSrc, + })} + , + ); + } + + const imageElement = ( + // eslint-disable-next-line @next/next/no-img-element -- Supports arbitrary R2 image URLs while reading natural dimensions for pan math. + {layer.alt { + markLayerFailed(layer.id); + }} + onLoad={(event) => { + if ( + event.currentTarget.naturalWidth <= 0 || + event.currentTarget.naturalHeight <= 0 + ) { + return; + } + + setLayerSize(layer.id, { + height: event.currentTarget.naturalHeight, + width: event.currentTarget.naturalWidth, + }); + }} + /> + ); + + if (!layer.sources?.length || layerSrc !== layer.src) { + return imageElement; + } + + return ( + + {layer.sources.map((source) => ( + + ))} + {imageElement} + + ); + }; + + const renderSceneLayer = (layer: ApplicationVisualLayer) => { + const parallax = layer.parallax ?? 1; + + return ( +
+ {renderLayerMedia(layer)} +
+ ); + }; + + const renderViewportLayer = (layer: ApplicationVisualLayer) => ( +
+ {renderLayerMedia(layer)} +
+ ); + + const renderSceneAmbientLayer = (layer: ApplicationVisualAmbientLayer) => { + const parallax = layer.parallax ?? 1; + + return ( +
+ ); + }; + + const renderViewportAmbientLayer = (layer: ApplicationVisualAmbientLayer) => ( +
+ ); + + const renderSceneFallingLeaves = () => { + if (visualConfig.fallingLeavesStartProgress === undefined) return null; + + return ( +
+ +
+ ); + }; + + const renderAssetCredit = (assetCredit: ApplicationVisualAssetCredit) => ( + + + + ); + + return ( + <> + {canRenderCustomVisual && ( +