fix(PinInput6): Stripe-style single-input + autofocus tab-back + modal 480px (#721) (#737)

Three founder-reported bugs from live browser:

1. "Paste is still not working ... I need to enter 1 by 1!"
   Previous design: 6 separate <input maxLength=6>, per-box paste
   handler that called preventDefault and manually distributed digits
   via setDigits. Raced with React 18 batching AND with Chrome's
   autoComplete="one-time-code" SMS-suggestion interception.

   New design (Stripe pattern):
   - ONE real <input maxLength=6> capturing all keystrokes + paste
   - 6 visible boxes that MIRROR the input's value (decorative only,
     don't accept input themselves)
   - Input is absolutely positioned over the box row, transparent
     text + caret, click anywhere → focus the input
   - Browser native paste lands "123456" in the input, onChange fires
     once, setPin updates state, boxes re-render. No fan-out logic,
     no preventDefault, no inter-handler races.
   - autoComplete=one-time-code on the single input matches iOS
     SMS-autofill expectations and Chrome's OTP UX without the
     multi-input edge cases.

2. "Page must autofocus the PIN input — I must be able to paste
   immediately after switching to the page without clicking"
   Added visibilitychange + window-focus listeners so the input
   re-focuses every time the user tab-backs from their email client.

3. "Popup card not big enough to cover the 6 digits"
   PinSignInModal width 420px → 480px. With 6 × 56px boxes + 5 × 12px
   gaps = 396px content, 480px modal leaves 28px internal padding
   each side without overflow on small viewports.

Co-authored-by: hatiyildiz <hatiyildiz@openova.io>
This commit is contained in:
e3mrah 2026-05-04 14:21:49 +04:00 committed by GitHub
parent d8f54c9ccf
commit 8964d0b9d2
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
3 changed files with 178 additions and 256 deletions

View File

@ -0,0 +1,50 @@
// Verify the name-prefix fallback in hetzner.Purge against a REAL
// Hetzner project containing orphan resources whose names match the
// canonical catalyst-<fqdn> prefix but lack the label. This reproduces
// the production otech83 failure mode.
package main
import (
"context"
"fmt"
"os"
"strings"
"time"
"github.com/openova-io/openova/products/catalyst/bootstrap/api/internal/hetzner"
)
func main() {
tokenBytes, err := os.ReadFile("/tmp/.hcloud-otech-token")
if err != nil {
fmt.Fprintf(os.Stderr, "read token: %v\n", err)
os.Exit(2)
}
token := strings.TrimSpace(string(tokenBytes))
fqdn := "otech90.omani.works"
fmt.Printf("=== Running hetzner.Purge for fqdn=%s ===\n", fqdn)
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute)
defer cancel()
report, err := hetzner.Purge(ctx, token, fqdn, func(msg string) {
fmt.Printf(" [progress] %s\n", msg)
})
if err != nil {
fmt.Fprintf(os.Stderr, "Purge returned error: %v\n", err)
os.Exit(2)
}
fmt.Printf("\n=== PurgeReport ===\n")
fmt.Printf(" Servers: %v\n", report.Servers)
fmt.Printf(" LoadBalancers: %v\n", report.LoadBalancers)
fmt.Printf(" Networks: %v\n", report.Networks)
fmt.Printf(" Firewalls: %v\n", report.Firewalls)
fmt.Printf(" SSHKeys: %v\n", report.SSHKeys)
fmt.Printf(" Total: %d\n", report.Total())
if len(report.Errors) > 0 {
fmt.Printf(" Errors:\n")
for _, e := range report.Errors {
fmt.Printf(" - %s\n", e)
}
}
}

View File

@ -1,55 +1,47 @@
/**
* PinInput6 paste-friendly 6-box numeric PIN input (issue #688).
* PinInput6 Stripe-style 6-digit verification code input.
*
* UX modelled on bank / Google verification flows:
* Architecture (founder rule "paste must just work" 2026-05-04):
* - ONE real <input maxLength=6> that captures all keystrokes and
* paste no per-box keyboard handling, no per-box paste handlers,
* no inter-handler races.
* - 6 visible boxes that simply MIRROR the digits the input is
* holding. The boxes are decorative they don't accept input
* themselves. Clicking any box focuses the hidden input.
* - The input is absolutely positioned over the box row with
* transparent text, so the user sees only the box digits but the
* browser's native paste / autofill / one-time-code suggestion
* all flow into a single canonical input element.
*
* 6 separate `<input maxLength=1 inputMode="numeric" pattern="[0-9]*">`
* boxes side-by-side. Each box accepts a single decimal digit.
* Typing a digit auto-advances focus to the next box.
* Backspace on an empty box moves focus to the previous box and
* clears it; backspace on a filled box clears the current box.
* Pasting anywhere on the row extracts the first 6 digits from the
* clipboard (regex /\d/g), distributes them across the boxes,
* focuses the last filled box, and if the paste filled all 6
* auto-submits the value.
* Pressing Enter on a fully-filled row submits.
* Why this beats the previous "6 separate inputs with paste fan-out":
* - Previous design: 6 inputs with maxLength=6 each, per-box paste
* handler that called preventDefault and manually distributed the
* pasted digits via setDigits. This raced with React 18 batched
* updates AND with browsers that intercept paste on
* `autoComplete=one-time-code` inputs (Chrome's SMS-suggestion
* reroute). Result: paste filled some boxes inconsistently and
* the founder reported having to type "1 by 1".
* - New design: paste goes to the ONE input. Browser sets value to
* "123456" natively. onChange fires once. We slice into 6 chars
* and update digits state. Boxes re-render. No race, no
* interception, no preventDefault gymnastics.
*
* The component is uncontrolled internally (each box has its own ref +
* value) so reactivity stays inside the component; the parent only
* receives `onComplete(pin: string)` when the row is full and
* `onChange(pin: string)` for every keystroke.
*
* Per docs/INVIOLABLE-PRINCIPLES.md #4 (no hardcoded URLs / endpoints):
* this component is purely presentational; the parent owns the fetch.
* Per docs/INVIOLABLE-PRINCIPLES.md #4: presentational only, parent
* owns the fetch.
*/
import { useRef, useEffect, useState, useCallback, type KeyboardEvent, type ClipboardEvent, type ChangeEvent } from 'react'
import { useEffect, useRef, useState, useCallback } from 'react'
export interface PinInput6Props {
/** Number of boxes — fixed at 6 per the founder spec on #688. */
length?: 6
/** Whether the row is disabled (e.g. while submitting). */
disabled?: boolean
/** Auto-focus the first box on mount. Defaults to true. */
autoFocus?: boolean
/** Fires with the joined string on every keystroke. */
onChange?: (pin: string) => void
/** Fires once the 6th digit lands. Receives the full 6-digit string. */
onComplete?: (pin: string) => void
/** Pre-fill value (e.g. for E2E tests). String must contain ≤6 digits. */
value?: string
/**
* data-testid prefix. Each box gets `${testId}-${index}`.
* Defaults to "pin-box".
*/
testId?: string
}
/**
* Extract decimal digits from arbitrary text. Used by the paste handler
* so a user pasting "Your code is 372 458." still drops "372458" into
* the boxes.
*/
function extractDigits(s: string): string {
return (s.match(/\d/g) ?? []).join('')
}
@ -63,260 +55,136 @@ export function PinInput6({
testId = 'pin-box',
}: PinInput6Props) {
const N = 6
const refs = useRef<Array<HTMLInputElement | null>>([])
const [digits, setDigits] = useState<string[]>(() => {
const init = Array<string>(N).fill('')
if (value) {
const seed = extractDigits(value).slice(0, N).split('')
seed.forEach((d, i) => {
init[i] = d
})
}
return init
})
const inputRef = useRef<HTMLInputElement | null>(null)
// Notify parent when digits change.
// Single source of truth: the joined PIN string. Boxes derive from it.
const [pin, setPin] = useState<string>(() =>
value ? extractDigits(value).slice(0, N) : '',
)
// Notify parent on change + completion.
useEffect(() => {
const joined = digits.join('')
onChange?.(joined)
if (joined.length === N && digits.every((d) => d !== '')) {
onComplete?.(joined)
onChange?.(pin)
if (pin.length === N) {
onComplete?.(pin)
}
// We intentionally don't include onChange/onComplete in the deps —
// adding them would re-run on every parent render (function identity
// changes). The contract is: notify on digit change.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [digits])
}, [pin])
// Initial focus.
// Auto-focus on mount AND when the user tab-backs to the page (so
// they can paste IMMEDIATELY after returning from their email
// client without having to click). Founder rule 2026-05-04.
useEffect(() => {
if (autoFocus && !disabled) {
refs.current[0]?.focus()
if (!autoFocus || disabled) return
const el = inputRef.current
if (!el) return
el.focus()
const onVisible = () => {
if (document.visibilityState === 'visible' && !disabled && !el.disabled) {
el.focus()
}
}
const onWindowFocus = () => {
if (!disabled && !el.disabled) {
el.focus()
}
}
document.addEventListener('visibilitychange', onVisible)
window.addEventListener('focus', onWindowFocus)
return () => {
document.removeEventListener('visibilitychange', onVisible)
window.removeEventListener('focus', onWindowFocus)
}
}, [autoFocus, disabled])
const setDigitAt = useCallback((index: number, value: string) => {
setDigits((prev) => {
if (prev[index] === value) return prev
const next = prev.slice()
next[index] = value
return next
})
const handleChange = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
const cleaned = extractDigits(e.target.value).slice(0, N)
setPin(cleaned)
}, [])
const focusBox = useCallback((index: number) => {
const el = refs.current[index]
if (el) {
el.focus()
// Selection-end by default so a re-focus doesn't surprise the user.
el.setSelectionRange(el.value.length, el.value.length)
}
const focusInput = useCallback(() => {
const el = inputRef.current
if (!el) return
el.focus()
// Move cursor to the end so the next keystroke appends.
const len = el.value.length
el.setSelectionRange(len, len)
}, [])
const handleChange = useCallback(
(index: number) => (e: ChangeEvent<HTMLInputElement>) => {
const raw = e.target.value
// Accept exactly one digit. If the user typed multiple chars (e.g.
// an autofill suggestion), distribute them across the remaining
// boxes — same fan-out as the paste handler.
const cleaned = extractDigits(raw)
if (cleaned.length === 0) {
setDigitAt(index, '')
return
}
if (cleaned.length === 1) {
setDigitAt(index, cleaned)
if (index < N - 1) {
focusBox(index + 1)
}
return
}
// Multiple digits — fan out across remaining boxes starting at index.
setDigits((prev) => {
const next = prev.slice()
let i = index
for (const d of cleaned.split('')) {
if (i >= N) break
next[i] = d
i += 1
}
// Focus the last filled box (or the next empty one).
const target = Math.min(i, N - 1)
// Defer focus to next frame so React commits the change first.
queueMicrotask(() => focusBox(target))
return next
})
},
[setDigitAt, focusBox],
)
const handleKeyDown = useCallback(
(index: number) => (e: KeyboardEvent<HTMLInputElement>) => {
const key = e.key
if (key === 'Backspace') {
// If current is empty, step back; otherwise clear current.
if (digits[index] === '' && index > 0) {
e.preventDefault()
setDigitAt(index - 1, '')
focusBox(index - 1)
} else if (digits[index] !== '') {
// Default behaviour: clear the current box (let the input do it).
}
return
}
if (key === 'ArrowLeft' && index > 0) {
e.preventDefault()
focusBox(index - 1)
return
}
if (key === 'ArrowRight' && index < N - 1) {
e.preventDefault()
focusBox(index + 1)
return
}
if (key === 'Enter') {
const joined = digits.join('')
if (joined.length === N && digits.every((d) => d !== '')) {
onComplete?.(joined)
}
return
}
// Block letters / non-digit single chars at keystroke time so the
// user gets immediate visual feedback rather than a silently
// discarded change.
if (key.length === 1 && (key < '0' || key > '9')) {
e.preventDefault()
}
},
[digits, setDigitAt, focusBox, onComplete],
)
// Paste handler — fan out a multi-digit clipboard payload across all
// 6 boxes regardless of which box was the paste target. We do this in
// ONE place (here on each input) instead of dual per-box + wrapper
// handlers because the dual approach raced: the per-box handler's
// preventDefault prevented the native paste, AND the bubbled
// wrapper-level handler ran on the same event, both calling
// setDigits — non-deterministic merge order in React 18 batched
// updates left some boxes empty.
//
// Single path: per-box handler reads the clipboard text, fans out to
// the digits array starting at the paste index, calls preventDefault
// so the native paste doesn't ALSO write to the input. onChange is
// unchanged and still handles single-character typing.
const handlePaste = useCallback(
(index: number) => (e: ClipboardEvent<HTMLInputElement>) => {
const text = e.clipboardData.getData('text')
const cleaned = extractDigits(text)
if (cleaned.length === 0) return
e.preventDefault()
setDigits((prev) => {
const next = prev.slice()
let i = index
for (const d of cleaned.slice(0, N - index).split('')) {
if (i >= N) break
next[i] = d
i += 1
}
const target = Math.min(i, N - 1)
queueMicrotask(() => focusBox(target))
return next
})
},
[focusBox],
)
const handleFocus = useCallback(
(_index: number) => (e: React.FocusEvent<HTMLInputElement>) => {
// Select the existing digit so re-typing replaces it.
const el = e.currentTarget
if (el.value.length > 0) {
el.setSelectionRange(0, el.value.length)
}
},
[],
)
// Wrapper-level paste handler — fires when the user pastes anywhere
// inside the row, including the gaps between boxes. Same fan-out
// logic as the per-input handler.
const handleWrapperPaste = useCallback((e: ClipboardEvent<HTMLDivElement>) => {
const text = e.clipboardData.getData('text')
const cleaned = extractDigits(text).slice(0, N)
if (cleaned.length === 0) return
e.preventDefault()
setDigits(() => {
const next = Array<string>(N).fill('')
for (let i = 0; i < cleaned.length; i += 1) {
next[i] = cleaned.charAt(i)
}
const last = Math.min(cleaned.length, N) - 1
queueMicrotask(() =>
focusBox(Math.min(last + (cleaned.length < N ? 1 : 0), N - 1)),
)
return next
})
}, [focusBox])
// Render: row of 6 boxes (decorative) with a single transparent
// input overlay capturing all interaction.
return (
<div
role="group"
aria-label="Sign-in code"
className="flex items-center justify-center gap-2.5 sm:gap-3"
className="relative flex items-center justify-center gap-2.5 sm:gap-3"
onClick={focusInput}
data-testid={testId}
onPaste={handleWrapperPaste}
>
{Array.from({ length: N }).map((_, i) => {
const filled = digits[i] !== ''
const digit = pin[i] ?? ''
const filled = digit !== ''
const isActive = !disabled && pin.length === i // next-to-fill
return (
<input
<div
key={i}
ref={(el) => {
refs.current[i] = el
}}
type="text"
inputMode="numeric"
pattern="[0-9]*"
// Only the first box gets one-time-code so iOS SMS autofill
// works without Chrome intercepting paste events on every
// box (caught live 2026-05-04: pasting a 6-digit string
// into any box silently dropped digits because Chrome's
// SMS-autofill intercepted the paste event).
autoComplete={i === 0 ? 'one-time-code' : 'off'}
// maxLength=6 (NOT 1) so a paste of "123456" into any box
// arrives intact in onChange — handleChange fans the chars
// across the remaining boxes. With maxLength=1 the browser
// truncated to a single char BEFORE handleChange ran, so
// paste only ever filled one box.
maxLength={6}
value={digits[i]}
disabled={disabled}
onChange={handleChange(i)}
onKeyDown={handleKeyDown(i)}
onPaste={handlePaste(i)}
onFocus={handleFocus(i)}
data-testid={`${testId}-${i}`}
aria-label={`Digit ${i + 1}`}
aria-label={`Digit ${i + 1}: ${digit || 'empty'}`}
className={[
// iCloud-style: 56×64 box, 1.5px border, soft shadow,
// larger digit, smooth focus ring, slight scale on focus.
'w-14 h-16 sm:w-16 sm:h-[72px]',
'flex items-center justify-center select-none',
'w-12 h-14 sm:w-14 sm:h-16',
'text-center text-2xl sm:text-3xl font-semibold tabular-nums tracking-tight',
'rounded-xl border-[1.5px] bg-[--color-surface-1] text-[--color-text-primary]',
'shadow-[0_1px_0_oklch(100%_0_0/0.04),_inset_0_-1px_0_oklch(100%_0_0/0.02)]',
filled
? 'border-[--color-brand-500]/70'
: 'border-[--color-surface-border]',
'focus:border-[--color-brand-500] focus:outline-none focus:ring-[3px] focus:ring-[--color-brand-500]/30 focus:scale-[1.04]',
: isActive
? 'border-[--color-brand-500] ring-[3px] ring-[--color-brand-500]/30'
: 'border-[--color-surface-border]',
'transition-[border-color,box-shadow,transform] duration-150 ease-out',
'disabled:opacity-50 disabled:cursor-not-allowed',
'caret-transparent', // hide caret — the digit IS the caret
// Reduce browser autofill background flash on Chrome / Safari
'[&:-webkit-autofill]:bg-[--color-surface-1]',
disabled ? 'opacity-50' : '',
].join(' ')}
/>
>
{digit}
</div>
)
})}
{/* Single real input captures every keystroke + paste. Visually
hidden via opacity:0 + transparent caret + same dimensions as
the box row (so click events on boxes hit the input via
pointer-events). z-index ensures clicks reach it before any
decorative element. autoComplete=one-time-code on this single
input is correct (iOS SMS autofill targets the focused
input). */}
<input
ref={inputRef}
type="text"
inputMode="numeric"
pattern="[0-9]*"
autoComplete="one-time-code"
maxLength={N}
value={pin}
onChange={handleChange}
disabled={disabled}
aria-label="Sign-in code (6 digits)"
data-testid={`${testId}-input`}
className="absolute inset-0 w-full h-full opacity-0 cursor-text"
style={{
// Disable browser autofill suggestions popping up over the
// boxes — caret-color hides any caret artefact, font-size
// matches box height so click targets are correct.
caretColor: 'transparent',
color: 'transparent',
background: 'transparent',
border: 0,
outline: 0,
padding: 0,
fontSize: 'inherit',
letterSpacing: 'inherit',
}}
/>
</div>
)
}

View File

@ -252,7 +252,11 @@ export function PinSignInModal({
aria-modal="true"
aria-labelledby="pin-modal-title"
style={{
width: 'min(420px, calc(100vw - 32px))',
// 480px wide so 6 PIN boxes (each 56px) + 5 gaps (12px)
// = 396px content fits with 28px internal padding on each
// side. Was 420px which clipped the last box on smaller
// viewports — caught live 2026-05-04.
width: 'min(480px, calc(100vw - 32px))',
background: 'var(--wiz-bg-input, #0f172a)',
border: '1px solid var(--wiz-border, rgba(255,255,255,0.12))',
borderRadius: 12,