Auth card block covering every flow — sign in, sign up, magic link, OTP/2FA, forgot and reset password — morphing between steps with direction-aware slides and a spring height. SSO buttons with per-button spinners, password reveal + caps-lock warning, a 4-segment strength meter with a live rule checklist, an envelope that folds shut on 'Check your inbox' with a resend countdown, Passcode cells that auto-submit and shake on a wrong code, and async callbacks driving pending, a slide-in error banner and a drawn-check success state.
@crucible
registry/default/app/wicket/wicket.tsx
"use client";
import {
AnimatePresence,
animate,
motion,
useInView,
type Transition,
type Variants,
} from "motion/react";
import * as React from "react";
import { cn } from "@/lib/utils";
import { useReducedMotion } from "@/hooks/use-reduced-motion";
import { Passcode } from "@/components/crucible/passcode";
/* ------------------------------------------------------------------ types */
/** Every flow the card can show. */
export type WicketView = "sign-in" | "sign-up" | "magic-link" | "otp" | "forgot" | "reset";
/** A single-sign-on provider button. */
export interface WicketProvider {
/** Stable id passed to `onProvider`. */
id: string;
/** Visible provider name — rendered as "Continue with {label}". */
label: string;
/** Optional glyph (16px). Defaults to a neutral generic mark. */
icon?: React.ReactNode;
}
/** Values submitted from the sign-in view. */
export interface WicketSignInValues {
email: string;
password: string;
remember: boolean;
}
/** Values submitted from the sign-up view. */
export interface WicketSignUpValues {
name: string;
email: string;
password: string;
acceptedTerms: boolean;
}
/** Per-view header copy override. */
export interface WicketCopy {
title?: string;
subtitle?: string;
}
/**
* Async auth callbacks resolve to finish the flow, reject (throw) to show the
* error banner with the error's message, or — for sign-in / sign-up / SSO —
* resolve to another view (e.g. `"otp"`) to continue into a second step.
*/
type Outcome = void | WicketView;
type Async<T> = T | Promise<T>;
/** Imperative verbs exposed through the `ref` prop. */
export interface WicketHandle {
/** Jump to a view (resets that view's pending/sent/done state). */
setView: (view: WicketView) => void;
/** Clear every field and state and return to `defaultView`. */
reset: () => void;
/** Replay the plate-in intro. */
replay: () => void;
}
export interface WicketProps {
/** Controlled view. Omit for internal navigation (see `defaultView`). */
view?: WicketView;
/** Initial view when uncontrolled. @default "sign-in" */
defaultView?: WicketView;
/** Fires whenever the card asks to move to another view (links, flow steps). */
onViewChange?: (view: WicketView) => void;
/** SSO buttons shown on sign-in and sign-up. Pass `[]` to hide the row. @default Google + GitHub with generic glyphs */
providers?: WicketProvider[];
/** Logo slot above the header. @default a neutral accent mark */
logo?: React.ReactNode;
/** Product name woven into the default copy ("Sign in to {title}"). @default "Halyard" */
title?: string;
/** Override the title/subtitle of any view. */
copy?: Partial<Record<WicketView, WicketCopy>>;
/** Sign in with email + password. Resolve to `"otp"` to require a second factor. */
onSignIn?: (values: WicketSignInValues) => Async<Outcome>;
/** Create an account. Resolve to a view to continue (e.g. `"otp"` to verify). */
onSignUp?: (values: WicketSignUpValues) => Async<Outcome>;
/** Send a magic sign-in link. Resolving shows the "Check your inbox" state. */
onMagicLink?: (email: string) => Async<void>;
/** Verify a one-time code (auto-submits once every cell is filled). Reject to shake + clear. */
onVerifyOtp?: (code: string) => Async<void>;
/** Send a password-reset email. Resolving shows the sent state. */
onForgot?: (email: string) => Async<void>;
/** Set a new password from a reset link. */
onReset?: (values: { password: string }) => Async<void>;
/** SSO button pressed. Resolve to finish, or to a view to continue. */
onProvider?: (id: string) => Async<Outcome>;
/** A "Resend" button was pressed (after its countdown). */
onResend?: (view: "magic-link" | "otp" | "forgot", email: string) => Async<void>;
/** Fires when a flow reaches its final success state. */
onComplete?: (view: WicketView) => void;
/** Prefill the email field. */
defaultEmail?: string;
/** Seconds before "Resend" re-enables. @default 30 */
resendSeconds?: number;
/** Number of OTP cells. @default 6 */
otpLength?: number;
/** The single signature accent — primary button, focus rings, links. @default violet #a78bfa */
accentColor?: string;
/** Imperative handle (`setView`, `reset`, `replay`). React 19 ref-as-prop. */
ref?: React.Ref<WicketHandle>;
className?: string;
}
/* -------------------------------------------------------------- constants */
const EASE: [number, number, number, number] = [0.22, 1, 0.36, 1];
const SPRING: Transition = { type: "spring", stiffness: 380, damping: 36, mass: 0.8 };
const INSTANT: Transition = { duration: 0 };
const SUCCESS = "#34d399";
const DANGER = "#fb7185";
const DEFAULT_PROVIDERS: WicketProvider[] = [
{ id: "google", label: "Google", icon: <GlobeGlyph /> },
{ id: "github", label: "GitHub", icon: <BracketsGlyph /> },
];
/** Depth for direction-aware slides: deeper = slides in from the right. */
const DEPTH: Record<WicketView, number> = {
"sign-in": 0,
"sign-up": 1,
"magic-link": 1,
forgot: 1,
otp: 2,
reset: 2,
};
const ORDER: WicketView[] = ["sign-in", "sign-up", "magic-link", "forgot", "otp", "reset"];
const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]{2,}$/;
const RULES = [
{ id: "len", label: "8+ characters", test: (p: string) => p.length >= 8 },
{ id: "case", label: "Upper & lowercase", test: (p: string) => /[a-z]/.test(p) && /[A-Z]/.test(p) },
{ id: "num", label: "A number", test: (p: string) => /\d/.test(p) },
{ id: "sym", label: "A symbol", test: (p: string) => /[^A-Za-z0-9]/.test(p) },
] as const;
const STRENGTH = [
{ label: "", color: "transparent" },
{ label: "Weak", color: "#f87171" },
{ label: "Fair", color: "#fbbf24" },
{ label: "Good", color: "#a3e635" },
{ label: "Strong", color: SUCCESS },
] as const;
/* ---------------------------------------------------------------- helpers */
/** Black or white text, whichever reads against the accent fill. */
function readableOn(hex: string): string {
const m = /^#?([0-9a-f]{3}|[0-9a-f]{6})$/i.exec(hex.trim());
if (!m) return "#0a0a0a";
let s = m[1];
if (s.length === 3) s = s.replace(/./g, (c) => c + c);
const n = parseInt(s, 16);
const lum = 0.2126 * ((n >> 16) & 255) + 0.7152 * ((n >> 8) & 255) + 0.0722 * (n & 255);
return lum > 140 ? "#0a0a0a" : "#ffffff";
}
function strength(p: string): number {
if (!p) return 0;
const met = RULES.filter((r) => r.test(p)).length;
// Short passwords can't be better than Weak no matter the mix.
return p.length < 8 ? Math.min(met, 1) || 1 : Math.max(1, met);
}
function maskEmail(email: string): string {
const [user, domain] = email.split("@");
if (!user || !domain) return email;
return `${user[0]}${"•".repeat(Math.min(4, Math.max(2, user.length - 1)))}@${domain}`;
}
const wait = (ms: number) => new Promise<void>((r) => window.setTimeout(r, ms));
function errorMessage(e: unknown, fallback: string): string {
if (e instanceof Error && e.message) return e.message;
if (typeof e === "string" && e) return e;
return fallback;
}
/** Ticking countdown; timestamps only read in handlers/effects. */
function useCountdown() {
const [left, setLeft] = React.useState(0);
const endRef = React.useRef(0);
const start = React.useCallback((seconds: number) => {
endRef.current = performance.now() + seconds * 1000;
setLeft(seconds);
}, []);
const clear = React.useCallback(() => {
endRef.current = 0;
setLeft(0);
}, []);
React.useEffect(() => {
if (left <= 0) return;
const t = window.setInterval(() => {
const next = Math.max(0, Math.ceil((endRef.current - performance.now()) / 1000));
setLeft(next);
}, 250);
return () => window.clearInterval(t);
}, [left > 0]); // eslint-disable-line react-hooks/exhaustive-deps
return { left, start, clear };
}
const fmtClock = (s: number) => `${Math.floor(s / 60)}:${String(s % 60).padStart(2, "0")}`;
/* ------------------------------------------------------------------ glyphs */
function GlobeGlyph() {
return (
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.3" className="size-4" aria-hidden>
<circle cx="8" cy="8" r="6.25" />
<path d="M1.75 8h12.5M8 1.75c1.8 1.9 2.6 4 2.6 6.25S9.8 12.35 8 14.25M8 1.75C6.2 3.65 5.4 5.75 5.4 8s.8 4.35 2.6 6.25" />
</svg>
);
}
function BracketsGlyph() {
return (
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.4" strokeLinecap="round" strokeLinejoin="round" className="size-4" aria-hidden>
<path d="M5.25 4.5 1.75 8l3.5 3.5M10.75 4.5 14.25 8l-3.5 3.5M9.1 2.75 6.9 13.25" />
</svg>
);
}
function DefaultLogo({ accent }: { accent: string }) {
return (
<span
className="relative grid size-10 place-items-center rounded-xl border border-white/[0.1] bg-neutral-950 shadow-[inset_0_1px_0_rgba(255,255,255,0.06)]"
aria-hidden
>
<span
className="absolute inset-0 rounded-xl opacity-60"
style={{ background: `radial-gradient(70% 70% at 50% 0%, color-mix(in srgb, ${accent} 35%, transparent), transparent 70%)` }}
/>
<svg viewBox="0 0 20 20" className="relative size-5" fill="none">
<path d="M4 15.5V8.2a6 6 0 0 1 12 0v7.3" stroke={accent} strokeWidth="1.8" strokeLinecap="round" />
<path d="M7.5 15.5V9.4a2.5 2.5 0 0 1 5 0v6.1" stroke="white" strokeOpacity="0.85" strokeWidth="1.8" strokeLinecap="round" />
</svg>
</span>
);
}
function Spinner({ className }: { className?: string }) {
return (
<svg viewBox="0 0 16 16" className={cn("size-4 animate-spin motion-reduce:animate-[spin_1.6s_linear_infinite]", className)} fill="none" aria-hidden>
<circle cx="8" cy="8" r="6" stroke="currentColor" strokeOpacity="0.25" strokeWidth="2" />
<path d="M14 8a6 6 0 0 0-6-6" stroke="currentColor" strokeWidth="2" strokeLinecap="round" />
</svg>
);
}
function EyeGlyph({ off }: { off: boolean }) {
return (
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.3" strokeLinecap="round" className="size-4" aria-hidden>
<path d="M1.5 8s2.4-4.5 6.5-4.5S14.5 8 14.5 8 12.1 12.5 8 12.5 1.5 8 1.5 8Z" />
<circle cx="8" cy="8" r="2" />
{off && <path d="M2.5 2.5l11 11" />}
</svg>
);
}
/* -------------------------------------------------------------- primitives */
interface Ctx {
accent: string;
ink: string;
reduced: boolean;
}
const WicketCtx = React.createContext<Ctx>({ accent: "#a78bfa", ink: "#0a0a0a", reduced: false });
const inputBase =
"h-10 w-full rounded-[10px] border bg-white/[0.03] px-3 text-sm text-white outline-none transition-[border-color,box-shadow,background-color] duration-150 placeholder:text-white/25 hover:bg-white/[0.045] focus:bg-white/[0.05] focus:border-[var(--wk-accent)] focus:shadow-[0_0_0_3px_var(--wk-ring)] disabled:opacity-60";
interface FieldProps extends Omit<React.ComponentPropsWithoutRef<"input">, "className"> {
label: string;
error?: string | null;
hint?: React.ReactNode;
aside?: React.ReactNode;
trailing?: React.ReactNode;
inputRef?: React.Ref<HTMLInputElement>;
}
function Field({ label, error, hint, aside, trailing, inputRef, id, ...input }: FieldProps) {
const auto = React.useId();
const fid = id ?? auto;
const errId = `${fid}-err`;
const hintId = `${fid}-hint`;
const describedBy = [error ? errId : null, hint ? hintId : null].filter(Boolean).join(" ") || undefined;
return (
<div className="flex flex-col gap-1.5">
<div className="flex items-baseline justify-between gap-3">
<label htmlFor={fid} className="text-[13px] font-medium text-white/75">
{label}
</label>
{aside}
</div>
<div className="relative">
<input
ref={inputRef}
id={fid}
aria-invalid={error ? true : undefined}
aria-describedby={describedBy}
className={cn(
inputBase,
trailing && "pr-10",
error ? "border-rose-400/55" : "border-white/[0.08]"
)}
{...input}
/>
{trailing && <div className="absolute inset-y-0 right-0 flex items-center pr-1.5">{trailing}</div>}
</div>
<Collapse show={!!error}>
<p id={errId} className="flex items-center gap-1.5 pt-0.5 text-[12.5px] text-rose-300">
<svg viewBox="0 0 16 16" className="size-3.5 shrink-0" fill="currentColor" aria-hidden>
<path d="M8 1.5a6.5 6.5 0 1 0 0 13 6.5 6.5 0 0 0 0-13Zm-.75 3.25h1.5v4.5h-1.5v-4.5Zm.75 7a.9.9 0 1 1 0-1.8.9.9 0 0 1 0 1.8Z" />
</svg>
{error}
</p>
</Collapse>
{hint && <div id={hintId}>{hint}</div>}
</div>
);
}
/** Height + opacity reveal for inline messages. */
function Collapse({ show, children }: { show: boolean; children: React.ReactNode }) {
const { reduced } = React.useContext(WicketCtx);
return (
<AnimatePresence initial={false}>
{show && (
<motion.div
initial={reduced ? { opacity: 0 } : { opacity: 0, height: 0 }}
animate={reduced ? { opacity: 1 } : { opacity: 1, height: "auto" }}
exit={reduced ? { opacity: 0 } : { opacity: 0, height: 0 }}
transition={reduced ? { duration: 0.12 } : { duration: 0.2, ease: EASE }}
className="overflow-hidden"
>
{children}
</motion.div>
)}
</AnimatePresence>
);
}
function PrimaryButton({
pending,
children,
pendingLabel,
...rest
}: React.ComponentPropsWithoutRef<"button"> & { pending?: boolean; pendingLabel?: string }) {
const { accent, ink } = React.useContext(WicketCtx);
const inert = rest.disabled && !pending;
return (
<button
{...rest}
aria-disabled={pending || undefined}
className="relative inline-flex h-10 w-full items-center justify-center gap-2 rounded-[10px] text-sm font-semibold shadow-[inset_0_1px_0_rgba(255,255,255,0.25),0_1px_2px_rgba(0,0,0,0.4)] transition-[filter,transform] duration-150 hover:brightness-110 active:scale-[0.99] focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[var(--wk-accent)] disabled:cursor-not-allowed disabled:shadow-none disabled:hover:brightness-100 disabled:active:scale-100"
style={inert ? { backgroundColor: "rgba(255,255,255,0.06)", color: "rgba(255,255,255,0.35)" } : { backgroundColor: accent, color: ink }}
>
{pending && <Spinner />}
<span>{pending ? (pendingLabel ?? children) : children}</span>
</button>
);
}
function TextLink({
className,
strong,
...rest
}: React.ComponentPropsWithoutRef<"button"> & { strong?: boolean }) {
return (
<button
type="button"
{...rest}
className={cn(
"rounded-[6px] text-[13px] font-medium outline-none transition-colors focus-visible:ring-2 focus-visible:ring-[var(--wk-accent)] focus-visible:ring-offset-2 focus-visible:ring-offset-neutral-900 disabled:cursor-not-allowed disabled:opacity-50",
strong ? "text-[var(--wk-accent)] hover:brightness-125" : "text-white/55 hover:text-white",
className
)}
/>
);
}
function Checkbox({
checked,
onChange,
children,
error,
id,
}: {
checked: boolean;
onChange: (v: boolean) => void;
children: React.ReactNode;
error?: string | null;
id?: string;
}) {
const { accent, ink, reduced } = React.useContext(WicketCtx);
const auto = React.useId();
const fid = id ?? auto;
return (
<div className="flex flex-col gap-1">
<label htmlFor={fid} className="group flex cursor-pointer items-start gap-2.5 text-[13px] leading-5 text-white/60">
<span className="relative mt-0.5 grid size-4 shrink-0 place-items-center">
<input
id={fid}
type="checkbox"
checked={checked}
onChange={(e) => onChange(e.target.checked)}
aria-invalid={error ? true : undefined}
aria-describedby={error ? `${fid}-err` : undefined}
className={cn(
"peer absolute inset-0 m-0 size-4 cursor-pointer appearance-none rounded-[5px] border bg-white/[0.03] outline-none transition-colors focus-visible:shadow-[0_0_0_3px_var(--wk-ring)] group-hover:border-white/30",
error ? "border-rose-400/60" : "border-white/20"
)}
style={checked ? { backgroundColor: accent, borderColor: accent } : undefined}
/>
<svg viewBox="0 0 12 12" className="pointer-events-none relative size-3" fill="none" aria-hidden>
<motion.path
d="M2.5 6.2 5 8.5l4.5-5"
stroke={ink}
strokeWidth="1.8"
strokeLinecap="round"
strokeLinejoin="round"
initial={false}
animate={{ pathLength: checked ? 1 : 0, opacity: checked ? 1 : 0 }}
transition={reduced ? INSTANT : { duration: 0.2, ease: EASE }}
/>
</svg>
</span>
<span>{children}</span>
</label>
<Collapse show={!!error}>
<p id={`${fid}-err`} className="pl-[26px] text-[12.5px] text-rose-300">
{error}
</p>
</Collapse>
</div>
);
}
function PasswordField({
label,
value,
onChange,
onBlur,
error,
autoComplete,
aside,
meter,
name,
placeholder,
disabled,
}: {
label: string;
value: string;
onChange: (v: string) => void;
onBlur?: () => void;
error?: string | null;
autoComplete: string;
aside?: React.ReactNode;
meter?: boolean;
name: string;
placeholder?: string;
disabled?: boolean;
}) {
const [shown, setShown] = React.useState(false);
const [caps, setCaps] = React.useState(false);
const capsCheck = (e: React.KeyboardEvent<HTMLInputElement>) => {
if (typeof e.getModifierState === "function") setCaps(e.getModifierState("CapsLock"));
};
return (
<Field
label={label}
name={name}
type={shown ? "text" : "password"}
autoComplete={autoComplete}
placeholder={placeholder}
value={value}
disabled={disabled}
onChange={(e) => onChange(e.target.value)}
onKeyDown={capsCheck}
onKeyUp={capsCheck}
onBlur={() => {
setCaps(false);
onBlur?.();
}}
error={error}
aside={aside}
spellCheck={false}
trailing={
<button
type="button"
onClick={() => setShown((s) => !s)}
aria-label={shown ? "Hide password" : "Show password"}
aria-pressed={shown}
className="grid size-7 place-items-center rounded-[7px] text-white/45 outline-none transition-colors hover:bg-white/[0.06] hover:text-white focus-visible:ring-2 focus-visible:ring-[var(--wk-accent)]"
>
<EyeGlyph off={shown} />
</button>
}
hint={
<>
<Collapse show={caps}>
<p className="flex items-center gap-1.5 pt-0.5 text-[12.5px] text-amber-300/90" role="status">
<svg viewBox="0 0 16 16" className="size-3.5" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinejoin="round" aria-hidden>
<path d="M8 2.5 2.75 8H5.5v3h5V8h2.75L8 2.5ZM5.5 13.5h5" />
</svg>
Caps Lock is on
</p>
</Collapse>
{meter && <StrengthMeter password={value} />}
</>
}
/>
);
}
function StrengthMeter({ password }: { password: string }) {
const { reduced } = React.useContext(WicketCtx);
const score = strength(password);
const tone = STRENGTH[score];
return (
<div className="pt-1.5">
<div className="flex items-center gap-3">
<div className="grid flex-1 grid-cols-4 gap-1.5" aria-hidden>
{[1, 2, 3, 4].map((seg) => (
<span key={seg} className="relative h-1 overflow-hidden rounded-full bg-white/[0.08]">
<motion.span
className="absolute inset-0 origin-left rounded-full"
initial={false}
animate={{ scaleX: score >= seg ? 1 : 0, backgroundColor: score >= seg ? tone.color : "rgba(255,255,255,0)" }}
transition={reduced ? INSTANT : { duration: 0.32, ease: EASE, delay: score >= seg ? (seg - 1) * 0.04 : 0 }}
/>
</span>
))}
</div>
<span
className="w-12 text-right text-[12px] font-medium tabular-nums transition-colors"
style={{ color: score ? tone.color : "rgba(255,255,255,0.3)" }}
aria-live="polite"
>
{password ? (
<>
<span className="sr-only">Password strength: </span>
{tone.label}
</>
) : (
"—"
)}
</span>
</div>
<ul className="mt-2.5 grid grid-cols-2 gap-x-3 gap-y-1.5">
{RULES.map((r) => {
const ok = !!password && r.test(password);
return (
<li key={r.id} className={cn("flex items-center gap-1.5 text-[12px] transition-colors", ok ? "text-white/75" : "text-white/35")}>
<span
className="grid size-3.5 shrink-0 place-items-center rounded-full border transition-colors"
style={{ borderColor: ok ? SUCCESS : "rgba(255,255,255,0.18)", backgroundColor: ok ? `color-mix(in srgb, ${SUCCESS} 18%, transparent)` : "transparent" }}
>
<svg viewBox="0 0 12 12" className="size-2.5" fill="none" aria-hidden>
<motion.path
d="M2.5 6.2 5 8.5l4.5-5"
stroke={SUCCESS}
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
initial={false}
animate={{ pathLength: ok ? 1 : 0, opacity: ok ? 1 : 0 }}
transition={reduced ? INSTANT : { duration: 0.25, ease: EASE }}
/>
</svg>
</span>
<span>
{r.label}
<span className="sr-only">{ok ? " — met" : " — not met"}</span>
</span>
</li>
);
})}
</ul>
</div>
);
}
function ErrorBanner({ message, onDismiss }: { message: string | null; onDismiss: () => void }) {
const { reduced } = React.useContext(WicketCtx);
return (
<AnimatePresence initial={false}>
{message && (
<motion.div
key="banner"
initial={reduced ? { opacity: 0 } : { opacity: 0, height: 0, y: -6 }}
animate={reduced ? { opacity: 1 } : { opacity: 1, height: "auto", y: 0 }}
exit={reduced ? { opacity: 0 } : { opacity: 0, height: 0, y: -6 }}
transition={reduced ? { duration: 0.12 } : { duration: 0.26, ease: EASE }}
className="overflow-hidden"
>
<div className="pb-4">
<div
role="alert"
className="flex items-start gap-2.5 rounded-[10px] border px-3 py-2.5 text-[13px] leading-5"
style={{ borderColor: `color-mix(in srgb, ${DANGER} 30%, transparent)`, backgroundColor: `color-mix(in srgb, ${DANGER} 9%, transparent)`, color: "#fecdd3" }}
>
<svg viewBox="0 0 16 16" className="mt-0.5 size-4 shrink-0" fill="currentColor" aria-hidden>
<path d="M8 1.5a6.5 6.5 0 1 0 0 13 6.5 6.5 0 0 0 0-13Zm-.75 3.25h1.5v4.5h-1.5v-4.5Zm.75 7a.9.9 0 1 1 0-1.8.9.9 0 0 1 0 1.8Z" />
</svg>
<span className="flex-1">{message}</span>
<button
type="button"
onClick={onDismiss}
aria-label="Dismiss error"
className="-m-1 grid size-6 place-items-center rounded-md text-rose-200/70 outline-none hover:bg-white/[0.06] hover:text-white focus-visible:ring-2 focus-visible:ring-rose-300"
>
<svg viewBox="0 0 16 16" className="size-3.5" fill="none" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" aria-hidden>
<path d="m4 4 8 8M12 4l-8 8" />
</svg>
</button>
</div>
</div>
</motion.div>
)}
</AnimatePresence>
);
}
function Divider({ children }: { children: React.ReactNode }) {
return (
<div className="flex items-center gap-3 text-[11.5px] font-medium uppercase tracking-[0.12em] text-white/30">
<span className="h-px flex-1 bg-white/[0.08]" />
{children}
<span className="h-px flex-1 bg-white/[0.08]" />
</div>
);
}
/** Envelope whose letter drops in and flap folds shut. */
function Envelope() {
const { accent, reduced } = React.useContext(WicketCtx);
const flap = "M8 22 L48 50 L88 22";
return (
<svg viewBox="0 -8 96 88" className="h-[80px] w-[88px]" fill="none" aria-hidden>
<defs>
<linearGradient id="wk-env-back" x1="0" y1="0" x2="0" y2="1">
<stop offset="0" stopColor="#262626" />
<stop offset="1" stopColor="#171717" />
</linearGradient>
</defs>
{/* back panel */}
<rect x="8" y="22" width="80" height="54" rx="7" fill="url(#wk-env-back)" stroke="rgba(255,255,255,0.14)" />
{/* open flap (behind the letter) — fades as the front flap folds */}
{!reduced && (
<motion.path
d="M8 22 L48 -6 L88 22 Z"
fill="#1f1f1f"
stroke="rgba(255,255,255,0.14)"
strokeLinejoin="round"
initial={{ opacity: 1 }}
animate={{ opacity: 0 }}
transition={{ delay: 0.5, duration: 0.01 }}
/>
)}
{/* letter */}
<motion.g initial={reduced ? false : { y: -26 }} animate={{ y: 0 }} transition={{ duration: 0.45, ease: EASE, delay: 0.05 }}>
<rect x="18" y="28" width="60" height="40" rx="4" fill="#fafafa" />
<rect x="26" y="36" width="26" height="4" rx="2" fill={accent} />
<rect x="26" y="45" width="44" height="3" rx="1.5" fill="#d4d4d4" />
<rect x="26" y="52" width="36" height="3" rx="1.5" fill="#d4d4d4" />
</motion.g>
{/* front pocket */}
<path d="M8 22 L48 50 L88 22 V69 a7 7 0 0 1 -7 7 H15 a7 7 0 0 1 -7 -7 Z" fill="#1c1c1c" stroke="rgba(255,255,255,0.14)" strokeLinejoin="round" />
{/* front flap folding shut */}
<motion.path
d={`${flap} Z`}
fill="#232323"
stroke="rgba(255,255,255,0.18)"
strokeLinejoin="round"
style={{ transformBox: "fill-box", transformOrigin: "50% 0%" }}
initial={reduced ? false : { scaleY: -1, opacity: 0 }}
animate={{ scaleY: 1, opacity: 1 }}
transition={reduced ? INSTANT : { scaleY: { delay: 0.5, duration: 0.42, ease: EASE }, opacity: { delay: 0.5, duration: 0.01 } }}
/>
{/* seal */}
<motion.circle
cx="48"
cy="50"
r="5"
fill={accent}
initial={reduced ? false : { scale: 0, opacity: 0 }}
animate={{ scale: 1, opacity: 1 }}
transition={reduced ? INSTANT : { delay: 0.86, type: "spring", stiffness: 520, damping: 26 }}
style={{ transformBox: "fill-box", transformOrigin: "50% 50%" }}
/>
</svg>
);
}
function DrawnCheck() {
const { reduced } = React.useContext(WicketCtx);
return (
<svg viewBox="0 0 56 56" className="size-14" fill="none" aria-hidden>
<circle cx="28" cy="28" r="26" fill={`color-mix(in srgb, ${SUCCESS} 10%, transparent)`} />
<motion.circle
cx="28"
cy="28"
r="25"
stroke={SUCCESS}
strokeWidth="2"
strokeLinecap="round"
initial={reduced ? false : { pathLength: 0 }}
animate={{ pathLength: 1 }}
transition={reduced ? INSTANT : { duration: 0.5, ease: EASE }}
style={{ rotate: -90, transformOrigin: "50% 50%" }}
/>
<motion.path
d="M18 28.5 25 35.5 38.5 21"
stroke={SUCCESS}
strokeWidth="3"
strokeLinecap="round"
strokeLinejoin="round"
initial={reduced ? false : { pathLength: 0 }}
animate={{ pathLength: 1 }}
transition={reduced ? INSTANT : { duration: 0.34, ease: EASE, delay: 0.38 }}
/>
</svg>
);
}
/* --------------------------------------------------------------- component */
type Phase = "form" | "sent" | "done";
type FieldName = "name" | "email" | "password" | "confirm" | "terms";
interface Values {
name: string;
email: string;
password: string;
confirm: string;
remember: boolean;
terms: boolean;
}
function validate(view: WicketView, v: Values): Partial<Record<FieldName, string>> {
const e: Partial<Record<FieldName, string>> = {};
const needsEmail = view === "sign-in" || view === "sign-up" || view === "magic-link" || view === "forgot";
if (needsEmail) {
if (!v.email.trim()) e.email = "Enter your email address";
else if (!EMAIL_RE.test(v.email.trim())) e.email = "That doesn't look like a valid email";
}
if (view === "sign-in" && !v.password) e.password = "Enter your password";
if (view === "sign-up" || view === "reset") {
const s = strength(v.password);
if (!v.password) e.password = "Choose a password";
else if (v.password.length < 8) e.password = "Use at least 8 characters";
else if (s < 3) e.password = "Mix in a number, symbol or capital letter";
}
if (view === "sign-up") {
if (!v.name.trim()) e.name = "Tell us your name";
if (!v.terms) e.terms = "Please accept the terms to continue";
}
if (view === "reset") {
if (!v.confirm) e.confirm = "Confirm your new password";
else if (v.confirm !== v.password) e.confirm = "Passwords don't match";
}
return e;
}
const FIELD_ORDER: FieldName[] = ["name", "email", "password", "confirm", "terms"];
const EMPTY: Values = { name: "", email: "", password: "", confirm: "", remember: true, terms: false };
/**
* Wicket — one auth card for every flow. Sign in, sign up (with a 4-segment
* strength meter and live rule checklist), magic link (an envelope folds shut
* on "Check your inbox" with a resend countdown), OTP/2FA (Passcode cells that
* auto-submit and shake on a wrong code), and forgot/reset password. Views
* morph with direction-aware slides inside a spring-animated height; async
* callbacks drive pending spinners, a slide-in error banner and a drawn-check
* success state. Real `<form>`s, autocomplete hints, labelled fields with
* `aria-invalid`/`aria-describedby`, validation on blur and a shake on invalid
* submit; focus lands on the first field whenever the view changes. Reduced
* motion swaps every slide and shake for crossfades — never blank.
*/
export function Wicket({
view: viewProp,
defaultView = "sign-in",
onViewChange,
providers = DEFAULT_PROVIDERS,
logo,
title = "Halyard",
copy,
onSignIn,
onSignUp,
onMagicLink,
onVerifyOtp,
onForgot,
onReset,
onProvider,
onResend,
onComplete,
defaultEmail = "",
resendSeconds = 30,
otpLength = 6,
accentColor = "#a78bfa",
ref,
className,
}: WicketProps) {
const reduced = useReducedMotion();
const accent = accentColor;
const ink = readableOn(accent);
const ctx = React.useMemo<Ctx>(() => ({ accent, ink, reduced }), [accent, ink, reduced]);
/* ----- view (controlled / uncontrolled) */
const [innerView, setInnerView] = React.useState<WicketView>(defaultView);
const view = viewProp ?? innerView;
const [values, setValues] = React.useState<Values>(() => ({ ...EMPTY, email: defaultEmail }));
const [touched, setTouched] = React.useState<Set<FieldName>>(() => new Set());
const [submitted, setSubmitted] = React.useState(false);
const [phase, setPhase] = React.useState<Phase>("form");
const [pending, setPending] = React.useState(false);
const [providerPending, setProviderPending] = React.useState<string | null>(null);
const [error, setError] = React.useState<string | null>(null);
const [otp, setOtp] = React.useState("");
const [doneOf, setDoneOf] = React.useState<WicketView>("sign-in");
const [announce, setAnnounce] = React.useState("");
const [dir, setDir] = React.useState(1);
const countdown = useCountdown();
// Reset per-view state whenever the visible view changes (controlled or not).
const [shownView, setShownView] = React.useState(view);
if (shownView !== view) {
const d = DEPTH[view] - DEPTH[shownView] || Math.sign(ORDER.indexOf(view) - ORDER.indexOf(shownView)) || 1;
setShownView(view);
setDir(d > 0 ? 1 : -1);
setPhase("form");
setPending(false);
setProviderPending(null);
setError(null);
setSubmitted(false);
setTouched(new Set());
setOtp("");
setValues((v) => ({ ...v, password: "", confirm: "" }));
}
// Focus management: after the user navigates, focus the next stage's first field.
const navigatedRef = React.useRef(false);
const stageRef = React.useRef<HTMLDivElement>(null);
const shakeRef = React.useRef<HTMLDivElement>(null);
const otpRef = React.useRef<HTMLDivElement>(null);
const go = React.useCallback(
(next: WicketView) => {
navigatedRef.current = true;
if (viewProp === undefined) setInnerView(next);
onViewChange?.(next);
},
[viewProp, onViewChange]
);
// OTP code was just "sent" on entering the view — start its resend timer.
React.useEffect(() => {
if (view === "otp") countdown.start(resendSeconds);
else countdown.clear();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [view]);
const focusStage = React.useCallback(() => {
const root = stageRef.current;
if (!root) return;
const target =
root.querySelector<HTMLElement>("[data-autofocus]") ??
root.querySelector<HTMLElement>("input:not([type=hidden]):not([type=checkbox]):not(:disabled)");
target?.focus({ preventScroll: true });
}, []);
/* ----- intro */
const rootRef = React.useRef<HTMLDivElement>(null);
const inView = useInView(rootRef, { once: true, amount: 0.2 });
const [introKey, setIntroKey] = React.useState(0);
const [fallback, setFallback] = React.useState(false);
React.useEffect(() => {
// Never stuck invisible if the in-view trigger never fires.
const t = window.setTimeout(() => setFallback(true), 1400);
return () => window.clearTimeout(t);
}, []);
const revealed = inView || fallback || reduced;
/* ----- field helpers */
const errors = React.useMemo(() => validate(view, values), [view, values]);
const errFor = (f: FieldName) => (submitted || touched.has(f) ? (errors[f] ?? null) : null);
const set = <K extends keyof Values>(k: K, v: Values[K]) => {
setValues((s) => ({ ...s, [k]: v }));
if (error) setError(null);
};
const touch = (f: FieldName) => setTouched((t) => (t.has(f) ? t : new Set(t).add(f)));
const shake = React.useCallback(
(el: HTMLElement | null) => {
if (!el || reduced) return;
animate(el, { x: [0, -9, 8, -6, 4, -2, 0] }, { duration: 0.42, ease: "easeOut" });
},
[reduced]
);
const finish = React.useCallback(
(of: WicketView) => {
setDoneOf(of);
setDir(1);
setPhase("done");
setAnnounce(of === "reset" ? "Password updated" : of === "sign-up" ? "Account created — redirecting" : "Signed in — redirecting");
navigatedRef.current = true;
onComplete?.(of);
},
[onComplete]
);
/** Run an async callback with pending / error / outcome handling. */
const run = async (fn: () => Promise<Outcome | void>, fallbackError: string, onOk: (out: Outcome | void) => void) => {
setPending(true);
setError(null);
setAnnounce("Working…");
try {
const out = await fn();
setPending(false);
onOk(out);
} catch (e) {
setPending(false);
const msg = errorMessage(e, fallbackError);
setError(msg);
setAnnounce("");
// OTP failures shake just the cells (see the effect below).
if (view !== "otp") shake(shakeRef.current);
}
};
const guard = (): boolean => {
setSubmitted(true);
const first = FIELD_ORDER.find((f) => errors[f]);
if (!first) return true;
shake(shakeRef.current);
const el = stageRef.current?.querySelector<HTMLElement>(`[data-field="${first}"] input`);
el?.focus({ preventScroll: true });
setAnnounce(errors[first] ?? "");
return false;
};
const onSubmit = (e: React.FormEvent) => {
e.preventDefault();
if (pending) return;
if (!guard()) return;
const email = values.email.trim();
switch (view) {
case "sign-in":
void run(
async () => (onSignIn ? onSignIn({ email, password: values.password, remember: values.remember }) : wait(900)),
"We couldn't sign you in. Try again.",
(out) => (out ? go(out) : finish("sign-in"))
);
break;
case "sign-up":
void run(
async () => (onSignUp ? onSignUp({ name: values.name.trim(), email, password: values.password, acceptedTerms: values.terms }) : wait(1000)),
"We couldn't create your account. Try again.",
(out) => (out ? go(out) : finish("sign-up"))
);
break;
case "magic-link":
case "forgot":
void run(
async () => (view === "magic-link" ? (onMagicLink ? onMagicLink(email) : wait(900)) : onForgot ? onForgot(email) : wait(900)),
"We couldn't send that email. Try again.",
() => {
navigatedRef.current = true;
setDir(1);
setPhase("sent");
setAnnounce(`Email sent to ${email}`);
countdown.start(resendSeconds);
}
);
break;
case "reset":
void run(
async () => (onReset ? onReset({ password: values.password }) : wait(900)),
"We couldn't update your password. Try again.",
() => finish("reset")
);
break;
case "otp":
submitOtp(otp);
break;
}
};
const submitOtp = (code: string) => {
if (pending) return;
if (code.length < otpLength) {
setError(`Enter all ${otpLength} digits`);
shake(otpRef.current);
return;
}
void run(
async () => (onVerifyOtp ? onVerifyOtp(code) : wait(900)),
"That code didn't match. Try again.",
() => finish("otp")
);
};
// When a verify fails, clear the cells and refocus the first one.
React.useEffect(() => {
if (view !== "otp" || !error || pending) return;
shake(otpRef.current);
const t = window.setTimeout(() => {
setOtp("");
otpRef.current?.querySelector<HTMLInputElement>("input")?.focus({ preventScroll: true });
}, reduced ? 0 : 420);
return () => window.clearTimeout(t);
}, [view, error, pending, shake, reduced]);
const provider = (id: string) => {
if (providerPending || pending) return;
setProviderPending(id);
setError(null);
const p = providers.find((x) => x.id === id);
setAnnounce(`Connecting to ${p?.label ?? id}…`);
Promise.resolve(onProvider ? onProvider(id) : wait(1100))
.then((out) => {
setProviderPending(null);
if (out) go(out);
else finish(view);
})
.catch((e) => {
setProviderPending(null);
setError(errorMessage(e, `We couldn't connect to ${p?.label ?? id}.`));
setAnnounce("");
});
};
const resend = () => {
if (countdown.left > 0) return;
const v = view === "otp" ? "otp" : view === "forgot" ? "forgot" : "magic-link";
countdown.start(resendSeconds);
setAnnounce(v === "otp" ? "A new code is on its way" : `Sent again to ${values.email.trim()}`);
void Promise.resolve(onResend?.(v, values.email.trim())).catch((e) => setError(errorMessage(e, "We couldn't resend. Try again.")));
};
const reset = React.useCallback(() => {
navigatedRef.current = false;
setValues({ ...EMPTY, email: defaultEmail });
setTouched(new Set());
setSubmitted(false);
setPhase("form");
setPending(false);
setProviderPending(null);
setError(null);
setOtp("");
setAnnounce("");
countdown.clear();
if (viewProp === undefined) setInnerView(defaultView);
else onViewChange?.(defaultView);
}, [defaultEmail, defaultView, viewProp, onViewChange, countdown]);
React.useImperativeHandle(
ref,
() => ({
setView: (v: WicketView) => {
if (v === view) {
navigatedRef.current = true;
setDir(-1);
setPhase("form");
setError(null);
setPending(false);
setSubmitted(false);
setTouched(new Set());
setOtp("");
return;
}
go(v);
},
reset,
replay: () => setIntroKey((k) => k + 1),
}),
[view, go, reset]
);
/* ----- height animation */
const measureRef = React.useRef<HTMLDivElement>(null);
const [height, setHeight] = React.useState<number | "auto">("auto");
React.useLayoutEffect(() => {
const el = measureRef.current;
if (!el || typeof ResizeObserver === "undefined") return;
const ro = new ResizeObserver(() => setHeight(el.offsetHeight));
ro.observe(el);
return () => ro.disconnect();
}, []);
const stageKey = phase === "done" ? `done-${doneOf}` : `${view}-${phase}`;
// A freshly mounted stage takes focus if the user navigated to it.
const attachStage = React.useCallback(
(el: HTMLDivElement | null) => {
stageRef.current = el;
if (el && navigatedRef.current) {
navigatedRef.current = false;
requestAnimationFrame(focusStage);
}
},
[focusStage]
);
/* ----- copy */
const email = values.email.trim();
const defaults: Record<WicketView, Required<WicketCopy>> = {
"sign-in": { title: "Welcome back", subtitle: `Sign in to your ${title} account` },
"sign-up": { title: "Create your account", subtitle: `Start your 14-day ${title} trial — no card needed` },
"magic-link": { title: "Sign in with email", subtitle: "We'll email you a one-tap sign-in link" },
otp: { title: "Two-step verification", subtitle: `Enter the ${otpLength}-digit code from your authenticator or texts` },
forgot: { title: "Reset your password", subtitle: "Enter your email and we'll send a reset link" },
reset: { title: "Choose a new password", subtitle: "Make it at least 8 characters with a good mix" },
};
const head = { ...defaults[view], ...copy?.[view] };
const variants: Variants = reduced
? { enter: { opacity: 0 }, center: { opacity: 1 }, exit: { opacity: 0 } }
: {
enter: (d: number) => ({ opacity: 0, x: d * 32 }),
center: { opacity: 1, x: 0 },
exit: (d: number) => ({ opacity: 0, x: d * -32 }),
};
const stageTransition: Transition = reduced
? { duration: 0.16 }
: { x: { type: "spring", stiffness: 420, damping: 38 }, opacity: { duration: 0.18 } };
const showProviders = (view === "sign-in" || view === "sign-up") && providers.length > 0;
const busy = pending || !!providerPending;
/* ----- stage bodies */
const header = (t: string, s: React.ReactNode, center = true) => (
<div className={cn("mb-6 flex flex-col gap-1.5", center && "items-center text-center")}>
<h2 className="text-[19px] font-semibold tracking-[-0.01em] text-white outline-none" tabIndex={-1}>
{t}
</h2>
<p className="max-w-[36ch] text-[13.5px] leading-5 text-white/50 text-balance">{s}</p>
</div>
);
let body: React.ReactNode;
if (phase === "done") {
const msg: Record<WicketView, [string, string]> = {
"sign-in": ["Signed in", "Signed in — redirecting…"],
"sign-up": ["Account created", "Welcome aboard — redirecting…"],
"magic-link": ["Signed in", "Signed in — redirecting…"],
forgot: ["Signed in", "Signed in — redirecting…"],
otp: ["Verified", "Signed in — redirecting…"],
reset: ["Password updated", "You can now sign in with your new password."],
};
const [t, s] = msg[doneOf];
body = (
<div className="flex flex-col items-center py-4 text-center" role="status">
<DrawnCheck />
<h2 className="mt-5 text-[19px] font-semibold tracking-[-0.01em] text-white outline-none" tabIndex={-1} data-autofocus>
{t}
</h2>
<p className="mt-1.5 text-[13.5px] text-white/50">{s}</p>
{doneOf === "reset" ? (
<div className="mt-6 w-full">
<PrimaryButton type="button" onClick={() => go("sign-in")}>
Continue to sign in
</PrimaryButton>
</div>
) : (
<div className="mt-6 h-1 w-40 overflow-hidden rounded-full bg-white/[0.06]" aria-hidden>
<motion.div
className="h-full rounded-full"
style={{ backgroundColor: accent, originX: 0 }}
initial={reduced ? { scaleX: 1 } : { scaleX: 0 }}
animate={{ scaleX: 1 }}
transition={reduced ? INSTANT : { duration: 2.4, ease: [0.4, 0, 0.2, 1], delay: 0.4 }}
/>
</div>
)}
</div>
);
} else if (phase === "sent") {
body = (
<div className="flex flex-col items-center text-center">
<Envelope />
<h2 className="mt-5 text-[19px] font-semibold tracking-[-0.01em] text-white outline-none" tabIndex={-1}>
Check your inbox
</h2>
<p className="mt-1.5 max-w-[32ch] text-[13.5px] leading-5 text-white/50">
{view === "forgot" ? "We sent a password reset link to" : "We sent a sign-in link to"}
<br />
<span className="font-medium text-white/85">{email}</span>
</p>
<p className="mt-1 text-[12.5px] text-white/35">The link expires in 15 minutes.</p>
<div className="mt-6 flex w-full flex-col gap-2.5">
<button
type="button"
data-autofocus
onClick={resend}
aria-disabled={countdown.left > 0}
className={cn(
"inline-flex h-10 w-full items-center justify-center gap-2 rounded-[10px] border border-white/[0.1] bg-white/[0.04] text-sm font-medium text-white/85 outline-none transition-colors focus-visible:border-[var(--wk-accent)] focus-visible:shadow-[0_0_0_3px_var(--wk-ring)]",
countdown.left > 0 ? "cursor-default text-white/45" : "hover:bg-white/[0.07]"
)}
>
{countdown.left > 0 ? (
<>
Resend in <span className="tabular-nums">{fmtClock(countdown.left)}</span>
</>
) : (
"Resend email"
)}
</button>
<TextLink className="self-center" onClick={() => { navigatedRef.current = true; setDir(-1); setPhase("form"); countdown.clear(); }}>
Use a different email
</TextLink>
</div>
</div>
);
} else {
const emailField = (
<div data-field="email">
<Field
label="Email"
name="email"
type="email"
inputMode="email"
autoComplete="email"
placeholder="you@company.com"
value={values.email}
onChange={(e) => set("email", e.target.value)}
onBlur={() => values.email && touch("email")}
error={errFor("email")}
disabled={busy}
spellCheck={false}
autoCapitalize="none"
/>
</div>
);
body = (
<>
{header(head.title, view === "otp" && email ? <>Enter the {otpLength}-digit code we sent to <span className="text-white/80">{maskEmail(email)}</span></> : head.subtitle)}
<ErrorBanner message={error} onDismiss={() => setError(null)} />
{showProviders && (
<>
<div className={cn("grid gap-2", providers.length === 1 ? "grid-cols-1" : providers.length === 2 ? "grid-cols-2" : "grid-cols-3")}>
{providers.map((p) => {
const isPending = providerPending === p.id;
return (
<button
key={p.id}
type="button"
onClick={() => provider(p.id)}
disabled={busy && !isPending}
aria-busy={isPending || undefined}
aria-label={`Continue with ${p.label}`}
title={`Continue with ${p.label}`}
className="inline-flex h-10 min-w-0 items-center justify-center gap-2 rounded-[10px] border border-white/[0.1] bg-white/[0.03] px-3 text-[13px] font-medium text-white/85 outline-none transition-colors hover:border-white/[0.16] hover:bg-white/[0.06] focus-visible:border-[var(--wk-accent)] focus-visible:shadow-[0_0_0_3px_var(--wk-ring)] disabled:opacity-45"
>
<span className="grid size-4 shrink-0 place-items-center text-white/70">{isPending ? <Spinner /> : (p.icon ?? <GlobeGlyph />)}</span>
<span className="truncate">
{providers.length === 1 && "Continue with "}
{p.label}
</span>
</button>
);
})}
</div>
<div className="my-5">
<Divider>or</Divider>
</div>
</>
)}
<form noValidate onSubmit={onSubmit} className="flex flex-col gap-4" aria-busy={pending || undefined}>
{view === "sign-in" && (
<>
{emailField}
<div data-field="password">
<PasswordField
label="Password"
name="password"
autoComplete="current-password"
placeholder="••••••••"
value={values.password}
onChange={(v) => set("password", v)}
onBlur={() => values.password && touch("password")}
error={errFor("password")}
disabled={busy}
aside={<TextLink onClick={() => go("forgot")}>Forgot password?</TextLink>}
/>
</div>
<Checkbox checked={values.remember} onChange={(v) => set("remember", v)}>
Keep me signed in for 30 days
</Checkbox>
<div className="flex flex-col gap-3 pt-1">
<PrimaryButton type="submit" pending={pending} pendingLabel="Signing in…">
Sign in
</PrimaryButton>
<TextLink className="self-center" onClick={() => go("magic-link")}>
Email me a link instead
</TextLink>
</div>
</>
)}
{view === "sign-up" && (
<>
<div data-field="name">
<Field
label="Full name"
name="name"
autoComplete="name"
placeholder="Ada Lindqvist"
value={values.name}
onChange={(e) => set("name", e.target.value)}
onBlur={() => values.name && touch("name")}
error={errFor("name")}
disabled={busy}
/>
</div>
{emailField}
<div data-field="password">
<PasswordField
label="Password"
name="new-password"
autoComplete="new-password"
placeholder="At least 8 characters"
value={values.password}
onChange={(v) => set("password", v)}
onBlur={() => values.password && touch("password")}
error={errFor("password")}
disabled={busy}
meter
/>
</div>
<div data-field="terms">
<Checkbox checked={values.terms} onChange={(v) => set("terms", v)} error={submitted ? (errors.terms ?? null) : null}>
I agree to the <span className="text-white/80 underline decoration-white/25 underline-offset-2">Terms</span> and{" "}
<span className="text-white/80 underline decoration-white/25 underline-offset-2">Privacy Policy</span>
</Checkbox>
</div>
<div className="pt-1">
<PrimaryButton type="submit" pending={pending} pendingLabel="Creating account…">
Create account
</PrimaryButton>
</div>
</>
)}
{(view === "magic-link" || view === "forgot") && (
<>
{emailField}
<div className="pt-1">
<PrimaryButton type="submit" pending={pending} pendingLabel="Sending…">
{view === "forgot" ? "Send reset link" : "Send magic link"}
</PrimaryButton>
</div>
</>
)}
{view === "otp" && (
<>
<div ref={otpRef} className="flex justify-center">
<Passcode
length={otpLength}
value={otp}
onChange={(v) => {
setOtp(v);
if (error) setError(null);
}}
onComplete={submitOtp}
disabled={pending}
colors={[accent, SUCCESS]}
aria-label={`${otpLength}-digit verification code`}
size="sm"
name="code"
/>
</div>
<PrimaryButton type="submit" pending={pending} pendingLabel="Verifying…" disabled={!pending && otp.length < otpLength}>
Verify
</PrimaryButton>
<p className="text-center text-[13px] text-white/45">
Didn't get it?{" "}
{countdown.left > 0 ? (
<span className="text-white/40">
Resend in <span className="tabular-nums text-white/60">{fmtClock(countdown.left)}</span>
</span>
) : (
<TextLink strong onClick={resend}>
Resend code
</TextLink>
)}
</p>
</>
)}
{view === "reset" && (
<>
<input type="email" name="email" autoComplete="username" value={email} readOnly hidden />
<div data-field="password">
<PasswordField
label="New password"
name="new-password"
autoComplete="new-password"
placeholder="At least 8 characters"
value={values.password}
onChange={(v) => set("password", v)}
onBlur={() => values.password && touch("password")}
error={errFor("password")}
disabled={busy}
meter
/>
</div>
<div data-field="confirm">
<PasswordField
label="Confirm password"
name="confirm-password"
autoComplete="new-password"
placeholder="Repeat it"
value={values.confirm}
onChange={(v) => set("confirm", v)}
onBlur={() => values.confirm && touch("confirm")}
error={errFor("confirm")}
disabled={busy}
/>
<Collapse show={!!values.confirm && !errFor("confirm")}>
<p
className="flex items-center gap-1.5 pt-1.5 text-[12.5px]"
style={{ color: values.confirm === values.password ? SUCCESS : "rgba(255,255,255,0.4)" }}
aria-live="polite"
>
{values.confirm === values.password ? "✓ Passwords match" : "Keep typing — doesn't match yet"}
</p>
</Collapse>
</div>
<div className="pt-1">
<PrimaryButton type="submit" pending={pending} pendingLabel="Updating…">
Update password
</PrimaryButton>
</div>
</>
)}
</form>
</>
);
}
/* ----- footer */
let footer: React.ReactNode = null;
if (phase !== "done") {
if (view === "sign-in")
footer = (
<>
New to {title}? <TextLink strong onClick={() => go("sign-up")}>Create an account</TextLink>
</>
);
else if (view === "sign-up")
footer = (
<>
Already have an account? <TextLink strong onClick={() => go("sign-in")}>Sign in</TextLink>
</>
);
else
footer = (
<TextLink onClick={() => go("sign-in")} className="inline-flex items-center gap-1.5">
<svg viewBox="0 0 16 16" className="size-3.5" fill="none" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round" aria-hidden>
<path d="M10 3.5 5.5 8l4.5 4.5" />
</svg>
Back to sign in
</TextLink>
);
}
const footerKey = phase === "done" ? "none" : view === "sign-in" || view === "sign-up" ? view : "back";
return (
<WicketCtx.Provider value={ctx}>
<div
ref={rootRef}
data-crucible="wicket"
className={cn("@container relative w-full max-w-[400px]", className)}
style={
{
"--wk-accent": accent,
"--wk-ring": `color-mix(in srgb, ${accent} 26%, transparent)`,
} as React.CSSProperties
}
>
<motion.div
key={introKey}
initial={{ opacity: 0, y: 14, scale: 0.985 }}
animate={revealed ? { opacity: 1, y: 0, scale: 1 } : { opacity: 0, y: 14, scale: 0.985 }}
transition={reduced ? INSTANT : { duration: 0.6, ease: EASE }}
className="relative overflow-hidden rounded-2xl border border-white/[0.08] bg-neutral-900/85 shadow-[0_1px_0_rgba(255,255,255,0.04)_inset,0_24px_60px_-20px_rgba(0,0,0,0.7)] backdrop-blur-xl"
>
{/* accent hairline + top glow */}
<span aria-hidden className="pointer-events-none absolute inset-x-10 top-0 h-px" style={{ background: `linear-gradient(90deg, transparent, ${accent}, transparent)`, opacity: 0.55 }} />
<span aria-hidden className="pointer-events-none absolute inset-x-0 top-0 h-32" style={{ background: `radial-gradient(60% 100% at 50% 0%, color-mix(in srgb, ${accent} 9%, transparent), transparent 70%)` }} />
<div className="relative px-5 pb-6 pt-7 @min-[380px]:px-8 @min-[380px]:pb-7">
<motion.div
className="mb-5 flex justify-center"
initial={{ opacity: 0, y: 6 }}
animate={revealed ? { opacity: 1, y: 0 } : { opacity: 0, y: 6 }}
transition={reduced ? INSTANT : { duration: 0.5, ease: EASE, delay: 0.12 }}
>
{logo ?? <DefaultLogo accent={accent} />}
</motion.div>
<motion.div
initial={false}
animate={{ height: reduced ? "auto" : height }}
transition={reduced ? INSTANT : SPRING}
className="-mx-2 -my-1 overflow-hidden"
>
<div ref={measureRef} className="px-2 py-1">
<motion.div
ref={shakeRef}
initial={{ opacity: 0, y: 8 }}
animate={revealed ? { opacity: 1, y: 0 } : { opacity: 0, y: 8 }}
transition={reduced ? INSTANT : { duration: 0.55, ease: EASE, delay: 0.2 }}
>
<AnimatePresence mode="wait" initial={false} custom={dir}>
<motion.div
key={stageKey}
ref={attachStage}
custom={dir}
variants={variants}
initial="enter"
animate="center"
exit="exit"
transition={stageTransition}
>
{body}
</motion.div>
</AnimatePresence>
</motion.div>
</div>
</motion.div>
</div>
<AnimatePresence initial={false}>
{footer && (
<motion.div
initial={reduced ? { opacity: 0 } : { opacity: 0, height: 0 }}
animate={reduced ? { opacity: 1 } : { opacity: 1, height: "auto" }}
exit={reduced ? { opacity: 0 } : { opacity: 0, height: 0 }}
transition={reduced ? { duration: 0.15 } : { duration: 0.28, ease: EASE }}
className="overflow-hidden"
>
<div className="border-t border-white/[0.06] bg-white/[0.015] px-5 py-4 text-center text-[13px] text-white/45">
<AnimatePresence mode="wait" initial={false}>
<motion.div
key={footerKey}
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: reduced ? 0.12 : 0.16 }}
>
{footer}
</motion.div>
</AnimatePresence>
</div>
</motion.div>
)}
</AnimatePresence>
</motion.div>
<p className="sr-only" aria-live="polite" role="status">
{announce}
</p>
</div>
</WicketCtx.Provider>
);
}
export default Wicket;





























