# Wicket

> 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.

A shadcn/ui block from the [@crucible](https://crucibleui.com/?utm_source=shadcn.exchange) registry, listed on [shadcn.exchange](https://shadcn.exchange/r/crucible/wicket).

- Page, with a live preview: https://shadcn.exchange/r/crucible/wicket
- Screenshot: https://abzjhwgwpdpz7ua5.public.blob.vercel-storage.com/shots/crucible/wicket-light-0137898c7651ec6c.png
- Registry item: https://crucibleui.com/r/wicket.json
- Listed: 2026-10-20, source last changed 2026-10-20

## Install

```bash
npx shadcn@latest add https://shadcn.exchange/r/crucible/wicket.json
```

Run it in a project that has shadcn/ui set up. It adds the files below and installs what they depend on.

## Dependencies

- npm packages: `motion`
- Registry items: `https://crucibleui.com/r/passcode.json`, `https://crucibleui.com/r/use-reduced-motion.json`

## Files

### `registry/default/app/wicket/wicket.tsx`

```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&apos;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;
```
