PageSourceSearch

https://subnsub.com/login

html subnsub.com collected 2026-10-02 01:44:46 UTC 136,258 bytes, 2,537 lines download raw bytes

1<!doctype html>
2<html lang="en" data-mode="dark">
3<head>
4<meta charset="utf-8">
5<title>Sign in — SUB&amp;SUB</title>
6<meta name="description" content="Sign in or create your SUB&amp;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&amp;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 0-9 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(/[0-9]/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.