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:
parent
d8f54c9ccf
commit
8964d0b9d2
50
products/catalyst/bootstrap/api/cmd/verify-purge/main.go
Normal file
50
products/catalyst/bootstrap/api/cmd/verify-purge/main.go
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@ -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,
|
||||
|
||||
Loading…
Reference in New Issue
Block a user