1<!doctype html> 2<html lang="en" data-mode="dark"> 3<head> 4<meta charset="utf-8"> 5<title>Sign in â SUB&SUB</title> 6<meta name="description" content="Sign in or create your SUB&SUB account. One key, every AI subscription â OpenAI-compatible." data-i18n-content="meta.desc.login"> 7<meta name="viewport" content="width=device-width,initial-scale=1,viewport-fit=cover"> 8<meta name="theme-color" content="#0d0a05"> 9<meta name="robots" content="noindex"> 10<link rel="canonical" href="https://www.subnsub.com/login"> 11<link rel="alternate" hreflang="x-default" href="https://www.subnsub.com/login"> 12<link rel="alternate" hreflang="de" href="https://www.subnsub.com/de/login"> 13<link rel="alternate" hreflang="en" href="https://www.subnsub.com/login"> 14<link rel="alternate" hreflang="es" href="https://www.subnsub.com/es/login"> 15<link rel="alternate" hreflang="fr" href="https://www.subnsub.com/fr/login"> 16<link rel="alternate" hreflang="hi" href="https://www.subnsub.com/hi/login"> 17<link rel="alternate" hreflang="ja" href="https://www.subnsub.com/ja/login"> 18<link rel="alternate" hreflang="ko" href="https://www.subnsub.com/ko/login"> 19<link rel="alternate" hreflang="pt" href="https://www.subnsub.com/pt/login"> 20<link rel="alternate" hreflang="ru" href="https://www.subnsub.com/ru/login"> 21<link rel="alternate" hreflang="zh-CN" href="https://www.subnsub.com/zh-CN/login"> 22<link rel="alternate" hreflang="zh-TW" href="https://www.subnsub.com/zh-TW/login"> 23<link rel="icon" type="image/png" sizes="32x32" href="/favicon-32.png"> 24<link rel="icon" type="image/png" sizes="16x16" href="/favicon-16.png"> 25<link rel="icon" type="image/svg+xml" href="/favicon.svg"> 26<link rel="apple-touch-icon" sizes="180x180" href="/apple-touch-icon.png"> 27<link rel="preload" href="/fonts/public-sans-latin.woff2" as="font" type="font/woff2" crossorigin> 28<link rel="preload" href="/fonts/manrope-700-latin.woff2" as="font" type="font/woff2" crossorigin> 29<link rel="preload" href="/fonts/manrope-var-latin.woff2" as="font" type="font/woff2" crossorigin>
30<script src="/translations.js?v=99b34cc0" defer></script>
vendor: 1 bytes, line 30
30
31<script src="/i18n.js?v=146622a9" defer></script>
vendor: 1 bytes, line 31
31
32<script src="/lisse.min.js?v=c4292518" defer></script>
vendor: 1 bytes, line 32
32
33<script src="/squircle.js?v=b270ac87" defer></script>
33 34<link rel="stylesheet" href="/ice-key.css?v=01b8ea8b">
35<script src="/ice-key.js?v=452b0d6e" defer></script>
35 36<style> 37@font-face{ 38 font-family:'Public Sans';font-style:normal;font-weight:400 700;font-display:swap; 39 src:url('/fonts/public-sans-latin.woff2') format('woff2'); 40 unicode-range:U+0000-00FF,U+0131,U+0152-0153,U+02BB-02BC,U+02C6,U+02DA,U+02DC,U+0304,U+0308,U+0329,U+2000-206F,U+20AC,U+2122,U+2191,U+2193,U+2212,U+2215,U+FEFF,U+FFFD; 41} 42@font-face{ 43 font-family:'Space Mono';font-style:normal;font-weight:400;font-display:swap; 44 src:url('/fonts/space-mono-latin.woff2') format('woff2'); 45 unicode-range:U+0000-00FF,U+0131,U+0152-0153,U+02BB-02BC,U+02C6,U+02DA,U+02DC,U+0304,U+0308,U+0329,U+2000-206F,U+20AC,U+2122,U+2191,U+2193,U+2212,U+2215,U+FEFF,U+FFFD; 46} 47@font-face{ 48 font-family:'Space Mono';font-style:normal;font-weight:700;font-display:swap; 49 src:url('/fonts/space-mono-700-latin.woff2') format('woff2'); 50 unicode-range:U+0000-00FF,U+0131,U+0152-0153,U+02BB-02BC,U+02C6,U+02DA,U+02DC,U+0304,U+0308,U+0329,U+2000-206F,U+20AC,U+2122,U+2191,U+2193,U+2212,U+2215,U+FEFF,U+FFFD; 51} 52@font-face{ 53 font-family:'Manrope Wordmark';font-style:normal;font-weight:700;font-display:swap; 54 src:url('/fonts/manrope-700-latin.woff2') format('woff2'); 55 unicode-range:U+0000-00FF,U+0131,U+0152-0153,U+02BB-02BC,U+02C6,U+02DA,U+02DC,U+0304,U+0308,U+0329,U+2000-206F,U+20AC,U+2122,U+2191,U+2193,U+2212,U+2215,U+FEFF,U+FFFD; 56} 57/* Variable-weight Manrope for UI chrome â every weight the old mono 58 chrome used (400..700) resolves inside one 24KB file. Latin-only, 59 like the wordmark face: CJK falls through to the sans stack. */ 60@font-face{ 61 font-family:'Manrope UI';font-style:normal;font-weight:200 800;font-display:swap; 62 src:url('/fonts/manrope-var-latin.woff2') format('woff2'); 63 unicode-range:U+0000-00FF,U+0131,U+0152-0153,U+02BB-02BC,U+02C6,U+02DA,U+02DC,U+0304,U+0308,U+0329,U+2000-206F,U+20AC,U+2122,U+2191,U+2193,U+2212,U+2215,U+FEFF,U+FFFD; 64} 65/* Color tokens inherited from totp-decoder â see /theme.js. */ 66:root { 67 --page-pad: 20px; 68 --bg: oklch(0.985 0.003 85); 69 --bg-sub: oklch(0.965 0.004 85); 70 --surface: oklch(1 0 0); 71 --hover: oklch(0.97 0.002 85); 72 --line: oklch(0.90 0.005 85); 73 --line-2: oklch(0.94 0.004 85); 74 --line-hover: oklch(0.82 0.007 85); 75 --fg: oklch(0.18 0.01 85); 76 --fg-mid: oklch(0.45 0.008 85); 77 --fg-dim: oklch(0.62 0.006 85); 78 --accent: oklch(0.62 0.13 220); 79 --accent-bg: oklch(0.96 0.03 220); 80 --accent-dim: oklch(0.88 0.04 220); 81 --accent-halo:oklch(0.62 0.13 220 / .18); 82 --accent-ring:oklch(0.62 0.13 220 / .09); 83 --on-accent: oklch(1 0 0); 84 --warn: oklch(0.70 0.16 65); 85 --warn-bg: oklch(0.97 0.03 65); 86 --warn-line: oklch(0.88 0.08 65); 87 --danger: oklch(0.58 0.19 25); 88 --danger-bg: oklch(0.97 0.03 25); 89 --danger-line:oklch(0.88 0.07 25); 90 --danger-ring:oklch(0.58 0.19 25 / .08); 91 --add-bg: oklch(0.96 0.05 220); 92 --add-fg: oklch(0.38 0.12 220); 93 --del-bg: oklch(0.96 0.04 25); 94 --del-fg: oklch(0.48 0.15 25); 95 --mark: oklch(0.92 0.12 85); 96 --mark-1: oklch(0.9 0.1 220); 97 --mark-2: oklch(0.9 0.1 310); 98 --page-glow: oklch(0.96 0.02 220 / .55); 99 /* Headline glint + switch light-bar â the landing page's shimmer voice, 100 brought to the auth card. --sw-ink/ink2 follow the theme fg via var(); 101 --sw-peak is a white-hot glint in both themes (matching index.html); 102 --sweep is the ice-blue bar that wipes the card on a tab switch. */ 103 --sw-ink: var(--fg); 104 --sw-ink2: var(--fg-mid); 105 --sw-peak: oklch(1 0 0); 106 --sweep: oklch(0.72 0.15 220 / .85); 107 --mono: 'Space Mono', ui-monospace, SFMono-Regular, Menlo, monospace; 108 --ui: 'Manrope UI', 'Public Sans', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; 109 --sans: 'Public Sans', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; 110 /* One easing family for every mode-switch move on this page â same 111 curve as the landing page's tab indicator, so the two segmented 112 controls glide identically. Durations live in the switch script's 113 SW table; keep the two in sync. */ 114 --glide: cubic-bezier(.22,.61,.36,1); 115} 116[data-mode="dark"]{ 117 --bg: oklch(0.145 0.014 85); 118 --bg-sub: oklch(0.20 0.016 85); 119 --surface: oklch(0.245 0.016 85); 120 --hover: oklch(0.30 0.018 85); 121 --line: oklch(0.36 0.016 85); 122 --line-2: oklch(0.29 0.014 85); 123 --line-hover: oklch(0.50 0.018 85); 124 --fg: oklch(0.95 0.006 85); 125 --fg-mid: oklch(0.76 0.010 85); 126 --fg-dim: oklch(0.62 0.010 85); 127 --accent: oklch(0.74 0.13 220); 128 --accent-bg: oklch(0.31 0.08 220); 129 --accent-dim: oklch(0.50 0.10 220); 130 --accent-halo:oklch(0.74 0.13 220 / .28); 131 --accent-ring:oklch(0.74 0.13 220 / .18); 132 --on-accent: oklch(0.16 0.02 220); 133 --warn: oklch(0.82 0.14 72); 134 --warn-bg: oklch(0.29 0.07 72); 135 --warn-line: oklch(0.48 0.09 72); 136 --danger: oklch(0.76 0.16 25); 137 --danger-bg: oklch(0.27 0.08 25); 138 --danger-line:oklch(0.48 0.12 25); 139 --danger-ring:oklch(0.76 0.16 25 / .16); 140 --add-bg: oklch(0.29 0.08 220); 141 --add-fg: oklch(0.82 0.14 220); 142 --del-bg: oklch(0.28 0.08 25); 143 --del-fg: oklch(0.82 0.16 25); 144 --mark: oklch(0.42 0.11 85); 145 --mark-1: oklch(0.40 0.10 220); 146 --mark-2: oklch(0.40 0.10 310); 147 --page-glow: oklch(0.22 0.05 220 / .35); 148 --sweep: oklch(0.80 0.14 220 / .9); 149} 150*{box-sizing:border-box} 151/* The auth document itself never owns a scrollbar. Short viewports still 152 need access to every field, so body remains wheel/touch/keyboard 153 scrollable while its scrollbar is hidden on every engine. Keeping the 154 scrollport width fixed also prevents the centred card from shifting when 155 the taller sign-up face replaces sign-in. */ 156html{margin:0;width:100%;height:100%;overflow:hidden} 157body{ 158 margin:0;width:100%;height:100dvh;min-height:0; 159 font-family:var(--sans);color:var(--fg); 160 background:radial-gradient(120% 460px at 50% -60px,var(--page-glow),transparent 70%),var(--bg); 161 -webkit-font-smoothing:antialiased;font-feature-settings:'ss01','cv11'; 162 padding:32px var(--page-pad); 163 display:flex;flex-direction:column;align-items:center;line-height:1.6; 164 overflow-x:hidden;overflow-y:auto;overscroll-behavior-y:contain; 165 scrollbar-width:none;-ms-overflow-style:none; 166 -webkit-overflow-scrolling:touch; 167} 168body::-webkit-scrollbar{display:none;width:0;height:0} 169 170/* Page copy is display-only â not selectable, not copyable (user call 171 2026-07-27). Editable controls opt back in: people must be able to 172 review and correct what they typed. Nothing here needs a JS guard â 173 what cannot be selected cannot be copied. */ 174html{-webkit-user-select:none;user-select:none} 175input,textarea{-webkit-user-select:text;user-select:text} 176 177/* shell â narrower than landing page; centred card */ 178.shell{position:relative;width:min(420px,calc(100vw - (var(--page-pad) * 2)));min-width:0;margin:clamp(48px,10vh,96px) 0 auto} 179 180/* wordmark â link back to home. Left-aligned (user, 2026-07-09), and 181 since 2026-07-27 the card copy below reads off the same left edge; 182 width:fit-content keeps the link's hit area on the glyphs only. */ 183.wordmark{display:flex;width:fit-content;align-items:center;gap:9px;margin:0 0 28px;text-decoration:none;color:inherit} 184.wordmark .name{font-family:'Manrope Wordmark', var(--sans);font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:0.12em;color:var(--fg-mid)} 185.wordmark:hover .name{color:var(--fg)} 186 187/* head â flush left under the wordmark (user, 2026-07-27: the centred 188 card read oddly). No text-align at all rather than `left`, so the 189 inline start edge still follows the document direction. */ 190.auth-head{margin-bottom:22px} 191.auth-head h1{font-size:26px;font-weight:600;letter-spacing:-0.02em;margin:0 0 6px;line-height:1.2} 192.auth-head .lead{font-size:13.5px;color:var(--fg-mid);margin:0;line-height:1.55} 193 194 195/* tab toggle â segmented control. The pressed state is a real element 196 (.auth-thumb) that glides between the two cells on transform (the 197 compositor path â immune to the height/opacity work the switch does on 198 the main thread); the buttons themselves only change text colour. Cell 199 geometry in CSS calc so the thumb tracks resize and locale width for 200 free: cells are 1fr/1fr with 4px padding + 4px gap, so one cell is 201 (100% â 12px)/2 of the padding box and the second sits its own width 202 + the gap to the right. Visual kit (surface fill, hairline, soft drop) 203 matches the landing page's .tab-indicator â one slider language 204 site-wide. */ 205.auth-tabs{position:relative;display:grid;grid-template-columns:1fr 1fr;gap:4px;padding:4px;background:var(--bg-sub);border:1px solid var(--line);border-radius:18.5px;margin-bottom:18px} 206.auth-thumb{position:absolute;top:4px;bottom:4px;left:4px;width:calc((100% - 12px)/2);background:var(--surface);border:1px solid oklch(0 0 0/.04);border-radius:13.6px;box-shadow:0 1px 3px oklch(0 0 0/.07);pointer-events:none;transition:transform .34s var(--glide)} 207.auth-tabs[data-active="signup"] .auth-thumb{transform:translateX(calc(100% + 4px))} 208[data-mode="dark"] .auth-thumb{border-color:oklch(1 0 0/.06);box-shadow:0 1px 3px oklch(0 0 0/.3)} 209.auth-tab{position:relative;z-index:1;font-family:var(--sans);font-size:13px;font-weight:600;letter-spacing:.01em;color:var(--fg-mid);background:transparent;border:0;border-radius:13.6px;padding:9px 0;cursor:pointer;transition:color .18s ease} 210.auth-tab.active{color:var(--fg)} 211.auth-tab:hover:not(.active){color:var(--fg)}
212/* forced-colors erases backgrounds/shadows â the thumb vanishes, so the 213 pressed tab must carry its own indicator. */ 214@media (forced-colors: active){ 215 .auth-thumb{display:none} 216 .auth-tab.active{outline:2px solid Highlight;outline-offset:-2px} 217} 218 219/* OAuth button (Google) â solid inverted primary: fg-on-bg flips with the 220 theme (near-black in light, near-white in dark); the tactile weight 221 comes from the full-contrast fill, not a border. */ 222.oauth-btn{display:flex;align-items:center;justify-content:center;gap:10px;width:100%;font-family:var(--sans);font-size:13.5px;font-weight:600;letter-spacing:.01em;color:var(--bg);background:var(--fg);border:1px solid transparent;border-radius:11px;padding:12px 14px;cursor:pointer;text-decoration:none;transition:opacity .15s,transform .1s} 223.oauth-btn:hover{opacity:.88} 224.oauth-btn:active{transform:translateY(1px)} 225.oauth-btn svg{width:18px;height:18px;flex-shrink:0} 226/* usernameless passkey door â same geometry as .oauth-btn, quiet outline 227 instead of the solid fill (one primary CTA per card) */ 228.pk-signin-btn{display:flex;align-items:center;justify-content:center;gap:10px;width:100%;margin-top:8px;font-family:var(--sans);font-size:13.5px;font-weight:600;letter-spacing:.01em;color:var(--fg);background:transparent;border:1px solid var(--line);border-radius:11px;padding:12px 14px;cursor:pointer;transition:background .15s,border-color .15s,transform .1s} 229/* display:flex above would defeat the UA's [hidden]{display:none} (see 230 the ice-key.css note: every JS-toggled element needs its own guard). 231 Without this the passkey door stayed rendered on the signup face â 232 pre-existing on every signup view, and it would also have undone the 233 collapse animation's final hide. */ 234.pk-signin-btn[hidden]{display:none} 235.pk-signin-btn:hover{background:var(--hover);border-color:var(--line-hover)} 236.pk-signin-btn:active{transform:translateY(1px)} 237.pk-signin-btn[disabled]{opacity:.55;cursor:default} 238.pk-signin-btn svg{width:18px;height:18px;flex-shrink:0} 239 240/* divider â sans body voice, matching the solid Google button */ 241.divider{display:flex;align-items:center;gap:12px;margin:16px 0;color:var(--fg-dim);font-family:var(--sans);font-size:11px;letter-spacing:.02em} 242.divider::before,.divider::after{content:"";flex:1;height:1px;background:var(--line-2)} 243 244/* form stage â wraps the signin/signup pair so a mode switch can glide 245 between the two forms' natural heights instead of snapping (the signup 246 form is one field taller, so everything below used to jump). Rest 247 state is height:auto with no clipping â the ice key's blurred shadow 248 and the fields' focus rings bleed past the box freely; .stage-anim 249 (clip + height transition) exists only for the ~260ms of the glide, 250 applied and removed by the switch script. */ 251.form-stage{position:relative} 252.form-stage.stage-anim{overflow:clip} 253 254 255@keyframes revealIn{from{opacity:0;transform:translateY(6px)}to{opacity:1;transform:none}} 256html[data-anim="on"] .reveal{animation:revealIn .3s var(--glide)} 257/* Scene veil â flow switches (sign-in â forgot/verify/passkey panels) 258 toggle whole blocks via display, which can't transition; re-adding 259 .scene-in on the wrapper fades the incoming scene up as one piece so 260 the cut reads as a deliberate change, not a glitch. */ 261@keyframes sceneIn{from{opacity:0}to{opacity:1}} 262html[data-anim="on"] .auth-body.scene-in{animation:sceneIn .24s ease} 263 264/* form */ 265.auth-form{display:flex;flex-direction:column;gap:12px} 266.auth-form[hidden]{display:none} 267.field{display:flex;flex-direction:column;gap:5px} 268.field label{font-family:var(--sans);font-size:12.5px;font-weight:500;color:var(--fg-mid)} 269/* focus ripple = the tools-site fi-pulse gesture (theme.js): an inward 270 accent wash that ignites almost instantly (0â1% â 100ms â focus must 271 feel like it lights the sweep), expands into the field and dissolves 272 by 15%, then rests â 15.1%â100% keyframes are identical, so the 8.5s 273 quiet phase does no repaint work. Gated on data-anim="on" (the seeder 274 above resolves cf-anim auto/on/off against OS reduced-motion), so an 275 explicit Motion=On overrides the system setting, same as the tools 276 site. Motion=Off leaves the steady ring below standing alone. */ 277.field input{font-family:var(--sans);font-size:14px;color:var(--fg);background:var(--surface);border:1px solid var(--line);border-radius:11px;padding:12px 13px;outline:none;transition:border-color .15s,box-shadow .15s;width:100%} 278.field input:hover{border-color:var(--line-hover)} 279.field input:focus{border-color:var(--accent);outline:none;box-shadow:inset 0 0 0 1px var(--accent),0 0 0 4px var(--accent-ring)} 280@supports (color: color-mix(in oklch, red 50%, transparent)){ 281 html[data-anim="on"] .field input:focus{animation:fieldPulse 10s cubic-bezier(.2,.55,.35,1) infinite} 282 @keyframes fieldPulse{ 283 0%{box-shadow:inset 0 0 0 1px var(--accent),0 0 0 4px var(--accent-ring),inset 0 0 2.5px 0.5px color-mix(in oklch, var(--accent) 0%, transparent)} 284 1%{box-shadow:inset 0 0 0 1px var(--accent),0 0 0 4px var(--accent-ring),inset 0 0 3px 1px color-mix(in oklch, var(--accent) 40%, transparent)} 285 15%{box-shadow:inset 0 0 0 1px var(--accent),0 0 0 4px var(--accent-ring),inset 0 0 10px 13px color-mix(in oklch, var(--accent) 0%, transparent)} 286 15.1%,100%{box-shadow:inset 0 0 0 1px var(--accent),0 0 0 4px var(--accent-ring),inset 0 0 2.5px 0.5px color-mix(in oklch, var(--accent) 0%, transparent)} 287 } 288} 289.field input::placeholder{color:var(--fg-dim)} 290.field input[inputmode="numeric"]{font-family:var(--ui);font-size:13.5px;letter-spacing:.1em} 291/* forced-colors strips box-shadows â hand focus back to a real outline */ 292@media (forced-colors: active){.field input:focus{outline:2px solid Highlight;outline-offset:2px;box-shadow:none;animation:none}} 293.field[hidden]{display:none} 294 295/* 2FA code cells â the six-digit code wears six one-digit boxes (user 296 call 2026-07-27). The REAL control is still the single #tf-code input, 297 stretched invisibly over the row: it keeps the label linkage, the 298 one-time-code autofill sheet, paste, Enter-to-submit and the existing 299 submit handler; the cells only mirror its value. Geometry: same 42px 300 rendered control height as the text fields, so the same 18.5px 301 superellipse corner box and the same parallel-offset focus ring apply 302 unchanged (squircle.js ports the gate to arc engines by itself). */ 303.tf-otp{position:relative;display:flex;gap:10px;justify-content:flex-start;direction:ltr}
304/* No width cap: the six cells divide the column, so the row lands flush 305 on both edges like the fields and keys around it. The 50px cap the 306 centred row used would have left ~70px dangling off the right once 307 the card went left-aligned (user call 2026-07-27). */ 308.tf-cell{flex:1 1 0;height:42px;display:flex;align-items:center;justify-content:center;position:relative;background:var(--surface);border:1px solid var(--line);border-radius:11px;font-family:var(--ui);font-size:16px;font-weight:600;color:var(--fg-dim);transition:border-color .15s,box-shadow .15s,background-color .15s} 309.tf-otp:hover .tf-cell{border-color:var(--line-hover)} 310.tf-cell.filled{color:var(--fg)} 311.tf-cell.active{border-color:var(--accent);box-shadow:inset 0 0 0 1px var(--accent),0 0 0 4px var(--accent-ring)} 312html[data-anim="on"] .tf-cell.active{animation:fieldPulse 10s cubic-bezier(.2,.55,.35,1) infinite} 313/* Synthetic caret â the real caret sits in the invisible overlay. Empty 314 cells carry no glyph at all (user call 2026-07-27: the six prefilled 315 zeros read as a typed code), so the caret has the cell to itself. 316 .active marks the focus-ring cell â exactly one whenever the control 317 is focused, full code and select-all included, so a visible (and
318 forced-colors) focus indicator never disappears; .caret additionally 319 marks the true insertion point. */ 320.tf-cell.caret::after{content:"";position:absolute;width:1.5px;height:16px;border-radius:1px;background:var(--accent)} 321html[data-anim="on"] .tf-cell.caret::after{animation:tfCaret 1.12s step-end infinite} 322@keyframes tfCaret{0%,100%{opacity:1}50%{opacity:0}} 323/* The post-error select() state: every typed digit reads as selected â 324 the next keystroke replaces the whole code, so paint it that way. */ 325.tf-cell.sel{background:var(--accent-bg);border-color:var(--accent-dim)} 326#tf-code{position:absolute;inset:0;width:100%;height:100%;opacity:0;padding:0;border:0;color:transparent;caret-color:transparent;cursor:text;font-size:16px} 327 328/* Factor switch ("Use your passkey instead" / "Use an authenticator code 329 instead") â a real button rather than a text link (user call 330 2026-07-27). Solid inverted fill, the same recipe and geometry as the 331 Google door on the sign-in face: a near-white slab with near-black 332 type in dark mode, and the reverse in light. It cannot reuse 333 .oauth-btn itself â the panel's own `.passkey-active .oauth-btn` 334 rule would force it hidden. The row stays a plain block so the UA's 335 [hidden] keeps working when there is no alternate factor to offer. */ 336.alt-btn{display:flex;align-items:center;justify-content:center;width:100%;font-family:var(--sans);font-size:13.5px;font-weight:600;letter-spacing:.01em;color:var(--bg);background:var(--fg);border:1px solid transparent;border-radius:11px;padding:12px 14px;cursor:pointer;transition:opacity .15s,transform .1s} 337.alt-btn:hover{opacity:.88} 338.alt-btn:active{transform:translateY(1px)} 339 340/* error banner */ 341.form-error{display:flex;align-items:flex-start;gap:8px;padding:9px 12px;background:var(--danger-bg);border:1px solid var(--danger-line);border-radius:15.3px;font-size:12.5px;color:var(--danger);line-height:1.45} 342.form-error[hidden]{display:none} 343.form-error svg{width:14px;height:14px;flex-shrink:0;margin-top:2px} 344 345/* Submit buttons are shared MAIN-variant ice keys (ice-key.css/.js) â 346 the frosted fracture/inclusions/reflection slab the corner Tools key 347 wears (user call 2026-07-14; they were the plain .pure slab before). 348 What stays here is the page's own state machinery â the stacking gap, 349 the busy spinner, and dimming the label text while a request is in 350 flight. The spinner rides inside the key's .label flex row, so only 351 the text spans dim. */ 352.submit-btn{margin-top:4px} 353/* The shared frost canvas (.ice-key::before) overhangs its key by 26px 354 a side to hold the main variant's sideways scatter artwork. On these 355 full-width keys the overhang would poke past the viewport at phone 356 widths (~10px sideways scroll) and the scatter would land on the 357 neighbouring form copy, so the canvas stays pinned to the key's own 358 width: the on-slab artwork (fractures, inclusions, reflection, the 359 58%-wide central haze) is untouched â only the off-key crumbs go. */ 360.submit-btn::before{left:0;right:0} 361/* Form keys stretch with the column instead of sizing from their label, 362 so the component's nowrap can force a >100% cap on long locales 363 (French/German) at phone widths â let these wrap. */ 364.submit-btn .label{white-space:normal;text-align:center} 365.submit-btn .spinner{width:13px;height:13px;border:2px solid oklch(100% 0 0 / .92);border-top-color:transparent;border-radius:50%;animation:spin .7s linear infinite;display:none} 366.submit-btn.loading .spinner{display:inline-block} 367.submit-btn.loading .label > span:not(.spinner){opacity:.55} 368@keyframes spin{to{transform:rotate(360deg)}} 369 370/* form footer (forgot password) */ 371.form-foot{display:flex;justify-content:flex-end;font-family:var(--sans);font-size:12.5px} 372.form-foot[hidden]{display:none} 373.form-foot a{color:var(--fg-dim);text-decoration:none;transition:color .15s} 374.form-foot a:hover{color:var(--fg)} 375 376/* Sign-up legal acknowledgement. The native checkbox remains in the 377 accessibility tree and owns the form state; .legal-ice is only its visual 378 skin. This is deliberately a flat hint of iceânot a miniature .ice-keyâso 379 the required acknowledgement stays secondary to the submit action. */ 380.signup-consent{margin:0;padding:11px 12px;background:var(--bg-sub);border:1px solid var(--line);border-radius:15.3px} 381.signup-consent[hidden]{display:none} 382.legal-check{position:relative;display:flex;align-items:flex-start;gap:9px;
382cursor:pointer;font-size:12px;line-height:1.5;color:var(--fg-mid)} 383.legal-check input{position:absolute;width:1px;height:1px;margin:0;overflow:hidden;clip:rect(0 0 0 0);clip-path:inset(50%);white-space:nowrap} 384.legal-ice{ 385 position:relative;flex:0 0 20px;width:20px;height:20px;margin-top:1px;border-radius:6px; 386 transition:filter .16s ease,transform .12s ease; 387} 388.legal-ice::after{ 389 content:"";position:absolute;inset:0;border-radius:inherit; 390 background: 391 radial-gradient(circle at 73% 27%,oklch(100% 0 0/.42) 0 .7px,transparent 1.2px), 392 linear-gradient(125deg,transparent 34%,oklch(100% 0 0/.3) 39%,transparent 46%), 393 radial-gradient(120% 120% at 48% 34%,oklch(91% .025 220/.18),oklch(72% .055 228/.26) 68%,oklch(59% .08 235/.34)); 394 box-shadow: 395 inset 0 1px 0 oklch(100% 0 0/.48), 396 inset 0 0 0 1px oklch(72% .08 226/.42), 397 0 1px 2px oklch(41% .06 235/.12); 398 transition:background .16s ease,box-shadow .16s ease; 399} 400.legal-ice-check{position:absolute;z-index:1;inset:3px;width:14px;height:14px;fill:none;stroke:oklch(100% 0 0);stroke-width:2.2;stroke-linecap:round;stroke-linejoin:round;opacity:0;transform:scale(.72);transition:opacity .14s ease,transform .16s cubic-bezier(.22,.8,.3,1)} 401.legal-check:hover .legal-ice{filter:brightness(1.06)} 402.legal-check:active .legal-ice{transform:scale(.96)} 403.legal-check input:checked + .legal-ice::after{ 404 background: 405 radial-gradient(circle at 73% 27%,oklch(100% 0 0/.58) 0 .7px,transparent 1.2px), 406 linear-gradient(125deg,transparent 32%,oklch(100% 0 0/.42) 39%,transparent 47%), 407 radial-gradient(120% 120% at 48% 34%,oklch(92% .03 220/.32),oklch(68% .11 228/.52) 66%,oklch(54% .14 236/.66)); 408 box-shadow:inset 0 1px 0 oklch(100% 0 0/.62),inset 0 0 0 1px oklch(74% .11 225/.64),0 1px 2px oklch(41% .08 235/.16); 409} 410.legal-check input:checked + .legal-ice .legal-ice-check{opacity:1;transform:scale(1)} 411.legal-check input:focus-visible + .legal-ice{outline:2px solid var(--accent);outline-offset:3px} 412.legal-check input[aria-invalid="true"] + .legal-ice{outline:2px solid var(--danger);outline-offset:3px} 413.legal-copy{min-width:0} 414.legal-check a,.auth-legal a{color:var(--fg-mid);text-decoration:underline;text-decoration-color:var(--line);text-underline-offset:2px;transition:color .15s} 415.legal-check a:hover,.auth-legal a:hover{color:var(--fg)} 416.legal-hint{margin:6px 0 0 29px;font-size:10.5px;line-height:1.45;color:var(--fg-dim)} 417.legal-error{margin:7px 0 0 29px;font-size:11px;line-height:1.4;color:var(--danger)} 418.legal-error[hidden]{display:none} 419html[data-anim="off"] .legal-ice,html[data-anim="off"] .legal-ice::after,html[data-anim="off"] .legal-ice-check{transition:none} 420/* Forced-colors cannot paint the material, so expose the native checkbox 421 rather than leaving an invisible decorative square. */ 422@media (forced-colors:active){ 423 .legal-check input{position:static;flex:0 0 auto;width:16px;height:16px;margin:2px 0 0;overflow:visible;clip:auto;clip-path:none;white-space:normal} 424 .legal-ice{display:none} 425 .legal-hint,.legal-error{margin-left:25px} 426} 427 428/* legal links */ 429.auth-legal{font-size:11.5px;color:var(--fg-dim);margin:18px 0 0;line-height:1.55} 430 431/* alt prompt at very bottom */ 432.auth-alt{font-size:13px;color:var(--fg-mid);margin:16px 0 0} 433.auth-alt[hidden]{display:none} 434 435/* Balanced description rows â the staircase engine at the foot of this 436 file picks the break points and inserts them as newlines; pre-line is 437 what renders them. Every one of these three blocks is authored on a 438 single source line, so no author newline can leak through before the 439 engine's first pass. */ 440.auth-head .lead,.verify-lead,.auth-legal{white-space:pre-line} 441.auth-alt a{color:var(--accent);font-weight:600;text-decoration:none} 442.auth-alt a:hover{text-decoration:underline} 443 444/* verification panel â replaces the form area while waiting for the 445 user to click the email link. */ 446.verify-panel{display:flex;flex-direction:column;gap:14px} 447.verify-panel[hidden]{display:none} 448.verify-lead{font-size:13.5px;color:var(--fg-mid);margin:0;line-height:1.55} 449.verify-email{font-family:var(--ui);font-size:12.5px;font-weight:500;color:var(--fg);background:var(--bg-sub);border:1px solid var(--line);border-radius:15.3px;padding:11px 13px;word-break:break-all} 450.verify-msg{display:flex;align-items:flex-start;gap:8px;padding:9px 12px;background:var(--accent-bg);border:1px solid var(--accent-dim);border-radius:15.3px;font-size:12.5px;color:var(--fg);line-height:1.45} 451.verify-msg[hidden]{display:none} 452.verify-msg.is-warn{background:var(--danger-bg);border-color:var(--danger-line);color:var(--danger)} 453.verify-back{font-family:var(--sans);font-size:12.5px;margin:6px 0 0} 454.verify-back a{color:var(--fg-dim);text-decoration:none;transition:color .15s} 455.verify-back a:hover{color:var(--fg)} 456/* hide auxiliary chrome when the verify panel takes over */ 457.verify-active .auth-tabs, 458.verify-active .signup-consent, 459.verify-active .oauth-btn, 460.verify-active .pk-signin-btn, 461.verify-active .divider, 462.verify-active .auth-form, 463.verify-active .auth-alt{display:none !important} 464 465/* passkey gate â replaces the form area while the mandated WebAuthn 466 ceremony (assert an existing passkey, or first-time enrolment) runs. */ 467.passkey-active .auth-tabs, 468.passkey-active .signup-consent, 469.passkey-active .oauth-btn, 470.passkey-active .pk-signin-btn, 471.passkey-active .divider, 472.passkey-active .auth-form, 473.passkey-active .auth-alt{display:none !important} 474 475/* forgot-password mode â single-field email form replaces the sign-in 476 form. The Google button, divider and segmented toggle hide so the 477 panel reads as a focused recovery flow. */ 478.forgot-active .auth-tabs, 479.forgot-active .signup-consent, 480.forgot-active .oauth-btn, 481.forgot-active .pk-signin-btn, 482.forgot-active .divider, 483.forgot-active #signin-form, 484.forgot-active #signup-form, 485.forgot-active .auth-alt{display:none !important} 486.forgot-back{font-family:var(--sans);font-size:12.5px;margin:6px 0 0} 487.forgot-back a{color:var(--fg-dim);text-decoration:none;transition:color .15s} 488.forgot-back a:hover{color:var(--fg)} 489 490/* theme switcher â same shape as index.html / console.html. */ 491.theme-btn{position:fixed;top:16px;right:20px;z-index:9000;width:32px;height:32px;border-radius:13.6px;border:1px solid var(--line);background:var(--bg-sub);color:var(--fg-dim);cursor:pointer;display:grid;place-items:center;transition:all .15s;backdrop-filter:blur(8px)} 492.theme-btn:hover{color:var(--fg);border-color:var(--line-hover)} 493.theme-btn svg{width:14px;
493height:14px} 494.theme-panel{position:fixed;top:56px;right:20px;z-index:9000;width:224px;background:var(--surface);border:1px solid var(--line);border-radius:21.7px;padding:14px;filter:drop-shadow(0 8px 32px oklch(0 0 0/.12)) drop-shadow(0 2px 8px oklch(0 0 0/.06));display:none;animation:tpIn .15s ease} 495.theme-panel.open{display:block} 496@keyframes tpIn{from{opacity:0;transform:translateY(-6px)}to{opacity:1;transform:translateY(0)}} 497.tp-lbl{font-family:var(--ui);font-size:9.5px;text-transform:uppercase;letter-spacing:.1em;color:var(--fg-dim);margin-bottom:8px} 498.tp-modes{display:flex;gap:4px;margin-bottom:14px} 499.tp-mode{flex:1;font-family:var(--ui);font-size:9.5px;text-transform:uppercase;letter-spacing:.06em;font-weight:500;color:var(--fg-dim);background:var(--bg-sub);border:1px solid var(--line);border-radius:18.5px;padding:7px 0 5px;cursor:pointer;transition:all .15s;display:flex;flex-direction:column;align-items:center;gap:4px} 500.tp-mode svg{width:12px;height:12px} 501.tp-mode.active{background:var(--accent);color:var(--on-accent);border-color:transparent} 502.tp-mode:hover:not(.active){color:var(--fg)} 503.tp-divider{height:1px;background:var(--line-2);margin:0 0 12px} 504[data-mode="dark"] .theme-panel{filter:drop-shadow(0 8px 32px oklch(0 0 0/.5)) drop-shadow(0 2px 8px oklch(0 0 0/.3))} 505 506@media(max-width:640px){ 507 :root{--page-pad:16px} 508 body{padding-top:calc(18px + env(safe-area-inset-top))} 509 /* body centres this flex item, so width:auto would shrink-wrap the 510 currently visible locale/form content: the longer sign-up consent 511 widened the whole card on every mode switch. Give both faces the same 512 definite content-box width instead. */ 513 .shell{width:100%;max-width:420px;min-width:0;margin:8vh 0 auto} 514 .auth-head h1{font-size:22px} 515 .theme-btn{top:calc(10px + env(safe-area-inset-top));right:calc(12px + env(safe-area-inset-right));width:40px;height:40px;border-radius:15.3px} 516 .theme-panel{top:calc(58px + env(safe-area-inset-top));right:var(--page-pad);left:var(--page-pad);width:auto} 517} 518 519/* MASTER ANIMATION GATE â see index.html. */ 520html[data-anim="off"] *, 521html[data-anim="off"] *::before, 522html[data-anim="off"] *::after{ 523 animation-duration:0.01ms!important; 524 animation-iteration-count:1!important; 525 transition-duration:0.01ms!important 526} 527 528/* Demo-field geometry â ChatGPT-style continuous corners (see totp repo 529 demo/gpt-email-input-corner.html for the measured spec). ONLY geometry: 530 fields adopt corner-shape:superellipse(1.53) with a corner box of 531 0.4255 x height; the crisp 2px of the focus stroke is re-painted as a 532 parallel-offset border-image ring (a plain 2px corner-shape stroke 533 thickens ~13% at the apex), baked in this page's ACCENT per mode â the 534 original accent colour, halo and fieldPulse wash stay untouched (the 535 inset hairline hides under the opaque ring). Non-supporting engines 536 keep the current radii and the original circular focus. */ 537@supports (corner-shape: superellipse(1.53)) { 538:root{--fring-b:url("data:image/svg+xml,%3Csvg xmlns=%22http://www.w3.org/2000/svg%22 width=%2253%22 height=%2253%22 viewBox=%220 0 53 53%22%3E%3Cpath d=%22M0 18.5C0 15.91 0.02 13.3 0.53 10.75C1.05 8.22 2.11 5.78 3.96 3.93C5.82 2.09 8.26 1.04 10.8 0.52C13.33 0.02 15.93 0 18.5 0L34.5 0C37.09 0 39.7 0.02 42.25 0.53C44.78 1.05 47.22 2.11 49.07 3.96C50.91 5.82 51.96 8.26 52.48 10.8C52.98 13.33 53 15.93 53 18.5L53 34.5C53 37.09 52.98 39.7 52.47 42.25C51.95 44.78 50.89 47.22 49.04 49.07C47.18 50.91 44.74 51.96 42.2 52.48C39.67 52.98 37.07 53 34.5 53L18.5 53C15.91 53 13.3 52.98 10.75 52.47C8.22 51.95 5.78 50.89 3.93 49.04C2.09 47.18 1.04 44.74 0.52 42.2C0.02 39.67 0 37.07 0 34.5Z M2 18.5C2 16.18 2 13.84 2.42 11.55C2.84 9.26 3.7 7.02 5.37 5.35C7.05 3.69 9.3 2.82 11.59 2.41C13.87 2 16.19 2 18.5 2L34.5 2C36.82 2 39.16 2 41.45 2.42C43.74 2.84 45.98 3.7 47.65 5.37C49.31 7.05 50.18 9.3 50.59 11.59C51 13.87 51 16.19 51 18.5L51 34.5C51 36.82 51 39.16 50.58 41.45C50.16 43.74 49.3 45.98 47.63 47.65C45.95 49.31 43.7 50.18 41.41 50.59C39.13 51 36.81 51 34.5 51L18.5 51C16.18 51 13.84 51 11.55 50.58C9.26 50.16 7.02 49.3 5.35 47.
53863C3.69 45.95 2.82 43.7 2.41 41.41C2 39.13 2 36.81 2 34.5Z%22 fill=%22oklch%280.62 0.13 220%29%22 fill-rule=%22evenodd%22/%3E%3C/svg%3E");} 539[data-mode="dark"]{--fring-b:url("data:image/svg+xml,%3Csvg xmlns=%22http://www.w3.org/2000/svg%22 width=%2253%22 height=%2253%22 viewBox=%220 0 53 53%22%3E%3Cpath d=%22M0 18.5C0 15.91 0.02 13.3 0.53 10.75C1.05 8.22 2.11 5.78 3.96 3.93C5.82 2.09 8.26 1.04 10.8 0.52C13.33 0.02 15.93 0 18.5 0L34.5 0C37.09 0 39.7 0.02 42.25 0.53C44.78 1.05 47.22 2.11 49.07 3.96C50.91 5.82 51.96 8.26 52.48 10.8C52.98 13.33 53 15.93 53 18.5L53 34.5C53 37.09 52.98 39.7 52.47 42.25C51.95 44.78 50.89 47.22 49.04 49.07C47.18 50.91 44.74 51.96 42.2 52.48C39.67 52.98 37.07 53 34.5 53L18.5 53C15.91 53 13.3 52.98 10.75 52.47C8.22 51.95 5.78 50.89 3.93 49.04C2.09 47.18 1.04 44.74 0.52 42.2C0.02 39.67 0 37.07 0 34.5Z M2 18.5C2 16.18 2 13.84 2.42 11.55C2.84 9.26 3.7 7.02 5.37 5.35C7.05 3.69 9.3 2.82 11.59 2.41C13.87 2 16.19 2 18.5 2L34.5 2C36.82 2 39.16 2 41.45 2.42C43.74 2.84 45.98 3.7 47.65 5.37C49.31 7.05 50.18 9.3 50.59 11.59C51 13.87 51 16.19 51 18.5L51 34.5C51 36.82 51 39.16 50.58 41.45C50.16 43.74 49.3 45.98 47.63 47.65C45.95 49.31 43.7 50.18 41.41 50.59C39.13 51 36.81 51 34.5 51L18.5 51C16.18 51 13.84 51 11.55 50.58C9.26 50.16 7.02 49.3 5.35 47.63C3.69 45.95 2.82 43.7 2.41 41.41C2 39.13 2 36.81 2 34.5Z%22 fill=%22oklch%280.74 0.13 220%29%22 fill-rule=%22evenodd%22/%3E%3C/svg%3E");} 540.field input{border-radius:18.5px;corner-shape:superellipse(1.53)} 541.field input:focus{border-image:var(--fring-b) 18 / 18px} 542.oauth-btn,.pk-signin-btn,.alt-btn{border-radius:18.5px;corner-shape:superellipse(1.53)} 543/* 2FA cells share the fields' 43.5px height, so the same corner box and 544 the same pre-painted focus ring hold without a resized asset. */ 545.tf-cell{border-radius:18.5px;corner-shape:superellipse(1.53)} 546.tf-cell.active{border-image:var(--fring-b) 18 / 18px} 547} 548 549@media (forced-colors: active){ 550.field input:focus{border-image:none;box-shadow:none;outline:2px solid Highlight;outline-offset:2px;animation:none} 551.tf-cell.active{border-image:none;box-shadow:none;outline:2px solid Highlight;outline-offset:2px;animation:none} 552} 553</style>
554<script> 555/* Pre-paint theme resolver â same script across every page (see index.html 556 for the full commentary). */ 557(function(){ 558 try { 559 var html = document.documentElement; 560 /* No stored choice -> dark: the site ships dark-first (user call 561 2026-07-13). 'auto' (follow the OS) only applies when the 562 visitor explicitly picked it in the theme panel. */ 563 var mode = localStorage.getItem('cf-mode'); 564 if (mode !== 'auto' && mode !== 'light' && mode !== 'dark') mode = 'dark'; 565 /* Signed-in accounts locked out of the SaaS carry cf-limited (the 566 cached /api/auth/me verdict â see theme.js) and render dark only; 567 everyone else, visitors included, follows cf-mode. */ 568 var limited = localStorage.getItem('cf-limited') === '1'; 569 /* Mirror the cached hint onto the root before first paint so CSS can 570 pre-hide limited-forbidden chrome (index's dashboard affordances) 571 without waiting for the async probe. */ 572 if (limited) html.setAttribute('data-limited', ''); 573 var dark = limited || mode === 'dark' || 574 (mode === 'auto' && matchMedia('(prefers-color-scheme: dark)').matches); 575 html.setAttribute('data-mode', dark ? 'dark' : 'light'); 576 var anim = localStorage.getItem('cf-anim') || 'auto'; 577 if (anim !== 'auto' && anim !== 'on' && anim !== 'off') anim = 'auto'; 578 var animOn = anim === 'on' || 579 (anim === 'auto' && !matchMedia('(prefers-reduced-motion: reduce)').matches); 580 html.setAttribute('data-anim', animOn ? 'on' : 'off'); 581 localStorage.removeItem('cf-color'); 582 localStorage.removeItem('cf-themecache'); 583 } catch(e){ 584 /* Storage blocked: no stored choice can exist â ship the dark 585 default. */ 586 try { html.setAttribute('data-mode', 'dark'); } catch(_){} 587 } 588})(); 589</script>
vendor: 1 bytes, line 589
589
590<script>self.__APP_VERSION__="650093fd";(function(){try{var K='cf-vchk',C=60000,M=5,b=self.__APP_VERSION__;try{sessionStorage.removeItem('cf-vbust');sessionStorage.removeItem('cf-vreload');}catch(e){}function rd(){try{var x=JSON.parse(sessionStorage.getItem(K)||'null');return x&&x.v?x:null;}catch(e){return null;}}function rm(v){try{var x=rd();if(x&&x.v===v)sessionStorage.removeItem(K);}catch(e){}}function arm(v){try{var x=rd(),n=Date.now(),c=x&&x.v===v?x.n||0:0;if(x&&x.v===v&&(c>=M||n-(x.t||0)<C))return false;sessionStorage.setItem(K,JSON.stringify({v:v,t:n,n:c+1}));return true;}catch(e){return false;}}var x=rd();if(x&&x.v===b)rm(b);var s=self.AbortSignal&&AbortSignal.timeout?AbortSignal.timeout(4000):undefined;fetch('/version.json?_='+Date.now(),{cache:'no-store',signal:s}).then(function(r){return r&&r.ok?r.json():null;}).then(function(d){var v=d&&d.v;if(!v)return null;if(v===b){rm(b);return null;}var q=self.AbortSignal&&AbortSignal.timeout?AbortSignal.timeout(6000):undefined;return fetch(location.pathname+location.search,{cache:'no-store',credentials:'same-origin',signal:q}).then(function(r){return r&&r.ok?r.text():'';}).then(function(t){var m=t.match(/self\.__APP_VERSION__=["']([0-9a-f]{8})["']/);return m&&m[1]===v?v:null;});}).then(function(v){if(!v)return;var ae=document.activeElement;if(!document.hidden&&((ae&&(ae.isContentEditable||/^(INPUT|TEXTAREA|SELECT)$/.test(ae.tagName)))||document.querySelector('.modal-shade:not([hidden])')))return;if(arm(v))location.reload();}).catch(function(){});}catch(e){}})();</script>
vendor: 1 bytes, line 590
590
591<script src="/theme.js?v=245375ca" defer></script>
591 592</head> 593<body> 594<button id="theme-btn" class="theme-btn" aria-label="Color theme"> 595 <svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"><circle cx="8" cy="8" r="5.5"/><path d="M8 2.5v11M2.5 8h11"/></svg> 596</button> 597<div id="theme-panel" class="theme-panel"> 598 <div class="tp-lbl" data-i18n="theme.label">Theme</div> 599 <div class="tp-modes"> 600 <button class="tp-mode" data-mode="auto" data-icon="auto"><span data-i18n="theme.auto">Auto</span></button> 601 <button class="tp-mode" data-mode="light" data-icon="light"><span data-i18n="theme.light">Light</span></button> 602 <button class="tp-mode" data-mode="dark" data-icon="dark"><span data-i18n="theme.dark">Dark</span></button> 603 </div> 604 <div class="tp-divider" style="margin-top:14px"></div> 605 <!-- language: folded into this panel; i18n.js detects #lang-list and populates it --> 606 <div class="tp-lbl" data-i18n="lang.label">Language</div> 607 <div class="lang-list" id="lang-list"></div> 608</div> 609 610<main class="shell"> 611 <a class="wordmark" translate="no" href="/"> 612 <span class="name">SUB&SUB</span> 613 </a> 614 615 <header class="auth-head"> 616 <h1 id="auth-title" data-i18n="login.title.signin">Welcome back</h1> 617 <p class="lead" id="auth-lead" data-i18n="login.lead.signin">Sign in to manage your keys, balance and usage.</p> 618 </header> 619 620 <!-- .auth-body scopes the scene veil: everything that flow switches 621 show/hide via display lives inside; the head above swaps its text 622 with its own move and the legal line below never changes. --> 623 <div class="auth-body" id="auth-body"> 624 625 <div class="auth-tabs" role="tablist" id="auth-tabs"> 626 <span class="auth-thumb" aria-hidden="true"></span> 627 <button class="auth-tab active" type="button" data-mode="signin" role="tab" aria-selected="true" aria-controls="signin-form" data-i18n="login.tab.signin">Sign in</button> 628 <button class="auth-tab" type="button" data-mode="signup" role="tab" aria-selected="false" aria-controls="signup-form" tabindex="-1" data-i18n="login.tab.signup">Sign up</button> 629 </div> 630 631 <a class="oauth-btn" id="google-btn" href="/api/auth/google/start"> 632 <!-- monochrome G: the four brand paths filled with currentColor merge 633 into the full G shape, engraved on the solid inverted fill --> 634 <svg viewBox="0 0 18 18" aria-hidden="true"> 635 <path fill="currentColor" d="M17.64 9.2c0-.637-.057-1.251-.164-1.84H9v3.481h4.844a4.14 4.14 0 0 1-1.796 2.717v2.258h2.908c1.702-1.567 2.684-3.875 2.684-6.615z"/> 636 <path fill="currentColor" d="M9 18c2.43 0 4.467-.806 5.956-2.18l-2.908-2.259c-.806.54-1.837.86-3.048.86-2.344 0-4.328-1.584-5.036-3.711H.957v2.332A9 9 0 0 0 9 18z"/> 637 <path fill="currentColor" d="M3.964 10.71A5.41 5.41 0 0 1 3.682 9c0-.593.102-1.17.282-1.71V4.958H.957A9 9 0 0 0 0 9c0 1.452.348 2.827.957 4.042l3.007-2.332z"/> 638 <path fill="currentColor" d="M9 3.58c1.321 0 2.508.454 3.44 1.345l2.582-2.58C13.463.891 11.426 0 9 0A9 9 0 0 0 .957 4.958L3.964 7.29C4.672 5.163 6.656 3.58 9 3.58z"/> 639 </svg> 640 <span id="google-label" data-i18n="login.google.signin">Continue with Google</span> 641 </a> 642 643 <!-- usernameless passkey sign-in: no email, no password â the browser 644 lists its discoverable subnsub.com credentials and the assertion 645 itself names the account. Revealed by JS where WebAuthn exists, 646 signin mode only. Label set from t() (no data-i18n): the whole 647 login.pk.* family is inline-English by design. --> 648 <button class="pk-signin-btn" id="pk-direct" type="button" hidden> 649 <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="M12 10a2 2 0 0 0-2 2c0 1.02-.1 2.51-.26 4"/><path d="M14 13.12c0 2.38 0 6.38-1 8.88"/><path d="M17.29 21.02c.12-.6.43-2.3.5-3.02"/><path d="M2 12a10 10 0 0 1 18-6"/><path d="M2 16h.01"/><path d="M21.8 16c.2-2 .131-5.354 0-6"/><path d="M5 19.5C5.5 18 6 15 6 12a6 6 0 0 1 .34-2"/><path d="M8.65 22c.21-.66.45-1.32.57-2"/><path d="M9 6.8a6 6 0 0 1 9 5.2v2"/></svg> 650 <span></span> 651 </button> 652 653 <div class="divider"><span data-i18n="login.divider">or</span></div> 654 655 <!-- Two separate <form> elements (not one form with JS-flipped 656 attributes) because WebKit fingerprints each form's password-field 657 autocomplete value at parse time. A dual-purpose form starting as 658 `current-password` is locked in as "sign-in" â toggling the 659 attribute later does not re-trigger Safari's Strong Password / 660 Hide My Email prompts on the signup side. Independent forms let 661 each one stand on its own as a clean sign-in or sign-up context. --> 662 <div class="form-stage" id="form-stage"> 663 <form class="auth-form" id="signin-form" autocomplete="on" novalidate> 664 <div class="field"> 665 <label for="signin-email" data-i18n="login.field.email">Email</label> 666 <!-- autocomplete="username" â see the signup form's note for why 667 it's not "email". --> 668 <input type="email" id="signin-email" name="email" required autocomplete="username" inputmode="email" autocapitalize="off" autocorrect="off" spellcheck="false" placeholder="[email protected]" data-i18n-placeholder="login.field.email.placeholder"> 669 </div> 670 671 <div class="field"> 672 <label for="signin-password" data-i18n="login.field.password">Password</label> 673 <input type="password" id="signin-password" name="password" required autocomplete="current-password" placeholder="â¢â¢â¢â¢â¢â¢â¢â¢" data-i18n-placeholder="login.field.password.placeholder"> 674 </div> 675 676 <div class="form-error" hidden role="alert"> 677 <svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"><circle cx="8" cy="8" r="7"/><path d="M8 4v5M8 11.5h.01"/></svg>
678 <span class="form-error-text"></span> 679 </div> 680 681 <button type="submit" class="ice-key submit-btn"> 682 <span class="key-shadow" aria-hidden="true"></span> 683 <span class="key-caustic" aria-hidden="true"></span> 684 <span class="key-base" aria-hidden="true"></span> 685 <span class="key-cap"> 686 <span class="reflection" aria-hidden="true"></span> 687 <span class="inclusions" aria-hidden="true"></span> 688 <span class="label"><span class="spinner" aria-hidden="true"></span><span data-i18n="login.submit.signin">Sign in</span></span> 689 </span> 690 </button> 691 692 <div class="form-foot"> 693 <a href="#" id="forgot-link" data-i18n="login.forgot">Forgot password?</a> 694 </div> 695 </form> 696 697 <form class="auth-form" id="signup-form" autocomplete="on" novalidate hidden> 698 <div class="field"> 699 <label for="signup-name" data-i18n="login.field.name">Name</label> 700 <input type="text" id="signup-name" name="name" autocomplete="name" placeholder="Jane Doe" data-i18n-placeholder="login.field.name.placeholder"> 701 </div> 702 703 <div class="field"> 704 <label for="signup-email" data-i18n="login.field.email">Email</label> 705 <!-- `autocomplete="username"` is the Apple-prescribed signal for 706 "this is the account identifier" â it lets Safari offer 707 "Hide My Email" on signup forms and lets 1Password / 708 iCloud Keychain / Chrome PW Manager save the right field. 709 Confusingly, `autocomplete="email"` does NOT trigger these 710 flows because it's defined as a generic email-collection 711 field (newsletters, contact forms), not an account login. --> 712 <input type="email" id="signup-email" name="email" required autocomplete="username" inputmode="email" autocapitalize="off" autocorrect="off" spellcheck="false" placeholder="[email protected]" data-i18n-placeholder="login.field.email.placeholder"> 713 </div> 714 715 <div class="field"> 716 <label for="signup-password" data-i18n="login.field.password">Password</label> 717 <input type="password" id="signup-password" name="password" required minlength="8" autocomplete="new-password" placeholder="â¢â¢â¢â¢â¢â¢â¢â¢" data-i18n-placeholder="login.field.password.placeholder"> 718 </div> 719 720 <div class="form-error" hidden role="alert"> 721 <svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"><circle cx="8" cy="8" r="7"/><path d="M8 4v5M8 11.5h.01"/></svg> 722 <span class="form-error-text"></span> 723 </div> 724 725 <button type="submit" class="ice-key submit-btn"> 726 <span class="key-shadow" aria-hidden="true"></span> 727 <span class="key-caustic" aria-hidden="true"></span> 728 <span class="key-base" aria-hidden="true"></span> 729 <span class="key-cap"> 730 <span class="reflection" aria-hidden="true"></span> 731 <span class="inclusions" aria-hidden="true"></span> 732 <span class="label"><span class="spinner" aria-hidden="true"></span><span data-i18n="login.submit.signup">Create account</span></span> 733 </span> 734 </button> 735 736 <div class="signup-consent" id="signup-consent" hidden> 737 <label class="legal-check" for="signup-legal"> 738 <input type="checkbox" id="signup-legal" name="accepted_legal" required aria-required="true" aria-describedby="signup-consent-hint signup-legal-error" autocomplete="off"> 739 <span class="legal-ice" aria-hidden="true"><svg class="legal-ice-check" viewBox="0 0 16 16"><path d="M3.2 8.2l3 3 6.7-6.7"/></svg></span> 740 <span class="legal-copy" data-i18n-html="login.signup.consent">I confirm that I am at least 18 (or the age of legal majority where I live), agree to the <a href="/terms" target="_blank" rel="noopener">Terms of Service</a>, and acknowledge the <a href="/privacy" target="_blank" rel="noopener">Privacy Policy</a>.</span> 741 </label> 742 <p class="legal-hint" id="signup-consent-hint" data-i18n="login.signup.consent_hint">Required to create an account. This is not a marketing opt-in; the linked legal documents are in English.</p> 743 <p class="legal-error" id="signup-legal-error" role="alert" data-i18n="login.err.legal_required" hidden>Please check the box before creating an account.</p> 744 </div> 745 </form> 746 </div> 747 748 <!-- Forgot-password form â single email field. Submitted to 749 /api/auth/forgot-password, which always returns ok:true to prevent 750 account enumeration. On success we swap to the #forgot-sent panel 751 below regardless of whether the address is registered. --> 752 <form class="auth-form reveal" id="forgot-form" autocomplete="on" novalidate hidden> 753 <div class="field"> 754 <label for="forgot-email" data-i18n="login.field.email">Email</label> 755 <input type="email" id="forgot-email" name="email" required autocomplete="username" inputmode="email" autocapitalize="off" autocorrect="off" spellcheck="false" placeholder="[email protected]" data-i18n-placeholder="login.field.email.placeholder"> 756 </div> 757 758 <div class="form-error" hidden role="alert"> 759 <svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"><circle cx="8" cy="8" r="7"/><path d="M8 4v5M8 11.5h.01"/></svg>
760 <span class="form-error-text"></span> 761 </div> 762 763 <button type="submit" class="ice-key submit-btn"> 764 <span class="key-shadow" aria-hidden="true"></span> 765 <span class="key-caustic" aria-hidden="true"></span> 766 <span class="key-base" aria-hidden="true"></span> 767 <span class="key-cap"> 768 <span class="reflection" aria-hidden="true"></span> 769 <span class="inclusions" aria-hidden="true"></span> 770 <span class="label"><span class="spinner" aria-hidden="true"></span><span data-i18n="login.forgot.submit">Send reset link</span></span> 771 </span> 772 </button> 773 774 <p class="forgot-back"> 775 <a href="#" id="forgot-back" data-i18n="login.forgot.back">Back to sign in</a> 776 </p> 777 </form> 778 779 <!-- Reset-email-sent panel â shown after a successful forgot-password 780 submit. Mirrors the verify panel: same shape, different copy. The 781 Resend button re-posts to /api/auth/forgot-password with the same 782 60s cooldown. --> 783 <section id="forgot-sent-panel" class="verify-panel reveal" hidden aria-live="polite"> 784 <p class="verify-lead" data-i18n="login.forgot.sent.lead">If that email is registered, we sent a reset link. Click it to choose a new password.</p> 785 <div class="verify-email" id="forgot-sent-email"></div> 786 <div class="verify-msg" id="forgot-sent-msg" hidden role="status"></div> 787 <button type="button" class="ice-key submit-btn" id="forgot-resend"> 788 <span class="key-shadow" aria-hidden="true"></span> 789 <span class="key-caustic" aria-hidden="true"></span> 790 <span class="key-base" aria-hidden="true"></span> 791 <span class="key-cap"> 792 <span class="reflection" aria-hidden="true"></span> 793 <span class="inclusions" aria-hidden="true"></span> 794 <span class="label"><span class="spinner" aria-hidden="true"></span><span data-i18n="login.forgot.resend">Resend reset email</span></span> 795 </span> 796 </button> 797 <p class="verify-back"> 798 <a href="#" id="forgot-sent-back" data-i18n="login.forgot.back">Back to sign in</a> 799 </p> 800 </section> 801 802 <!-- Two-step panel â the second step for accounts that opted into 2FA. 803 Reached from a `twofa_required` login/reset response, or via 804 /login?pkgate=⦠when a redirect flow (Google OAuth, email verify) 805 parked its destination in the gate. The options call decides which 806 factor to render: the WebAuthn ceremony button (passkey accounts, 807 against /api/auth/passkey/{options,verify}) or the authenticator 808 code form (TOTP accounts, against /api/auth/twofa/check). --> 809 <style>#tf-form{display:flex;flex-direction:column;gap:14px}#tf-form[hidden]{display:none}</style> 810 <section id="passkey-panel" class="verify-panel reveal" hidden aria-live="polite"> 811 <p class="verify-lead" id="pk-lead"></p> 812 <div class="verify-msg is-warn" id="pk-msg" hidden role="status"></div> 813 <button type="button" class="ice-key submit-btn" id="pk-go" hidden> 814 <span class="key-shadow" aria-hidden="true"></span> 815 <span class="key-caustic" aria-hidden="true"></span> 816 <span class="key-base" aria-hidden="true"></span> 817 <span class="key-cap"> 818 <span class="reflection" aria-hidden="true"></span> 819 <span class="inclusions" aria-hidden="true"></span> 820 <span class="label"><span class="spinner" aria-hidden="true"></span><span id="pk-go-label"></span></span> 821 </span> 822 </button> 823 <form id="tf-form" hidden> 824 <div class="field"> 825 <label for="tf-code" data-i18n="login.tf.code">Authenticator code</label> 826 <!-- Cells are the visible face of #tf-code and carry no semantics 827 of their own; the input has no maxlength on purpose â a paste 828 like "123 456" must survive until the JS sanitiser strips the 829 separators, not be truncated by the browser first. --> 830 <div class="tf-otp"> 831 <span class="tf-cell" aria-hidden="true"></span> 832 <span class="tf-cell" aria-hidden="true"></span> 833 <span class="tf-cell" aria-hidden="true"></span> 834 <span class="tf-cell" aria-hidden="true"></span>
835 <span class="tf-cell" aria-hidden="true"></span> 836 <span class="tf-cell" aria-hidden="true"></span> 837 <input type="text" id="tf-code" inputmode="numeric" autocomplete="one-time-code" pattern="[0-9]*" spellcheck="false" autocapitalize="off"> 838 </div> 839 </div> 840 <button type="submit" class="ice-key submit-btn" id="tf-go"> 841 <span class="key-shadow" aria-hidden="true"></span> 842 <span class="key-caustic" aria-hidden="true"></span> 843 <span class="key-base" aria-hidden="true"></span> 844 <span class="key-cap"> 845 <span class="reflection" aria-hidden="true"></span> 846 <span class="inclusions" aria-hidden="true"></span> 847 <span class="label"><span class="spinner" aria-hidden="true"></span><span data-i18n="login.tf.btn">Verify</span></span> 848 </span> 849 </button> 850 </form> 851 <!-- 'both' accounts may finish with either factor â this button flips 852 between the passkey ceremony and the code form. Hidden unless 853 options says alt:'totp'. --> 854 <div id="tf-alt-row" hidden> 855 <button type="button" class="alt-btn" id="tf-alt"></button> 856 </div> 857 <p class="verify-back"> 858 <a href="#" id="pk-back" data-i18n="login.pk.back">Back to sign in</a> 859 </p> 860 </section> 861 862 <!-- Verification panel â shown after a successful signup, or after a 863 sign-in attempt that returns `email_unverified`. The forms, OAuth 864 button, and divider are hidden while this is visible. --> 865 <section id="verify-panel" class="verify-panel reveal" hidden aria-live="polite"> 866 <p class="verify-lead" data-i18n="login.verify.lead">We sent a verification link to your email. Click it to finish signing in.</p> 867 <div class="verify-email" id="verify-email"></div> 868 <div class="verify-msg" id="verify-msg" hidden role="status"></div> 869 <button type="button" class="ice-key submit-btn" id="verify-resend"> 870 <span class="key-shadow" aria-hidden="true"></span> 871 <span class="key-caustic" aria-hidden="true"></span> 872 <span class="key-base" aria-hidden="true"></span> 873 <span class="key-cap"> 874 <span class="reflection" aria-hidden="true"></span> 875 <span class="inclusions" aria-hidden="true"></span> 876 <span class="label"><span class="spinner" aria-hidden="true"></span><span data-i18n="login.verify.resend">Resend verification email</span></span> 877 </span> 878 </button> 879 <p class="verify-back"> 880 <a href="#" id="verify-back" data-i18n="login.verify.back">Back to sign in</a> 881 </p> 882 </section> 883 884 <p class="auth-alt" id="auth-alt"> 885 <span id="auth-alt-prompt" data-i18n="login.alt.signin.prompt">Don't have an account?</span> 886 <a href="?mode=signup" id="auth-alt-link" data-i18n="login.alt.signin.link">Sign up</a> 887 </p> 888 889 </div><!-- /.auth-body --> 890 891 <p class="auth-legal" data-i18n-html="login.legal.notice">Use of the service is governed by our <a href="/terms">Terms</a> and described in our <a href="/privacy">Privacy Policy</a>.</p> 892</main> 893
894<script> 895/* Theme switcher is owned by /theme.js â loaded from <head>. */ 896 897/* ââ Sign in / Sign up mode toggle âââââââââââââââââââââââââââââââââââââ */ 898(function(){ 899 const tabs = document.querySelectorAll('.auth-tab'); 900 const signinForm = document.getElementById('signin-form'); 901 const signupForm = document.getElementById('signup-form'); 902 const googleLabel = document.getElementById('google-label'); 903 const authTitle = document.getElementById('auth-title'); 904 const authLead = document.getElementById('auth-lead'); 905 const altPrompt = document.getElementById('auth-alt-prompt'); 906 const altLink = document.getElementById('auth-alt-link'); 907 const googleBtn = document.getElementById('google-btn'); 908 const legalBox = document.getElementById('signup-consent'); 909 const legalCheck = document.getElementById('signup-legal'); 910 const legalError = document.getElementById('signup-legal-error'); 911 /* Usernameless passkey door â declared before the first setMode() call 912 below, which owns its visibility (signin mode + WebAuthn only) and 913 stamps its label (t()'s FALLBACK table initialises further down, so 914 no t() calls up here). */ 915 const pkDirect = document.getElementById('pk-direct'); 916 const pkdSupported = !!(window.PublicKeyCredential && navigator.credentials && 917 typeof navigator.credentials.get === 'function'); 918 919 /* If we arrived with a ?return= (e.g. the sign-in button on 920 tools.subnsub.com), send the user back there after auth instead of the 921 default /console. Restricted to *.subnsub.com so a crafted return can't 922 redirect off-site; the Google path forwards it through start.js. */ 923 const RETURN_TO = (function(){ 924 const raw = new URLSearchParams(location.search).get('return'); 925 if (!raw) return null; 926 /* Mirror the server guard exactly: parse with NO base (so relative 927 URLs are rejected, not resolved against this origin) and require a 928 real http(s) scheme before trusting the host â without the scheme 929 check `javascript://subnsub.com/â¦` slips past a host-only test and 930 becomes an XSS sink via the location.assign() below. */ 931 let u; 932 try { u = new URL(raw); } catch (_) { return null; } 933 if (u.protocol !== 'https:' && u.protocol !== 'http:') return null; 934 const h = u.hostname.toLowerCase(); 935 if (h === 'subnsub.com' || h.endsWith('.subnsub.com')) return u.href; 936 return null; 937 })(); 938 if (RETURN_TO) { 939 if (googleBtn) googleBtn.href = '/api/auth/google/start?return=' + encodeURIComponent(RETURN_TO); 940 } 941 942 /* Translation keys per mode â i18n.js may not be loaded yet (defer), so 943 we read from window.I18N when available and fall back to data-i18n-* on 944 the elements (which point at the *current* key, ie last mode applied). */ 945 const T = { 946 signin: { 947 title: 'login.title.signin', 948 lead: 'login.lead.signin', 949 google: 'login.google.signin', 950 altPrompt:'login.alt.signin.prompt', 951 altLink: 'login.alt.signin.link', 952 }, 953 signup: { 954 title: 'login.title.signup', 955 lead: 'login.lead.signup', 956 google: 'login.google.signup', 957 altPrompt:'login.alt.signup.prompt', 958 altLink: 'login.alt.signup.link', 959 }, 960 }; 961 /* English fallback text â used until i18n.js has applied translations, 962 and as the base content for the data-i18n keys that don't yet exist 963 in translations.js. */ 964 const FALLBACK = { 965 'login.title.signin': 'Welcome back', 966 'login.title.signup': 'Create your account', 967 'login.title.verify': 'Check your inbox', 968 'login.title.forgot': 'Reset your password', 969 'login.title.forgot.sent': 'Check your inbox', 970 'login.lead.signin': 'Sign in to manage your keys, balance and usage.', 971 'login.lead.signup': 'AI API, Pay as you go.', 972 'login.lead.verify': 'Click the link in the email we just sent to finish signing in.', 973 'login.lead.forgot': 'Enter your account email and weâll send a link to choose a new password.', 974 'login.lead.forgot.sent': 'Click the link in the email we just sent to finish resetting your password.', 975 'login.google.signin': 'Continue with Google', 976 'login.google.signup': 'Continue with Google', 977 'login.alt.signin.prompt': "Don't have an account?", 978 'login.alt.signin.link': 'Sign up', 979 'login.alt.signup.prompt': 'Already have an account?', 980 'login.alt.signup.link': 'Sign in', 981 'login.verify.lead': 'We sent a verification link to your email. Click it to finish signing in.', 982 'login.verify.resend': 'Resend verification email', 983 'login.verify.back': 'Back to sign in', 984 'login.verify.sent': 'Verification email sent. Check your inbox.', 985 'login.verify.cooldown': 'Please wait a moment before requesting another email.', 986 'login.verify.send_failed': "We couldn't send the verification email. Please try again in a minute.", 987 'login.err.email_unverified': 'Please verify your email before signing in. We can resend the link below.', 988 'login.forgot.submit': 'Send reset link', 989 'login.forgot.resend': 'Resend reset email', 990 'login.forgot.back': 'Back to sign in', 991 'login.forgot.sent': 'If that email is registered, we sent a reset link.', 992 'login.forgot.sent.lead': 'If that email is registered, we sent a reset link. Click it to choose a new password.', 993 'login.forgot.cooldown': 'Please wait a moment before requesting another email.', 994 'login.forgot.send_failed': "We couldn't send the reset email. Please try again in a minute.", 995 'login.pk.checking': 'Checking this sign-inâ¦', 996 'login.pk.lead.create': 'Your account requires a passkey. Create one now â your device will ask for Face ID, a fingerprint or its screen lock â to finish signing in.', 997 'login.pk.lead.get': 'Confirm itâs you with your passkey to finish signing in.', 998 'login.pk.btn.create': 'Create passkey', 999 'login.pk.btn.get': 'Use passkey', 1000 'login.pk.back': 'Back to sign in', 1001 'login.pk.cancelled': 'The passkey prompt was cancelled â try again.', 1002 'login.pk.expired': 'This sign-in attempt expired. Please sign in again.', 1003 'login.pk.unsupported': 'This browser canât use passkeys. Please switch to a current version of Chrome, Edge, Safari or Firefox.', 1004 'login.pk.failed': 'Passkey verification failed â please try again.', 1005 'login.pk.dupe': 'This passkey is already registered to an account.', 1006 'login.tf.lead': 'Enter the 6-digit code from your authenticator app to finish signing in.', 1007 'login.tf.code': 'Authenticator code', 1008 'login.tf.btn': 'Verify', 1009 'login.tf.bad': 'That code didnât work â try again.', 1010 'login.tf.used': 'That code was just used â wait for the next one.', 1011 'login.tf.useCode': 'Use an authenticator code instead', 1012 'login.tf.usePk': 'Use your passkey instead', 1013 'login.pkdirect': 'Sign in with a passkey', 1014 'login.pkdirect.fail': 'Passkey sign-in didnât complete â try again, or use your password below.', 1015 'login.pkdirect.rate': 'Too many attempts â wait 15 minutes and try again.', 1016 'login.err.legal_required': 'Please check the box before creating an account.', 1017 'login.err.invalid_email': 'Please enter a valid email address.', 1018 'login.err.failed': 'Sign-in failed. Please try again.', 1019 /* Per-IP outbound-mail brake (functions/_lib/mail-rate.js). Kept apart 1020 from login.err.failed on purpose â "try again" is the one 1021 instruction that keeps a throttled caller throttled. */ 1022 'login.err.rate_limited': 'Too many requests from your network â please try again later.', 1023 'login.err.google_cancelled': 'Google sign-in was cancelled. Please try again.', 1024 'login.err.google_unconfigured': 'Google sign-in is not configured on this deployment.', 1025 'login.err.google_state': 'Sign-in request expired. Please try again.', 1026 'login.err.google_bad_request': 'Invalid Google sign-in response.', 1027 'login.err.google_token_exchange': 'Could not reach Google. Please try again.', 1028 'login.err.google_no_id_token': 'Google returned an unexpected response.', 1029 'login.err.google_bad_id_token': 'Google returned an invali
1029d token.', 1030 'login.err.google_bad_issuer': 'Google returned an invalid token issuer.', 1031 'login.err.google_bad_audience': 'Google token audience mismatch.', 1032 'login.err.google_email_unverified': 'Your Google email is not verified.', 1033 'login.err.google_upsert': 'Could not create or update your account.', 1034 'login.err.verify_missing': 'Verification link is missing its token. Please request a new email below.', 1035 'login.err.verify_invalid': 'This verification link is invalid or has expired. Please request a new one.', 1036 'login.err.verify_db': 'Something went wrong while verifying your email. Please try again.', 1037 }; 1038 function t(key){ 1039 if (window.I18N && window.I18N.t) { 1040 const s = window.I18N.t(key); 1041 if (s && s !== key) return s; 1042 } 1043 return FALLBACK[key] != null ? FALLBACK[key] : key; 1044 } 1045 1046 function showFormError(form, msg){ 1047 const txt = form.querySelector('.form-error-text'); 1048 const box = form.querySelector('.form-error'); 1049 if (txt) txt.textContent = msg; 1050 if (box) box.hidden = false; 1051 } 1052 function clearFormError(form){ 1053 const box = form.querySelector('.form-error'); 1054 if (box) box.hidden = true; 1055 } 1056 function showLegalError(){ 1057 if (legalError) { 1058 legalError.textContent = t('login.err.legal_required'); 1059 legalError.hidden = false; 1060 } 1061 if (legalCheck) { 1062 legalCheck.setAttribute('aria-invalid', 'true'); 1063 legalCheck.focus(); 1064 } 1065 } 1066 function clearLegalError(){ 1067 if (legalError) legalError.hidden = true; 1068 if (legalCheck) legalCheck.removeAttribute('aria-invalid'); 1069 } 1070 if (legalCheck) { 1071 /* Defeat browser form-state restoration: every fresh registration view 1072 starts unselected, while switching tabs within this page preserves the 1073 user's explicit choice. */ 1074 legalCheck.checked = false; 1075 legalCheck.addEventListener('change', () => { if (legalCheck.checked) clearLegalError(); }); 1076 } 1077 1078 /* ââ Mode-switch choreography ââââââââââââââââââââââââââââââââââââââ 1079 One generation counter arbitrates every animated sequence: each new 1080 switch (or panel entry) bumps it, and pending timeline steps compare 1081 their captured generation before acting â hammering the tabs never 1082 interleaves two half-played sequences; the newest switch owns the 1083 final state. All motion is inline-style transitions, which the 1084 engine picks up from the CURRENT computed value, so an interrupted 1085 fade or glide reverses from wherever it is, never from a snap. 1086 Under data-anim="off" every path short-circuits to instant. */ 1087 const authTabs = document.getElementById('auth-tabs'); 1088 const authBody = document.getElementById('auth-body'); 1089 const formStage = document.getElementById('form-stage'); 1090 const authAlt = document.getElementById('auth-alt'); 1091 const shell = document.querySelector('.shell'); 1092 /* The sign-up-only Name row â the single field that differs between the 1093 two forms above the shared email/password pair. Growing/collapsing it 1094 is what makes the shared fields GLIDE to their new place (the demo's 1095 magnetic-reflow) instead of cross-fading or jumping. */ 1096 const nameField = (function(){ const i = document.getElementById('signup-name'); return i ? i.closest('.field') : null; })(); 1097 const GLIDE = 'cubic-bezier(.22,.61,.36,1)'; /* = --glide in the stylesheet */ 1098 /* Switch timeline (ms) â all zero since 2026-07-14 (user call: the 1099 sign-in page needs none of the switch flourish). With every value at 1100 zero setMode bypasses the legacy height choreography entirely and swaps 1101 the forms atomically; otherwise its queued 0ms callbacks briefly left 1102 the old legal block in layout and inflated the scroll height for one 1103 frame. The keys stay so a future retune is a number edit, not a rebuild; 1104 .auth-thumb's independent CSS glide is the one move left. */ 1105 const SW = { out: 0, in: 0, height: 0, expand: 0, collapse: 0 }; 1106 const SWITCH_LAYOUT_MOTION = Object.values(SW).some(ms => ms > 0); 1107 let swapGen = 0; 1108 let booted = false;
1109 const animOn = () => document.documentElement.getAttribute('data-anim') !== 'off'; 1110 1111 /* Everything the switch touches with inline styles. */ 1112 const swapEls = () => [authTitle, authLead, googleLabel, authAlt, signinForm, signupForm, nameField] 1113 .concat(pkDirect ? [pkDirect] : []); 1114 1115 function clearSwapStyles(){ 1116 swapEls().forEach(el => { 1117 if (!el) return; 1118 el.style.transition = ''; 1119 el.style.opacity = ''; 1120 el.style.transform = ''; 1121 el.style.animation = ''; 1122 el.style.height = ''; 1123 el.style.minHeight = ''; 1124 el.style.marginTop = ''; 1125 el.style.marginBottom = ''; 1126 el.style.paddingTop = ''; 1127 el.style.paddingBottom = ''; 1128 el.style.overflow = ''; 1129 }); 1130 formStage.classList.remove('stage-anim'); 1131 formStage.style.height = ''; 1132 } 1133 1134 /* freezeStage: a tabâtab re-switch taking over mid-glide holds the 1135 stage at its current animated height so the next timeline glides on 1136 from there (its other inline styles stay too â the new timeline 1137 re-targets every one of them). All other callers (panel entries, 1138 instant repaints) tear the choreography down completely. */ 1139 function cancelSwap(freezeStage){ 1140 swapGen++; 1141 if (freezeStage){ 1142 if (formStage.classList.contains('stage-anim')){ 1143 const cur = formStage.offsetHeight; 1144 formStage.classList.remove('stage-anim'); 1145 formStage.style.height = cur + 'px'; 1146 } 1147 return; 1148 } 1149 clearSwapStyles(); 1150 } 1151 function at(gen, ms, fn){ setTimeout(() => { if (gen === swapGen) fn(); }, ms); } 1152 1153 function paintTabs(){
1154 tabs.forEach(b => { 1155 const on = b.dataset.mode === mode; 1156 b.classList.toggle('active', on); 1157 b.setAttribute('aria-selected', on ? 'true' : 'false'); 1158 b.tabIndex = on ? 0 : -1; 1159 }); 1160 /* data-active slides .auth-thumb via its CSS transform transition â 1161 compositor-driven, so it stays smooth through the layout work 1162 below, and an interrupted glide simply turns around mid-track. */ 1163 if (authTabs) authTabs.dataset.active = mode; 1164 } 1165 1166 /* Scene veil â flow switches (sign-in â forgot/verify/passkey) toggle 1167 whole blocks via display, which can't transition; fading the wrapper 1168 back in presents the new scene as one deliberate piece. */ 1169 function sceneFade(){ 1170 if (!authBody || !animOn()) return; 1171 authBody.classList.remove('scene-in'); 1172 void authBody.offsetWidth; 1173 authBody.classList.add('scene-in'); 1174 setTimeout(() => authBody.classList.remove('scene-in'), 400); 1175 } 1176 1177 /* Title/lead hand-off for flow switches â same fade shape as the tab 1178 switch but on the vertical axis: the head yields to a new scene 1179 rather than travelling sideways with the thumb. */ 1180 function swapHead(applyFn){ 1181 if (!animOn()){ applyFn(); return; } 1182 const gen = swapGen; 1183 [authTitle, authLead].forEach(el => { 1184 el.style.transition = `opacity ${SW.out}ms ease-in, transform ${SW.out}ms ease-in`; 1185 el.style.opacity = '0'; 1186 el.style.transform = 'translateY(-4px)'; 1187 }); 1188 at(gen, SW.out, () => { 1189 applyFn(); 1190 [authTitle, authLead].forEach(el => { 1191 el.style.transition = 'none'; 1192 el.style.transform = 'translateY(5px)'; 1193 }); 1194 void authTitle.offsetHeight; 1195 [authTitle, authLead].forEach((el, i) => { 1196 at(gen, 10 + i * SW.stag, () => { 1197 el.style.transition = `opacity ${SW.in}ms ${GLIDE}, transform ${SW.in}ms ${GLIDE}`; 1198 el.style.opacity = '1'; 1199 el.style.transform = 'translateY(0)'; 1200 }); 1201 }); 1202 at(gen, 10 + SW.stag + SW.in + 80, () => { 1203 [authTitle, authLead].forEach(el => { 1204 el.style.transition = ''; el.style.opacity = ''; el.style.transform = ''; 1205 }); 1206 }); 1207 }); 1208 } 1209 1210 /* Collapse / expand the passkey door in the same window as the stage 1211 glide â height and margin melt away while it fades, so the divider 1212 below travels smoothly instead of jumping a whole row. Both start 1213 from the CURRENT rendered height, so interrupting one mid-flight 1214 reverses it from wherever it is. */ 1215 /* pkTrans: the row's whole box must reach zero â height alone floors at the 1216 button's min-content (icon + padding â 26px) because overflow:clip trims 1217 the children, not the element's own intrinsic height. So padding and 1218 min-height melt away with the height (and margin), and only then does it 1219 read as one clean row folding shut. Duration = SW.expand so the door and 1220 the sign-up Name row move on exactly the same beat. */ 1221 const pkTrans = `height ${SW.expand}ms ${GLIDE}, margin-top ${SW.expand}ms ${GLIDE}, padding-top ${SW.expand}ms ${GLIDE}, padding-bottom ${SW.expand}ms ${GLIDE}, opacity ${Math.round(SW.expand*.7)}ms ease`; 1222 function pkCollapse(gen){ 1223 const el = pkDirect; 1224 if (!el || el.hidden) return; 1225 const h = el.offsetHeight; 1226 const cs = getComputedStyle(el);
1227 el.style.animation = 'none'; 1228 el.style.transition = 'none'; 1229 el.style.overflow = 'clip'; 1230 el.style.minHeight = '0px'; 1231 el.style.height = h + 'px'; 1232 el.style.marginTop = cs.marginTop; 1233 el.style.paddingTop = cs.paddingTop; 1234 el.style.paddingBottom = cs.paddingBottom; 1235 void el.offsetHeight; 1236 el.style.transition = pkTrans; 1237 el.style.height = '0px'; 1238 el.style.marginTop = '0px'; 1239 el.style.paddingTop = '0px'; 1240 el.style.paddingBottom = '0px'; 1241 el.style.opacity = '0'; 1242 at(gen, SW.expand + 50, () => { el.hidden = true; }); 1243 } 1244 function pkExpand(gen){ 1245 const el = pkDirect; 1246 if (!el || !pkdSupported) return; 1247 el.hidden = false; 1248 el.style.animation = 'none'; 1249 el.style.transition = 'none'; 1250 el.style.height = ''; el.style.marginTop = ''; el.style.paddingTop = ''; el.style.paddingBottom = ''; el.style.opacity = ''; 1251 const h = el.offsetHeight; 1252 const cs = getComputedStyle(el); 1253 const mt = cs.marginTop, pt = cs.paddingTop, pb = cs.paddingBottom; 1254 el.style.overflow = 'clip'; 1255 el.style.minHeight = '0px'; 1256 el.style.height = '0px'; el.style.marginTop = '0px'; el.style.paddingTop = '0px'; el.style.paddingBottom = '0px'; el.style.opacity = '0'; 1257 void el.offsetHeight; 1258 el.style.transition = pkTrans; 1259 el.style.height = h + 'px'; el.style.marginTop = mt; el.style.paddingTop = pt; el.style.paddingBottom = pb; el.style.opacity = '1'; 1260 at(gen, SW.expand + 50, () => { 1261 el.style.transition = ''; el.style.height = ''; el.style.marginTop = ''; el.style.paddingTop = ''; el.style.paddingBottom = ''; 1262 el.style.minHeight = ''; el.style.overflow = ''; el.style.opacity = ''; el.style.animation = ''; 1263 }); 1264 } 1265 1266 let mode = 'signin'; 1267 function setMode(next, opts){ 1268 if (next !== 'signin' && next !== 'signup') next = 'signin'; 1269 const fromPanel = ['forgot-active', 'verify-active', 'passkey-active'] 1270 .some(c => document.body.classList.contains(c)); 1271 const animate = SWITCH_LAYOUT_MOTION && booted && !fromPanel && next !== mode && 1272 animOn() && !(opts && opts.instant); 1273 cancelSwap(animate); 1274 mode = next; 1275 /* leaving the forgot/verify/passkey flows: drop their body classes and 1276 hide their panels so the regular sign-in chrome reappears. */ 1277 document.body.classList.remove('forgot-active', 'verify-active', 'passkey-active'); 1278 const fp = document.getElementById('forgot-sent-panel'); 1279 if (fp) fp.hidden = true; 1280 const ff = document.getElementById('forgot-form'); 1281 if (ff) ff.hidden = true; 1282 const vp = document.getElementById('verify-panel'); 1283 if (vp) vp.hidden = true; 1284 const pp = document.getElementById('passkey-panel'); 1285 if (pp) pp.hidden = true; 1286 paintTabs(); 1287 /* clear any prior error on either form */ 1288 clearFormError(signinForm); 1289 clearFormError(signupForm); 1290 /* URL reflect (?mode=signup / drop param for signin) without reload */ 1291 try { 1292 const url = new URL(location.href); 1293 if (mode === 'signup') url.searchParams.set('mode', 'signup'); 1294 else url.searchParams.delete('mode'); 1295 history.replaceState(null, '', url.pathname + (url.searchParams.toString() ? '?' + url.searchParams.toString() : '') + url.hash); 1296 } catch(_) {} 1297 /* Returning from a panel flow: the sign-in chrome pops back via 1298 display â veil the scene so it fades up as one piece. */ 1299 if (fromPanel && !(opts && opts.instant)) sceneFade(); 1300 const keys = T[mode]; 1301 const apply = { 1302 title(){ authTitle.dataset.i18n = keys.title; authTitle.textContent = t(keys.title); }, 1303 lead(){ authLead.dataset.i18n = keys.lead; authLead.textContent = t(keys.lead); }, 1304 google(){ googleLabel.dataset.i18n = keys.google; googleLabel.textContent = t(keys.google); }, 1305 alt(){ 1306 altPrompt.dataset.i18n = keys.altPrompt; altPrompt.textContent = t(keys.altPrompt); 1307 altLink.dataset.i18n = keys.altLink; altLink.textContent = t(keys.altLink); 1308 altLink.setAttribute('href', mode === 'signin' ? '?mode=signup' : '?mode=signin'); 1309 }, 1310 /* form swap â each <form> declares its own autocomplete= contract, 1311 so Safari/Keychain sees a clean sign-in or sign-up context. */ 1312 forms(){ 1313 signinForm.hidden = mode !== 'signin'; 1314 signupForm.hidden = mode !== 'signup'; 1315 if (legalBox) legalBox.hidden = mode !== 'signup'; 1316 if (mode !== 'signup') clearLegalError(); 1317 }, 1318 }; 1319 /* passkey label is mode-independent â refresh it on every pass */ 1320 if (pkDirect) pkDirect.querySelector('span').textContent = t('login.pkdirect'); 1321 if (!animate){ 1322 apply.title(); apply.lead(); apply.google(); apply.alt(); apply.forms(); 1323 /* passkey sign-in only makes sense on the sign-in face */ 1324 if (pkDirect) pkDirect.hidden = mode !== 'signin' || !pkdSupported; 1325 booted = true; 1326 return; 1327 } 1328 animateSwitch(apply, keys); 1329 } 1330 1331 /* ââ Switch primitives ââââââââââââââââââââââââââââââââââââââââââââââââ */ 1332 1333 /* Grow (open) or collapse (open=false) the sign-up Name row while the 1334 shared email/password hold formation â the magnetic reflow. The form is 1335 flex with gap:12; a 0-height first row still leaves a 12px ghost gap 1336 below it, so the row's own margin-bottom is driven to â12 in lockstep 1337 with the height (margins don't collapse in flex, so email lands flush). */ 1338 function animRow(gen, el, open, dur){ 1339 if (!el) return;
1340 el.style.animation = 'none'; 1341 el.style.overflow = 'clip'; 1342 el.style.transition = 'none'; 1343 el.style.height = 'auto'; el.style.marginBottom = ''; 1344 const nat = el.offsetHeight; 1345 const from = open ? { h:'0px', m:'-12px', o:'0' } : { h:nat+'px', m:'0px', o:'1' }; 1346 const to = open ? { h:nat+'px', m:'0px', o:'1' } : { h:'0px', m:'-12px', o:'0' }; 1347 el.style.height = from.h; el.style.marginBottom = from.m; el.style.opacity = from.o; 1348 void el.offsetHeight; 1349 el.style.transition = `height ${dur}ms ${GLIDE}, margin-bottom ${dur}ms ${GLIDE}, opacity ${Math.round(dur*.6)}ms ease`; 1350 el.style.height = to.h; el.style.marginBottom = to.m; el.style.opacity = to.o; 1351 at(gen, dur + 60, () => { 1352 el.style.transition=''; el.style.height=''; el.style.marginBottom=''; el.style.overflow=''; el.style.opacity=''; el.style.animation=''; 1353 }); 1354 } 1355 /* Preset the Name row at its collapsed extreme, no transition â used to 1356 preload the just-shown sign-up form so its email/password start exactly 1357 where sign-in had them (the invisible-swap invariant). */ 1358 function parkRowCollapsed(el){ 1359 if (!el) return; 1360 el.style.animation='none'; el.style.overflow='clip'; el.style.transition='none'; 1361 el.style.height='0px'; el.style.marginBottom='-12px'; el.style.opacity='0'; 1362 } 1363 /* Clipped height glide between the two forms' natural heights â carries 1364 the copy/legal below on one monotonic track while the rows reflow inside 1365 (internal add/remove of the Name/Forgot rows is hidden by the clip). */ 1366 function glideStage(gen, h0, h1){ 1367 if (h0 === h1) return; 1368 formStage.style.height = h0 + 'px'; 1369 formStage.classList.add('stage-anim'); 1370 void formStage.offsetHeight; 1371 formStage.style.height = h1 + 'px'; 1372 at(gen, SW.height + 40, () => { formStage.classList.remove('stage-anim'); formStage.style.height = ''; }); 1373 } 1374 1375 /* The tab-switch timeline â the card moves as ONE gesture, and every part 1376 starts on the click frame (no fade-out lead-in), so it reads as instant: 1377 t=0 ice-blue bar sweeps the card (thumb glides under it); the copy 1378 dips (fast, clip-safe opacity); the shared fields begin gliding 1379 to their new place; the passkey door collapses / expands 1380 tâ110 copy text swaps behind zero opacity and lifts back in 1381 tâ450 page at rest 1382 Email + password never fade or jump: the Name row grows in (âsign-up) or 1383 collapses out (âsign-in) and they glide with it; the two forms are 1384 swapped only at the Name=0 instant, where their [email][password][submit] 1385 geometry is identical so the swap moves nothing. */ 1386 function animateSwitch(apply, keys){ 1387 const gen = swapGen; 1388 const toSignup = mode === 'signup'; 1389 1390 /* Copy hand-off â whole-element opacity dip. NOT a per-child fade: the 1391 headline carries a background-clip:text shimmer, and any opacity<1 on 1392 a child would drop its glyphs out of the clip mask. Copy that doesn't 1393 change this switch (the Google label in most locales) is skipped. */ 1394 const copy = []; 1395 if (authTitle.textContent !== t(keys.title)) copy.push({ el:authTitle, fn:apply.title }); 1396 if (authLead.textContent !== t(keys.lead)) copy.push({ el:authLead, fn:apply.lead }); 1397 if (googleLabel.textContent !== t(keys.google)) copy.push({ el:googleLabel, fn:apply.google }); 1398 copy.push({ el:authAlt, fn:apply.alt }); 1399 copy.forEach(it => { it.el.style.transition = `opacity ${SW.out}ms ease`; it.el.style.opacity = '0'; }); 1400 at(gen, SW.out, () => { 1401 copy.forEach(it => it.fn()); 1402 void authTitle.offsetHeight; 1403 copy.forEach(it => { it.el.style.transition = `opacity ${SW.in}ms ease`; it.el.style.opacity = '1'; }); 1404 }); 1405 1406 /* passkey door (sign-in only) rides the same window */ 1407 if (pkDirect && pkdSupported){ if (toSignup) pkCollapse(gen); else pkExpand(gen); } 1408 1409 /* Shared-field reflow. */ 1410 const frozen = parseFloat(formStage.style.height); 1411 formStage.style.height = ''; 1412 const h0 = isNaN(frozen) ? formStage.offsetHeight : frozen; 1413 1414 if (toSignup){ 1415 apply.forms(); // show sign-up (measured at Name-full) 1416 const h1 = formStage.offsetHeight; 1417 parkRowCollapsed(nameField); // email/password â top, matching the hidden sign-in 1418 void formStage.offsetHeight; 1419 glideStage(gen, h0, h1); 1420 animRow(gen, nameField, true, SW.expand); // Name grows in â shared fields glide down 1421 } else { 1422 signupForm.hidden = true; signinForm.hidden = false; // peek at sign-in's height⦠1423 const h1 = formStage.offsetHeight; 1424 signinForm.hidden = true; signupForm.hidden = false; // â¦then revert: sign-up stays on screen 1425 void formStage.offsetHeight; 1426 glideStage(gen, h0, h1); 1427 animRow(gen, nameField, false, SW.collapse); // Name collapses out â shared fields glide up 1428 at(gen, SW.collapse, () => { apply.forms(); }); // swap to sign-in at the matched Name=0 layout 1429 } 1430 1431 at(gen, Math.max(SW.height, SW.collapse + SW.expand) + 80, clearSwapStyles); 1432 } 1433
1434 tabs.forEach(b => b.addEventListener('click', () => { 1435 if (b.dataset.mode !== mode) setMode(b.dataset.mode); 1436 })); 1437 /* Roving arrow keys â with two tabs, either arrow toggles to the other. */ 1438 if (authTabs) authTabs.addEventListener('keydown', e => { 1439 if (e.key !== 'ArrowLeft' && e.key !== 'ArrowRight') return; 1440 e.preventDefault(); 1441 const next = mode === 'signin' ? 'signup' : 'signin'; 1442 tabs.forEach(b => { if (b.dataset.mode === next) b.focus(); }); 1443 setMode(next); 1444 }); 1445 document.getElementById('auth-alt-link').addEventListener('click', e => { 1446 e.preventDefault(); 1447 setMode(mode === 'signin' ? 'signup' : 'signin'); 1448 }); 1449 1450 /* Google is both a sign-in and a registration entry point. Existing users 1451 may sign in directly; a new account can only be created from the sign-up 1452 face after the same affirmative legal acknowledgement as email signup. */ 1453 googleBtn?.addEventListener('click', e => { 1454 const locale = document.documentElement.lang || 'en'; 1455 const url = new URL(googleBtn.getAttribute('href') || '/api/auth/google/start', location.origin); 1456 url.searchParams.set('locale', locale); 1457 url.searchParams.delete('accept_legal'); 1458 if (mode === 'signup') { 1459 if (!legalCheck?.checked) { 1460 e.preventDefault(); 1461 showLegalError(); 1462 return; 1463 } 1464 url.searchParams.set('accept_legal', '1'); 1465 } 1466 googleBtn.href = url.pathname + url.search; 1467 }); 1468 1469 /* Capture the URL ?mode= once, BEFORE setMode runs (setMode rewrites 1470 the query string so `?mode=forgot` would be stripped by the initial 1471 signin call below). The forgot-mode hook at the bottom of the IIFE 1472 reads this same variable. */ 1473 const requestedMode = new URLSearchParams(location.search).get('mode'); 1474 const initial = requestedMode === 'signup' ? 'signup' : 'signin'; 1475 setMode(initial); 1476 /* re-apply text after i18n.js finishes hydrating â but never while the 1477 passkey gate is on screen: setMode() tears the panel down, and late 1478 i18n hydration would otherwise strand an OAuth/email-verify arrival 1479 (?pkgate=â¦) back on the sign-in form with the gate silently dropped. 1480 The same applies to the forgot/verify panels (a ?mode=forgot arrival 1481 used to be torn back down to sign-in by this refresh): their 1482 data-i18n keys were re-pointed by their enter functions, so i18n.js's 1483 own hydration already refreshed their copy â no setMode needed. 1484 Instant: this is a text refresh, not a user gesture. */ 1485 window.addEventListener('i18n:changed', () => { 1486 const b = document.body.classList; 1487 if (!b.contains('passkey-active') && !b.contains('forgot-active') && !b.contains('verify-active')) 1488 setMode(mode, { instant: true }); 1489 }); 1490 1491 /* Surface OAuth callback errors handed back via ?error=google_xxx. The 1492 server redirects to /login with one of these codes whenever the 1493 Google flow fails (start.js / callback.js â redirectToLogin). We 1494 strip the param after reading so a refresh doesn't keep showing it. */ 1495 (function(){ 1496 const params = new URLSearchParams(location.search); 1497 const code = params.get('error'); 1498 if (!code) return; 1499 const activeForm = mode === 'signin' ? signinForm : signupForm; 1500 const errorKey = 'login.err.' + code; 1501 const translated = t(errorKey); 1502 showFormError(activeForm, translated === errorKey ? t('login.err.failed') : translated); 1503 params.delete('error'); 1504 const u = new URL(location.href); 1505 u.search = params.toString(); 1506 history.replaceState(null, '', u.pathname + (u.search ? u.search : '') + u.hash); 1507 })(); 1508 1509 /* ââ Passkey gate panel ââââââââââââââââââââââââââââââââââââââââââââ 1510 Enforced accounts don't get a session from a password / Google / 1511 email link alone â the server hands back a short-lived `gate` token 1512 and this panel finishes the WebAuthn ceremony: POST /options tells 1513 us whether to assert an existing passkey ('get') or enrol the very 1514 first one ('create'), the browser runs it, POST /verify checks the 1515 result and mints the session. Every button press fetches FRESH 1516 options (challenges are single-use server-side), so cancel/retry 1517 always works. */ 1518 const pkLead = document.getElementById('pk-lead'); 1519 const pkMsgEl = document.getElementById('pk-msg'); 1520 const pkGo = document.getElementById('pk-go'); 1521 const pkGoLabel = document.getElementById('pk-go-label'); 1522 const tfForm = document.getElementById('tf-form'); 1523 const tfCode = document.getElementById('tf-code'); 1524 const tfGo = document.getElementById('tf-go'); 1525 let pkGate = null; 1526 let pkBusy = false;
1527 let tfBusy = false; 1528 1529 /* Six-cell mirror for the code input. #tf-code is an invisible overlay 1530 across the row; the cells are its only visible face. The caret is 1531 pinned to the end â a click on the invisible layer could otherwise 1532 park it mid-string and the next key would silently insert into the 1533 middle of the code. One exception: the full-span selection select() 1534 leaves behind after a rejected code stays, renders on the cells, and 1535 lets the next keystroke replace everything â same as before. */ 1536 const tfCells = Array.prototype.slice.call(document.querySelectorAll('.tf-cell')); 1537 function tfAllSelected(){ 1538 const n = tfCode.value.length; 1539 return n > 0 && tfCode.selectionStart === 0 && tfCode.selectionEnd >= n; 1540 } 1541 function tfRender(){ 1542 const v = tfCode.value; 1543 const focused = document.activeElement === tfCode; 1544 const all = focused && tfAllSelected(); 1545 /* .active = the focus-ring cell: parked on the last cell for a full 1546 code and for select-all, so focus never goes invisible; .caret = 1547 the true insertion point (none once full â typing is a no-op). */ 1548 const active = focused ? (all || v.length >= 6 ? 5 : v.length) : -1; 1549 const caret = focused && !all && v.length < 6 ? v.length : -1; 1550 for (let i = 0; i < tfCells.length; i++) { 1551 const c = tfCells[i]; 1552 c.textContent = v[i] || ''; 1553 c.classList.toggle('filled', !!v[i]); 1554 c.classList.toggle('sel', all && i < v.length); 1555 c.classList.toggle('active', i === active); 1556 c.classList.toggle('caret', i === caret); 1557 } 1558 } 1559 function tfCaretToEnd(){ 1560 if (!tfAllSelected()) { 1561 const n = tfCode.value.length;
1562 try { tfCode.setSelectionRange(n, n); } catch(_){} 1563 } 1564 tfRender(); 1565 } 1566 /* Sanitise to at most six ASCII digits (fullwidth ï¼-ï¼ normalised so 1567 an IME numeric row isn't silently dropped) and pin the caret to the 1568 end. Never rewrite mid-composition â assigning .value there cancels 1569 the IME's in-flight text; compositionend runs the sweep instead. */ 1570 let tfComposing = false; 1571 function tfSanitize(){ 1572 const v = tfCode.value 1573 .replace(/[ï¼-ï¼]/g, ch => String.fromCharCode(ch.charCodeAt(0) - 0xFEE0)) 1574 .replace(/\D/g, '').slice(0, 6); 1575 if (v !== tfCode.value) tfCode.value = v; 1576 try { tfCode.setSelectionRange(v.length, v.length); } catch(_){} 1577 tfRender(); 1578 } 1579 tfCode.addEventListener('compositionstart', () => { tfComposing = true; }); 1580 tfCode.addEventListener('compositionend', () => { tfComposing = false; tfSanitize(); }); 1581 tfCode.addEventListener('input', e => { 1582 if (tfComposing || e.isComposing) { tfRender(); return; } 1583 tfSanitize(); 1584 }); 1585 /* Left/Right/Home/End would unpin the caret â swallow them, except 1586 while an IME is composing (candidate navigation needs its keys). 1587 Up/Down pass through: autofill / one-time-code suggestion lists 1588 navigate with them; the keyup below re-pins any caret drift. */ 1589 tfCode.addEventListener('keydown', e => { 1590 if (e.isComposing || e.keyCode === 229) return; 1591 if (e.key === 'ArrowLeft' || e.key === 'ArrowRight' || e.key === 'Home' || e.key === 'End') e.preventDefault(); 1592 }); 1593 tfCode.addEventListener('keyup', e => { 1594 if (tfComposing || e.isComposing) return; 1595 const n = tfCode.value.length; 1596 if (tfCode.selectionStart === tfCode.selectionEnd && tfCode.selectionEnd !== n) { 1597 try { tfCode.setSelectionRange(n, n); } catch(_){} 1598 tfRender(); 1599 } 1600 }); 1601 tfCode.addEventListener('focus', tfCaretToEnd); 1602 tfCode.addEventListener('click', tfCaretToEnd); 1603 /* Drag/double-click selections of the transparent text collapse to the 1604 end unless they span everything (then they read as the replace-all 1605 state, same as the post-error select()). */ 1606 tfCode.addEventListener('select', () => { 1607 if (!tfAllSelected() && tfCode.selectionStart !== tfCode.selectionEnd) tfCaretToEnd(); 1608 else tfRender(); 1609 }); 1610 tfCode.addEventListener('blur', tfRender); 1611 tfRender(); 1612 1613 function pkB64uToBuf(s){ 1614 s = s.replace(/-/g, '+').replace(/_/g, '/'); 1615 s += '='.repeat((4 - (s.length % 4)) % 4); 1616 const bin = atob(s); 1617 const out = new Uint8Array(bin.length); 1618 for (let i = 0; i < bin.length; i++) out[i] = bin.charCodeAt(i); 1619 return out; 1620 } 1621 function pkBufToB64u(buf){ 1622 const b = new Uint8Array(buf); 1623 let s = ''; 1624 for (let i = 0; i < b.length; i++) s += String.fromCharCode(b[i]); 1625 return btoa(s).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, ''); 1626 } 1627 function pkShowMsg(text){ pkMsgEl.textContent = text; pkMsgEl.hidden = false; } 1628 function pkPost(path, body){ 1629 return fetch(path, { 1630 method: 'POST', 1631 headers: { 'Content-Type': 'application/json' }, 1632 credentials: 'same-origin', 1633 cache: 'no-store', 1634 body: JSON.stringify(body), 1635 }).then(r => r.json()); 1636 } 1637 1638 /* The factor-switch button for 'both' accounts: kind 'totp' offers the 1639 code form from the passkey step, 'pk' the way back. null hides it. */ 1640 function setTfAlt(kind){ 1641 const row = document.getElementById('tf-alt-row'); 1642 const link = document.getElementById('tf-alt'); 1643 if (!row || !link) return; 1644 if (!kind) { row.hidden = true; link.onclick = null; return; } 1645 link.textContent = t(kind === 'totp' ? 'login.tf.useCode' : 'login.tf.usePk'); 1646 row.hidden = false; 1647 link.onclick = (e) => { 1648 e.preventDefault(); 1649 if (kind === 'totp') showTfStep(true); 1650 else showPkStep('get', true); 1651 }; 1652 } 1653 1654 function showPkStep(mode, altTotp){ 1655 tfForm.hidden = true; 1656 pkMsgEl.hidden = true; 1657 pkLead.textContent = t(mode === 'create' ? 'login.pk.lead.create' : 'login.pk.lead.get'); 1658 pkGoLabel.textContent = t(mode === 'create' ? 'login.pk.btn.create' : 'login.pk.btn.get'); 1659 pkGo.hidden = false;
1660 setTfAlt(altTotp ? 'totp' : null); 1661 } 1662 1663 function showTfStep(altPk){ 1664 pkGo.hidden = true; 1665 pkMsgEl.hidden = true; 1666 pkLead.textContent = t('login.tf.lead'); 1667 tfCode.value = ''; 1668 tfForm.hidden = false; 1669 setTfAlt(altPk ? 'pk' : null); 1670 try { tfCode.focus(); } catch(_){} 1671 } 1672 1673 function enterPasskeyMode(gate, opts){ 1674 pkGate = gate; 1675 /* tear down any in-flight tab choreography, then veil the scene â 1676 the deep-link arrival (?pkgate=â¦) lands before first paint and 1677 skips the veil. */ 1678 cancelSwap(); 1679 if (!(opts && opts.instant)) sceneFade(); 1680 document.body.classList.remove('forgot-active', 'verify-active'); 1681 document.body.classList.add('passkey-active'); 1682 const vp = document.getElementById('verify-panel'); if (vp) vp.hidden = true; 1683 const fp = document.getElementById('forgot-sent-panel'); if (fp) fp.hidden = true; 1684 const ff = document.getElementById('forgot-form'); if (ff) ff.hidden = true; 1685 document.getElementById('passkey-panel').hidden = false; 1686 pkMsgEl.hidden = true; 1687 pkGo.hidden = true; 1688 tfForm.hidden = true; 1689 setTfAlt(null); 1690 pkLead.textContent = t('login.pk.checking'); 1691 /* First options call decides WHICH factor this account uses and 1692 paints the copy â the passkey ceremony re-fetches its own fresh 1693 options on click. The WebAuthn support check only gates the 1694 passkey branch: an account with a TOTP factor ('totp' or 'both') 1695 must be able to sign in from any browser. */ 1696 pkPost('/api/auth/passkey/options', { gate: pkGate }).then(data => { 1697 if (!data || !data.ok) { 1698 pkLead.textContent = t(data && (data.error === 'gate_expired' || data.error === 'gate_stale') ? 'login.pk.expired' : 'login.pk.failed'); 1699 return; 1700 } 1701 if (data.method === 'totp') { showTfStep(false); return; } 1702 const altTotp = data.alt === 'totp'; 1703 if (!window.PublicKeyCredential || !navigator.credentials) { 1704 if (altTotp) { showTfStep(false); return; } 1705 pkLead.textContent = t('login.pk.unsupported'); 1706 return; 1707 } 1708 showPkStep(data.mode, altTotp); 1709 }).catch(() => { pkLead.textContent = t('login.pk.failed'); }); 1710 } 1711 1712 async function pkRunCeremony(){ 1713 if (pkBusy || !pkGate) return; 1714 pkBusy = true; 1715 pkMsgEl.hidden = true; 1716 pkGo.disabled = true; 1717 pkGo.classList.add('loading'); 1718 try { 1719 const o = await pkPost('/api/auth/passkey/options', { gate: pkGate }); 1720 if (!o || !o.ok) { 1721 pkShowMsg(t(o && (o.error === 'gate_expired' || o.error === 'gate_stale') ? 'login.pk.expired' : 'login.pk.failed')); 1722 return; 1723 } 1724 if (o.method === 'totp') { 1725 /* The account switched to TOTP while this panel was open â 1726 repaint for the right factor instead of running a ceremony. */ 1727 enterPasskeyMode(pkGate); 1728 return; 1729 } 1730 const pub = o.publicKey; 1731 pub.challenge = pkB64uToBuf(pub.challenge); 1732 let payload; 1733 if (o.mode === 'create') { 1734 pub.user = Object.assign({}, pub.user, { id: new TextEncoder().encode(pub.user.id) }); 1735 const cred = await navigator.credentials.create({ publicKey: pub }); 1736 /* getPublicKey()/getAuthenticatorData() are how we avoid CBOR 1737 server-side; a browser without them can't enrol here. */ 1738 if (!cred || !cred.response || typeof cred.response.getPublicKey !== 'function' || 1739 typeof cred.response.getAuthenticatorData !== 'function') { 1740 pkShowMsg(t('login.pk.unsupported')); 1741 return; 1742 } 1743 const spki = cred.response.getPublicKey(); 1744 if (!spki) { pkShowMsg(t('login.pk.unsupported')); return; } 1745 payload = { id: cred.id, response: { 1746 clientDataJSON: pkBufToB64u(cred.response.clientDataJSON), 1747 authenticatorData: pkBufToB64u(cred.response.getAuthenticatorData()), 1748 publicKey: pkBufToB64u(spki), 1749 publicKeyAlgorithm: cred.response.getPublicKeyAlgorithm(), 1750 transports: (typeof cred.response.getTransports === 'function') ? cred.response.getTransports() : [], 1751 } }; 1752 } else { 1753 pub.allowCredentials = (pub.allowCredentials || []).map(c => Object.assign({}, c, { id: pkB64uToBuf(c.id) })); 1754 const cred = await navigator.credentials.get({ publicKey: pub }); 1755 payload = { id: cred.id, response: { 1756 clientDataJSON: pkBufToB64u(cred.response.clientDataJSON), 1757 authenticatorData: pkBufToB64u(cred.response.authenticatorData), 1758 signature: pkBufToB64u(cred.response.signature), 1759 userHandle: (cred.response.userHandle && cred.response.userHandle.byteLength) 1760 ? pkBufToB64u(cred.response.userHandle) : null, 1761 } }; 1762 } 1763 const v = await pkPost('/api/auth/passkey/verify', { gate: pkGate, mode: o.mode, cred: payload }); 1764 if (v && v.ok) { 1765 /* Redirect flows (OAuth / email verify) parked their destination 1766 in the gate; JSON flows fall back to the same routing the plain 1767 login success path uses (cutoff must match _lib/cutoff.js). */ 1768 /* Post-cutoff accounts are tools-tier â no SaaS access: flag them 1769 limited (dark-only, no dashboard affordances) before navigating; 1770 full-access sign-ins clear the flag. */ 1771 if (window.CFTheme) CFTheme.setLimited(!!(v.user && v.user.created_at >= 1780848000)); 1772 if (v.return) { location.assign(v.return); return; } 1773 if (v.user && v.user.created_at >= 1780848000) { location.assign('https://tools.subnsub.com/'); return; } 1774 location.assign(RETURN_TO || '/console'); 1775 return; 1776 } 1777 const code = v && v.error; 1778 if (code === 'gate_expired' || code === 'gate_stale') pkShowMsg(t('login.pk.expired')); 1779 else if (code === 'credential_exists') pkShowMsg(t('login.pk.dupe')); 1780 else pkShowMsg(t('login.pk.failed')); 1781 } catch (err) { 1782 const n = err && err.name; 1783 if (n === 'NotAllowedError' || n === 'AbortError') pkShowMsg(t('login.pk.cancelled')); 1784 else if (n === 'InvalidStateError') pkShowMsg(t('login.pk.dupe')); 1785 else pkShowMsg(t('login.pk.failed')); 1786 } finally { 1787 pkBusy = false;
1788 pkGo.disabled = false; 1789 pkGo.classList.remove('loading'); 1790 } 1791 } 1792 1793 pkGo.addEventListener('click', pkRunCeremony); 1794 document.getElementById('pk-back').addEventListener('click', e => { 1795 e.preventDefault(); 1796 pkGate = null; 1797 setMode('signin'); 1798 }); 1799 1800 /* ââ Usernameless passkey sign-in âââââââââââââââââââââââââââââââââââââ 1801 One tap, zero typing: an anonymous challenge from login-begin, a 1802 discoverable-credential get() with NO allowCredentials (the browser 1803 lists whatever passkeys it holds for subnsub.com), and login-verify 1804 resolves the account from the assertion itself, mints the session and 1805 answers with the same user block the password path routes on. */ 1806 let pkdBusy = false; 1807 /* The ceremony is async: the user can flip to signup (or the forgot / 1808 verify panels) while the native picker is open, so any error must 1809 only render if the sign-in form is still the visible one â otherwise 1810 it lands in a hidden form. Success navigates away, so it needs no 1811 such guard. */ 1812 const pkdErr = (msg) => { if (mode === 'signin' && !document.body.classList.contains('forgot-active') && !document.body.classList.contains('verify-active')) showFormError(signinForm, msg); }; 1813 if (pkDirect) pkDirect.addEventListener('click', async () => { 1814 if (pkdBusy) return; 1815 pkdBusy = true; 1816 pkDirect.disabled = true; 1817 clearFormError(signinForm); 1818 try { 1819 const o = await pkPost('/api/auth/passkey/login-begin', {}); 1820 if (!o || !o.ok) { 1821 pkdErr(t(o && o.error === 'rate_limited' ? 'login.pkdirect.rate' : 'login.pkdirect.fail')); 1822 return; 1823 } 1824 const pub = o.publicKey; 1825 pub.challenge = pkB64uToBuf(pub.challenge); 1826 const cred = await navigator.credentials.get({ publicKey: pub }); 1827 const v = await pkPost('/api/auth/passkey/login-verify', { cred: { id: cred.id, response: { 1828 clientDataJSON: pkBufToB64u(cred.response.clientDataJSON), 1829 authenticatorData: pkBufToB64u(cred.response.authenticatorData), 1830 signature: pkBufToB64u(cred.response.signature), 1831 userHandle: (cred.response.userHandle && cred.response.userHandle.byteLength) 1832 ? pkBufToB64u(cred.response.userHandle) : null, 1833 } } }); 1834 if (v && v.ok) { 1835 /* Same routing as the password/gate success paths (cutoff must 1836 match _lib/cutoff.js). */ 1837 /* Post-cutoff accounts are tools-tier â no SaaS access: flag them 1838 limited (dark-only, no dashboard affordances) before navigating; 1839 full-access sign-ins clear the flag. */ 1840 if (window.CFTheme) CFTheme.setLimited(!!(v.user && v.user.created_at >= 1780848000)); 1841 if (v.return) { location.assign(v.return); return; } 1842 if (v.user && v.user.created_at >= 1780848000) { location.assign('https://tools.subnsub.com/'); return; } 1843 location.assign(RETURN_TO || '/console'); 1844 return; 1845 } 1846 pkdErr(t(v && v.error === 'rate_limited' ? 'login.pkdirect.rate' : 'login.pkdirect.fail')); 1847 } catch (err) { 1848 const n = err && err.name; 1849 /* Dismissing the browser's picker isn't an error state. */ 1850 if (n !== 'NotAllowedError' && n !== 'AbortError') pkdErr(t('login.pkdirect.fail')); 1851 } finally { 1852 pkdBusy = false; 1853 pkDirect.disabled = false; 1854 } 1855 }); 1856 1857 /* TOTP branch: 6 digits â /api/auth/twofa/check mints the session and 1858 routes exactly like the passkey verify success path. */ 1859 tfForm.addEventListener('submit', async e => { 1860 e.preventDefault(); 1861 if (tfBusy || !pkGate) return; 1862 const codeVal = (tfCode.value || '').replace(/\D/g, ''); 1863 if (codeVal.length !== 6) { 1864 pkShowMsg(t('login.tf.bad')); 1865 /* The Verify click parked focus on the button â bring it back so 1866 the cells show where the missing digits go. */ 1867 try { tfCode.focus(); } catch(_){} 1868 tfCaretToEnd(); 1869 return; 1870 } 1871 tfBusy = true; 1872 pkMsgEl.hidden = true; 1873 tfGo.disabled = true; 1874 tfGo.classList.add('loading'); 1875 try { 1876 const v = await pkPost('/api/auth/twofa/check', { gate: pkGate, code: codeVal }); 1877 if (v && v.ok) { 1878 /* Post-cutoff accounts are tools-tier â no SaaS access: flag them 1879 limited (dark-only, no dashboard affordances) before navigating; 1880 full-access sign-ins clear the flag. */ 1881 if (window.CFTheme) CFTheme.setLimited(!!(v.user && v.user.created_at >= 1780848000)); 1882 if (v.return) { location.assign(v.return); return; } 1883 if (v.user && v.user.created_at >= 1780848000) { location.assign('https://tools.subnsub.com/'); return; } 1884 location.assign(RETURN_TO || '/console'); 1885 return; 1886 } 1887 const code = v && v.error; 1888 if (code === 'gate_expired' || code === 'gate_stale') pkShowMsg(t('login.pk.expired')); 1889 else if (code === 'code_used') pkShowMsg(t('login.tf.used')); 1890 else pkShowMsg(t('login.tf.bad')); 1891 /* select() alone doesn't reliably refocus once the Verify click 1892 moved focus to the button â and the cells only render selection 1893 while the input is focused. */
1894 try { tfCode.focus(); tfCode.select(); } catch(_){} 1895 tfRender(); 1896 } catch (_) { 1897 pkShowMsg(t('login.pk.failed')); 1898 } finally { 1899 tfBusy = false; 1900 tfGo.disabled = false; 1901 tfGo.classList.remove('loading'); 1902 } 1903 }); 1904 1905 /* Redirect flows (Google OAuth callback, email verify) land here with 1906 ?pkgate=⦠â jump straight into the passkey panel. The param is 1907 stripped immediately so the single-use-ish token doesn't linger in 1908 the address bar / history; the gate lives on in pkGate. */ 1909 (function(){ 1910 const params = new URLSearchParams(location.search); 1911 const g = params.get('pkgate'); 1912 if (!g) return; 1913 params.delete('pkgate'); 1914 try { 1915 const u = new URL(location.href); 1916 u.search = params.toString(); 1917 history.replaceState(null, '', u.pathname + (u.search ? '?' + params.toString() : '') + u.hash); 1918 } catch(_) {} 1919 enterPasskeyMode(g, { instant: true }); 1920 })(); 1921 1922 /* ââ form submit (shared between sign-in and sign-up) âââââââââââââââ */ 1923 async function submitAuth(form, formMode){ 1924 clearFormError(form); 1925 const email = (form.querySelector('input[name="email"]').value || '').trim(); 1926 const password = form.querySelector('input[name="password"]').value; 1927 const nameInput = form.querySelector('input[name="name"]'); 1928 const name = nameInput ? (nameInput.value || '').trim() : ''; 1929 if (!email || !password) { 1930 const k = t('login.err.missing'); 1931 showFormError(form, k === 'login.err.missing' ? 'Email and password are required.' : k); 1932 return; 1933 } 1934 if (formMode === 'signup' && password.length < 8) { 1935 const k = t('login.err.weak'); 1936 showFormError(form, k === 'login.err.weak' ? 'Password must be at least 8 characters.' : k); 1937 return; 1938 } 1939 if (formMode === 'signup' && !legalCheck?.checked) { 1940 showLegalError(); 1941 return; 1942 } 1943 const submitBtn = form.querySelector('.submit-btn'); 1944 submitBtn.disabled = true; 1945 submitBtn.classList.add('loading'); 1946 try { 1947 const endpoint = formMode === 'signin' ? '/api/auth/login' : '/api/auth/signup'; 1948 const res = await fetch(endpoint, { 1949 method: 'POST', 1950 headers: { 'Content-Type': 'application/json' }, 1951 body: JSON.stringify(formMode === 'signup' 1952 ? { 1953 email, password, name, 1954 accepted_legal: legalCheck.checked, 1955 legal_locale: document.documentElement.lang || 'en', 1956 } 1957 : { email, password }), 1958 credentials: 'same-origin', 1959 }); 1960 const data = await res.json().catch(() => ({})); 1961 if (!res.ok || !data.ok) { 1962 const code = data.error || 'network'; 1963 /* Sign-in against an unverified account routes to the verify 1964 panel instead of showing an inline error; the user needs the 1965 Resend button, not just a red banner. */ 1966 if (code === 'email_unverified') { 1967 const e = (data.email || email); 1968 enterVerifyMode(e, { initialMsg: t('login.err.email_unverified'), warn: true }); 1969 return; 1970 } 1971 /* Password accepted, but this account opted into 2FA: the 1972 server withheld the session and handed us a gate instead. */ 1973 if (code === 'twofa_required' && data.gate) { 1974 enterPasskeyMode(data.gate); 1975 return; 1976 } 1977 const msgKey = ({ 1978 missing_fields: 'login.err.missing', 1979 legal_required: 'login.err.legal_required', 1980 invalid_credentials: 'login.err.invalid', 1981 email_in_use: 'login.err.exists', 1982 invalid_email: 'login.err.invalid_email', 1983 weak_password: 'login.err.weak', 1984 rate_limited: 'login.err.rate_limited', 1985 network: 'login.err.network', 1986 })[code] || 'login.err.failed'; 1987 const msg = t(msgKey); 1988 if (code === 'legal_required') showLegalError(); 1989 showFormError(form, msg); 1990 return; 1991 } 1992 /* Signup success no longer carries a session cookie â it instead 1993 hands us `awaiting_verification` and we route the user to the 1994 "check your inbox" panel with a Resend button. Login success 1995 (verified accounts only) go to the validated ?return= target, or /console. */ 1996 if (formMode === 'signup' && data.awaiting_verification) { 1997 const sendFailed = data.email_sent === false; 1998 enterVerifyMode(data.email || email, sendFailed 1999 ? { initialMsg: t('login.verify.send_failed'), warn: true } 2000 : { initialMsg: t('login.verify.sent'), warn: false }); 2001 return; 2002 } 2003 /* Session cookie is set (the awaiting-verification branch above 2004 returned already). Post-cutoff accounts are tools-tier â no SaaS 2005 access: flag them limited before navigating; full-access 2006 sign-ins clear the flag. */ 2007 if (window.CFTheme) CFTheme.setLimited(!!(data.user && data.user.created_at >= 1780848000)); 2008 /* Post-cutoff users always go to tools, even with a ?return= target. 2009 Cutoff must match functions/_lib/cutoff.js (NEW_USER_CUTOFF). */ 2010 if (data.user && data.user.created_at >= 1780848000) { 2011 location.assign('https://tools.subnsub.com/'); 2012 } else if (RETURN_TO) { 2013 location.assign(RETURN_TO); 2014 } else { 2015 location.assign('/console'); 2016 } 2017 } catch (err) { 2018 showFormError(form, t('login.err.network')); 2019 } finally { 2020 submitBtn.disabled = false;
2021 submitBtn.classList.remove('loading'); 2022 } 2023 } 2024 2025 signinForm.addEventListener('submit', e => { e.preventDefault(); submitAuth(signinForm, 'signin'); }); 2026 signupForm.addEventListener('submit', e => { e.preventDefault(); submitAuth(signupForm, 'signup'); }); 2027 2028 /* ââ Verify panel: hand-off after signup or unverified-login ââââââââ */ 2029 const verifyPanel = document.getElementById('verify-panel'); 2030 const verifyEmail = document.getElementById('verify-email'); 2031 const verifyMsg = document.getElementById('verify-msg'); 2032 const verifyResend = document.getElementById('verify-resend'); 2033 const verifyBack = document.getElementById('verify-back'); 2034 let resendCooldownTimer = null; 2035 let pendingVerifyEmail = null; 2036 2037 function setVerifyMsg(text, warn){ 2038 if (!verifyMsg) return; 2039 if (!text) { verifyMsg.hidden = true; return; } 2040 verifyMsg.textContent = text; 2041 verifyMsg.hidden = false; 2042 verifyMsg.classList.toggle('is-warn', !!warn); 2043 } 2044 2045 function enterVerifyMode(emailAddr, opts){ 2046 pendingVerifyEmail = emailAddr || null; 2047 cancelSwap(); 2048 sceneFade(); 2049 document.body.classList.add('verify-active'); 2050 if (verifyPanel) verifyPanel.hidden = false; 2051 if (verifyEmail) verifyEmail.textContent = emailAddr || ''; 2052 swapHead(() => { 2053 authTitle.dataset.i18n = 'login.title.verify'; authTitle.textContent = t('login.title.verify'); 2054 authLead.dataset.i18n = 'login.lead.verify'; authLead.textContent = t('login.lead.verify'); 2055 }); 2056 setVerifyMsg(opts && opts.initialMsg, opts && opts.warn); 2057 /* the verify panel doesn't have its own form; clear any leftover form errors */ 2058 clearFormError(signinForm); clearFormError(signupForm); 2059 } 2060 2061 function exitVerifyMode(){ 2062 /* verify-active stays on for setMode to see (fromPanel â scene veil); 2063 it tears the class down itself. */ 2064 if (verifyPanel) verifyPanel.hidden = true; 2065 pendingVerifyEmail = null; 2066 setVerifyMsg(''); 2067 setMode('signin'); 2068 } 2069 2070 function startResendCooldown(seconds){ 2071 if (resendCooldownTimer) clearInterval(resendCooldownTimer); 2072 if (!verifyResend) return; 2073 let remaining = seconds | 0; 2074 /* The inner text span, not .label itself â the label row also holds 2075 the spinner, and replacing its textContent would wipe both it and 2076 the data-i18n hook for good. */ 2077 const label = verifyResend.querySelector('.label span[data-i18n]'); 2078 const baseText = t('login.verify.resend'); 2079 function paint(){ 2080 if (remaining <= 0) { 2081 verifyResend.disabled = false; 2082 if (label) label.textContent = baseText; 2083 clearInterval(resendCooldownTimer); resendCooldownTimer = null; 2084 return; 2085 } 2086 verifyResend.disabled = true; 2087 if (label) label.textContent = baseText + ' (' + remaining + 's)'; 2088 remaining--; 2089 } 2090 paint(); 2091 resendCooldownTimer = setInterval(paint, 1000); 2092 } 2093 2094 if (verifyResend) verifyResend.addEventListener('click', async () => { 2095 if (!pendingVerifyEmail || verifyResend.disabled) return; 2096 verifyResend.disabled = true; 2097 verifyResend.classList.add('loading'); 2098 try { 2099 const res = await fetch('/api/auth/resend-verification', { 2100 method: 'POST', 2101 headers: { 'Content-Type': 'application/json' }, 2102 body: JSON.stringify({ email: pendingVerifyEmail }), 2103 credentials: 'same-origin', 2104 }); 2105 const data = await res.json().catch(() => ({})); 2106 if (data && data.cooldown) { 2107 setVerifyMsg(t('login.verify.cooldown'), false); 2108 } else if (data && data.ok) { 2109 setVerifyMsg(t('login.verify.sent'), false); 2110 } else if (data && data.error === 'rate_limited') { 2111 setVerifyMsg(t('login.err.rate_limited'), true); 2112 } else { 2113 setVerifyMsg(t('login.verify.send_failed'), true); 2114 } 2115 const cd = (data && Number(data.cooldown_seconds)) || 60; 2116 startResendCooldown(cd); 2117 } catch (_) { 2118 setVerifyMsg(t('login.verify.send_failed'), true); 2119 verifyResend.disabled = false; 2120 } finally { 2121 verifyResend.classList.remove('loading'); 2122 } 2123 }); 2124 2125 if (verifyBack) verifyBack.addEventListener('click', e => { 2126 e.preventDefault(); 2127 exitVerifyMode(); 2128 }); 2129 2130 /* ââ Forgot-password flow âââââââââââââââââââââââââââââââââââââââââââ */ 2131 const forgotLink = document.getElementById('forgot-link'); 2132 const forgotForm = document.getElementById('forgot-form'); 2133 const forgotSent = document.getElementById('forgot-sent-panel'); 2134 const forgotSentMail = document.getElementById('forgot-sent-email'); 2135 const forgotSentMsg = document.getElementById('forgot-sent-msg'); 2136 const forgotResend = document.getElementById('forgot-resend'); 2137 const forgotBack = document.getElementById('forgot-back'); 2138 const forgotSentBack = document.getElementById('forgot-sent-back'); 2139 let forgotResendTimer = null; 2140 let pendingForgotEmail = null; 2141 2142 function setForgotSentMsg(text, warn){ 2143 if (!forgotSentMsg) return; 2144 if (!text) { forgotSentMsg.hidden = true; return; } 2145 forgotSentMsg.textContent = text; 2146 forgotSentMsg.hidden = false;
2147 forgotSentMsg.classList.toggle('is-warn', !!warn); 2148 } 2149 2150 /* Email-entry step. The signin/signup forms hide via .forgot-active; we 2151 reveal #forgot-form by clearing its hidden attribute. The title/lead 2152 copy switches to the reset variant. */ 2153 function enterForgotMode(prefillEmail, opts){ 2154 const instant = !!(opts && opts.instant); /* ?mode=forgot deep-link */ 2155 cancelSwap(); 2156 if (!instant) sceneFade(); 2157 document.body.classList.remove('verify-active'); 2158 document.body.classList.add('forgot-active'); 2159 if (forgotSent) forgotSent.hidden = true; 2160 if (forgotForm) forgotForm.hidden = false; 2161 const paint = () => { 2162 authTitle.dataset.i18n = 'login.title.forgot'; 2163 authTitle.textContent = t('login.title.forgot'); 2164 authLead.dataset.i18n = 'login.lead.forgot'; 2165 authLead.textContent = t('login.lead.forgot'); 2166 }; 2167 if (instant) paint(); else swapHead(paint); 2168 clearFormError(forgotForm); 2169 if (prefillEmail) { 2170 const input = document.getElementById('forgot-email'); 2171 if (input && !input.value) input.value = prefillEmail; 2172 } 2173 } 2174 2175 /* "Check your inbox" step â same shape as the verify panel, different 2176 copy. The forgot-form hides; #forgot-sent-panel reveals. */ 2177 function enterForgotSentMode(emailAddr, opts){ 2178 pendingForgotEmail = emailAddr || null; 2179 cancelSwap(); 2180 sceneFade(); 2181 document.body.classList.add('forgot-active'); 2182 if (forgotForm) forgotForm.hidden = true; 2183 if (forgotSent) forgotSent.hidden = false; 2184 if (forgotSentMail) forgotSentMail.textContent = emailAddr || ''; 2185 swapHead(() => { 2186 authTitle.dataset.i18n = 'login.title.forgot.sent'; 2187 authTitle.textContent = t('login.title.forgot.sent'); 2188 authLead.dataset.i18n = 'login.lead.forgot.sent'; 2189 authLead.textContent = t('login.lead.forgot.sent'); 2190 }); 2191 setForgotSentMsg(opts && opts.initialMsg, opts && opts.warn); 2192 } 2193 2194 function startForgotResendCooldown(seconds){ 2195 if (forgotResendTimer) clearInterval(forgotResendTimer); 2196 if (!forgotResend) return; 2197 let remaining = seconds | 0; 2198 /* Inner text span only â same spinner/data-i18n preservation as the 2199 verify cooldown above. */ 2200 const label = forgotResend.querySelector('.label span[data-i18n]'); 2201 const baseText = t('login.forgot.resend'); 2202 function paint(){ 2203 if (remaining <= 0) { 2204 forgotResend.disabled = false; 2205 if (label) label.textContent = baseText; 2206 clearInterval(forgotResendTimer); forgotResendTimer = null; 2207 return; 2208 } 2209 forgotResend.disabled = true; 2210 if (label) label.textContent = baseText + ' (' + remaining + 's)'; 2211 remaining--; 2212 } 2213 paint(); 2214 forgotResendTimer = setInterval(paint, 1000); 2215 } 2216 2217 async function submitForgot(emailAddr, opts){ 2218 const fromResend = !!(opts && opts.fromResend); 2219 const targetBtn = fromResend 2220 ? forgotResend 2221 : forgotForm.querySelector('.submit-btn'); 2222 if (targetBtn) { 2223 targetBtn.disabled = true; 2224 targetBtn.classList.add('loading'); 2225 } 2226 /* Track whether we actually started a cooldown timer â only then does 2227 the timer own `disabled`. If the request errors out before we get 2228 there, the finally block must re-enable the button or the resend 2229 UI is dead until the page reloads. */ 2230 let cooldownStarted = false; 2231 try { 2232 const res = await fetch('/api/auth/forgot-password', { 2233 method: 'POST', 2234 headers: { 'Content-Type': 'application/json' }, 2235 body: JSON.stringify({ email: emailAddr }), 2236 credentials: 'same-origin', 2237 }); 2238 const data = await res.json().catch(() => ({})); 2239 if (!res.ok || !data.ok) { 2240 const code = data && data.error; 2241 const msg = code === 'invalid_email' 2242 ? 'Please enter a valid email address.' 2243 : code === 'rate_limited' 2244 ? t('login.err.rate_limited') 2245 : 'Something went wrong. Please try again.'; 2246 if (fromResend) { 2247 setForgotSentMsg(msg, true); 2248 } else { 2249 showFormError(forgotForm, msg); 2250 } 2251 return; 2252 } 2253 /* Same response shape whether the address exists or not â server 2254 intentionally hides that. We always advance to the sent panel. */ 2255 const cd = Number(data.cooldown_seconds) || 60; 2256 const msgKey = data.cooldown ? 'login.forgot.cooldown' : 'login.forgot.sent'; 2257 if (fromResend) { 2258 setForgotSentMsg(t(msgKey), false); 2259 } else { 2260 enterForgotSentMode(emailAddr, { initialMsg: t(msgKey), warn: false }); 2261 } 2262 startForgotResendCooldown(cd); 2263 cooldownStarted = true; 2264 } catch (_) { 2265 const msg = t('login.forgot.send_failed'); 2266 if (fromResend) setForgotSentMsg(msg, true);
2267 else showFormError(forgotForm, msg); 2268 } finally { 2269 if (targetBtn) { 2270 targetBtn.classList.remove('loading'); 2271 /* Re-enable the button on any path that didn't hand control off 2272 to the cooldown timer. Without this, an error during resend 2273 leaves "Resend reset email" stuck disabled. */ 2274 if (!cooldownStarted) targetBtn.disabled = false; 2275 } 2276 } 2277 } 2278 2279 if (forgotLink) forgotLink.addEventListener('click', e => { 2280 e.preventDefault(); 2281 /* If the user typed an email into the sign-in form, carry it over so 2282 they don't retype. */ 2283 const prefill = (signinForm.querySelector('input[name="email"]').value || '').trim(); 2284 enterForgotMode(prefill); 2285 }); 2286 2287 if (forgotForm) forgotForm.addEventListener('submit', e => { 2288 e.preventDefault(); 2289 clearFormError(forgotForm); 2290 const email = (forgotForm.querySelector('input[name="email"]').value || '').trim(); 2291 if (!email) { 2292 const k = t('login.err.missing'); 2293 showFormError(forgotForm, k === 'login.err.missing' ? 'Email is required.' : k); 2294 return; 2295 } 2296 submitForgot(email, { fromResend: false }); 2297 }); 2298 2299 if (forgotResend) forgotResend.addEventListener('click', () => { 2300 if (!pendingForgotEmail || forgotResend.disabled) return; 2301 submitForgot(pendingForgotEmail, { fromResend: true }); 2302 }); 2303 2304 if (forgotBack) forgotBack.addEventListener('click', e => { 2305 e.preventDefault(); 2306 setMode('signin'); 2307 }); 2308 if (forgotSentBack) forgotSentBack.addEventListener('click', e => { 2309 e.preventDefault(); 2310 setMode('signin'); 2311 }); 2312 2313 /* `?mode=forgot` deep-links into the forgot panel â used by the expired 2314 CTA on /reset.html and any future flow that wants to drop users 2315 straight into the recovery form. Done at the tail of the IIFE so 2316 the forgot-mode closures (enterForgotMode etc.) are bound. The 2317 setMode('signin') call above already stripped the param from the 2318 URL, so no extra cleanup is needed. */ 2319 if (requestedMode === 'forgot') enterForgotMode(undefined, { instant: true }); 2320})(); 2321</script>
2321 2322 2323<!-- ââ Balanced description rows ââââââââââââââââââââââââââââââââââââââ 2324 A port of the tools site's .phead staircase engine (totp-decoder 2325 index.html, pheadDescriptionRows). Browser wrapping is greedy: it 2326 fills a row before considering the next, which left the 2FA lead as 2327 a 418px row plus a 14px orphan ("in.") and the legal notice as 384px 2328 plus "Policy.". Measure the real copy in its real font, exhaustively 2329 score the word boundaries, insert the winning breaks as newlines 2330 (white-space:pre-line renders them): fewest rows that fit, minimum 2331 width variance, with a small extra cost on a descending step so 2332 balanced ties keep the gentle ascending staircase. 2333 2334 Two deliberate departures from the original: 2335 ⢠Cap. On the tools site a description may run right, past its 640 2336 column, so the cap comes from the viewport. Here every block sits 2337 in the fixed card column, so the cap is the element's own content 2338 box less 3px â NOT the original's 0.98 shave. Canvas measurement 2339 on this page reads 0.12%-0.36% WIDER than layout across all five 2340 blocks, so it already errs safe, and a 2% shave would split the 2341 verify lead â a 415px row that fits â into two. 2342 ⢠No line clamp. The tools site clamps descriptions to three rows 2343 and ellipsises the rest; auth copy has to stay readable, so a long 2344 locale simply wraps on past the third row. -->
2345<script> 2346(function authDescriptionRows(){ 2347 const SEL = '.auth-head .lead, .verify-lead, .auth-legal'; 2348 const store = new WeakMap(); /* element â {html: source innerHTML, out: our textContent, cap} */ 2349 let ctx = null, frame = 0; 2350 const measureCtx = () => ctx || (ctx = document.createElement('canvas').getContext('2d')); 2351 const fontOf = cs => cs.fontWeight + ' ' + cs.fontSize + ' ' + cs.fontFamily; 2352 const targets = () => Array.prototype.slice.call(document.querySelectorAll(SEL)); 2353 function words(text){ 2354 try { 2355 const seg = new Intl.Segmenter(document.documentElement.lang || 'en', { granularity: 'word' }); 2356 return Array.from(seg.segment(text), s => s.segment); 2357 } catch(_) { 2358 return text.split(/(\s+)/).filter(Boolean); 2359 } 2360 } 2361 /* Intl.Segmenter deliberately returns punctuation as separate word 2362 tokens, but unlike the browser's native line breaker it does not say 2363 which boundaries are typographically legal. Apply the common 2364 kinsoku/UAX #14 constraints before scoring: sentence and closing 2365 punctuation stay with the text before them, opening punctuation and 2366 prefixes with the text after â this covers CJK and the punctuation 2367 of the site's Latin locales (French guillemets, Spanish inverted 2368 marks included). */ 2369 const noLineStart = /^(?:[\p{Pe}\p{Pf}\p{Pd}]|[!%",.\/:;?'ââ»âºÂ°Â·ããï¼ï¼ã»ï¼ï¼ï¼ï¼ãï½â¦â¥â¼âââï¼ â°â±ââ])/u; 2370 const noLineEnd = /(?:[\p{Ps}\p{Pi}\p{Sc}]|["'¿¡#@])$/u; 2371 const tokenText = t => t.str != null ? t.str : (t.el ? t.el.textContent : ''); 2372 function legalBreak(toks, i){ 2373 const next = tokenText(toks[i]).trimStart(); 2374 if (!next || noLineStart.test(next)) return false; 2375 let prev = i - 1; 2376 while (prev >= 0 && toks[prev].ws) prev--; 2377 return prev < 0 || !noLineEnd.test(tokenText(toks[prev]).trimEnd()); 2378 } 2379 /* The card column. A panel that is still hidden measures 0 wide, so 2380 borrow the shell â every block here is a full-width child of it. */ 2381 function capFor(el){ 2382 const shell = el.closest('main.shell'); 2383 return (el.clientWidth || (shell ? shell.clientWidth : 0)) - 3; 2384 } 2385 /* Flatten prose through inline emphasis so the legal notice's links 2386 break like ordinary text; compact <code> fragments stay atomic. Each 2387 text token keeps its source-node offset so rendering can insert 2388 breaks without flattening or losing the original markup. */ 2389 function tokenize(el){ 2390 const m = measureCtx(); 2391 const toks = []; 2392 function walk(parent, font){ 2393 parent.childNodes.forEach(n => { 2394 if (n.nodeType === 3){ 2395 const clean = n.data.replace(/\s+/g, ' '); 2396 n.data = clean; 2397 m.font = font; 2398 let offset = 0; 2399 for (const str of words(clean)){ 2400 toks.push({ node:n, offset, str, w:m.measureText(str).width, ws:/^\s+$/.test(str) }); 2401 offset += str.length; 2402 } 2403 } else if (n.nodeType === 1){ 2404 const ownFont = fontOf(getComputedStyle(n)); 2405 if (n.tagName === 'CODE'){ 2406 m.font = ownFont; 2407 toks.push({ el:n, w:m.measureText(n.textContent).width, ws:false }); 2408 } else { 2409 walk(n, ownFont); 2410 } 2411 } 2412 }); 2413 } 2414 walk(el, fontOf(getComputedStyle(el))); 2415 return toks; 2416 } 2417 /* Pick one or two break indexes by exhaustive search (token counts are 2418 tiny). Prefer the minimum row count, then minimise raggedness. */ 2419 function pickBounds(toks, total, cap){ 2420 const pre = [0]; toks.forEach(t => pre.push(pre[pre.length - 1] + t.w)); 2421 const starts = []; /* legal line-start indexes (non-ws, not first) */ 2422 for (let i = 1; i < toks.length; i++) if (!toks[i].ws && legalBreak(toks, i)) starts.push(i); 2423 /* pre-line collapses whitespace immediately before an inserted 2424 newline; omit it here too so the score measures the rows users 2425 actually see. */ 2426 const lineW = (a, b) => { 2427 while (a < b && toks[a].ws) a++; 2428 while (b > a && toks[b - 1].ws) b--; 2429 return pre[b] - pre[a]; 2430 }; 2431 function score(widths, target){ 2432 let out = 0; 2433 widths.forEach(w => { out += (w - target) * (w - target); }); 2434 for (let i = 0; i + 1 < widths.length; i++){ 2435 const drop = Math.max(0, widths[i] - widths[i + 1]); 2436 out += drop * drop * 0.35; 2437 } 2438 return out; 2439 } 2440 let best = null, bestScore = Infinity; 2441 const consider = (bounds, value) => { if (value < bestScore){ bestScore = value; best = bounds; } }; 2442 if (total <= 2 * cap){ 2443 for (const i of starts){ 2444 const w1 = lineW(0, i), w2 = lineW(i, toks.length); 2445 if (w1 > cap || w2 > cap) continue; 2446 consider([i], score([w1, w2], total / 2)); 2447 } 2448 } 2449 if (best) return best; 2450 2451 const over = total > 3 * cap; 2452 for (let a = 0; a < starts.length; a++){ 2453 for (let b = a + 1; b < starts.length; b++){ 2454 const i = starts[a], j = starts[b]; 2455 const w1 = lineW(0, i), w2 = lineW(i, j), w3 = lineW(j, toks.length); 2456 if (w1 > cap || w2 > cap || (!over && w3 > cap)) continue; 2457 if (over){ 2458 /* Rows beyond the third are left to wrap naturally: make the 2459 three we control feel full and even rather than sacrificing 2460 the first two to the tail. */ 2461 consider([i, j], score([w1, w2, cap], cap * 0.94)); 2462 } else { 2463 consider([i, j], score([w1, w2, w3], total / 3)); 2464 } 2465 } 2466 } 2467 return best; /* null â unbreakable huge token; leave natural wrap */ 2468 } 2469 function render(el, toks, bounds){ 2470 const offsets = new Map();
2471 (bounds || []).forEach(i => { 2472 const t = toks[i]; 2473 if (t.node){ 2474 if (!offsets.has(t.node)) offsets.set(t.node, []); 2475 offsets.get(t.node).push(t.offset); 2476 } else if (t.el){ 2477 t.el.before(document.createTextNode('\n')); 2478 } 2479 }); 2480 /* Descending offsets keep earlier insertion points stable. pre-line 2481 collapses the ordinary space immediately before each break. */ 2482 offsets.forEach((points, node) => { 2483 points.sort((a, b) => b - a).forEach(at => { 2484 node.data = node.data.slice(0, at) + '\n' + node.data.slice(at); 2485 }); 2486 }); 2487 } 2488 function layout(el){ 2489 const cur = el.textContent; 2490 let rec = store.get(el); 2491 if (!rec || rec.out !== cur){ /* first sight, or the copy was swapped */ 2492 rec = { html: el.innerHTML, out: null, cap: 0 }; 2493 store.set(el, rec); 2494 } else { 2495 el.innerHTML = rec.html; /* re-layout: start from the source */ 2496 } 2497 const cap = capFor(el); 2498 const toks = tokenize(el); 2499 if (!toks.length){ rec.out = el.textContent; rec.cap = cap; return; } 2500 const total = toks.reduce((s, t) => s + t.w, 0); 2501 const bounds = total > cap ? pickBounds(toks, total, cap) : null; 2502 render(el, toks, bounds); 2503 rec.out = el.textContent; 2504 rec.cap = cap; 2505 } 2506 function run(){ targets().forEach(layout); } 2507 function stale(el){ 2508 const rec = store.get(el); 2509 return !rec || rec.out !== el.textContent || rec.cap !== capFor(el); 2510 } 2511 function sync(){ targets().forEach(el => { if (stale(el)) layout(el); }); } 2512 2513 /* This page rewrites its panel copy from JS (every 2FA step, the 2514 sign-in/sign-up lead swap, the passkey status line) and reveals 2515 panels that measured 0 wide while hidden â watch for both. Our own 2516 newline insertions leave text and cap unchanged, so they can never 2517 re-trigger the observer. Synchronous, not rAF-deferred: the callback 2518 is a microtask, so the rebalanced rows are in place before the frame 2519 that shows the new copy is painted. */ 2520 const shell = document.querySelector('main.shell'); 2521 if (shell && window.MutationObserver){ 2522 new MutationObserver(sync).observe(shell, { 2523 subtree: true, childList: true, characterData: true, 2524 attributes: true, attributeFilter: ['hidden'], 2525 }); 2526 } 2527 window.addEventListener('i18n:changed', run); 2528 window.addEventListener('resize', () => { 2529 if (frame) cancelAnimationFrame(frame); 2530 frame = requestAnimationFrame(() => { frame = 0; run(); }); 2531 }); 2532 if (document.fonts && document.fonts.ready) document.fonts.ready.then(run).catch(() => {}); 2533 run(); 2534})(); 2535</script>
2535 2536</body> 2537</html>
Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.