[READ-ONLY] Mirror of https://github.com/mrgnw/anahtar. Svelte auth with passkey / email+otp
0

Configure Feed

Select the types of activity you want to include in your feed.

docs: preview deck for AuthPill go-button spinner (#3)

+240
+24
analysis/capture_preview.py
··· 1 + # /// script 2 + # requires-python = ">=3.12" 3 + # dependencies = ["playwright"] 4 + # /// 5 + import pathlib 6 + import sys 7 + 8 + from playwright.sync_api import sync_playwright 9 + 10 + html = pathlib.Path(sys.argv[1]).resolve() 11 + outdir = pathlib.Path(sys.argv[2]) 12 + outdir.mkdir(parents=True, exist_ok=True) 13 + cases = ['idle', 'after', 'before'] 14 + 15 + with sync_playwright() as p: 16 + browser = p.chromium.launch() 17 + page = browser.new_page(viewport={'width': 1200, 'height': 620}, device_scale_factor=2) 18 + for cid in cases: 19 + page.goto(f'file://{html}#{cid}') 20 + page.wait_for_timeout(250) 21 + out = outdir / f'go-{cid}.png' 22 + page.screenshot(path=str(out)) 23 + print(f'wrote {out}') 24 + browser.close()
docs/preview/issue-3/go-after.png

This is a binary file and will not be displayed.

docs/preview/issue-3/go-before.png

This is a binary file and will not be displayed.

docs/preview/issue-3/go-idle.png

This is a binary file and will not be displayed.

+216
static/authpill-go-spinner.html
··· 1 + <!doctype html> 2 + <html lang="en"> 3 + <head> 4 + <meta charset="utf-8" /> 5 + <meta name="viewport" content="width=device-width, initial-scale=1" /> 6 + <title>AuthPill go-button loading state (#3)</title> 7 + <style> 8 + /* Tokens + rules copied inline from AuthPill.svelte so this reads as the product. */ 9 + :root { 10 + --anahtar-primary: #3730a3; 11 + --anahtar-primary-fg: #fff; 12 + --anahtar-pill-bg: rgba(255, 255, 255, 0.9); 13 + --anahtar-pill-border: rgba(0, 0, 0, 0.06); 14 + --anahtar-pill-shadow: 0 2px 12px rgba(0, 0, 0, 0.08); 15 + --anahtar-pill-icon: #6b7280; 16 + --anahtar-pill-fg: #111827; 17 + --anahtar-pill-placeholder: #9ca3af; 18 + --fg: #0f172a; 19 + --muted: #64748b; 20 + --border: #e2e8f0; 21 + } 22 + * { box-sizing: border-box; } 23 + body { 24 + margin: 0; 25 + font: 15px/1.5 -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; 26 + color: var(--fg); 27 + background: #f1f5f9; 28 + } 29 + .wrap { max-width: 1200px; margin: 0 auto; padding: 1.25rem 1.5rem 3rem; } 30 + header h1 { font-size: 1.05rem; margin: 0 0 0.25rem; } 31 + header p { margin: 0 0 1rem; color: var(--muted); max-width: 60ch; } 32 + .controls { display: flex; flex-wrap: wrap; gap: 0.5rem; align-items: center; margin-bottom: 1.5rem; } 33 + .seg { display: inline-flex; border: 1px solid var(--border); border-radius: 0.6rem; overflow: hidden; background: #fff; } 34 + .seg button { 35 + border: 0; background: #fff; padding: 0.4rem 0.8rem; font: inherit; font-size: 0.85rem; 36 + cursor: pointer; color: var(--muted); border-right: 1px solid var(--border); 37 + } 38 + .seg button:last-child { border-right: 0; } 39 + .seg button[aria-pressed='true'] { background: var(--anahtar-primary); color: #fff; } 40 + .stage { display: flex; flex-wrap: wrap; gap: 2rem; align-items: flex-start; } 41 + .lane[hidden] { display: none; } 42 + .lane-label { font-size: 0.75rem; color: var(--muted); text-transform: uppercase; letter-spacing: 0.04em; margin-bottom: 0.5rem; } 43 + 44 + /* Phone lane — real 390px device, never scaled. */ 45 + .phone { width: 390px; } 46 + .phone-bezel { border: 11px solid #22272e; border-radius: 42px; padding: 16px 12px; background: #dbe4f0; min-height: 300px; position: relative; } 47 + /* Desktop lane. */ 48 + .desk { flex: 1 1 560px; max-width: 760px; } 49 + .win { border: 1px solid var(--border); border-radius: 0.7rem; overflow: hidden; background: #fff; box-shadow: 0 1px 2px rgba(15,23,42,.06); } 50 + .win-bar { display: flex; gap: 6px; padding: 0.6rem 0.8rem; border-bottom: 1px solid var(--border); background: #f8fafc; } 51 + .win-bar i { width: 11px; height: 11px; border-radius: 50%; background: #cbd5e1; } 52 + .win-body { padding: 3rem 2.5rem; display: flex; justify-content: flex-end; min-height: 220px; background: #dbe4f0; } 53 + .stage-note { margin-top: 0.75rem; font-size: 0.8rem; color: var(--muted); } 54 + 55 + /* ---- Real AuthPill styles ---- */ 56 + .anahtar-pill { 57 + display: flex; align-items: center; gap: 0.4rem; 58 + background: var(--anahtar-pill-bg); 59 + backdrop-filter: blur(8px); 60 + border: 1px solid var(--anahtar-pill-border); 61 + border-radius: 9999px; 62 + padding: 0.25rem 0.75rem; 63 + box-shadow: var(--anahtar-pill-shadow); 64 + font-size: 0.875rem; white-space: nowrap; height: 2.25rem; 65 + box-sizing: border-box; overflow: hidden; 66 + } 67 + .anahtar-pill-icon { 68 + display: flex; align-items: center; justify-content: center; 69 + background: none; border: none; cursor: pointer; padding: 0.2rem; 70 + border-radius: 9999px; color: var(--anahtar-pill-icon); line-height: 1; 71 + } 72 + .anahtar-pill-form { display: flex; align-items: center; gap: 0.375rem; flex: 1; min-width: 0; overflow: hidden; } 73 + .anahtar-pill-email-input { 74 + border: none; background: transparent; outline: none; font-size: 0.875rem; 75 + color: var(--anahtar-pill-fg); flex: 1; min-width: 0; padding: 0.125rem 0; 76 + } 77 + .anahtar-pill-email-input::placeholder { color: var(--anahtar-pill-placeholder); } 78 + .anahtar-pill-go { 79 + background: var(--anahtar-primary); color: var(--anahtar-primary-fg); 80 + border: none; border-radius: 9999px; padding: 0.3rem; 81 + display: flex; align-items: center; justify-content: center; 82 + flex-shrink: 0; cursor: pointer; line-height: 1; 83 + } 84 + .anahtar-pill-go:disabled { opacity: 0.4; cursor: not-allowed; } 85 + .go-dots { color: var(--anahtar-primary-fg); font-weight: 700; font-size: 14px; letter-spacing: 1px; line-height: 1; padding: 0 3px; } 86 + @keyframes anahtar-spin { to { transform: rotate(360deg); } } 87 + .anahtar-spinner { animation: anahtar-spin 0.8s linear infinite; } 88 + @media (prefers-reduced-motion: reduce) { .anahtar-spinner { animation-duration: 3s; } } 89 + </style> 90 + </head> 91 + <body> 92 + <svg width="0" height="0" style="position:absolute" aria-hidden="true"><defs> 93 + <symbol id="i-more" viewBox="0 0 24 24"><circle cx="12" cy="12" r="1"/><circle cx="19" cy="12" r="1"/><circle cx="5" cy="12" r="1"/></symbol> 94 + <symbol id="i-arrow" viewBox="0 0 24 24"><path d="M5 12h14"/><path d="m12 5 7 7-7 7"/></symbol> 95 + <symbol id="i-spinner" viewBox="0 0 24 24"><path d="M21 12a9 9 0 1 1-6.219-8.56"/></symbol> 96 + </defs></svg> 97 + 98 + <div class="wrap"> 99 + <header> 100 + <h1>AuthPill go-button loading state — issue #3</h1> 101 + <p> 102 + While the email submit is in flight the go button rendered a literal 103 + <code>...</code>, which reads as a second ⋯ options button (see “Before”). It now 104 + renders the same arc spinner AuthFlow uses, with <code>aria-label="Continue"</code>. 105 + Use ←/→ or the pager. 106 + </p> 107 + <div class="controls"> 108 + <div class="seg" id="pager" role="group" aria-label="Cases"></div> 109 + <div class="seg" id="focus" role="group" aria-label="Which device"> 110 + <button data-focus="both" aria-pressed="true">Both</button> 111 + <button data-focus="phone" aria-pressed="false">Phone</button> 112 + <button data-focus="desk" aria-pressed="false">Desktop</button> 113 + </div> 114 + </div> 115 + </header> 116 + <main class="stage"> 117 + <section class="lane phone" id="lane-phone"> 118 + <div class="lane-label">Phone · 390px</div> 119 + <div class="phone-bezel"><div id="card-phone"></div></div> 120 + </section> 121 + <section class="lane desk" id="lane-desk"> 122 + <div class="lane-label">Desktop</div> 123 + <div class="win"><div class="win-bar"><i></i><i></i><i></i></div><div class="win-body"><div id="card-desk"></div></div></div> 124 + </section> 125 + </main> 126 + <p class="stage-note" id="note"></p> 127 + </div> 128 + 129 + <script> 130 + const svg = (id, extra) => 131 + `<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"${extra || ''}><use href="#${id}"/></svg>`; 132 + 133 + const CASES = [ 134 + { 135 + id: 'idle', label: 'Idle', 136 + note: 'Email entered, request not yet sent — arrow, button enabled.', 137 + go: `<button class="anahtar-pill-go" aria-label="Continue">${svg('i-arrow')}</button>`, 138 + }, 139 + { 140 + id: 'after', label: 'After · spinner', 141 + note: 'In flight — arc spinner, button disabled, aria-label="Continue". The fix.', 142 + go: `<button class="anahtar-pill-go" disabled aria-label="Continue">${svg('i-spinner', ' class="anahtar-spinner" aria-hidden="true"')}</button>`, 143 + }, 144 + { 145 + id: 'before', label: 'Before · "..."', 146 + note: 'The old behavior — literal "..." reads as a second ⋯ options button. Removed.', 147 + go: `<button class="anahtar-pill-go" disabled aria-label="Continue"><span class="go-dots">...</span></button>`, 148 + }, 149 + ]; 150 + 151 + function cardHTML(c) { 152 + return `<div class="anahtar-pill"> 153 + <button class="anahtar-pill-icon" aria-label="Options">${svg('i-more')}</button> 154 + <div class="anahtar-pill-form"> 155 + <input class="anahtar-pill-email-input" value="you@example.com" readonly /> 156 + ${c.go} 157 + </div> 158 + </div>`; 159 + } 160 + 161 + const pager = document.getElementById('pager'); 162 + const cardPhone = document.getElementById('card-phone'); 163 + const cardDesk = document.getElementById('card-desk'); 164 + const note = document.getElementById('note'); 165 + let active = CASES[0].id; 166 + 167 + CASES.forEach((c) => { 168 + const b = document.createElement('button'); 169 + b.type = 'button'; b.textContent = c.label; b.dataset.set = c.id; 170 + b.setAttribute('aria-pressed', 'false'); 171 + b.addEventListener('click', () => select(c.id, true)); 172 + pager.appendChild(b); 173 + }); 174 + 175 + function render() { 176 + const c = CASES.find((x) => x.id === active) || CASES[0]; 177 + cardPhone.innerHTML = cardHTML(c); 178 + cardDesk.innerHTML = cardHTML(c); 179 + note.textContent = c.note; 180 + pager.querySelectorAll('button').forEach((b) => 181 + b.setAttribute('aria-pressed', String(b.dataset.set === active))); 182 + } 183 + 184 + function select(id, push) { 185 + if (!CASES.some((c) => c.id === id)) return; 186 + active = id; 187 + if (push) history.replaceState(null, '', '#' + id); 188 + render(); 189 + } 190 + 191 + const focusBar = document.getElementById('focus'); 192 + const lanePhone = document.getElementById('lane-phone'); 193 + const laneDesk = document.getElementById('lane-desk'); 194 + focusBar.addEventListener('click', (e) => { 195 + const btn = e.target.closest('button'); if (!btn) return; 196 + const mode = btn.dataset.focus; 197 + focusBar.querySelectorAll('button').forEach((b) => b.setAttribute('aria-pressed', String(b === btn))); 198 + lanePhone.hidden = mode === 'desk'; 199 + laneDesk.hidden = mode === 'phone'; 200 + }); 201 + 202 + addEventListener('keydown', (e) => { 203 + if (e.key !== 'ArrowLeft' && e.key !== 'ArrowRight') return; 204 + const i = CASES.findIndex((c) => c.id === active); 205 + const n = (i + (e.key === 'ArrowRight' ? 1 : CASES.length - 1)) % CASES.length; 206 + select(CASES[n].id, true); 207 + }); 208 + addEventListener('hashchange', () => { 209 + const id = location.hash.replace('#', ''); 210 + if (CASES.some((c) => c.id === id)) select(id, false); 211 + }); 212 + const fromHash = location.hash.replace('#', ''); 213 + select(CASES.some((c) => c.id === fromHash) ? fromHash : CASES[0].id, false); 214 + </script> 215 + </body> 216 + </html>