<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta
name="viewport"
content="width=device-width, initial-scale=1.0, maximum-scale=1"
/>
<meta
name="description"
content="Lumen is a quiet, intelligent workspace where focused professionals come to do their best work."
/>
<title>Lumen | Sign In</title>
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link
href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap"
rel="stylesheet"
/>
<script type="importmap">
{
"imports": {
"three": "https://unpkg.com/three@0.160.0/build/three.module.js"
}
}
</script>
<style>
:root {
--bg: #f3f7f6;
--bg-grad-1: rgba(17, 94, 89, 0.10);
--bg-grad-2: rgba(13, 148, 136, 0.08);
--card: rgba(255, 255, 255, 0.65);
--card-border: rgba(15, 75, 70, 0.10);
--text: #0f1f1d;
--text-muted: #5b6b69;
--primary: #0d6e64;
--primary-hover: #0a5953;
--primary-text: #ffffff;
--input-bg: rgba(255, 255, 255, 0.7);
--input-border: rgba(15, 75, 70, 0.18);
--input-border-focus: #0d6e64;
--face-fill: #ffffff;
--face-stroke: #d8e3e1;
--hair: #0d6e64;
--eye: #1c2826;
--hand-fill: #fde7d3;
--hand-stroke: #c9a886;
--blush: #ffb3a8;
--error: #b54848;
--shadow: 0 30px 80px -20px rgba(15, 75, 70, 0.25),
0 10px 30px -10px rgba(15, 75, 70, 0.15);
--toggle-bg: rgba(255, 255, 255, 0.7);
--divider: rgba(15, 75, 70, 0.12);
--three-color: #0d6e64;
}
html.dark {
--bg: #06100f;
--bg-grad-1: rgba(45, 212, 191, 0.18);
--bg-grad-2: rgba(34, 197, 184, 0.10);
--card: rgba(15, 30, 28, 0.55);
--card-border: rgba(94, 234, 212, 0.12);
--text: #e6f3f1;
--text-muted: #8aa3a0;
--primary: #2dd4bf;
--primary-hover: #5eead4;
--primary-text: #052622;
--input-bg: rgba(7, 22, 20, 0.55);
--input-border: rgba(94, 234, 212, 0.18);
--input-border-focus: #2dd4bf;
--face-fill: #e6f3f1;
--face-stroke: #2dd4bf;
--hair: #2dd4bf;
--eye: #06100f;
--hand-fill: #f5d4b3;
--hand-stroke: #b08864;
--blush: #ff8d80;
--error: #ff8a8a;
--shadow: 0 30px 80px -20px rgba(0, 0, 0, 0.6),
0 10px 30px -10px rgba(0, 0, 0, 0.4);
--toggle-bg: rgba(15, 30, 28, 0.7);
--divider: rgba(94, 234, 212, 0.12);
--three-color: #2dd4bf;
}
* { box-sizing: border-box; }
html, body {
margin: 0; padding: 0; height: 100%;
font-family: "Inter", -apple-system, BlinkMacSystemFont, sans-serif;
background: var(--bg); color: var(--text);
transition: background 0.5s ease, color 0.5s ease;
-webkit-font-smoothing: antialiased; overflow: hidden;
}
#bg-canvas { position: fixed; inset: 0; z-index: 0; pointer-events: none; }
.bg-fallback {
position: fixed; inset: 0; z-index: 0; pointer-events: none;
background:
radial-gradient(ellipse at 20% 20%, var(--bg-grad-1), transparent 55%),
radial-gradient(ellipse at 80% 75%, var(--bg-grad-2), transparent 55%);
}
.layout {
position: relative; z-index: 1; min-height: 100vh;
display: flex; align-items: center; justify-content: center;
padding: 24px; overflow-y: auto;
}
.stack {
display: flex; flex-direction: column; align-items: center;
gap: 0; width: 100%; max-width: 420px;
}
.avatar-wrap {
position: relative; width: 160px; height: 160px;
margin-bottom: -36px; z-index: 2;
filter: drop-shadow(0 18px 30px rgba(0, 0, 0, 0.18));
animation: avatarIntro 0.8s ease-out backwards;
}
@keyframes avatarIntro {
from { opacity: 0; transform: translateY(-12px) scale(0.85); }
to { opacity: 1; transform: translateY(0) scale(1); }
}
.avatar-wrap svg { width: 100%; height: 100%; overflow: visible; }
.card {
width: 100%; background: var(--card);
backdrop-filter: blur(28px) saturate(140%);
-webkit-backdrop-filter: blur(28px) saturate(140%);
border: 1px solid var(--card-border);
border-radius: 24px; padding: 56px 36px 32px;
box-shadow: var(--shadow);
animation: cardIntro 0.8s 0.1s ease-out backwards;
}
@keyframes cardIntro {
from { opacity: 0; transform: translateY(20px); }
to { opacity: 1; transform: translateY(0); }
}
.card h1 { margin: 0; font-size: 26px; font-weight: 600; text-align: center; letter-spacing: -0.01em; }
.card .subtitle { margin: 8px 0 28px; text-align: center; color: var(--text-muted); font-size: 14px; }
.field { margin-bottom: 16px; }
.field-label-row { display: flex; justify-content: space-between; align-items: baseline; margin-bottom: 6px; }
.field label { font-size: 13px; font-weight: 500; color: var(--text); }
.field a.link { font-size: 12px; color: var(--primary); text-decoration: none; font-weight: 500; }
.field a.link:hover { text-decoration: underline; }
.input-wrap { position: relative; }
.field input[type="email"], .field input[type="password"], .field input[type="text"] {
width: 100%; height: 44px; padding: 0 14px;
font-family: inherit; font-size: 14px; color: var(--text);
background: var(--input-bg); border: 1px solid var(--input-border);
border-radius: 10px; outline: none;
transition: border-color 0.2s ease, box-shadow 0.2s ease, background 0.2s ease;
}
.field input::placeholder { color: var(--text-muted); }
.field input:focus { border-color: var(--input-border-focus); box-shadow: 0 0 0 3px rgba(13, 110, 100, 0.12); }
html.dark .field input:focus { box-shadow: 0 0 0 3px rgba(45, 212, 191, 0.18); }
.pwd-toggle {
position: absolute; right: 8px; top: 50%; transform: translateY(-50%);
width: 32px; height: 32px;
display: flex; align-items: center; justify-content: center;
border: none; background: transparent; color: var(--text-muted);
cursor: pointer; border-radius: 6px;
}
.pwd-toggle:hover { color: var(--text); background: rgba(0, 0, 0, 0.04); }
html.dark .pwd-toggle:hover { background: rgba(255, 255, 255, 0.06); }
.field-error { font-size: 12px; color: var(--error); margin-top: 6px; min-height: 14px; }
.row-checkbox {
display: flex; align-items: center; gap: 8px;
margin: 4px 0 22px; font-size: 13px; color: var(--text-muted);
}
.row-checkbox input { accent-color: var(--primary); width: 14px; height: 14px; }
.row-checkbox label { cursor: pointer; }
.btn-primary {
width: 100%; height: 46px;
background: var(--primary); color: var(--primary-text);
border: none; border-radius: 10px;
font-family: inherit; font-size: 14px; font-weight: 600;
cursor: pointer; transition: background 0.2s ease, transform 0.06s ease;
display: flex; align-items: center; justify-content: center; gap: 8px;
}
.btn-primary:hover { background: var(--primary-hover); }
.btn-primary:active { transform: scale(0.99); }
.btn-primary:disabled { opacity: 0.7; cursor: progress; }
.spinner {
width: 16px; height: 16px;
border: 2px solid rgba(255, 255, 255, 0.4);
border-top-color: var(--primary-text);
border-radius: 50%; animation: spin 0.7s linear infinite;
}
@keyframes spin { to { transform: rotate(360deg); } }
.divider {
display: flex; align-items: center; gap: 12px;
margin: 20px 0; font-size: 11px; color: var(--text-muted);
letter-spacing: 0.08em; text-transform: uppercase;
}
.divider::before, .divider::after { content: ""; flex: 1; height: 1px; background: var(--divider); }
.sso-row { display: grid; grid-template-columns: repeat(3, 1fr); gap: 8px; }
.sso-btn {
height: 44px; background: var(--input-bg);
border: 1px solid var(--input-border); border-radius: 10px;
cursor: pointer; display: flex; align-items: center; justify-content: center;
color: var(--text); transition: background 0.15s ease, transform 0.06s ease;
}
.sso-btn:hover { background: rgba(0, 0, 0, 0.03); }
html.dark .sso-btn:hover { background: rgba(255, 255, 255, 0.04); }
.sso-btn svg { width: 18px; height: 18px; }
.footer-text { margin-top: 22px; text-align: center; font-size: 13px; color: var(--text-muted); }
.footer-text a { color: var(--primary); text-decoration: none; font-weight: 500; }
.footer-text a:hover { text-decoration: underline; }
.theme-toggle {
position: fixed; top: 20px; right: 20px; z-index: 10;
width: 40px; height: 40px; border-radius: 50%;
background: var(--toggle-bg); border: 1px solid var(--card-border);
color: var(--text); cursor: pointer;
display: flex; align-items: center; justify-content: center;
backdrop-filter: blur(12px); -webkit-backdrop-filter: blur(12px);
transition: background 0.2s ease;
}
.theme-toggle:hover { background: var(--input-bg); }
.theme-toggle svg { width: 18px; height: 18px; }
.success-banner {
position: absolute; inset: 0;
background: var(--card);
backdrop-filter: blur(28px); -webkit-backdrop-filter: blur(28px);
border-radius: 24px;
display: flex; align-items: center; justify-content: center; flex-direction: column; gap: 8px;
opacity: 0; pointer-events: none; transition: opacity 0.4s ease;
font-weight: 600; font-size: 18px;
}
.success-banner.show { opacity: 1; }
.success-banner small { font-weight: 400; font-size: 13px; color: var(--text-muted); }
.card-wrap { position: relative; width: 100%; }
/* Avatar parts */
.face-circle { fill: var(--face-fill); stroke: var(--face-stroke); stroke-width: 2; transition: fill 0.3s ease, stroke 0.3s ease; }
.hair { fill: var(--hair); transition: fill 0.3s ease; }
.eye-pupil {
fill: var(--eye);
transition: cx 0.25s cubic-bezier(0.34, 1.56, 0.64, 1),
cy 0.25s cubic-bezier(0.34, 1.56, 0.64, 1), fill 0.3s ease;
}
.eye-white { fill: var(--face-fill); stroke: var(--eye); stroke-width: 1.2; transition: fill 0.3s ease, stroke 0.3s ease; }
.eyelid {
fill: var(--face-fill); stroke: var(--eye); stroke-width: 2; stroke-linecap: round;
transform-origin: center; transform: scaleY(0); transform-box: fill-box;
transition: transform 0.18s ease;
}
.closed .eyelid { transform: scaleY(1); }
.blink { animation: blink 5s infinite; }
.closed .blink { animation: none; }
@keyframes blink {
0%, 92%, 100% { transform: scaleY(0); }
94%, 96% { transform: scaleY(1); }
}
.blush { fill: var(--blush); opacity: 0; transition: opacity 0.3s ease; }
.closed .blush, .smiling .blush { opacity: 0.55; }
.mouth { fill: none; stroke: var(--eye); stroke-width: 2.6; stroke-linecap: round; transition: d 0.3s ease; }
.hands {
opacity: 0; transform: translateY(40px); transform-origin: 80px 130px;
transition: opacity 0.32s ease, transform 0.45s cubic-bezier(0.34, 1.56, 0.64, 1);
}
.closed .hands { opacity: 1; transform: translateY(0); }
.hand { fill: var(--hand-fill); stroke: var(--hand-stroke); stroke-width: 1.4; stroke-linejoin: round; stroke-linecap: round; }
.head-group { transform-origin: 80px 110px; transition: transform 0.4s cubic-bezier(0.34, 1.56, 0.64, 1); }
.tilted .head-group { transform: rotate(-5deg); }
.sparkle { opacity: 0; transition: opacity 0.3s ease; }
.successful .sparkle { opacity: 1; }
.visually-hidden {
position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px;
overflow: hidden; clip: rect(0, 0, 0, 0); white-space: nowrap; border: 0;
}
</style>
</head>
<body>
<canvas id="bg-canvas"></canvas>
<div class="bg-fallback" id="bg-fallback" style="display:none"></div>
<button class="theme-toggle" id="themeToggle" type="button" aria-label="Toggle theme">
<svg id="themeIcon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<circle cx="12" cy="12" r="4"></circle>
<path d="M12 2v2M12 20v2M4.93 4.93l1.41 1.41M17.66 17.66l1.41 1.41M2 12h2M20 12h2M4.93 19.07l1.41-1.41M17.66 6.34l1.41-1.41"></path>
</svg>
</button>
<main class="layout">
<div class="stack">
<div class="avatar-wrap" id="avatar">
<svg viewBox="0 0 160 160" aria-hidden="true">
<defs>
<clipPath id="faceClip"><circle cx="80" cy="80" r="60" /></clipPath>
</defs>
<g class="head-group">
<circle class="face-circle" cx="80" cy="80" r="60" />
<g clip-path="url(#faceClip)">
<path class="hair" d="M 18 70 C 28 22, 132 22, 142 70 C 132 36, 28 36, 18 70 Z" />
<path class="hair" d="M 22 78 C 24 60, 36 50, 50 50 L 50 78 Z" opacity="0.85" />
<path class="hair" d="M 138 78 C 136 60, 124 50, 110 50 L 110 78 Z" opacity="0.85" />
</g>
<ellipse class="face-circle" cx="22" cy="86" rx="6" ry="9" />
<ellipse class="face-circle" cx="138" cy="86" rx="6" ry="9" />
<ellipse class="blush" cx="50" cy="98" rx="8" ry="5" />
<ellipse class="blush" cx="110" cy="98" rx="8" ry="5" />
<g id="eyesGroup">
<ellipse class="eye-white" cx="60" cy="82" rx="9" ry="11" />
<ellipse class="eye-white" cx="100" cy="82" rx="9" ry="11" />
<circle class="eye-pupil" id="leftPupil" cx="60" cy="82" r="4.5" />
<circle class="eye-pupil" id="rightPupil" cx="100" cy="82" r="4.5" />
<circle id="leftHL" class="eye-highlight" cx="61.4" cy="80.6" r="1.4" fill="white" opacity="0.9" />
<circle id="rightHL" class="eye-highlight" cx="101.4" cy="80.6" r="1.4" fill="white" opacity="0.9" />
<g class="sparkle">
<path d="M 50 70 l 2 2 l -2 2 l -2 -2 z" fill="#ffd166" />
<path d="M 110 70 l 2 2 l -2 2 l -2 -2 z" fill="#ffd166" />
</g>
</g>
<path class="eyelid" d="M 51 82 Q 60 76 69 82" />
<path class="eyelid" d="M 91 82 Q 100 76 109 82" />
<path d="M 51 67 Q 60 63 69 67" stroke="var(--eye)" stroke-width="2" fill="none" stroke-linecap="round" opacity="0.85" />
<path d="M 91 67 Q 100 63 109 67" stroke="var(--eye)" stroke-width="2" fill="none" stroke-linecap="round" opacity="0.85" />
<path id="mouth" class="mouth" d="M 70 110 Q 80 112 90 110" />
<g class="hands" id="hands">
<g transform="translate(0,0)">
<path class="hand" d="M 38 100 Q 36 84 46 78 Q 58 74 70 80 L 72 96 Q 70 108 60 112 Q 46 114 38 110 Z" />
<path class="hand" d="M 44 86 Q 44 76 48 76 Q 52 76 52 86 L 52 92 Z" />
<path class="hand" d="M 53 84 Q 53 72 57 72 Q 61 72 61 84 L 61 92 Z" />
<path class="hand" d="M 62 84 Q 62 74 66 74 Q 70 74 70 84 L 70 92 Z" />
<path class="hand" d="M 71 88 Q 71 80 75 80 Q 79 80 79 88 L 79 96 Z" opacity="0.95" />
</g>
<g transform="translate(160,0) scale(-1,1)">
<path class="hand" d="M 38 100 Q 36 84 46 78 Q 58 74 70 80 L 72 96 Q 70 108 60 112 Q 46 114 38 110 Z" />
<path class="hand" d="M 44 86 Q 44 76 48 76 Q 52 76 52 86 L 52 92 Z" />
<path class="hand" d="M 53 84 Q 53 72 57 72 Q 61 72 61 84 L 61 92 Z" />
<path class="hand" d="M 62 84 Q 62 74 66 74 Q 70 74 70 84 L 70 92 Z" />
<path class="hand" d="M 71 88 Q 71 80 75 80 Q 79 80 79 88 L 79 96 Z" opacity="0.95" />
</g>
</g>
</g>
</svg>
</div>
<div class="card-wrap">
<div class="card">
<h1>Sign in to Lumen</h1>
<p class="subtitle">Welcome back to your workspace</p>
<form id="loginForm" novalidate>
<div class="field">
<div class="field-label-row"><label for="email">Email</label></div>
<div class="input-wrap">
<input id="email" name="email" type="email" placeholder="name@company.com" autocomplete="email" required />
</div>
<div class="field-error" id="emailError"></div>
</div>
<div class="field">
<div class="field-label-row">
<label for="password">Password</label>
<a class="link" href="#">Forgot password?</a>
</div>
<div class="input-wrap">
<input id="password" name="password" type="password" placeholder="••••••••" autocomplete="current-password" required />
<button class="pwd-toggle" type="button" id="pwdToggle" aria-label="Show password">
<svg id="pwdIconOpen" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"></path>
<circle cx="12" cy="12" r="3"></circle>
</svg>
<svg id="pwdIconOff" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="display:none">
<path d="M17.94 17.94A10.94 10.94 0 0 1 12 20c-7 0-11-8-11-8a19.5 19.5 0 0 1 4.22-5.22"></path>
<path d="M9.9 4.24A10.94 10.94 0 0 1 12 4c7 0 11 8 11 8a19.5 19.5 0 0 1-3.17 4.31"></path>
<path d="M14.12 14.12A3 3 0 1 1 9.88 9.88"></path>
<line x1="1" y1="1" x2="23" y2="23"></line>
</svg>
</button>
</div>
<div class="field-error" id="passwordError"></div>
</div>
<div class="row-checkbox">
<input id="remember" type="checkbox" />
<label for="remember">Remember me for 30 days</label>
</div>
<button class="btn-primary" type="submit" id="submitBtn">
<span id="submitLabel">Sign in</span>
</button>
<div class="divider">Or continue with</div>
<div class="sso-row">
<button class="sso-btn" type="button" aria-label="Sign in with Google">
<svg viewBox="0 0 24 24">
<path fill="#4285F4" d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92c-.26 1.37-1.04 2.53-2.21 3.31v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.09z"/>
<path fill="#34A853" d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.99.66-2.25 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99 20.53 7.7 23 12 23z"/>
<path fill="#FBBC05" d="M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.07H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.93l3.66-2.84z"/>
<path fill="#EA4335" d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53z"/>
</svg>
</button>
<button class="sso-btn" type="button" aria-label="Sign in with GitHub">
<svg viewBox="0 0 24 24" fill="currentColor">
<path d="M12 .5C5.65.5.5 5.65.5 12c0 5.08 3.29 9.39 7.86 10.91.58.1.79-.25.79-.56 0-.27-.01-1.18-.02-2.13-3.2.69-3.87-1.36-3.87-1.36-.52-1.33-1.27-1.69-1.27-1.69-1.04-.71.08-.7.08-.7 1.15.08 1.76 1.18 1.76 1.18 1.02 1.75 2.68 1.24 3.34.95.1-.74.4-1.24.72-1.53-2.55-.29-5.24-1.27-5.24-5.66 0-1.25.45-2.27 1.18-3.07-.12-.29-.51-1.46.11-3.04 0 0 .96-.31 3.15 1.17.91-.25 1.89-.38 2.86-.39.97.01 1.95.14 2.86.39 2.18-1.48 3.14-1.17 3.14-1.17.62 1.58.23 2.75.11 3.04.74.8 1.18 1.82 1.18 3.07 0 4.4-2.69 5.36-5.25 5.65.41.36.78 1.06.78 2.14 0 1.55-.01 2.79-.01 3.17 0 .31.21.67.8.56C20.21 21.39 23.5 17.07 23.5 12 23.5 5.65 18.35.5 12 .5z"/>
</svg>
</button>
<button class="sso-btn" type="button" aria-label="Sign in with Apple">
<svg viewBox="0 0 24 24" fill="currentColor">
<path d="M16.365 1.43c0 1.14-.493 2.27-1.177 3.08-.744.9-1.99 1.57-2.987 1.57-.12 0-.23-.02-.3-.03-.01-.06-.04-.22-.04-.39 0-1.15.572-2.27 1.206-2.98.804-.94 2.142-1.64 3.248-1.68.03.13.05.28.05.43zM21.97 17.4c-.55 1.27-1.32 2.51-2.5 3.45-.92.74-1.68 1.05-2.71 1.05-1.13 0-1.84-.4-2.83-.4-1.04 0-1.84.43-2.92.43-1.06 0-1.96-.6-2.93-1.45-2.32-2.04-3.92-5.94-2.42-8.84.86-1.65 2.62-2.7 4.32-2.73 1.14-.02 2.21.78 2.86.78.66 0 1.96-.96 3.41-.82.6.03 2.41.24 3.59 1.84-3.21 1.86-2.7 6.43.06 7.69z"/>
</svg>
</button>
</div>
<p class="footer-text">Don't have an account? <a href="#">Sign up</a></p>
</form>
</div>
<div class="success-banner" id="successBanner">
Welcome back
<small>Redirecting to your workspace…</small>
</div>
</div>
</div>
</main>
<script type="module">
import * as THREE from "three";
// ---------- Theme handling ----------
const root = document.documentElement;
const themeIcon = document.getElementById("themeIcon");
const themeBtn = document.getElementById("themeToggle");
const SUN_PATH = `<circle cx="12" cy="12" r="4"></circle>
<path d="M12 2v2M12 20v2M4.93 4.93l1.41 1.41M17.66 17.66l1.41 1.41M2 12h2M20 12h2M4.93 19.07l1.41-1.41M17.66 6.34l1.41-1.41"></path>`;
const MOON_PATH = `<path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z"></path>`;
const stored = localStorage.getItem("lumen-theme");
let manualOverride = stored === "light" || stored === "dark";
let theme = stored || (window.matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light");
applyTheme(theme);
function applyTheme(t) {
if (t === "dark") { root.classList.add("dark"); themeIcon.innerHTML = SUN_PATH; }
else { root.classList.remove("dark"); themeIcon.innerHTML = MOON_PATH; }
theme = t;
if (window.__updateThreeTheme) window.__updateThreeTheme(t);
}
themeBtn.addEventListener("click", () => {
const next = root.classList.contains("dark") ? "light" : "dark";
manualOverride = true;
localStorage.setItem("lumen-theme", next);
applyTheme(next);
});
window.matchMedia("(prefers-color-scheme: dark)")
.addEventListener("change", (e) => {
if (manualOverride) return;
applyTheme(e.matches ? "dark" : "light");
});
// ---------- 3D Background ----------
function initThree() {
const canvas = document.getElementById("bg-canvas");
let renderer;
try {
renderer = new THREE.WebGLRenderer({ canvas, antialias: true, alpha: true });
} catch (e) {
document.getElementById("bg-fallback").style.display = "block";
canvas.style.display = "none";
return;
}
renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
const scene = new THREE.Scene();
const camera = new THREE.PerspectiveCamera(60, window.innerWidth / window.innerHeight, 0.1, 100);
camera.position.z = 5;
const colorFromTheme = (t) => new THREE.Color(t === "dark" ? "#2dd4bf" : "#0d6e64");
const PARTICLE_COUNT = 1800;
const positions = new Float32Array(PARTICLE_COUNT * 3);
for (let i = 0; i < PARTICLE_COUNT; i++) {
positions[i * 3] = (Math.random() - 0.5) * 22;
positions[i * 3 + 1] = (Math.random() - 0.5) * 22;
positions[i * 3 + 2] = (Math.random() - 0.5) * 22;
}
const pGeom = new THREE.BufferGeometry();
pGeom.setAttribute("position", new THREE.BufferAttribute(positions, 3));
const pMat = new THREE.PointsMaterial({
size: 0.04, color: colorFromTheme(theme),
transparent: true, opacity: 0.55, sizeAttenuation: true,
});
const points = new THREE.Points(pGeom, pMat);
scene.add(points);
const wireMat = new THREE.MeshBasicMaterial({
color: colorFromTheme(theme),
wireframe: true, transparent: true, opacity: 0.18,
});
const torusKnot = new THREE.Mesh(new THREE.TorusKnotGeometry(1.4, 0.38, 128, 16), wireMat);
torusKnot.position.set(-2, 0, -4); scene.add(torusKnot);
const ico = new THREE.Mesh(new THREE.IcosahedronGeometry(1.8, 0), wireMat.clone());
ico.position.set(2.6, 1.6, -6); scene.add(ico);
const torus = new THREE.Mesh(new THREE.TorusGeometry(1.1, 0.28, 16, 64), wireMat.clone());
torus.position.set(0, -2.4, -5); scene.add(torus);
window.__updateThreeTheme = (t) => {
const c = colorFromTheme(t);
pMat.color.copy(c);
torusKnot.material.color.copy(c);
ico.material.color.copy(c);
torus.material.color.copy(c);
};
function resize() {
camera.aspect = window.innerWidth / window.innerHeight;
camera.updateProjectionMatrix();
renderer.setSize(window.innerWidth, window.innerHeight, false);
}
resize();
window.addEventListener("resize", resize);
const start = performance.now();
function tick() {
const t = (performance.now() - start) / 1000;
points.rotation.y = t * 0.05;
points.rotation.x = Math.sin(t * 0.05) * 0.4;
torusKnot.rotation.x = t * 0.18;
torusKnot.rotation.y = t * 0.14;
ico.rotation.x = -t * 0.12;
ico.rotation.y = t * 0.16;
torus.rotation.x = t * 0.1;
torus.rotation.z = t * 0.08;
renderer.render(scene, camera);
requestAnimationFrame(tick);
}
tick();
canvas.addEventListener("webglcontextlost", (e) => {
e.preventDefault();
document.getElementById("bg-fallback").style.display = "block";
canvas.style.display = "none";
});
}
try { initThree(); }
catch (err) {
document.getElementById("bg-fallback").style.display = "block";
document.getElementById("bg-canvas").style.display = "none";
}
// ---------- Avatar reactivity ----------
const avatar = document.getElementById("avatar");
const leftPupil = document.getElementById("leftPupil");
const rightPupil = document.getElementById("rightPupil");
const leftHL = document.getElementById("leftHL");
const rightHL = document.getElementById("rightHL");
const mouth = document.getElementById("mouth");
const MOUTH = {
idle: "M 70 110 Q 80 112 90 110",
email: "M 70 110 Q 80 114 90 110",
password: "M 66 108 Q 80 122 94 108",
"password-visible": "M 68 109 Q 80 118 92 109",
submitting: "M 72 110 Q 80 116 88 110",
success: "M 62 106 Q 80 130 98 106",
};
const EYE_POS = {
idle: { l: { x: 60, y: 82 }, r: { x: 100, y: 82 } },
email: { l: { x: 56, y: 86 }, r: { x: 96, y: 86 } },
success: { l: { x: 60, y: 80 }, r: { x: 100, y: 80 } },
};
function setAvatarState(state) {
avatar.classList.remove("closed", "smiling", "tilted", "successful");
mouth.setAttribute("d", MOUTH[state] || MOUTH.idle);
const pos = state === "email" ? EYE_POS.email
: state === "success" ? EYE_POS.success
: EYE_POS.idle;
leftPupil.setAttribute("cx", pos.l.x); leftPupil.setAttribute("cy", pos.l.y);
rightPupil.setAttribute("cx", pos.r.x); rightPupil.setAttribute("cy", pos.r.y);
leftHL.setAttribute("cx", pos.l.x + 1.4); leftHL.setAttribute("cy", pos.l.y - 1.4);
rightHL.setAttribute("cx", pos.r.x + 1.4); rightHL.setAttribute("cy", pos.r.y - 1.4);
if (state === "email") avatar.classList.add("tilted");
else if (state === "password") avatar.classList.add("closed", "smiling");
else if (state === "password-visible") avatar.classList.add("smiling");
else if (state === "success") avatar.classList.add("smiling", "successful");
}
function passwordFocusState() {
return pwdInput.type === "text" ? "password-visible" : "password";
}
setAvatarState("idle");
// ---------- Form behavior ----------
const emailInput = document.getElementById("email");
const pwdInput = document.getElementById("password");
const emailError = document.getElementById("emailError");
const pwdError = document.getElementById("passwordError");
const form = document.getElementById("loginForm");
const submitBtn = document.getElementById("submitBtn");
const submitLabel = document.getElementById("submitLabel");
const successBanner = document.getElementById("successBanner");
let currentFocus = null;
emailInput.addEventListener("focus", () => { currentFocus = "email"; setAvatarState("email"); });
emailInput.addEventListener("blur", () => { if (currentFocus === "email") currentFocus = null; if (!currentFocus) setAvatarState("idle"); });
pwdInput.addEventListener("focus", () => { currentFocus = "password"; setAvatarState(passwordFocusState()); });
pwdInput.addEventListener("blur", () => { if (currentFocus === "password") currentFocus = null; if (!currentFocus) setAvatarState("idle"); });
function validEmail(v) { return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(v); }
emailInput.addEventListener("input", () => {
if (emailInput.value && !validEmail(emailInput.value)) emailError.textContent = "Enter a valid email address";
else emailError.textContent = "";
});
pwdInput.addEventListener("input", () => { pwdError.textContent = ""; });
// Password show/hide
const pwdToggle = document.getElementById("pwdToggle");
const pwdIconOpen = document.getElementById("pwdIconOpen");
const pwdIconOff = document.getElementById("pwdIconOff");
pwdToggle.addEventListener("click", () => {
const willShow = pwdInput.type === "password";
pwdInput.type = willShow ? "text" : "password";
pwdIconOpen.style.display = willShow ? "none" : "";
pwdIconOff.style.display = willShow ? "" : "none";
pwdToggle.setAttribute("aria-label", willShow ? "Hide password" : "Show password");
pwdInput.focus();
if (currentFocus === "password") setAvatarState(passwordFocusState());
});
// Submit
form.addEventListener("submit", async (e) => {
e.preventDefault();
let ok = true;
if (!validEmail(emailInput.value)) { emailError.textContent = "Enter a valid email address"; ok = false; }
if (pwdInput.value.length < 6) { pwdError.textContent = "Password must be at least 6 characters"; ok = false; }
if (!ok) return;
submitBtn.disabled = true;
submitLabel.textContent = "Signing in";
const sp = document.createElement("span");
sp.className = "spinner";
submitBtn.prepend(sp);
emailInput.blur(); pwdInput.blur();
setAvatarState("submitting");
await new Promise((r) => setTimeout(r, 1300));
setAvatarState("success");
submitBtn.disabled = false;
sp.remove();
submitLabel.textContent = "Sign in";
successBanner.classList.add("show");
setTimeout(() => {
successBanner.classList.remove("show");
setAvatarState("idle");
form.reset();
emailError.textContent = "";
pwdError.textContent = "";
}, 2200);
});
</script>
</body>
</html>
Comments