{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "contact-section",
  "title": "Contact Section",
  "description": "A contact block built around its submit state machine: zod-validated name/email/topic/message, a duplicate-submit guard, a confirmation panel that names the address it replied to, and a failure that keeps every character you typed — beside contact rows whose mailto:/tel:/map links are derived from their own values.",
  "dependencies": [
    "zod",
    "lucide-react"
  ],
  "registryDependencies": [
    "utils"
  ],
  "files": [
    {
      "path": "src/registry/blocks/contact-section.tsx",
      "content": "\"use client\"\n\nimport * as React from \"react\"\nimport {\n  ChevronDown,\n  CircleAlert,\n  CircleCheck,\n  Clock,\n  LoaderCircle,\n  Mail,\n  MapPin,\n  Phone,\n} from \"lucide-react\"\nimport { z } from \"zod\"\n\nimport { cn } from \"@/lib/utils\"\n\n/* -------------------------------------------------------------------------- */\n/* Contract                                                                   */\n/* -------------------------------------------------------------------------- */\n\n/**\n * Longest message the form will hand over. The textarea deliberately carries no\n * native `maxLength`: a paste that is 40 characters too long has to be reported,\n * not silently trimmed to the limit — truncation destroys what someone wrote and\n * turns \"too long\" into \"the end of my message vanished\".\n */\nexport const CONTACT_MESSAGE_MAX = 2000\n\n/**\n * The single validation rule, exported so the receiving endpoint can run the\n * identical check. zod owns validation: the form is `noValidate`, so the\n * browser never competes for the same field, and `type=\"email\"` survives only\n * for the mobile keyboard (the native rule accepts `a@b`, which every mail\n * provider bounces).\n *\n * Parsing trims, so `parsed.data` — the object handed to `onSubmit` — is\n * already the payload you want to send, and `\"   \"` counts as empty.\n */\nexport const contactFormSchema = z.object({\n  name: z\n    .string()\n    .trim()\n    .min(1, \"Tell us who we're replying to.\")\n    .max(80, \"Keep the name under 80 characters.\"),\n  email: z\n    .string()\n    .trim()\n    .min(1, \"Enter the address we should reply to.\")\n    .pipe(z.email(\"That doesn't look like an email address.\")),\n  subject: z.string().min(1, \"Pick what this message is about.\"),\n  message: z\n    .string()\n    .trim()\n    .min(10, \"Add a little more detail — at least 10 characters.\")\n    .max(CONTACT_MESSAGE_MAX, `Keep the message under ${CONTACT_MESSAGE_MAX} characters.`),\n})\n\nexport type ContactFormValues = z.infer<typeof contactFormSchema>\n\nexport type ContactField = keyof ContactFormValues\n\n/** Tab order, and therefore the order the first invalid field is looked for in. */\nconst FIELD_ORDER: readonly ContactField[] = [\"name\", \"email\", \"subject\", \"message\"]\n\nexport type ContactSectionStatus = \"idle\" | \"submitting\" | \"success\" | \"error\"\n\n/** Decides the icon and, for `email` / `phone`, the link scheme derived from `value`. */\nexport type ContactChannelType = \"email\" | \"phone\" | \"address\" | \"hours\"\n\nexport interface ContactChannel {\n  type: ContactChannelType\n  /** Row label — \"Email\", \"Sales line\", \"Studio\", \"Support hours\". */\n  label: string\n  /** What the visitor reads. `\\n` starts a new line, for addresses and opening hours. */\n  value: string\n  /**\n   * Overrides the derived link, and is the only way an `address` row becomes one\n   * (a street address has no scheme to derive from — pass a real map URL).\n   * `\"\"` and `\"#\"` are ignored on purpose: a row with nowhere to go renders as\n   * plain text, never as a link that does nothing.\n   */\n  href?: string\n  /** Small second line: \"Replies within one business day.\" */\n  note?: string\n}\n\nexport interface ContactSubject {\n  /** Submitted verbatim as `values.subject`. */\n  value: string\n  label: string\n}\n\nexport const DEFAULT_CONTACT_SUBJECTS: readonly ContactSubject[] = [\n  { value: \"general\", label: \"General question\" },\n  { value: \"sales\", label: \"Sales and pricing\" },\n  { value: \"support\", label: \"Technical support\" },\n  { value: \"partnership\", label: \"Partnership\" },\n  { value: \"press\", label: \"Press and media\" },\n]\n\nexport const DEFAULT_CONTACT_CHANNELS: readonly ContactChannel[] = [\n  {\n    type: \"email\",\n    label: \"Email\",\n    value: \"hello@example.com\",\n    note: \"Replies within one business day.\",\n  },\n  {\n    type: \"phone\",\n    label: \"Phone\",\n    value: \"+1 (415) 555-0132\",\n    note: \"Monday to Friday, 9:00–17:00 PT.\",\n  },\n  {\n    type: \"address\",\n    label: \"Office\",\n    value: \"500 Terry Francois Blvd\\nSuite 200\\nSan Francisco, CA 94158\",\n    href: \"https://www.google.com/maps/search/?api=1&query=500+Terry+Francois+Blvd+San+Francisco\",\n  },\n  {\n    type: \"hours\",\n    label: \"Support hours\",\n    value: \"Monday to Friday\\n9:00 – 17:00 (UTC−8)\",\n  },\n]\n\n/**\n * Every user-visible string except the field validation messages, which live in\n * `contactFormSchema` so the same wording can be reused on the server. Override\n * any subset through the `messages` prop.\n */\nexport const CONTACT_MESSAGES = {\n  detailsHeading: \"Other ways to reach us\",\n  nameLabel: \"Your name\",\n  namePlaceholder: \"Alex Morgan\",\n  emailLabel: \"Email\",\n  emailPlaceholder: \"you@example.com\",\n  subjectLabel: \"What is this about?\",\n  subjectPlaceholder: \"Choose a topic\",\n  messageLabel: \"Message\",\n  messagePlaceholder: \"A sentence or two about what you need.\",\n  counterLabel: \"characters used\",\n  requiredHint: \"required\",\n  submitLabel: \"Send message\",\n  pendingLabel: \"Sending…\",\n  retryLabel: \"Try again\",\n  sendingAnnouncement: \"Sending your message…\",\n  failureMessage: \"We couldn't send your message just now. Nothing you typed was lost — try again.\",\n  successTitle: \"Message sent\",\n  successDescription: \"We'll reply to this address within one business day.\",\n  successSubjectLabel: \"Topic\",\n  resetLabel: \"Send another message\",\n}\n\nexport type ContactMessages = typeof CONTACT_MESSAGES\n\nconst CHANNEL_ICONS: Record<ContactChannelType, React.ComponentType<{ className?: string }>> = {\n  email: Mail,\n  phone: Phone,\n  address: MapPin,\n  hours: Clock,\n}\n\n/**\n * The link for a row, or `undefined` when it should stay text. An explicit\n * `href` wins; `mailto:` / `tel:` are derived from the displayed value so the\n * common rows cannot drift out of sync with what is printed next to them.\n */\nfunction channelHref(channel: ContactChannel): string | undefined {\n  const explicit = channel.href?.trim()\n  if (explicit !== undefined && explicit !== \"\" && explicit !== \"#\") return explicit\n  if (channel.type === \"email\") {\n    const address = channel.value.trim()\n    return address === \"\" ? undefined : `mailto:${address}`\n  }\n  if (channel.type === \"phone\") {\n    // Everything a human writes for legibility — spaces, brackets, dashes — is\n    // noise to the dialler. A number the regex empties out (an extension-only\n    // string, say) gets no link rather than `tel:`.\n    const dialable = channel.value.replace(/[^+\\d]/g, \"\")\n    return dialable === \"\" || dialable === \"+\" ? undefined : `tel:${dialable}`\n  }\n  return undefined\n}\n\n/** A rejection that is not an `Error` still has to become a sentence a human can read. */\nfunction rejectionText(error: unknown, fallback: string): string {\n  if (error instanceof Error && error.message) return error.message\n  if (typeof error === \"string\" && error) return error\n  return fallback\n}\n\n/* -------------------------------------------------------------------------- */\n/* Styling primitives                                                         */\n/* -------------------------------------------------------------------------- */\n\nconst CONTROL_CLASS = cn(\n  \"w-full min-w-0 rounded-lg border border-input bg-background px-3 py-2 text-sm text-foreground\",\n  \"outline-none transition-colors motion-reduce:transition-none placeholder:text-muted-foreground\",\n  \"focus-visible:border-ring focus-visible:ring-2 focus-visible:ring-ring/40\",\n  \"disabled:cursor-not-allowed disabled:opacity-60\",\n  \"aria-invalid:border-destructive aria-invalid:focus-visible:ring-destructive/30\",\n)\n\nfunction Field({\n  children,\n  controlId,\n  error,\n  errorId,\n  label,\n  suffix,\n}: {\n  children: React.ReactNode\n  controlId: string\n  error: string | undefined\n  errorId: string\n  label: string\n  /** Right-hand slot on the label row — the message counter lives here. */\n  suffix?: React.ReactNode\n}) {\n  return (\n    <div className=\"flex min-w-0 flex-col gap-1.5\">\n      <div className=\"flex items-baseline justify-between gap-2\">\n        <label className=\"text-sm font-medium text-foreground\" htmlFor={controlId}>\n          {label}\n        </label>\n        {suffix}\n      </div>\n      {children}\n      {/* The message is plain markup, not role=\"alert\": submitting an invalid form\n          moves focus to the first offending control, and a screen reader reads\n          this text there as that control's description. Firing four alerts at\n          once would talk over that. */}\n      {error !== undefined ? (\n        <p className=\"text-xs leading-4 font-medium text-destructive\" id={errorId}>\n          {error}\n        </p>\n      ) : null}\n    </div>\n  )\n}\n\n/* -------------------------------------------------------------------------- */\n/* Component                                                                  */\n/* -------------------------------------------------------------------------- */\n\nexport interface ContactSectionProps\n  extends Omit<React.HTMLAttributes<HTMLElement>, \"onSubmit\" | \"children\"> {\n  /**\n   * Owns the network call — the block never fetches anything itself. It receives\n   * the parsed, trimmed values. Resolve to swap the form for the confirmation\n   * panel; reject to show a retryable failure with every field still filled in\n   * (an `Error`'s message is shown verbatim, so a server can explain itself).\n   */\n  onSubmit: (values: ContactFormValues) => Promise<void>\n  /** Section heading, and the accessible name of the region. Pass `\"\"` to drop it. */\n  heading?: string\n  /** Optional line under the heading. */\n  description?: string\n  /** Left column rows. Pass `[]` to ship the form on its own. */\n  channels?: readonly ContactChannel[]\n  /** Options for the topic dropdown. */\n  subjects?: readonly ContactSubject[]\n  /** Seeds the form — a signed-in visitor's name and address, or an `?about=` param. */\n  defaultValues?: Partial<ContactFormValues>\n  /**\n   * Slot under the contact rows for a map: an `<iframe>`, a static map image, a\n   * link card. Rendered inside a bordered 16:9 frame; omit it and no frame appears.\n   */\n  map?: React.ReactNode\n  /** Fine print under the form — consent line, privacy link, response promise. */\n  footnote?: React.ReactNode\n  /** Overrides for any of the built-in strings; the field errors come from the schema. */\n  messages?: Partial<ContactMessages>\n  className?: string\n}\n\n/**\n * A contact block whose real subject is the submit state machine:\n * `idle → submitting → success | error`, with the contact details beside it.\n *\n * Three things make it shippable rather than decorative: a rejection keeps every\n * character the visitor typed, a repeated press cannot fire a second request,\n * and the confirmation names the address the reply is going to.\n */\nexport const ContactSection = React.forwardRef<HTMLElement, ContactSectionProps>(\n  (\n    {\n      onSubmit,\n      heading = \"Talk to us\",\n      description = \"Tell us what you're building and we'll point you at the right person.\",\n      channels = DEFAULT_CONTACT_CHANNELS,\n      subjects = DEFAULT_CONTACT_SUBJECTS,\n      defaultValues,\n      map,\n      footnote,\n      messages,\n      className,\n      ...rest\n    },\n    forwardedRef,\n  ) => {\n    const copy = { ...CONTACT_MESSAGES, ...messages }\n\n    const baseId = React.useId()\n    const headingId = `${baseId}-heading`\n    const detailsId = `${baseId}-details`\n    const failureId = `${baseId}-failure`\n    const counterId = `${baseId}-counter`\n    const successTitleId = `${baseId}-success-title`\n    const successDescId = `${baseId}-success-desc`\n    const fieldId = (field: ContactField) => `${baseId}-${field}`\n    const errorId = (field: ContactField) => `${baseId}-${field}-error`\n\n    const rootRef = React.useRef<HTMLElement | null>(null)\n    const nameRef = React.useRef<HTMLInputElement>(null)\n    const emailRef = React.useRef<HTMLInputElement>(null)\n    const subjectRef = React.useRef<HTMLSelectElement>(null)\n    const messageRef = React.useRef<HTMLTextAreaElement>(null)\n    const submitRef = React.useRef<HTMLButtonElement>(null)\n    const panelRef = React.useRef<HTMLDivElement>(null)\n\n    // Flipped synchronously at the top of the handler. State cannot do this job:\n    // five presses inside one tick would all read the same stale `status`.\n    const pendingRef = React.useRef(false)\n    // Set in the effect BODY as well as cleared in cleanup — StrictMode runs\n    // mount → cleanup → mount in dev, so a ref that is only cleared would read\n    // false for the live instance and no submit would ever land.\n    const mountedRef = React.useRef(false)\n    // Set by \"send another message\", so the effect below knows the caret is owed a home.\n    const refocusRef = React.useRef(false)\n\n    const [values, setValues] = React.useState<ContactFormValues>(() => ({\n      name: defaultValues?.name ?? \"\",\n      email: defaultValues?.email ?? \"\",\n      subject: defaultValues?.subject ?? \"\",\n      message: defaultValues?.message ?? \"\",\n    }))\n    const [errors, setErrors] = React.useState<Partial<Record<ContactField, string>>>({})\n    const [status, setStatus] = React.useState<ContactSectionStatus>(\"idle\")\n    const [failure, setFailure] = React.useState<string | null>(null)\n    const [sent, setSent] = React.useState<ContactFormValues | null>(null)\n\n    // Latest-ref: consumers pass an inline arrow function, so this must never\n    // sit in a dependency array.\n    const onSubmitRef = React.useRef(onSubmit)\n    React.useEffect(() => {\n      onSubmitRef.current = onSubmit\n    })\n\n    React.useEffect(() => {\n      mountedRef.current = true\n      return () => {\n        mountedRef.current = false\n      }\n    }, [])\n\n    const busy = status === \"submitting\"\n\n    React.useLayoutEffect(() => {\n      if (status !== \"submitting\") return\n      // The controls take the native `disabled` attribute while the request is in\n      // flight, and the browser blurs a control the instant it becomes disabled —\n      // so someone who pressed Enter inside the textarea would be dumped on\n      // <body> with the pending state announced from nowhere. The submit button\n      // is only ever `aria-disabled`, which keeps it focusable, so it can take\n      // the handover and carry `aria-busy` while the request runs.\n      const active = document.activeElement\n      if (active !== null && active !== document.body) return\n      submitRef.current?.focus()\n    }, [status])\n\n    React.useEffect(() => {\n      if (status === \"success\") {\n        // Only if the visitor is still here. A five-second request they scrolled\n        // away from must not yank the caret back across the page; the live region\n        // below tells them either way.\n        const root = rootRef.current\n        const active = document.activeElement\n        const nearby =\n          active === null || active === document.body || (root !== null && root.contains(active))\n        if (nearby) panelRef.current?.focus()\n        return\n      }\n      if (status === \"idle\" && refocusRef.current) {\n        refocusRef.current = false\n        messageRef.current?.focus()\n      }\n    }, [status])\n\n    const focusField = (field: ContactField) => {\n      const node =\n        field === \"name\"\n          ? nameRef.current\n          : field === \"email\"\n            ? emailRef.current\n            : field === \"subject\"\n              ? subjectRef.current\n              : messageRef.current\n      node?.focus()\n    }\n\n    const setField = (field: ContactField, next: string) => {\n      setValues(prev => (prev[field] === next ? prev : { ...prev, [field]: next }))\n      // Typing never raises an error and it clears the one showing. The failure\n      // banner is left alone on purpose: the reason a send failed is still true\n      // while the visitor edits, and taking it away mid-fix looks like it worked.\n      setErrors(prev => (prev[field] === undefined ? prev : { ...prev, [field]: undefined }))\n    }\n\n    const handleSubmit = async (event: React.FormEvent<HTMLFormElement>) => {\n      event.preventDefault()\n      if (pendingRef.current) return\n\n      const parsed = contactFormSchema.safeParse(values)\n      if (!parsed.success) {\n        const next: Partial<Record<ContactField, string>> = {}\n        for (const issue of parsed.error.issues) {\n          const key = issue.path[0]\n          if (typeof key !== \"string\") continue\n          const field = key as ContactField\n          if (FIELD_ORDER.includes(field) && next[field] === undefined) next[field] = issue.message\n        }\n        setErrors(next)\n        // A field problem supersedes an earlier network failure: this press never\n        // reached the server, so leaving \"the relay is down\" on screen next to\n        // \"that isn't an email address\" would blame the wrong thing. Back to idle,\n        // and the button stops offering a retry it cannot perform.\n        setFailure(null)\n        setStatus(\"idle\")\n        const first = FIELD_ORDER.find(field => next[field] !== undefined)\n        if (first !== undefined) focusField(first)\n        return\n      }\n\n      pendingRef.current = true\n      setErrors({})\n      setFailure(null)\n      setStatus(\"submitting\")\n\n      try {\n        // The call sits inside the try, so a consumer that throws synchronously\n        // is caught here rather than escaping and leaving the button pending forever.\n        await onSubmitRef.current(parsed.data)\n      } catch (error) {\n        pendingRef.current = false\n        // Re-check liveness after the await: a late rejection landing on an\n        // unmounted instance would setState on a dead component.\n        if (!mountedRef.current) return\n        setFailure(rejectionText(error, copy.failureMessage))\n        setStatus(\"error\")\n        return\n      }\n      pendingRef.current = false\n      if (!mountedRef.current) return\n      setSent(parsed.data)\n      setStatus(\"success\")\n    }\n\n    const sendAnother = () => {\n      refocusRef.current = true\n      // Name and address survive: the second message comes from the same person,\n      // and making them retype who they are is the fastest way to lose it.\n      setValues(prev => ({ ...prev, subject: \"\", message: \"\" }))\n      setErrors({})\n      setFailure(null)\n      setSent(null)\n      setStatus(\"idle\")\n    }\n\n    // No rows and no map means no left column at all — and then the split has to\n    // go too, or the form would sit in the narrow half of a two-column grid with\n    // an empty half beside it.\n    const hasDetails = channels.length > 0 || Boolean(map)\n    const used = values.message.trim().length\n    const overLimit = used > CONTACT_MESSAGE_MAX\n    const submitLabel = busy\n      ? copy.pendingLabel\n      : status === \"error\"\n        ? copy.retryLabel\n        : copy.submitLabel\n    const sentSubject = sent === null\n      ? null\n      : (subjects.find(option => option.value === sent.subject)?.label ?? sent.subject)\n\n    const describedBy = (field: ContactField, extra?: string) =>\n      [errors[field] !== undefined ? errorId(field) : null, extra].filter(Boolean).join(\" \") ||\n      undefined\n\n    return (\n      <section\n        {...rest}\n        aria-labelledby={heading ? headingId : undefined}\n        className={cn(\"@container/block w-full\", className)}\n        data-status={status}\n        ref={node => {\n          rootRef.current = node\n          if (typeof forwardedRef === \"function\") forwardedRef(node)\n          else if (forwardedRef !== null) forwardedRef.current = node\n        }}\n      >\n        {/* Mounted for the life of the block, so a transition is announced by a\n            live region that already existed — one inserted at the same moment as\n            its text is announced unreliably. */}\n        <p className=\"sr-only\" role=\"status\">\n          {busy ? copy.sendingAnnouncement : status === \"success\" ? copy.successTitle : \"\"}\n        </p>\n\n        <div className=\"flex flex-col gap-8\">\n          {heading || description ? (\n            <div className=\"flex max-w-2xl flex-col gap-2\">\n              {heading ? (\n                <h2\n                  className=\"text-2xl font-semibold tracking-tight text-balance sm:text-3xl\"\n                  id={headingId}\n                >\n                  {heading}\n                </h2>\n              ) : null}\n              {description ? (\n                <p className=\"text-sm text-pretty text-muted-foreground\">{description}</p>\n              ) : null}\n            </div>\n          ) : null}\n\n          <div\n            className={cn(\n              \"grid items-start gap-8\",\n              hasDetails &&\n                \"@[46rem]/block:grid-cols-[minmax(0,4fr)_minmax(0,5fr)] @[46rem]/block:gap-12\",\n            )}\n          >\n            {hasDetails ? (\n              <div className=\"flex min-w-0 flex-col gap-6\">\n                {channels.length > 0 ? (\n                  <div className=\"flex min-w-0 flex-col gap-4\" role=\"group\" aria-labelledby={detailsId}>\n                    <h3\n                      className=\"text-xs font-semibold tracking-wider text-muted-foreground uppercase\"\n                      id={detailsId}\n                    >\n                      {copy.detailsHeading}\n                    </h3>\n                    <ul className=\"flex min-w-0 flex-col gap-4\">\n                      {channels.map(channel => {\n                        const Icon = CHANNEL_ICONS[channel.type]\n                        const href = channelHref(channel)\n                        const lines = channel.value.split(\"\\n\")\n                        return (\n                          <li className=\"flex min-w-0 gap-3\" key={`${channel.type}-${channel.label}`}>\n                            <span\n                              aria-hidden=\"true\"\n                              className=\"mt-0.5 inline-flex size-9 shrink-0 items-center justify-center rounded-lg border bg-muted text-muted-foreground\"\n                            >\n                              <Icon className=\"size-4\" />\n                            </span>\n                            <div className=\"flex min-w-0 flex-col gap-0.5\">\n                              <span className=\"text-xs font-medium text-muted-foreground\">\n                                {channel.label}\n                              </span>\n                              {/* No href, no anchor. A row that has nowhere to go\n                                  renders as text instead of a link that lies. */}\n                              {href === undefined ? (\n                                <span className=\"text-sm break-words\">\n                                  {lines.map((line, index) => (\n                                    <React.Fragment key={line + String(index)}>\n                                      {index > 0 ? <br /> : null}\n                                      {line}\n                                    </React.Fragment>\n                                  ))}\n                                </span>\n                              ) : (\n                                <a\n                                  className=\"text-sm break-words underline-offset-4 transition-colors hover:text-primary hover:underline focus-visible:rounded-sm focus-visible:ring-2 focus-visible:ring-ring focus-visible:outline-none motion-reduce:transition-none\"\n                                  href={href}\n                                  {...(href.startsWith(\"http\")\n                                    ? { rel: \"noreferrer\", target: \"_blank\" }\n                                    : {})}\n                                >\n                                  {lines.map((line, index) => (\n                                    <React.Fragment key={line + String(index)}>\n                                      {index > 0 ? <br /> : null}\n                                      {line}\n                                    </React.Fragment>\n                                  ))}\n                                </a>\n                              )}\n                              {channel.note ? (\n                                <span className=\"text-xs text-muted-foreground\">{channel.note}</span>\n                              ) : null}\n                            </div>\n                          </li>\n                        )\n                      })}\n                    </ul>\n                  </div>\n                ) : null}\n\n                {map ? (\n                  // The frame owns the aspect ratio and the clipping; whatever is\n                  // dropped in is stretched to fill it, so an <iframe> with its own\n                  // width/height attributes cannot break the column. `flex` rather\n                  // than a `[&>*]:block` override: blockifying a flex item leaves its\n                  // own `display:flex` intact, while forcing `block` would silently\n                  // undo the layout of whatever was passed in — and it removes the\n                  // inline-baseline gap under a bare <img> at the same time.\n                  <div className=\"flex aspect-video w-full overflow-hidden rounded-xl border bg-muted [&>*]:size-full\">\n                    {map}\n                  </div>\n                ) : null}\n              </div>\n            ) : null}\n\n            <div className=\"@container/form min-w-0 rounded-2xl border bg-card p-5 text-card-foreground @[30rem]/form:p-6\">\n              {status === \"success\" && sent !== null ? (\n                <div\n                  aria-describedby={successDescId}\n                  aria-labelledby={successTitleId}\n                  className=\"flex flex-col items-start gap-4 focus-visible:ring-2 focus-visible:ring-ring focus-visible:outline-none\"\n                  ref={panelRef}\n                  tabIndex={-1}\n                >\n                  <span className=\"inline-flex size-10 items-center justify-center rounded-full bg-primary text-primary-foreground\">\n                    <CircleCheck aria-hidden=\"true\" className=\"size-5\" />\n                  </span>\n                  <div className=\"flex flex-col gap-1\">\n                    <p className=\"text-base font-medium\" id={successTitleId}>\n                      {copy.successTitle}\n                    </p>\n                    <p className=\"text-sm text-muted-foreground\" id={successDescId}>\n                      {copy.successDescription}\n                    </p>\n                  </div>\n                  {/* A confirmation that cannot say which inbox to watch is not a\n                      confirmation. */}\n                  <dl className=\"flex w-full min-w-0 flex-col gap-2 rounded-lg border bg-muted/40 p-3 text-sm\">\n                    <div className=\"flex min-w-0 flex-wrap items-baseline gap-x-2 gap-y-0.5\">\n                      <dt className=\"text-xs text-muted-foreground\">{copy.emailLabel}</dt>\n                      <dd className=\"min-w-0 font-mono text-xs break-all\">{sent.email}</dd>\n                    </div>\n                    <div className=\"flex min-w-0 flex-wrap items-baseline gap-x-2 gap-y-0.5\">\n                      <dt className=\"text-xs text-muted-foreground\">{copy.successSubjectLabel}</dt>\n                      <dd className=\"min-w-0 text-xs break-words\">{sentSubject}</dd>\n                    </div>\n                  </dl>\n                  <button\n                    className=\"inline-flex cursor-pointer items-center justify-center rounded-lg border px-3 py-2 text-sm font-medium transition-colors hover:bg-accent hover:text-accent-foreground focus-visible:ring-2 focus-visible:ring-ring focus-visible:outline-none motion-reduce:transition-none\"\n                    onClick={sendAnother}\n                    type=\"button\"\n                  >\n                    {copy.resetLabel}\n                  </button>\n                </div>\n              ) : (\n                /* A real <form onSubmit>, not a button with an onClick: that is\n                   the only way Enter inside a field submits, and it keeps the\n                   whole flow working with the mouse unplugged. `noValidate` stops\n                   the browser's own bubbles from racing zod for the same field. */\n                <form\n                  aria-busy={busy || undefined}\n                  className=\"flex flex-col gap-4\"\n                  noValidate\n                  onSubmit={handleSubmit}\n                >\n                  <div className=\"grid gap-4 @[30rem]/form:grid-cols-2\">\n                    <Field\n                      controlId={fieldId(\"name\")}\n                      error={errors.name}\n                      errorId={errorId(\"name\")}\n                      label={copy.nameLabel}\n                    >\n                      <input\n                        aria-describedby={describedBy(\"name\")}\n                        aria-invalid={errors.name !== undefined || undefined}\n                        autoComplete=\"name\"\n                        className={CONTROL_CLASS}\n                        disabled={busy}\n                        id={fieldId(\"name\")}\n                        name=\"name\"\n                        onChange={event => setField(\"name\", event.target.value)}\n                        placeholder={copy.namePlaceholder}\n                        ref={nameRef}\n                        required\n                        type=\"text\"\n                        value={values.name}\n                      />\n                    </Field>\n\n                    <Field\n                      controlId={fieldId(\"email\")}\n                      error={errors.email}\n                      errorId={errorId(\"email\")}\n                      label={copy.emailLabel}\n                    >\n                      <input\n                        aria-describedby={describedBy(\"email\")}\n                        aria-invalid={errors.email !== undefined || undefined}\n                        autoComplete=\"email\"\n                        className={CONTROL_CLASS}\n                        disabled={busy}\n                        id={fieldId(\"email\")}\n                        name=\"email\"\n                        onChange={event => setField(\"email\", event.target.value)}\n                        placeholder={copy.emailPlaceholder}\n                        ref={emailRef}\n                        required\n                        // Kept for the mobile keyboard only — zod is the validator.\n                        type=\"email\"\n                        value={values.email}\n                      />\n                    </Field>\n                  </div>\n\n                  <Field\n                    controlId={fieldId(\"subject\")}\n                    error={errors.subject}\n                    errorId={errorId(\"subject\")}\n                    label={copy.subjectLabel}\n                  >\n                    <div className=\"relative flex min-w-0 items-center\">\n                      <select\n                        aria-describedby={describedBy(\"subject\")}\n                        aria-invalid={errors.subject !== undefined || undefined}\n                        className={cn(\n                          CONTROL_CLASS,\n                          // The closed control matches the inputs beside it, but the\n                          // option rows carry explicit token colors: a browser that\n                          // paints the popup from the control's own colors would draw\n                          // near-white text on the default white listbox in dark mode\n                          // and the whole list would disappear.\n                          \"h-10 appearance-none bg-background pe-9 [&>option]:bg-background [&>option]:text-foreground\",\n                        )}\n                        disabled={busy}\n                        id={fieldId(\"subject\")}\n                        name=\"subject\"\n                        onChange={event => setField(\"subject\", event.target.value)}\n                        ref={subjectRef}\n                        required\n                        value={values.subject}\n                      >\n                        <option value=\"\">{copy.subjectPlaceholder}</option>\n                        {subjects.map(option => (\n                          <option key={option.value} value={option.value}>\n                            {option.label}\n                          </option>\n                        ))}\n                      </select>\n                      <ChevronDown\n                        aria-hidden=\"true\"\n                        className=\"pointer-events-none absolute end-3 size-4 text-muted-foreground\"\n                      />\n                    </div>\n                  </Field>\n\n                  <Field\n                    controlId={fieldId(\"message\")}\n                    error={errors.message}\n                    errorId={errorId(\"message\")}\n                    label={copy.messageLabel}\n                    suffix={\n                      <span\n                        className={cn(\n                          \"text-xs tabular-nums\",\n                          overLimit ? \"font-medium text-destructive\" : \"text-muted-foreground\",\n                        )}\n                        id={counterId}\n                      >\n                        {used} / {CONTACT_MESSAGE_MAX}\n                        <span className=\"sr-only\"> {copy.counterLabel}</span>\n                      </span>\n                    }\n                  >\n                    <textarea\n                      aria-describedby={describedBy(\"message\", counterId)}\n                      aria-invalid={errors.message !== undefined || undefined}\n                      className={cn(CONTROL_CLASS, \"min-h-32 resize-y\")}\n                      disabled={busy}\n                      id={fieldId(\"message\")}\n                      name=\"message\"\n                      onChange={event => setField(\"message\", event.target.value)}\n                      placeholder={copy.messagePlaceholder}\n                      ref={messageRef}\n                      required\n                      rows={5}\n                      value={values.message}\n                    />\n                  </Field>\n\n                  {/* Mounting this is what makes a screen reader announce it, which\n                      is why the failure is a node that appears rather than text\n                      swapped into a region that was always there. */}\n                  {failure !== null ? (\n                    <p\n                      className=\"flex items-start gap-2 rounded-lg bg-destructive/10 px-3 py-2 text-sm text-destructive\"\n                      id={failureId}\n                      role=\"alert\"\n                    >\n                      <CircleAlert aria-hidden=\"true\" className=\"mt-0.5 size-4 shrink-0\" />\n                      <span className=\"min-w-0\">{failure}</span>\n                    </p>\n                  ) : null}\n\n                  <div className=\"flex flex-wrap items-center gap-x-4 gap-y-2\">\n                    {/* aria-disabled, never the native attribute: the browser blurs\n                        a control the instant it becomes disabled, so a keyboard\n                        user who pressed Enter would lose the button they are\n                        waiting on. `pendingRef` is what actually stops the second\n                        request — the attribute only says so out loud. */}\n                    <button\n                      aria-busy={busy || undefined}\n                      aria-describedby={failure !== null ? failureId : undefined}\n                      aria-disabled={busy || undefined}\n                      className={cn(\n                        \"inline-flex h-10 cursor-pointer items-center justify-center gap-2 rounded-lg bg-primary px-5 text-sm font-medium text-primary-foreground transition-colors\",\n                        \"hover:bg-primary/90 focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:outline-none\",\n                        \"motion-reduce:transition-none\",\n                        busy && \"cursor-progress opacity-80\",\n                      )}\n                      ref={submitRef}\n                      type=\"submit\"\n                    >\n                      {busy ? (\n                        <LoaderCircle\n                          aria-hidden=\"true\"\n                          className=\"size-4 animate-spin motion-reduce:animate-none\"\n                        />\n                      ) : null}\n                      {submitLabel}\n                    </button>\n                    {footnote ? (\n                      <p className=\"min-w-0 flex-1 text-xs text-muted-foreground\">{footnote}</p>\n                    ) : null}\n                  </div>\n                </form>\n              )}\n            </div>\n          </div>\n        </div>\n      </section>\n    )\n  },\n)\n\nContactSection.displayName = \"ContactSection\"\n\nexport default ContactSection\n",
      "type": "registry:block"
    }
  ],
  "type": "registry:block"
}