CodeBlanks Logo Icon CodeBlanks

Login Page

Futuristic orbital access terminal login with live password strength oscilloscope, canvas planet scene, and cinematic micro-interactions.

Fullscreen
HTML
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>AXIOM — Access Terminal</title>
<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=Space+Grotesk:wght@400;500;600;700&family=IBM+Plex+Mono:wght@400;500&display=swap" rel="stylesheet">
</head>
<body>

<main class="stage">

  <!-- ================= ORBITAL SCENE ================= -->
  <section class="scene">
    <canvas id="scene" aria-hidden="true"></canvas>
    <div class="scene-vignette" aria-hidden="true"></div>
    <div class="grain" aria-hidden="true"></div>

    <header class="scene-top">
      <div class="telemetry">
        <span class="tel"><span class="tel-k">RELAY</span><span class="tel-v">K-7 · ORBITAL</span></span>
        <span class="tel"><span class="tel-k">SIG</span><span class="tel-v" id="telSig">98.6%</span></span>
        <span class="tel"><span class="tel-k">UTC</span><span class="tel-v" id="telClock">--:--:--</span></span>
      </div>
      <p class="scene-manifest">DEEP-FIELD RELAY NETWORK<br>4,812 NODES · HANDSHAKE READY</p>
    </header>

    <footer class="scene-bottom">
      <p class="scene-caption">Every channel routes through the dark relay belt. No traces, no logs, no compromise.</p>
      <div class="scene-meta">
        <span>REV 7.4.1</span><span id="telLat">LAT 44.21°N</span><span>LON 03.14°E</span>
      </div>
    </footer>
  </section>

  <!-- ================= ACCESS TERMINAL ================= -->
  <section class="terminal" id="terminal">
    <div class="cursor-light" id="cursorLight" aria-hidden="true"></div>
    <span class="edge-note">AUTH.GATE 07 — RESTRICTED ACCESS</span>

    <header class="term-head">
      <div class="brand">
        <svg width="22" height="22" viewBox="0 0 24 24" fill="none" aria-hidden="true">
          <circle cx="12" cy="12" r="4.1" stroke="currentColor" stroke-width="1.4"/>
          <ellipse cx="12" cy="12" rx="10" ry="4.3" stroke="currentColor" stroke-width="1" opacity=".5" transform="rotate(-24 12 12)"/>
          <circle cx="21.1" cy="7.9" r="1.4" fill="#E2A44D"/>
        </svg>
        <span class="brand-word">AXIOM</span>
      </div>
      <div class="channel" id="channel"><i class="dot"></i><span id="channelText">SECURE CHANNEL</span></div>
    </header>

    <div class="term-body">
      <div class="term-inner">
        <h1 class="rise" style="--d:0">Access Terminal</h1>
        <p class="lede rise" style="--d:1">Authenticate to open a private channel with the Axiom deep-field relay network.</p>

        <form id="loginForm" novalidate>

          <!-- 01 · identifier -->
          <div class="field rise" style="--d:2" id="fId">
            <div class="field-head">
              <label for="identifier"><span class="idx">01</span>IDENTIFIER</label>
              <span class="field-msg" id="idMsg" aria-live="polite"></span>
            </div>
            <div class="field-line">
              <input id="identifier" type="text" autocomplete="username" autocapitalize="off"
                     spellcheck="false" placeholder="operator@axiom.space">
              <i class="tick tl" aria-hidden="true"></i><i class="tick br" aria-hidden="true"></i>
              <i class="rail" aria-hidden="true"></i>
            </div>
          </div>

          <!-- 02 · passphrase -->
          <div class="field rise" style="--d:3" id="fPw">
            <div class="field-head">
              <label for="password"><span class="idx">02</span>PASSPHRASE</label>
              <span class="field-msg" id="pwMsg" aria-live="polite"></span>
            </div>
            <div class="field-line">
              <input id="password" type="password" autocomplete="current-password"
                     placeholder="••••••••••••">
              <button type="button" class="eye" id="eyeBtn" aria-label="Show passphrase" aria-pressed="false">
                <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
                  <path d="M2.6 12c1.9-3.6 5.4-5.7 9.4-5.7s7.5 2.1 9.4 5.7c-1.9 3.6-5.4 5.7-9.4 5.7S4.5 15.6 2.6 12Z"/>
                  <circle cx="12" cy="12" r="2.6"/>
                  <path class="lash" d="M4.5 19.5 19.5 4.5"/>
                </svg>
              </button>
              <i class="tick tl" aria-hidden="true"></i><i class="tick br" aria-hidden="true"></i>
              <i class="rail" aria-hidden="true"></i>
            </div>

            <!-- strength instrument -->
            <div class="strength" id="strength" data-level="0">
              <div class="strength-inner">
                <div class="strength-row">
                  <div class="cells" aria-hidden="true"><i></i><i></i><i></i><i></i><i></i></div>
                  <span class="strength-label" id="strengthLabel" role="status" aria-live="polite">—</span>
                </div>
                <canvas id="wave" aria-hidden="true"></canvas>
                <div class="strength-meta">
                  <span id="mBits">EST. ENTROPY 0 BITS</span>
                  <span id="mLen">LENGTH 0</span>
                  <span id="mPool">POOL 0</span>
                </div>
                <p class="strength-tip" id="strengthTip"></p>
              </div>
            </div>
          </div>

          <div class="row-between rise" style="--d:4">
            <label class="check">
              <input type="checkbox" id="remember">
              <span class="box" aria-hidden="true">
                <svg viewBox="0 0 14 12"><path d="M2 6.5l3.5 3.5L12 2"/></svg>
              </span>
              <span class="check-label">Remember this terminal</span>
            </label>
            <button type="button" class="link" id="forgotLink">Forgot passphrase?</button>
          </div>

          <div class="rise" style="--d:5">
            <button class="btn" id="loginBtn" type="submit">
              <span class="fill" aria-hidden="true"></span>
              <span class="scan" aria-hidden="true"></span>
              <span class="content">
                <span id="btnLabel">Open secure channel</span>
                <svg class="ic arrow" viewBox="0 0 24 24" aria-hidden="true"><path d="M4 12h15M13 5.5 19.5 12 13 18.5"/></svg>
                <svg class="ic ic-check" viewBox="0 0 24 24" aria-hidden="true"><path d="M4.5 12.5 9.5 17.5 19.5 6.5"/></svg>
              </span>
            </button>
          </div>
        </form>

        <div class="term-foot rise" style="--d:6">
          <span>NO CLEARANCE YET?</span>
          <button type="button" class="link" id="requestLink" style="text-transform:uppercase;letter-spacing:.16em;font-size:9.5px">Request access</button>
        </div>
      </div>
    </div>

    <footer class="term-base">
      <span>© 2087 AXIOM DYNAMICS</span>
      <span>TLS 1.3 · X25519</span>
    </footer>
  </section>
</main>

<!-- ================= MODAL ================= -->
<div class="overlay" id="overlay" role="dialog" aria-modal="true" aria-labelledby="modalTitle" hidden>
  <div class="sheet">
    <div class="sheet-head">
      <span class="sheet-k" id="modalKicker">RECOVERY PROTOCOL</span>
      <button type="button" class="sheet-close" id="modalClose" aria-label="Close dialog">
        <svg viewBox="0 0 14 14" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round"><path d="M2 2l10 10M12 2 2 12"/></svg>
      </button>
    </div>
    <h2 id="modalTitle">Reset passphrase</h2>
    <p class="sheet-desc" id="modalDesc"></p>
    <form id="modalForm" novalidate>
      <div class="field" id="fModal">
        <div class="field-head">
          <label for="modalInput" id="modalLabel">IDENTIFIER</label>
          <span class="field-msg" id="modalMsg" aria-live="polite"></span>
        </div>
        <div class="field-line">
          <input id="modalInput" type="text" autocomplete="email" autocapitalize="off" spellcheck="false" placeholder="operator@axiom.space">
          <i class="rail" aria-hidden="true"></i>
        </div>
      </div>
      <button class="btn" id="modalBtn" type="submit">
        <span class="fill" aria-hidden="true"></span>
        <span class="content"><span id="modalBtnLabel">Send recovery signal</span></span>
      </button>
    </form>
    <div class="sheet-done" id="sheetDone" hidden>
      <svg viewBox="0 0 36 36" aria-hidden="true"><circle cx="18" cy="18" r="16"/><path d="M11.5 18.5l4.5 4.5 9-10"/></svg>
      <p id="sheetDoneText"></p>
    </div>
  </div>
</div>

<div class="toasts" id="toasts" aria-live="polite"></div>
</body>
</html>
CSS
/* ============================================================
   AXIOM ACCESS TERMINAL — design tokens
   ============================================================ */
:root{
  --bg:#08080A;
  --panel:#0B0B0E;
  --panel-2:#101014;
  --hair:rgba(255,255,255,.09);
  --hair-soft:rgba(255,255,255,.05);
  --txt:#EDEDEF;
  --txt-2:#9A9AA3;
  --txt-3:#5E5E67;
  --amber:#E2A44D;
  --amber-bright:#F2BE6B;
  --phos:#4FE0A4;
  --ember:#F45B41;
  --mono:'IBM Plex Mono',ui-monospace,monospace;
  --sans:'Space Grotesk',sans-serif;
  --ease:cubic-bezier(.22,1,.36,1);
}
*{margin:0;padding:0;box-sizing:border-box}
html{-webkit-text-size-adjust:100%}
body{
  background:var(--bg);color:var(--txt);
  font-family:var(--sans);
  -webkit-font-smoothing:antialiased;
  overflow-x:hidden;
}
::selection{background:rgba(226,164,77,.28);color:#fff}
:focus-visible{outline:1px solid var(--amber);outline-offset:3px;border-radius:2px}
button{font-family:inherit}

/* ============================================================
   STAGE LAYOUT
   ============================================================ */
.stage{
  min-height:100dvh;
  display:grid;
  grid-template-columns:1.15fr 1fr;
}

/* ============================================================
   LEFT — ORBITAL SCENE
   ============================================================ */
.scene{
  position:relative;isolation:isolate;
  background:#050507;
  overflow:hidden;
  animation:sceneIn 1.4s ease both;
}
@keyframes sceneIn{from{opacity:0}to{opacity:1}}
.scene canvas{position:absolute;inset:0;width:100%;height:100%;display:block}
.scene-vignette{
  position:absolute;inset:0;pointer-events:none;z-index:2;
  background:radial-gradient(120% 92% at 50% 28%,transparent 52%,rgba(3,3,5,.6) 100%);
}
.grain{
  position:absolute;inset:0;pointer-events:none;z-index:3;opacity:.05;mix-blend-mode:overlay;
  background-image:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='180' height='180'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.85' numOctaves='2' stitchTiles='stitch'/%3E%3CfeColorMatrix type='saturate' values='0'/%3E%3C/filter%3E%3Crect width='180' height='180' filter='url(%23n)'/%3E%3C/svg%3E");
}
.scene-top,.scene-bottom{position:absolute;left:0;right:0;z-index:4;display:flex;justify-content:space-between;gap:24px}
.scene-top{top:0;padding:clamp(20px,3vw,36px)}
.scene-bottom{bottom:0;padding:clamp(20px,3vw,36px);align-items:flex-end}
.telemetry{display:flex;flex-wrap:wrap;gap:6px 22px;font-family:var(--mono)}
.tel{display:flex;gap:8px;align-items:baseline;font-size:10px;letter-spacing:.12em;white-space:nowrap}
.tel-k{color:var(--txt-3)}
.tel-v{color:var(--txt-2)}
.scene-manifest{
  font-family:var(--mono);font-size:9.5px;letter-spacing:.2em;
  color:var(--txt-3);text-align:right;line-height:1.9;flex:none;
}
.scene-caption{
  max-width:300px;font-size:15px;line-height:1.65;color:var(--txt-2);
  letter-spacing:.01em;
}
.scene-meta{
  display:flex;gap:20px;font-family:var(--mono);font-size:9.5px;
  letter-spacing:.16em;color:var(--txt-3);flex:none;
}

/* ============================================================
   RIGHT — TERMINAL PANEL
   ============================================================ */
.terminal{
  position:relative;overflow:hidden;
  display:flex;flex-direction:column;
  border-left:1px solid var(--hair-soft);
  background:
    repeating-linear-gradient(90deg,rgba(255,255,255,.026) 0,rgba(255,255,255,.026) 1px,transparent 1px,transparent 92px),
    var(--panel);
}
.cursor-light{
  position:absolute;left:0;top:0;width:560px;height:560px;
  margin:-280px 0 0 -280px;border-radius:50%;pointer-events:none;
  opacity:0;transition:opacity .6s ease;will-change:transform;
  background:radial-gradient(circle,rgba(226,164,77,.055),rgba(226,164,77,.018) 42%,transparent 66%);
}
@media (hover:hover) and (pointer:fine){ .terminal:hover .cursor-light{opacity:1} }
.edge-note{
  position:absolute;right:16px;top:50%;transform:translateY(-50%);
  writing-mode:vertical-rl;font-family:var(--mono);font-size:9px;
  letter-spacing:.34em;color:var(--txt-3);white-space:nowrap;pointer-events:none;
}
.term-head{
  display:flex;justify-content:space-between;align-items:center;
  padding:26px clamp(24px,4.5vw,56px) 22px;
  border-bottom:1px solid var(--hair-soft);
}
.brand{display:flex;align-items:center;gap:12px;color:var(--txt)}
.brand svg{display:block;color:var(--txt)}
.brand-word{font-size:14px;font-weight:600;letter-spacing:.36em;transform:translateY(1px)}
.channel{display:flex;align-items:center;gap:8px;font-family:var(--mono);font-size:9.5px;letter-spacing:.18em;color:var(--txt-3);transition:color .4s}
.channel .dot{width:5px;height:5px;border-radius:50%;background:var(--amber);animation:softPulse 2.6s ease-in-out infinite;transition:background .4s,box-shadow .4s}
.channel.handshake{color:var(--amber)}
.channel.handshake .dot{animation:blink .55s steps(1) infinite}
.channel.open{color:var(--phos)}
.channel.open .dot{background:var(--phos);box-shadow:0 0 8px rgba(79,224,164,.9);animation:none}
@keyframes softPulse{0%,100%{opacity:1}50%{opacity:.35}}
@keyframes blink{0%,100%{opacity:1}50%{opacity:.15}}

.term-body{
  flex:1;display:flex;flex-direction:column;justify-content:center;
  padding:44px clamp(24px,4.5vw,56px);
}
.term-inner{width:min(400px,100%);margin-inline:auto}
h1{font-size:clamp(27px,2.4vw,34px);font-weight:600;letter-spacing:-.015em;line-height:1.12}
.lede{margin:13px 0 36px;font-size:14.5px;line-height:1.65;color:var(--txt-2)}

/* entrance choreography */
.rise{animation:rise .8s var(--ease) both;animation-delay:calc(140ms + var(--d)*75ms)}
@keyframes rise{from{opacity:0;transform:translateY(14px)}to{opacity:1;transform:none}}

/* ============================================================
   FIELDS
   ============================================================ */
.field + .field{margin-top:28px}
.field-head{display:flex;justify-content:space-between;align-items:baseline;gap:12px;margin-bottom:2px}
.field-head label{
  font-family:var(--mono);font-size:10px;font-weight:500;
  letter-spacing:.2em;color:var(--txt-3);cursor:pointer;
}
.field-head .idx{color:rgba(226,164,77,.65);margin-right:9px}
.field-msg{
  font-family:var(--mono);font-size:9.5px;letter-spacing:.08em;
  color:var(--ember);text-align:right;min-height:12px;
}
.field-line{
  position:relative;display:flex;align-items:center;gap:6px;
  padding:9px 0 12px;border-bottom:1px solid var(--hair);
}
.field-line input{
  flex:1;min-width:0;background:transparent;border:0;outline:none;
  color:var(--txt);font-family:var(--sans);font-size:15px;letter-spacing:.015em;
  caret-color:var(--amber);
}
.field-line input::placeholder{color:var(--txt-3);letter-spacing:.02em}
input:-webkit-autofill{
  -webkit-box-shadow:0 0 0 60px var(--panel) inset;
  -webkit-text-fill-color:var(--txt);
}
@media (pointer:coarse){.field-line input{font-size:16px}}
/* focus rail */
.rail{
  position:absolute;left:0;right:0;bottom:-1px;height:1px;background:var(--amber);
  transform:scaleX(0);transform-origin:left;transition:transform .45s var(--ease),background .3s;
}
.field-line:focus-within .rail{transform:scaleX(1)}
/* corner brackets */
.tick{position:absolute;width:8px;height:8px;opacity:0;transition:opacity .35s,transform .35s var(--ease);pointer-events:none}
.tick.tl{top:3px;left:-15px;border-left:1px solid rgba(226,164,77,.7);border-top:1px solid rgba(226,164,77,.7);transform:translate(4px,4px)}
.tick.br{bottom:11px;right:-15px;border-right:1px solid rgba(226,164,77,.7);border-bottom:1px solid rgba(226,164,77,.7);transform:translate(-4px,-4px)}
.field-line:focus-within .tick{opacity:1;transform:none}
/* error state */
.field.err .rail{background:var(--ember);transform:scaleX(1)}
.field.err .field-head label{color:var(--txt-2)}
.field.err.shake .field-line{animation:shake .3s ease}
@keyframes shake{25%{transform:translateX(-3px)}55%{transform:translateX(3px)}85%{transform:translateX(-1px)}}

/* eye toggle */
.eye{
  flex:none;width:32px;height:32px;display:grid;place-items:center;
  background:none;border:0;color:var(--txt-3);cursor:pointer;
  transition:color .25s;border-radius:2px;
}
.eye:hover{color:var(--amber-bright)}
.eye svg{width:19px;height:19px;overflow:visible}
.eye .lash{stroke-dasharray:23;stroke-dashoffset:23;transition:stroke-dashoffset .3s var(--ease)}
.eye circle{transition:r .3s var(--ease)}
.eye.revealed .lash{stroke-dashoffset:0}
.eye.revealed circle{r:1.7px}

/* ============================================================
   STRENGTH INSTRUMENT
   ============================================================ */
.strength{
  max-height:0;opacity:0;overflow:hidden;transform:translateY(-5px);
  transition:max-height .5s var(--ease),opacity .4s ease,transform .5s var(--ease);
}
.strength.on{max-height:230px;opacity:1;transform:none}
.strength-inner{padding-top:16px}
.strength[data-level="1"]{--lvl:#F45B41}
.strength[data-level="2"]{--lvl:#F58A3C}
.strength[data-level="3"]{--lvl:#E7B34C}
.strength[data-level="4"]{--lvl:#B4DE6A}
.strength[data-level="5"]{--lvl:#4FE0A4}
.strength-row{display:flex;align-items:center;gap:14px}
.cells{display:flex;gap:5px;flex:1}
.cells i{
  flex:1;height:4px;position:relative;overflow:hidden;
  background:rgba(255,255,255,.09);
  transition:background .4s,box-shadow .4s;
}
.cells i.filled{
  background:var(--lvl);
  box-shadow:0 0 9px color-mix(in srgb,var(--lvl) 45%,transparent);
}
.cells i.filled::after{
  content:'';position:absolute;inset:0;
  background:linear-gradient(105deg,transparent 25%,rgba(255,255,255,.4) 50%,transparent 75%);
  transform:translateX(-130%);
  animation:sheen 3.2s var(--ease) infinite;
  animation-delay:inherit;
}
.cells i:nth-child(2).filled::after{animation-delay:.18s}
.cells i:nth-child(3).filled::after{animation-delay:.36s}
.cells i:nth-child(4).filled::after{animation-delay:.54s}
.cells i:nth-child(5).filled::after{animation-delay:.72s}
@keyframes sheen{0%{transform:translateX(-130%)}45%,100%{transform:translateX(130%)}}
.strength-label{
  flex:none;min-width:88px;text-align:right;
  font-family:var(--mono);font-size:10px;font-weight:500;
  letter-spacing:.14em;color:var(--lvl);transition:color .4s;
}
.strength-label.swap{animation:labelSwap .35s var(--ease)}
@keyframes labelSwap{from{opacity:0;transform:translateY(5px)}}
#wave{display:block;width:100%;height:30px;margin:12px 0 9px}
.strength-meta{display:flex;flex-wrap:wrap;gap:6px 16px;font-family:var(--mono);font-size:9.5px;letter-spacing:.1em;color:var(--txt-3)}
.strength-tip{margin-top:8px;font-family:var(--mono);font-size:9.5px;letter-spacing:.12em;color:var(--lvl);transition:color .4s}

/* ============================================================
   ROW / CHECKBOX / LINKS
   ============================================================ */
.row-between{display:flex;justify-content:space-between;align-items:center;margin:26px 0 22px}
.check{display:flex;align-items:center;gap:10px;cursor:pointer;user-select:none;position:relative}
.check input{position:absolute;opacity:0;width:1px;height:1px}
.check .box{
  width:15px;height:15px;border:1px solid rgba(255,255,255,.25);border-radius:2px;
  display:grid;place-items:center;
  transition:border-color .25s,background .25s;
}
.check .box svg{width:10px;height:9px;overflow:visible}
.check .box path{
  stroke:var(--amber);stroke-width:2;fill:none;stroke-linecap:round;stroke-linejoin:round;
  stroke-dasharray:18;stroke-dashoffset:18;transition:stroke-dashoffset .28s var(--ease) .05s;
}
.check input:checked + .box{border-color:var(--amber);background:rgba(226,164,77,.08)}
.check input:checked + .box path{stroke-dashoffset:0}
.check input:focus-visible + .box{outline:1px solid var(--amber);outline-offset:2px}
.check-label{font-family:var(--mono);font-size:11px;letter-spacing:.05em;color:var(--txt-2)}
.link{
  background:none;border:0;padding:0;cursor:pointer;
  font-family:var(--mono);font-size:11px;font-weight:500;letter-spacing:.05em;
  color:var(--txt-2);text-decoration:underline;
  text-decoration-color:rgba(255,255,255,.25);text-underline-offset:4px;
  transition:color .25s,text-decoration-color .25s;
}
.link:hover{color:var(--amber-bright);text-decoration-color:var(--amber)}

/* ============================================================
   LOGIN BUTTON
   ============================================================ */
.btn{
  position:relative;display:flex;justify-content:center;align-items:center;
  width:100%;height:54px;border-radius:2px;overflow:hidden;
  border:1px solid var(--amber);background:transparent;color:var(--amber);
  font-family:var(--mono);font-size:12px;font-weight:500;
  letter-spacing:.22em;text-transform:uppercase;cursor:pointer;
  transition:color .35s,border-color .35s,transform .15s;
}
.btn .fill{
  position:absolute;inset:0;background:var(--amber);
  transform:scaleX(0);transform-origin:left;transition:transform .5s var(--ease);
}
.btn:hover:not(.loading):not(.success){color:#0B0B0D}
.btn:hover:not(.loading):not(.success) .fill{transform:scaleX(1)}
.btn:active:not(.loading):not(.success){transform:translateY(1px)}
.btn .content{position:relative;z-index:1;display:flex;align-items:center;gap:11px}
.btn .ic{width:15px;height:15px;overflow:visible}
.btn .ic path{stroke:currentColor;stroke-width:1.9;fill:none;stroke-linecap:round;stroke-linejoin:round}
.btn .arrow{transition:transform .35s var(--ease)}
.btn:hover .arrow{transform:translateX(4px)}
.btn .scan{position:absolute;bottom:0;left:0;height:1px;width:32%;background:var(--amber);opacity:0}
.btn.loading{pointer-events:none;color:var(--txt-2);border-color:rgba(226,164,77,.4)}
.btn.loading .arrow{display:none}
.btn.loading .scan{opacity:.85;animation:scan 1.05s cubic-bezier(.45,0,.55,1) infinite}
@keyframes scan{from{transform:translateX(-120%)}to{transform:translateX(430%)}}
.btn.success{pointer-events:none;border-color:var(--phos);color:var(--phos)}
.btn.success .arrow{display:none}
.btn .ic-check{opacity:0;transition:opacity .3s}
.btn.success .ic-check{opacity:1}
.btn .ic-check path{stroke-dasharray:24;stroke-dashoffset:24;transition:stroke-dashoffset .45s var(--ease) .1s}
.btn.success .ic-check path{stroke-dashoffset:0}

.term-foot{
  margin-top:34px;padding-top:20px;border-top:1px solid var(--hair-soft);
  display:flex;align-items:center;gap:14px;
  font-family:var(--mono);font-size:9.5px;letter-spacing:.2em;color:var(--txt-3);
}
.term-base{
  display:flex;justify-content:space-between;gap:16px;
  padding:20px clamp(24px,4.5vw,56px) 26px;
  border-top:1px solid var(--hair-soft);
  font-family:var(--mono);font-size:9px;letter-spacing:.18em;color:var(--txt-3);
}

/* ============================================================
   MODAL
   ============================================================ */
.overlay{
  position:fixed;inset:0;z-index:60;display:flex;align-items:center;justify-content:center;
  padding:20px;background:rgba(5,5,7,.74);backdrop-filter:blur(7px);
  -webkit-backdrop-filter:blur(7px);
  opacity:0;transition:opacity .3s ease;
}
.overlay.open{opacity:1}
.sheet{
  width:min(432px,100%);position:relative;
  background:var(--panel-2);border:1px solid var(--hair);border-radius:3px;
  padding:30px 30px 34px;
  transform:translateY(16px) scale(.985);transition:transform .4s var(--ease);
}
.overlay.open .sheet{transform:none}
.sheet::before,.sheet::after{content:'';position:absolute;width:12px;height:12px;pointer-events:none}
.sheet::before{top:-5px;left:-5px;border-left:1px solid rgba(226,164,77,.55);border-top:1px solid rgba(226,164,77,.55)}
.sheet::after{bottom:-5px;right:-5px;border-right:1px solid rgba(226,164,77,.55);border-bottom:1px solid rgba(226,164,77,.55)}
.sheet-head{display:flex;justify-content:space-between;align-items:center;margin-bottom:14px}
.sheet-k{font-family:var(--mono);font-size:9.5px;letter-spacing:.26em;color:var(--amber)}
.sheet-close{
  width:30px;height:30px;display:grid;place-items:center;background:none;
  border:0;color:var(--txt-3);cursor:pointer;transition:color .25s;border-radius:2px;
}
.sheet-close:hover{color:var(--txt)}
.sheet-close svg{width:14px;height:14px}
.sheet h2{font-size:22px;font-weight:600;letter-spacing:-.01em}
.sheet-desc{margin:10px 0 26px;font-size:13.5px;line-height:1.65;color:var(--txt-2)}
.sheet .btn{height:48px;margin-top:24px}
.sheet-done{display:flex;gap:14px;align-items:flex-start;padding:8px 0 4px}
.sheet-done svg{flex:none;width:34px;height:34px;overflow:visible}
.sheet-done circle{stroke:var(--phos);stroke-width:1.4;fill:none;stroke-dasharray:64;stroke-dashoffset:64;animation:draw .6s var(--ease) forwards}
.sheet-done path{stroke:var(--phos);stroke-width:1.9;fill:none;stroke-linecap:round;stroke-linejoin:round;stroke-dasharray:24;stroke-dashoffset:24;animation:draw .4s var(--ease) .35s forwards}
@keyframes draw{to{stroke-dashoffset:0}}
.sheet-done p{font-size:13.5px;line-height:1.65;color:var(--txt-2);padding-top:4px}
[hidden]{display:none !important}

/* ============================================================
   TOASTS
   ============================================================ */
.toasts{
  position:fixed;right:22px;bottom:22px;z-index:80;
  display:flex;flex-direction:column;gap:10px;pointer-events:none;
}
.toast{
  pointer-events:auto;display:flex;align-items:stretch;gap:12px;
  min-width:270px;max-width:370px;
  background:#121217;border:1px solid var(--hair);border-radius:2px;
  padding:12px 12px 12px 14px;
  animation:toastIn .45s var(--ease);
}
.toast.out{animation:toastOut .3s ease forwards}
@keyframes toastIn{from{opacity:0;transform:translateY(10px)}}
@keyframes toastOut{to{opacity:0;transform:translateY(8px)}}
.toast .bar{flex:none;width:2px;background:currentColor}
.toast.ok{color:var(--phos)}
.toast.err{color:var(--ember)}
.toast.info{color:var(--amber)}
.toast p{flex:1;font-family:var(--mono);font-size:11px;line-height:1.55;letter-spacing:.05em;color:var(--txt)}
.t-close{background:none;border:0;padding:0 2px;cursor:pointer;color:var(--txt-3);font-family:var(--mono);font-size:14px;line-height:1.4}
.t-close:hover{color:var(--txt)}

/* ============================================================
   RESPONSIVE
   ============================================================ */
@media (max-width:1280px){ .edge-note{display:none} }
@media (max-width:1020px){
  .stage{grid-template-columns:.9fr 1.1fr}
  .scene-manifest{display:none}
}
@media (max-width:760px){
  .stage{display:block}
  .scene{height:clamp(280px,42vh,380px)}
  .scene-caption{display:none}
  .scene-top{padding:18px 20px}
  .scene-bottom{padding:16px 20px}
  .telemetry{gap:5px 16px}
  .term-head{padding:20px}
  .term-body{padding:36px 22px 30px;justify-content:flex-start}
  .term-base{padding:16px 20px 22px;flex-wrap:wrap;gap:8px 18px}
  .toasts{left:16px;right:16px;bottom:16px}
  .toast{max-width:none;min-width:0}
}
@media (prefers-reduced-motion:reduce){
  *,*::before,*::after{
    animation-duration:.01ms !important;
    animation-iteration-count:1 !important;
    transition-duration:.15s !important;
  }
  .rise,.scene{animation:none}
}
JavaScript
(() => {
'use strict';
/* ============================ utils ============================ */
const $  = (s, c = document) => c.querySelector(s);
const $$ = (s, c = document) => [...c.querySelectorAll(s)];
const clamp = (v, a, b) => Math.min(b, Math.max(a, v));
const lerp  = (a, b, t) => a + (b - a) * t;
const REDUCED = matchMedia('(prefers-reduced-motion: reduce)').matches;

/* ============================ toasts ============================ */
const Toast = (() => {
  const host = $('#toasts');
  function show(type, msg) {
    const el = document.createElement('div');
    el.className = 'toast ' + type;
    el.innerHTML = `<span class="bar"></span><p></p><button class="t-close" aria-label="Dismiss">×</button>`;
    el.querySelector('p').textContent = msg;
    host.appendChild(el);
    const kill = () => { el.classList.add('out'); setTimeout(() => el.remove(), 320); };
    el.querySelector('.t-close').addEventListener('click', kill);
    setTimeout(kill, 4200);
  }
  return { ok: m => show('ok', m), err: m => show('err', m), info: m => show('info', m) };
})();

/* ============================ password engine ============================ */
const COMMON = new Set(['password','123456','12345678','123456789','1234567890','qwerty',
  'qwerty123','abc123','password1','password123','iloveyou','admin','admin123','welcome',
  'monkey','dragon','letmein','letmein1','111111','000000','master','sunshine','princess',
  'football','baseball','shadow','superman','batman','trustno1','whatever','freedom',
  'passw0rd','p@ssw0rd','changeme','default','guest','root','toor','starwars','hello123',
  'zaq12wsx','1q2w3e4r','qwe123','654321','123123','121212','666666','888888','abcdef']);
const ROOTS = ['password','qwerty','letmein','welcome','iloveyou','admin','master','monkey',
  'dragon','sunshine','princess','football','baseball','superman','trustno','starwars',
  'whatever','freedom','changeme','access','login'];

function analyze(pw) {
  const len = pw.length;
  if (!len) return { len: 0, bits: 0, pool: 0, score: 0, level: 0 };

  const lower  = /[a-z]/.test(pw), upper = /[A-Z]/.test(pw);
  const digit  = /[0-9]/.test(pw), sym   = /[^A-Za-z0-9]/.test(pw);
  const classes = lower + upper + digit + sym;
  const pool = (lower ? 26 : 0) + (upper ? 26 : 0) + (digit ? 10 : 0) + (sym ? 32 : 0);
  const unique = new Set(pw).size;
  const bits = Math.round(len * Math.log2(Math.max(pool, 2)) * (unique / len));

  let score = 0;
  score += Math.min(len, 16) / 16 * 34;              // length (max 34)
  score += classes * 8;                              // character classes (max 32)
  score += unique / len * 14;                        // diversity (max 14)
  score -= Math.min(len - unique, len * .4) * 1.2;   // repeated characters
  let seq = 0;                                       // sequential runs (abc / 321)
  for (let i = 2; i < len; i++) {
    const a = pw.charCodeAt(i - 2), b = pw.charCodeAt(i - 1), c = pw.charCodeAt(i);
    if ((b === a + 1 && c === b + 1) || (b === a - 1 && c === b - 1)) seq++;
  }
  score -= seq * 3;
  let rep = 0;                                       // immediate doubles (aa, 11)
  for (let i = 1; i < len; i++) if (pw[i] === pw[i - 1]) rep++;
  score -= rep * 2.5;

  const norm = pw.toLowerCase();
  if (COMMON.has(norm)) score = Math.min(score, 6);
  else if (len >= 6) for (const w of ROOTS) if (norm.includes(w)) { score = Math.min(score, 22); break; }

  score = clamp(Math.round(score), 0, 100);
  const level = score <= 20 ? 1 : score <= 40 ? 2 : score <= 62 ? 3 : score <= 82 ? 4 : 5;
  return { len, bits, pool, score, level };
}

const LEVELS = [null,
  { name: 'VERY WEAK',   rgb: [244, 91, 65],   tip: 'EASILY COMPROMISED — AVOID COMMON PATTERNS' },
  { name: 'WEAK',        rgb: [245, 138, 60],  tip: 'ADD LENGTH AND CHARACTER VARIETY' },
  { name: 'MEDIUM',      rgb: [231, 179, 76],  tip: 'ADEQUATE — SYMBOLS WOULD HARDEN IT' },
  { name: 'STRONG',      rgb: [180, 222, 106], tip: 'RESISTANT TO BRUTE-FORCE ATTACKS' },
  { name: 'VERY STRONG', rgb: [79, 224, 164],  tip: 'EXEMPLARY — SUITABLE FOR ROOT ACCESS' }];

/* ============================ entropy oscilloscope ============================ */
const Wave = (() => {
  const cv = $('#wave'), ctx = cv.getContext('2d');
  let W = 0, H = 0, hash = 0;
  let amp = 2, ampT = 2, spd = .5, spdT = .5;
  let col = [154, 154, 163], colT = [154, 154, 163];
  let f1 = 1.2, f2 = 2.7, f3 = 4.3, ph1 = 0, ph2 = 0, ph3 = 0;

  function resize() {
    const r = cv.getBoundingClientRect();
    if (!r.width) return;
    const dpr = Math.min(devicePixelRatio || 1, 2);
    W = r.width; H = r.height;
    cv.width = W * dpr; cv.height = H * dpr;
    ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
  }
  new ResizeObserver(resize).observe(cv);

  function setSignal(pw, score, rgb) {
    colT = rgb;
    ampT = 3 + (score / 100) * (H ? H * .5 - 4 : 10);
    spdT = .5 + (score / 100) * 2.6;
    let h = 2166136261;                             // FNV-1a — deterministic per password
    for (let i = 0; i < pw.length; i++) { h ^= pw.charCodeAt(i); h = Math.imul(h, 16777619) >>> 0; }
    hash = h >>> 0;
    f1 = 1 + (hash % 260) / 100;
    f2 = 2 + ((hash >>> 7) % 420) / 100;
    f3 = 3 + ((hash >>> 15) % 700) / 100;
    if (REDUCED) { amp = ampT; spd = 0; col = [...colT]; }
  }

  function draw(dt) {
    if (!W) { resize(); if (!W) return; }
    const k = 1 - Math.exp(-dt / 220);
    amp = lerp(amp, ampT, k); spd = lerp(spd, spdT, k);
    col = col.map((c, i) => lerp(c, colT[i], k));
    ph1 += dt * .0012 * spd; ph2 += dt * .0017 * spd; ph3 += dt * .0023 * spd;

    ctx.clearRect(0, 0, W, H);
    ctx.strokeStyle = 'rgba(255,255,255,.07)'; ctx.lineWidth = 1;
    ctx.beginPath(); ctx.moveTo(0, H / 2 + .5); ctx.lineTo(W, H / 2 + .5); ctx.stroke();

    const c = `rgb(${col[0] | 0},${col[1] | 0},${col[2] | 0})`;
    ctx.beginPath();
    for (let x = 0; x <= W; x += 2) {
      const n = x / W;
      let y = Math.sin(n * Math.PI * 2 * f1 + ph1) * .55
            + Math.sin(n * Math.PI * 2 * f2 + ph2) * .30
            + Math.sin(n * Math.PI * 2 * f3 + ph3) * .15
            + Math.sin(x * .55 + (hash % 97)) * .1 * (amp / (H / 2));
      const py = H / 2 + y * amp;
      x === 0 ? ctx.moveTo(x, py) : ctx.lineTo(x, py);
    }
    ctx.strokeStyle = c; ctx.lineWidth = 1.4;
    ctx.shadowColor = c; ctx.shadowBlur = 4 + amp * .14;
    ctx.stroke(); ctx.shadowBlur = 0;
  }
  return { draw, setSignal };
})();

/* ============================ strength UI ============================ */
const Strength = (() => {
  const block = $('#strength'), cells = $$('.cells i', block);
  const labelEl = $('#strengthLabel'), tipEl = $('#strengthTip');
  const mBits = $('#mBits'), mLen = $('#mLen'), mPool = $('#mPool');

  function setLabel(txt) {
    if (labelEl.textContent === txt) return;
    labelEl.textContent = txt;
    labelEl.classList.remove('swap'); void labelEl.offsetWidth;
    labelEl.classList.add('swap');
  }
  function update(pw) {
    const r = analyze(pw);
    block.classList.toggle('on', r.len > 0);
    if (!r.len) { Wave.setSignal('', 0, [154, 154, 163]); return r; }
    block.dataset.level = r.level;
    const L = LEVELS[r.level];
    cells.forEach((c, i) => c.classList.toggle('filled', i < r.level));
    setLabel(L.name);
    tipEl.textContent = L.tip;
    mBits.textContent = `EST. ENTROPY ${r.bits} BITS`;
    mLen.textContent  = `LENGTH ${r.len}`;
    mPool.textContent = `POOL ${r.pool}`;
    Wave.setSignal(pw, r.score, L.rgb);
    if (REDUCED) Wave.draw(0);
    return r;
  }
  return { update };
})();

/* ============================ orbital scene ============================ */
const Scene = (() => {
  const cv = $('#scene'), ctx = cv.getContext('2d');
  let W = 0, H = 0, stars = [], dust = [], meteors = [], pulses = [], atmoBoost = 0;
  let m = { x: .5, y: .5 }, mt = { x: .5, y: .5 };
  let nextMeteor = 4500 + Math.random() * 7000;
  const sats = [{ a: Math.random() * 6.28, v: .00006 }, { a: Math.random() * 6.28, v: .00010 }];

  const sprite = document.createElement('canvas');   // soft round star sprite
  sprite.width = sprite.height = 32;
  (() => {
    const s = sprite.getContext('2d');
    const g = s.createRadialGradient(16, 16, 0, 16, 16, 16);
    g.addColorStop(0, 'rgba(255,255,255,1)');
    g.addColorStop(.35, 'rgba(255,255,255,.45)');
    g.addColorStop(1, 'rgba(255,255,255,0)');
    s.fillStyle = g; s.fillRect(0, 0, 32, 32);
  })();

  function resize() {
    const r = cv.getBoundingClientRect();
    W = r.width; H = r.height;
    const dpr = Math.min(devicePixelRatio || 1, 2);
    cv.width = W * dpr; cv.height = H * dpr;
    ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
    const n = Math.round(W * H / 5200);
    stars = Array.from({ length: n }, () => ({
      x: Math.random(), y: Math.random(), z: .25 + Math.random() * .75,
      tw: Math.random() * 6.28, ts: .4 + Math.random() * 1.6 }));
    dust = Array.from({ length: Math.max(12, n / 5 | 0) }, () => ({
      x: Math.random(), y: Math.random(),
      vx: (Math.random() - .5) * .006, vy: -.002 - Math.random() * .008,
      r: .4 + Math.random() * 1.2, a: .06 + Math.random() * .16 }));
  }
  addEventListener('resize', resize); resize();

  addEventListener('pointermove', e => {
    mt.x = e.clientX / innerWidth; mt.y = e.clientY / innerHeight;
  });

  const geo = () => {
    const R = Math.max(W, H) * .72;
    return { R, cx: W * .40 + (m.x - .5) * 26, cy: H + R * .64 + (m.y - .5) * 16 };
  };
  const ring = (cx, cy, rx, ry, rot, a) => {
    ctx.beginPath(); ctx.ellipse(cx, cy, rx, ry, rot, 0, 6.2832);
    ctx.strokeStyle = `rgba(214,218,228,${a})`; ctx.lineWidth = 1; ctx.stroke();
  };
  const satPos = (s, rx, ry, rot, cx, cy) => {
    s.a += s.v;
    const ca = Math.cos(s.a), sa = Math.sin(s.a);
    const cr = Math.cos(rot), sr = Math.sin(rot);
    return { x: cx + rx * ca * cr - ry * sa * sr,
             y: cy + rx * ca * sr + ry * sa * cr,
             front: rx * ca * sr + ry * sa * cr > 0 };
  };
  const satDraw = (p, cx, cy, rx, ry, rot) => {
    ctx.beginPath(); ctx.ellipse(cx, cy, rx, ry, rot, p.a - .5, p.a);
    ctx.strokeStyle = 'rgba(226,164,77,.20)'; ctx.lineWidth = 1; ctx.stroke();
    ctx.save(); ctx.shadowColor = 'rgba(242,190,107,.9)'; ctx.shadowBlur = 9;
    ctx.fillStyle = 'rgba(244,196,116,.95)';
    ctx.beginPath(); ctx.arc(p.x, p.y, 2, 0, 6.2832); ctx.fill(); ctx.restore();
  };

  function draw(dt, now) {
    const t = now * .001;
    const k = 1 - Math.exp(-dt / 260);
    m.x = lerp(m.x, mt.x, k); m.y = lerp(m.y, mt.y, k);
    ctx.clearRect(0, 0, W, H);
    const { R, cx, cy } = geo();
    const rot1 = -.38, rx1 = R * 1.55, ry1 = R * .30;
    const rot2 = -.28, rx2 = R * 1.22, ry2 = R * .22;
    sats[0].v && 0; // velocities used below
    const p1 = satPos({ ...sats[0], a: sats[0].a }, rx1, ry1, rot1, cx, cy); sats[0].a += 0;
    // advance angles properly
    if (!REDUCED) { sats[0].a += 0; }
    // (angles advance inside satPos via s.v — handled above/below)

    /* stars */
    for (const s of stars) {
      const px = s.x * W + (m.x - .5) * -18 * s.z;
      const py = s.y * H + (m.y - .5) * -12 * s.z;
      const tw = REDUCED ? .8 : .45 + .55 * Math.sin(t * s.ts + s.tw);
      const sz = s.z * 1.5 + .4;
      ctx.globalAlpha = (0.14 + .5 * s.z) * tw;
      ctx.drawImage(sprite, px - sz, py - sz, sz * 2, sz * 2);
    }
    ctx.globalAlpha = 1;

    /* dust */
    for (const d of dust) {
      if (!REDUCED) { d.x += d.vx * dt * .06; d.y += d.vy * dt * .06; }
      if (d.y < -.05) { d.y = 1.05; d.x = Math.random(); }
      if (d.x < -.05) d.x = 1.05; if (d.x > 1.05) d.x = -.05;
      ctx.fillStyle = `rgba(226,180,120,${d.a})`;
      ctx.beginPath(); ctx.arc(d.x * W, d.y * H, d.r, 0, 6.2832); ctx.fill();
    }

    /* meteors */
    if (!REDUCED) {
      nextMeteor -= dt;
      if (nextMeteor <= 0 && W > 480) {
        nextMeteor = 6500 + Math.random() * 9000;
        meteors.push({ x: W * (.15 + Math.random() * .7), y: -20,
          vx: (Math.random() < .5 ? -1 : 1) * (.22 + Math.random() * .25),
          vy: .18 + Math.random() * .16, life: 1 });
      }
      meteors = meteors.filter(mm => mm.life > 0);
      for (const mm of meteors) {
        mm.x += mm.vx * dt; mm.y += mm.vy * dt; mm.life -= dt * .0009;
        const g = ctx.createLinearGradient(mm.x, mm.y, mm.x - mm.vx * 60, mm.y - mm.vy * 60);
        g.addColorStop(0, `rgba(235,240,250,${.75 * mm.life})`);
        g.addColorStop(1, 'rgba(235,240,250,0)');
        ctx.strokeStyle = g; ctx.lineWidth = 1.1;
        ctx.beginPath(); ctx.moveTo(mm.x, mm.y);
        ctx.lineTo(mm.x - mm.vx * 60, mm.y - mm.vy * 60); ctx.stroke();
      }
    }

    /* rings + satellites (behind planet pass) */
    ring(cx, cy, rx1, ry1, rot1, .07);
    ring(cx, cy, rx2, ry2, rot2, .05);
    const pos1 = { ...p1 }; const pos2 = satPos(sats[1], rx2, ry2, rot2, cx, cy);
    satDraw(pos1, cx, cy, rx1, ry1, rot1);
    satDraw(pos2, cx, cy, rx2, ry2, rot2);

    /* planet body */
    ctx.beginPath(); ctx.arc(cx, cy, R, 0, 6.2832);
    const bg = ctx.createRadialGradient(cx - (m.x - .5) * R * .3, cy - R * .45, R * .1, cx, cy, R);
    bg.addColorStop(0, '#101117'); bg.addColorStop(.55, '#0B0B0F'); bg.addColorStop(1, '#070709');
    ctx.fillStyle = bg; ctx.fill();

    /* atmosphere halo */
    const ga = .10 + atmoBoost * .28;
    const glow = ctx.createRadialGradient(cx, cy, R * .985, cx, cy, R * 1.15);
    glow.addColorStop(0, 'rgba(226,164,77,0)');
    glow.addColorStop(.12, `rgba(226,164,77,${ga})`);
    glow.addColorStop(1, 'rgba(226,164,77,0)');
    ctx.beginPath(); ctx.arc(cx, cy, R * 1.15, 0, 6.2832); ctx.fillStyle = glow; ctx.fill();

    /* rim light — warm key toward the light source */
    const la = Math.atan2(-H * .35 - cy, W * (.5 + (m.x - .5) * .3) - cx);
    ctx.save();
    ctx.beginPath(); ctx.arc(cx, cy, R + .5, la - 1.15, la + 1.15);
    ctx.strokeStyle = `rgba(242,190,107,${.7 + atmoBoost * .3})`;
    ctx.lineWidth = 1.6; ctx.shadowColor = 'rgba(232,170,80,.9)'; ctx.shadowBlur = 14 + atmoBoost * 24;
    ctx.stroke();
    ctx.beginPath(); ctx.arc(cx, cy, R + 1, la - 1.6, la + 1.6);
    ctx.strokeStyle = `rgba(226,164,77,${.26 + atmoBoost * .4})`;
    ctx.lineWidth = 4; ctx.shadowBlur = 26; ctx.stroke();
    /* faint cool rim on the night side */
    ctx.beginPath(); ctx.arc(cx, cy, R, la + Math.PI - .8, la + Math.PI + .8);
    ctx.strokeStyle = 'rgba(150,180,210,.10)'; ctx.lineWidth = 1; ctx.shadowBlur = 0; ctx.stroke();
    ctx.restore();

    /* settlement beacon on the limb */
    const ba = -Math.PI / 2 - .5;
    const bl = REDUCED ? .5 : .2 + .8 * Math.pow(Math.max(0, Math.sin(t * 1.6)), 4);
    ctx.save(); ctx.shadowColor = 'rgba(79,224,164,.9)'; ctx.shadowBlur = 8;
    ctx.fillStyle = `rgba(79,224,164,${bl})`;
    ctx.beginPath();
    ctx.arc(cx + Math.cos(ba) * R * 1.005, cy + Math.sin(ba) * R * 1.005, 1.6, 0, 6.2832);
    ctx.fill(); ctx.restore();

    /* auth-success pulse rings */
    for (const p of pulses) {
      p.delay -= dt; if (p.delay > 0) continue;
      p.r += dt * .28; p.a -= dt * .00075;
      if (p.a <= 0) continue;
      ctx.beginPath(); ctx.arc(cx, cy, p.r, Math.PI, Math.PI * 2);
      ctx.strokeStyle = `rgba(242,190,107,${p.a})`;
      ctx.lineWidth = 1.2; ctx.shadowColor = 'rgba(226,164,77,.7)'; ctx.shadowBlur = 10;
      ctx.stroke(); ctx.shadowBlur = 0;
    }
    pulses = pulses.filter(p => p.a > 0);
    atmoBoost = Math.max(0, atmoBoost - dt * .0005);

    /* front satellite re-draw */
    if (pos1.front) satDraw(pos1, cx, cy, rx1, ry1, rot1);
    if (pos2.front) satDraw(pos2, cx, cy, rx2, ry2, rot2);
  }

  function pulse() {
    const { R } = geo();
    pulses.push({ r: R + 4, a: .55, delay: 0 }, { r: R + 4, a: .4, delay: 240 });
    atmoBoost = 1;
  }
  return { draw, pulse };
})();

/* ============================ cursor light ============================ */
const Light = (() => {
  const el = $('#cursorLight'), term = $('#terminal');
  let x = 0, y = 0, tx = 0, ty = 0, active = false;
  if (matchMedia('(hover:hover) and (pointer:fine)').matches && !REDUCED) {
    active = true;
    addEventListener('pointermove', e => {
      const r = term.getBoundingClientRect();
      tx = e.clientX - r.left; ty = e.clientY - r.top;
    });
  } else el.style.display = 'none';
  function update(dt) {
    if (!active) return;
    const k = 1 - Math.exp(-dt / 170);
    x = lerp(x, tx, k); y = lerp(y, ty, k);
    el.style.transform = `translate3d(${x}px,${y}px,0)`;
  }
  return { update };
})();

/* ============================ telemetry ============================ */
(() => {
  const clock = $('#telClock'), sig = $('#telSig'), lat = $('#telLat');
  const tick = () => clock.textContent = new Date().toISOString().slice(11, 19);
  tick(); setInterval(tick, 1000);
  setInterval(() => sig.textContent = (97 + Math.random() * 2.9).toFixed(1) + '%', 2400);
  let v = 44.21;
  setInterval(() => { v = (v + .0001) % 90; lat.textContent = `LAT ${v.toFixed(2)}°N`; }, 1000);
})();

/* ============================ modal ============================ */
const Modal = (() => {
  const overlay = $('#overlay'), form = $('#modalForm'), input = $('#modalInput');
  const msg = $('#modalMsg'), field = $('#fModal'), done = $('#sheetDone');
  const kicker = $('#modalKicker'), title = $('#modalTitle'), desc = $('#modalDesc');
  const mLabel = $('#modalLabel'), btnL = $('#modalBtnLabel'), doneT = $('#sheetDoneText');
  const V = {
    forgot: { k: 'RECOVERY PROTOCOL', t: 'Reset passphrase',
      d: 'Enter the identifier tied to your clearance. We will dispatch a recovery signal to your relay inbox.',
      btn: 'Send recovery signal', toast: 'RECOVERY SIGNAL DISPATCHED',
      done: 'Recovery signal dispatched. Check your relay inbox for further instructions.' },
    request: { k: 'CLEARANCE REQUEST', t: 'Request access',
      d: 'Axiom is invitation-first. Leave your identifier and we will queue your request for review.',
      btn: 'Submit request', toast: 'REQUEST QUEUED — AWAITING REVIEW',
      done: 'Request queued. Clearance review typically completes within 48 hours.' }
  };
  let trigger = null, v = V.forgot, closer = null;

  function open(kind, el) {
    v = V[kind]; trigger = el;
    kicker.textContent = v.k; title.textContent = v.t; desc.textContent = v.d;
    btnL.textContent = v.btn; doneT.textContent = v.done;
    form.reset(); form.hidden = false; done.hidden = true;
    msg.textContent = ''; field.classList.remove('err');
    clearTimeout(closer);
    overlay.hidden = false;
    requestAnimationFrame(() => overlay.classList.add('open'));
    setTimeout(() => input.focus(), 90);
    document.addEventListener('keydown', onKey);
  }
  function close() {
    overlay.classList.remove('open');
    document.removeEventListener('keydown', onKey);
    clearTimeout(closer);
    setTimeout(() => { overlay.hidden = true; if (trigger) trigger.focus(); }, 320);
  }
  const onKey = e => { if (e.key === 'Escape') close(); };
  $('#modalClose').addEventListener('click', close);
  overlay.addEventListener('click', e => { if (e.target === overlay) close(); });
  form.addEventListener('submit', e => {
    e.preventDefault();
    const val = input.value.trim();
    if (!/^[^\s@]+@[^\s@]+\.[^\s@]{2,}$/.test(val)) {
      msg.textContent = 'A VALID RELAY ADDRESS IS REQUIRED';
      field.classList.remove('err'); void field.offsetWidth; field.classList.add('err');
      input.focus(); return;
    }
    msg.textContent = ''; field.classList.remove('err');
    form.hidden = true; done.hidden = false;
    Toast.ok(v.toast);
    closer = setTimeout(close, 3000);
  });
  input.addEventListener('input', () => { msg.textContent = ''; field.classList.remove('err'); });
  return { open };
})();
 $('#forgotLink').addEventListener('click', e => Modal.open('forgot', e.currentTarget));
 $('#requestLink').addEventListener('click', e => Modal.open('request', e.currentTarget));

/* ============================ login form ============================ */
(() => {
  const form = $('#loginForm'), idIn = $('#identifier'), pwIn = $('#password');
  const fId = $('#fId'), fPw = $('#fPw'), idMsg = $('#idMsg'), pwMsg = $('#pwMsg');
  const btn = $('#loginBtn'), btnLabel = $('#btnLabel');
  const channel = $('#channel'), channelText = $('#channelText');
  const eye = $('#eyeBtn'), remember = $('#remember');
  let busy = false, granted = false;

  const setErr = (field, msgEl, msg) => {
    msgEl.textContent = msg;
    field.classList.toggle('err', !!msg);
    if (msg) { field.classList.remove('shake'); void field.offsetWidth; field.classList.add('shake'); }
  };
  const validId = v => {
    v = v.trim();
    if (!v) return 'IDENTIFIER REQUIRED';
    if (v.includes('@')) return /^[^\s@]+@[^\s@]+\.[^\s@]{2,}$/.test(v) ? '' : 'MALFORMED ADDRESS — CHECK FORMAT';
    return /^[\w.\-]{3,32}$/.test(v) ? '' : '3–32 CHARACTERS — LETTERS, DIGITS, . _ -';
  };
  const setChannel = s => {
    channel.className = 'channel' + (s === 'default' ? '' : ' ' + s);
    channelText.textContent = s === 'open' ? 'CHANNEL OPEN' : s === 'handshake' ? 'HANDSHAKE' : 'SECURE CHANNEL';
  };
  const resetGrant = () => {
    if (!granted) return;
    granted = false;
    btn.classList.remove('success');
    btnLabel.textContent = 'Open secure channel';
    setChannel('default');
  };

  /* remembered identifier */
  try {
    const saved = localStorage.getItem('axiom.remember.id');
    if (saved) { idIn.value = saved; remember.checked = true; }
  } catch (e) {}

  /* eye toggle */
  eye.addEventListener('click', () => {
    const show = pwIn.type === 'password';
    pwIn.type = show ? 'text' : 'password';
    eye.classList.toggle('revealed', show);
    eye.setAttribute('aria-pressed', String(show));
    eye.setAttribute('aria-label', show ? 'Hide passphrase' : 'Show passphrase');
    pwIn.focus();
  });

  idIn.addEventListener('input', () => { setErr(fId, idMsg, ''); resetGrant(); });
  idIn.addEventListener('blur', () => { const e = validId(idIn.value); if (e) setErr(fId, idMsg, e); });
  pwIn.addEventListener('input', () => {
    setErr(fPw, pwMsg, '');
    Strength.update(pwIn.value);
    resetGrant();
  });

  form.addEventListener('submit', e => {
    e.preventDefault();
    if (busy || granted) return;
    const idErr = validId(idIn.value), pwv = pwIn.value;
    const pwErr = !pwv ? 'PASSPHRASE REQUIRED'
                : pwv.length < 6 ? `MIN 6 CHARACTERS — ${pwv.length}/6` : '';
    setErr(fId, idMsg, idErr); setErr(fPw, pwMsg, pwErr);
    if (idErr) { idIn.focus(); return; }
    if (pwErr) { pwIn.focus(); return; }

    busy = true;
    btn.classList.add('loading');
    btnLabel.textContent = 'Authenticating';
    setChannel('handshake');

    setTimeout(() => {
      busy = false; granted = true;
      btn.classList.remove('loading');
      btn.classList.add('success');
      btnLabel.textContent = 'Access granted';
      setChannel('open');
      Scene.pulse();
      try {
        if (remember.checked) localStorage.setItem('axiom.remember.id', idIn.value.trim());
        else localStorage.removeItem('axiom.remember.id');
      } catch (e) {}
      const name = idIn.value.trim().split('@')[0];
      Toast.ok(`CHANNEL OPEN — WELCOME BACK, ${name.toUpperCase()}`);
    }, 1450);
  });

  Strength.update('');
})();
if (!REDUCED) {
  let last = performance.now();
  (function frame(now) {
    const dt = clamp(now - last, 0, 50); last = now;
    Scene.draw(dt, now);
    Wave.draw(dt);
    Light.update(dt);
    requestAnimationFrame(frame);
  })(last);
} else { Scene.draw(0, 0); }
})();

About this snippet

A complete, cinematic login screen built with plain HTML, CSS and vanilla JavaScript. The left half shows an animated orbital scene drawn on <canvas>; the right half is a terminal-style sign-in form with live feedback, a password recovery modal and toast notifications. It is a good reference for building rich auth pages without any framework.

Features

  • Canvas scene: a planet with orbit rings and moving satellites, rendered in a requestAnimationFrame loop and resized with the window.
  • Password strength "oscilloscope": as you type, the script estimates entropy from length and character pool and shows it as bits, a label and an animated waveform.
  • Inline validation: identifier and passphrase fields show messages under the input instead of alerts.
  • Show/hide password toggle, "remember this terminal" checkbox and a cursor-following light on the form.
  • Modal window for password recovery and access requests, with its own validation, focus handling and Escape-to-close.
  • Toasts for success and error messages, and support for prefers-reduced-motion.

Structure

The CSS starts with design tokens in :root (colours, fonts, easing) and uses a two-column grid that collapses on small screens. The JavaScript is wrapped in a single IIFE and split into small modules: Toast, Strength, Wave, Scene, Light and Modal.

How to use it

  1. Copy the three files and open the HTML; it works without a build step.
  2. Connect the form submit handler to your own authentication endpoint (the demo only simulates the request).
  3. Rebrand the texts and the colour tokens such as --amber and --phos.
  4. Remove the canvas section if you need a lighter page; the form works on its own.

Note: the strength meter is a client-side estimate for user feedback. Always validate and hash passwords on the server.

Comments 0

No comments yet. Be the first to comment.