diff --git a/frontend/src/index.css b/frontend/src/index.css index 42c03f1..5a3ad04 100644 --- a/frontend/src/index.css +++ b/frontend/src/index.css @@ -3,18 +3,67 @@ @import "./styles/layout.css"; .auth-shell { + position: relative; + isolation: isolate; min-height: 100vh; + min-height: 100dvh; display: grid; place-items: center; padding: 24px; + overflow-x: clip; + background: + radial-gradient(circle at 50% 44%, color-mix(in srgb, var(--brand-primary) 8%, transparent), transparent 38%), + var(--surface-base); +} + +.auth-shell::before, +.auth-shell::after { + position: absolute; + z-index: 0; + width: 34vw; + aspect-ratio: 1; + border-radius: 50%; + background: var(--accent-glow); + filter: blur(80px); + opacity: .22; + content: ""; + pointer-events: none; +} + +.auth-shell::before { + top: -18%; + left: -12%; +} + +.auth-shell::after { + right: -12%; + bottom: -22%; +} + +.auth-graph { + position: absolute; + z-index: -1; + inset: 0; + display: block; + width: 100%; + height: 100%; + pointer-events: none; + opacity: .9; + background: + radial-gradient(circle at 12% 18%, color-mix(in srgb, var(--brand-primary) 12%, transparent), transparent 24%), + radial-gradient(circle at 88% 82%, color-mix(in srgb, var(--brand-primary) 10%, transparent), transparent 26%); } .auth-card { + position: relative; + z-index: 1; width: 100%; max-width: 440px; padding: 32px; display: grid; gap: 18px; + box-shadow: 0 24px 80px rgba(0, 0, 0, .34), 0 0 0 1px color-mix(in srgb, var(--brand-primary) 12%, transparent); + backdrop-filter: blur(18px); } .auth-card h1 { @@ -45,6 +94,25 @@ color: var(--status-danger); } +@media (max-width: 600px) { + .auth-shell { + padding: 16px; + } + + .auth-card { + padding: 24px; + } + + .auth-graph { + inset: -8% -28%; + opacity: .7; + } +} + +@media (prefers-reduced-motion: reduce) { + .auth-graph { opacity: .72; } +} + button:focus-visible, input:focus-visible, select:focus-visible, diff --git a/frontend/src/views/auth/AuthView.tsx b/frontend/src/views/auth/AuthView.tsx index 39b2c2c..f444f43 100644 --- a/frontend/src/views/auth/AuthView.tsx +++ b/frontend/src/views/auth/AuthView.tsx @@ -1,9 +1,217 @@ import { Button } from "../common/ui"; -import { useEffect, useState, type ReactNode } from "react"; +import { useEffect, useRef, useState, type ReactNode } from "react"; import { ApiError } from "../../api/api_auth"; import { useAuth } from "../../viewmodels/useAuthViewModel"; import { loginDestination, navigate, safeDestination } from "../../models/navigation"; +type Particle = { x: number; y: number; vx: number; vy: number; radius: number }; + +function LoginGraphBackground() { + const canvasRef = useRef(null); + + useEffect(() => { + const canvas = canvasRef.current; + if (!canvas) return; + const context = canvas.getContext("2d"); + if (!context) return; + + const reducedMotion = window.matchMedia("(prefers-reduced-motion: reduce)"); + const pointer = { x: -1000, y: -1000 }; + const isDocumentHidden = () => document.visibilityState === "hidden"; + let particles: Particle[] = []; + let animationFrame: number | null = null; + let width = 0; + let height = 0; + let pixelRatio = 1; + let brandColor = "rgb(237, 106, 50)"; + let highlightColor = "rgb(255, 173, 125)"; + let brandColorWithAlpha = (alpha: number) => `rgba(237, 106, 50, ${alpha})`; + + const parseColor = (value: string) => { + const hexMatch = value.match(/^#([\da-f]{3}|[\da-f]{6})$/i); + if (hexMatch) { + const hex = hexMatch[1].length === 3 + ? [...hexMatch[1]].map(part => part + part).join("") + : hexMatch[1]; + return [0, 2, 4].map(offset => Number.parseInt(hex.slice(offset, offset + 2), 16)); + } + + const rgbMatch = value.match(/^rgba?\(\s*([\d.]+)[,\s]+([\d.]+)[,\s]+([\d.]+)/i); + if (rgbMatch) return rgbMatch.slice(1, 4).map(channel => Number.parseFloat(channel)); + return [237, 106, 50]; + }; + + const resize = () => { + const bounds = canvas.getBoundingClientRect(); + width = bounds.width; + height = bounds.height; + pixelRatio = Math.min(window.devicePixelRatio || 1, 2); + const configuredBrandColor = window.getComputedStyle(canvas).getPropertyValue("--brand-primary").trim(); + const [red, green, blue] = parseColor(configuredBrandColor || "#ed6a32"); + brandColor = `rgb(${red}, ${green}, ${blue})`; + highlightColor = `rgb(${Math.round(red + (255 - red) * .48)}, ${Math.round(green + (255 - green) * .48)}, ${Math.round(blue + (255 - blue) * .48)})`; + brandColorWithAlpha = (alpha: number) => `rgba(${red}, ${green}, ${blue}, ${alpha})`; + canvas.width = Math.floor(width * pixelRatio); + canvas.height = Math.floor(height * pixelRatio); + context.setTransform(pixelRatio, 0, 0, pixelRatio, 0, 0); + const count = Math.max(34, Math.min(78, Math.floor((width * height) / 15500))); + const columns = Math.max(5, Math.ceil(Math.sqrt(count * width / Math.max(height, 1)))); + const rows = Math.ceil(count / columns); + particles = Array.from({ length: count }, (_, index) => ({ + x: ((index % columns) + .5 + ((index * 17) % 7 - 3) * .045) * width / columns, + y: (Math.floor(index / columns) + .5 + ((index * 11) % 7 - 3) * .045) * height / rows, + vx: (index % 2 ? 1 : -1) * (0.08 + (index % 4) * 0.025), + vy: (index % 3 ? 1 : -1) * (0.06 + (index % 5) * 0.018), + radius: index % 7 === 0 ? 2.6 : 1.5, + })); + }; + + const drawScene = (advanceParticles: boolean) => { + context.clearRect(0, 0, width, height); + const linkDistance = Math.min(180, Math.max(135, width * 0.22)); + const pointerRadius = 190; + const pointerActive = advanceParticles && pointer.x > -500; + + if (pointerActive) { + const pointerGlow = context.createRadialGradient( + pointer.x, + pointer.y, + 0, + pointer.x, + pointer.y, + pointerRadius, + ); + pointerGlow.addColorStop(0, brandColorWithAlpha(.18)); + pointerGlow.addColorStop(.45, brandColorWithAlpha(.06)); + pointerGlow.addColorStop(1, brandColorWithAlpha(0)); + context.fillStyle = pointerGlow; + context.beginPath(); + context.arc(pointer.x, pointer.y, pointerRadius, 0, Math.PI * 2); + context.fill(); + } + + particles.forEach((particle, index) => { + if (advanceParticles) { + const dx = pointer.x - particle.x; + const dy = pointer.y - particle.y; + const distance = Math.hypot(dx, dy); + if (distance < pointerRadius && distance > 0) { + const influence = (1 - distance / pointerRadius) ** 2; + particle.vx += dx / distance * 0.0024 * influence; + particle.vy += dy / distance * 0.0024 * influence; + } + particle.x += particle.vx; + particle.y += particle.vy; + if (particle.x < -20 || particle.x > width + 20) particle.vx *= -1; + if (particle.y < -20 || particle.y > height + 20) particle.vy *= -1; + particle.vx = Math.max(-0.24, Math.min(0.24, particle.vx)); + particle.vy = Math.max(-0.24, Math.min(0.24, particle.vy)); + } + + for (let nextIndex = index + 1; nextIndex < particles.length; nextIndex += 1) { + const other = particles[nextIndex]; + const distance = Math.hypot(particle.x - other.x, particle.y - other.y); + if (distance > linkDistance) continue; + const pointerBoost = pointerActive && Math.hypot(pointer.x - particle.x, pointer.y - particle.y) < pointerRadius + ? .14 + : 0; + context.strokeStyle = brandColorWithAlpha(Math.min(.65, (1 - distance / linkDistance) * .32 + pointerBoost)); + context.lineWidth = 0.7; + context.beginPath(); + context.moveTo(particle.x, particle.y); + context.lineTo(other.x, other.y); + context.stroke(); + } + + context.fillStyle = index % 7 === 0 ? highlightColor : brandColor; + context.shadowColor = brandColor; + context.shadowBlur = pointerActive && Math.hypot(pointer.x - particle.x, pointer.y - particle.y) < pointerRadius ? 14 : 8; + context.beginPath(); + context.arc(particle.x, particle.y, particle.radius, 0, Math.PI * 2); + context.fill(); + context.shadowBlur = 0; + }); + }; + + const cancelPendingFrame = () => { + if (animationFrame === null) return; + window.cancelAnimationFrame(animationFrame); + animationFrame = null; + }; + + function scheduleFrame() { + if (animationFrame !== null || reducedMotion.matches || isDocumentHidden()) return; + animationFrame = window.requestAnimationFrame(renderFrame); + } + + function renderFrame() { + animationFrame = null; + const advanceParticles = !reducedMotion.matches && !isDocumentHidden(); + drawScene(advanceParticles); + if (advanceParticles) scheduleFrame(); + } + + const movePointer = (event: MouseEvent) => { + if (reducedMotion.matches || isDocumentHidden()) return; + const bounds = canvas.getBoundingClientRect(); + pointer.x = event.clientX - bounds.left; + pointer.y = event.clientY - bounds.top; + }; + const resetPointer = () => { + pointer.x = -1000; + pointer.y = -1000; + }; + + const handleResize = () => { + resize(); + if (reducedMotion.matches) drawScene(false); + else scheduleFrame(); + }; + + const handleMotionPreferenceChange = () => { + resetPointer(); + if (reducedMotion.matches) { + cancelPendingFrame(); + drawScene(false); + } else { + scheduleFrame(); + } + }; + + const handleVisibilityChange = () => { + resetPointer(); + if (isDocumentHidden()) { + cancelPendingFrame(); + } else if (reducedMotion.matches) { + drawScene(false); + } else { + scheduleFrame(); + } + }; + + resize(); + if (reducedMotion.matches) drawScene(false); + else scheduleFrame(); + window.addEventListener("resize", handleResize); + window.addEventListener("mousemove", movePointer); + window.addEventListener("mouseleave", resetPointer); + reducedMotion.addEventListener("change", handleMotionPreferenceChange); + document.addEventListener("visibilitychange", handleVisibilityChange); + return () => { + window.removeEventListener("resize", handleResize); + window.removeEventListener("mousemove", movePointer); + window.removeEventListener("mouseleave", resetPointer); + reducedMotion.removeEventListener("change", handleMotionPreferenceChange); + document.removeEventListener("visibilitychange", handleVisibilityChange); + cancelPendingFrame(); + }; + }, []); + + return ( +