# Parley — Animated Section Block for React

> Contact section that routes before it asks: topic chips (Sales / Support / Press / Other) reshape the form with layout animation — fields glide in and out, the submit label and expected-reply line change. Blur validation with a shake, async pending → drawn-check success or an inline error that keeps every value, plus channel cards with copy-to-clipboard and an office card with an abstract SVG street plate and pulsing pin.

- Page: https://crucibleui.com/components/sections/parley
- Category: [sections](https://crucibleui.com/components/sections)
- Tier: free (full source)
- Engine: motion
- npm dependencies: motion

## Install

```bash
npx shadcn@latest add @crucible/parley
```

Registry JSON: https://crucibleui.com/r/parley.json

Import from `@/components/crucible/parley`.

## Props (`ParleyProps`)

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `eyebrow?` | `React.ReactNode` | `"Contact"` | Small label above the title. |
| `title?` | `React.ReactNode` | `"Talk to the right person"` | Section heading. |
| `subtitle?` | `React.ReactNode` | — | Line under the heading. |
| `topics?` | `ParleyTopic[]` | — | Topic chips and the form each one routes to. Omit for Sales / Support / Press / Other. |
| `defaultTopic?` | `string` | `first topic` | Uncontrolled initial topic id. |
| `topic?` | `string` | — | Controlled topic id. |
| `onTopicChange?` | `(id: string) => void` | — | Called when the visitor picks a topic. |
| `onSubmit?` | `(values: Record<string, string>, topic: string) => void \| Promise<unknown>` | — | Called with the current topic's values. Return a Promise to hold the button in its pending state — resolve shows the success card, reject keeps every value and shows the rejection's message inline. |
| `channels?` | `ParleyChannel[]` | — | Side-column channel cards with copy-to-clipboard. Omit for a sample set; `[]` hides them. |
| `office?` | `ParleyOffice \| false` | — | Office card with an abstract location plate, or `false` to hide it. |
| `accentColor?` | `string` | `"#60a5fa"` | Accent hex for the active chip, submit button, focus rings and pin. |
| `ref?` | `React.Ref<ParleyHandle>` | — | Imperative handle (`reset()`, `replay()`, `setTopic()`, `focus()`). React 19 ref-as-prop. |
| `className?` | `string` | — | Extra classes for the `<section>` root. |

Also accepts: `Omit<React.ComponentPropsWithoutRef<"section">, "children" | "title" | "onSubmit">`.

## Usage example

```tsx
"use client";

import * as React from "react";

import { Parley, type ParleyHandle } from "@/components/crucible/parley";

/** Simulated backend: ~1.1s round trip; any email at fail.test is rejected so the error state is reachable. */
function send(values: Record<string, string>): Promise<void> {
  return new Promise((resolve, reject) =>
    window.setTimeout(() => {
      if (values.email?.toLowerCase().endsWith("@fail.test")) {
        reject(new Error("Our inbox didn't answer. Your message is still here — try again."));
      } else {
        resolve();
      }
    }, 1100)
  );
}

export default function ParleyDemo() {
  const ref = React.useRef<ParleyHandle>(null);

  return (
    <div className="relative isolate h-[640px] w-full overflow-y-auto overscroll-contain rounded-xl bg-neutral-950">
      <div className="relative z-10 flex items-center justify-between gap-3 px-4 pt-4 @container sm:px-8">
        <p className="rounded-full border border-white/10 bg-white/[0.03] px-3 py-1 text-xs text-white/55">
          Pick a topic — the form adapts.
        </p>
        <div className="flex gap-4 text-xs">
          <button
            type="button"
            onClick={() => ref.current?.replay()}
            className="rounded text-white/40 underline-offset-4 transition-colors hover:text-white hover:underline focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-white/60"
          >
            Replay
          </button>
          <button
            type="button"
            onClick={() => ref.current?.reset()}
            className="rounded text-white/40 underline-offset-4 transition-colors hover:text-white hover:underline focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-white/60"
          >
            Reset
          </button>
        </div>
      </div>
      <Parley ref={ref} className="relative z-10" onSubmit={send} />
    </div>
  );
}
```

## Accessibility & performance

Honors `prefers-reduced-motion` with a designed static frame or fade (never a blank box) and passes `className` through.

## Source (`parley.tsx`)

```tsx
"use client";

import { AnimatePresence, motion, useAnimate, type Transition } from "motion/react";
import * as React from "react";

import { cn } from "@/lib/utils";
import { useReducedMotion } from "@/hooks/use-reduced-motion";

/* ---------------------------------------------------------------- types */

/** Input kind of a form field. */
export type ParleyFieldType = "text" | "email" | "tel" | "number" | "textarea" | "select" | "segmented";

export interface ParleyOption {
  /** Submitted value. */
  value: string;
  /** Visible label. */
  label: string;
}

export interface ParleyField {
  /**
   * Key in the submitted values. Fields that share a `name` across topics
   * keep their value and glide to their new position when the topic changes.
   */
  name: string;
  /** Visible label. */
  label: string;
  /** Input kind. @default "text" */
  type?: ParleyFieldType;
  /** Placeholder text (text-like inputs and textarea). */
  placeholder?: string;
  /** Choices for `select` and `segmented` fields (strings use the same value and label). */
  options?: (ParleyOption | string)[];
  /** Initial value (e.g. the preselected segmented choice). */
  defaultValue?: string;
  /** Must be filled before submit. @default false */
  required?: boolean;
  /** Message shown when a required field is empty. @default "{label} is required." */
  requiredMessage?: string;
  /** Format check applied to non-empty values. */
  pattern?: RegExp;
  /** Message shown when `pattern` fails. @default "Check the format of this field." */
  patternMessage?: string;
  /** Custom validator; return a message to flag the field. Runs after the built-in checks. */
  validate?: (value: string, values: Record<string, string>) => string | undefined | null;
  /** Half width beside its neighbour when the form is wide enough. @default false */
  half?: boolean;
  /** Small helper line under the input. */
  hint?: string;
  /** `autocomplete` attribute for text-like inputs. */
  autoComplete?: string;
  /** Visible rows for a textarea. @default 4 */
  rows?: number;
}

/** Built-in topic glyphs. Pass a node for your own icon. */
export type ParleyTopicGlyph = "sales" | "support" | "press" | "other";

export interface ParleyTopic {
  /** Stable id, passed back to `onSubmit`. */
  id: string;
  /** Chip label. */
  label: string;
  /** Built-in glyph name or your own icon node. @default "other" */
  icon?: ParleyTopicGlyph | React.ReactNode;
  /** Fields shown, in order, while this topic is selected. */
  fields: ParleyField[];
  /** Submit button label. @default "Send message" */
  submitLabel?: string;
  /** Expected-reply line under the form (e.g. "Replies within 2 hours"). */
  replyLine?: string;
  /** Body of the success card. @default "Your message is in. {replyLine}." */
  successMessage?: string;
}

/** Built-in channel glyphs. */
export type ParleyChannelKind = "email" | "chat" | "phone" | "other";

export interface ParleyChannel {
  /** Glyph and default link scheme. @default "other" */
  kind?: ParleyChannelKind;
  /** Card heading (e.g. "Email us"). */
  label: string;
  /** The visible contact value (address, handle, number). */
  value: string;
  /** Muted supporting line (hours, response time). */
  detail?: string;
  /** Link for the value. Email/phone default to `mailto:` / `tel:`. */
  href?: string;
  /** Text written to the clipboard. @default value */
  copyValue?: string;
}

export interface ParleyOffice {
  /** Card heading. @default "Visit the studio" */
  label?: string;
  /** Address lines. */
  lines: string[];
  /** Muted supporting line (opening hours). */
  detail?: string;
  /** Directions link. */
  href?: string;
  /** Text written to the clipboard. @default lines joined with ", " */
  copyValue?: string;
}

/** Imperative verbs exposed through the `ref` prop. */
export interface ParleyHandle {
  /** Clear every field, drop errors and the success card, and return to the default topic. */
  reset: () => void;
  /** Re-run the designed intro. */
  replay: () => void;
  /** Switch topic programmatically. */
  setTopic: (id: string) => void;
  /** Focus the first field of the form. */
  focus: () => void;
}

export interface ParleyProps extends Omit<React.ComponentPropsWithoutRef<"section">, "children" | "title" | "onSubmit"> {
  /** Small label above the title. @default "Contact" */
  eyebrow?: React.ReactNode;
  /** Section heading. @default "Talk to the right person" */
  title?: React.ReactNode;
  /** Line under the heading. */
  subtitle?: React.ReactNode;
  /** Topic chips and the form each one routes to. Omit for Sales / Support / Press / Other. */
  topics?: ParleyTopic[];
  /** Uncontrolled initial topic id. @default first topic */
  defaultTopic?: string;
  /** Controlled topic id. */
  topic?: string;
  /** Called when the visitor picks a topic. */
  onTopicChange?: (id: string) => void;
  /**
   * Called with the current topic's values. Return a Promise to hold the
   * button in its pending state — resolve shows the success card, reject
   * keeps every value and shows the rejection's message inline.
   */
  onSubmit?: (values: Record<string, string>, topic: string) => void | Promise<unknown>;
  /** Side-column channel cards with copy-to-clipboard. Omit for a sample set; `[]` hides them. */
  channels?: ParleyChannel[];
  /** Office card with an abstract location plate, or `false` to hide it. */
  office?: ParleyOffice | false;
  /** Accent hex for the active chip, submit button, focus rings and pin. @default "#60a5fa" */
  accentColor?: string;
  /** Imperative handle (`reset()`, `replay()`, `setTopic()`, `focus()`). React 19 ref-as-prop. */
  ref?: React.Ref<ParleyHandle>;
  /** Extra classes for the `<section>` root. */
  className?: string;
}

/* ---------------------------------------------------------------- defaults */

const DEFAULT_ACCENT = "#60a5fa";
const EASE: [number, number, number, number] = [0.22, 1, 0.36, 1];
const SPRING: Transition = { type: "spring", duration: 0.45, bounce: 0 };
const INSTANT: Transition = { duration: 0 };
const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]{2,}$/;

const NAME: ParleyField = {
  name: "name",
  label: "Full name",
  placeholder: "Ada Moreno",
  required: true,
  requiredMessage: "Tell us who we're talking to.",
  autoComplete: "name",
  half: true,
};
const EMAIL: ParleyField = {
  name: "email",
  label: "Work email",
  type: "email",
  placeholder: "ada@company.com",
  required: true,
  requiredMessage: "We need an email to reply to.",
  autoComplete: "email",
  half: true,
};
const MESSAGE: ParleyField = {
  name: "message",
  label: "How can we help?",
  type: "textarea",
  placeholder: "A few lines of context goes a long way.",
  required: true,
  requiredMessage: "Add a short message.",
  validate: (v) => (v.trim().length < 12 ? "Add a little more detail (12+ characters)." : undefined),
  rows: 4,
};

const DEFAULT_TOPICS: ParleyTopic[] = [
  {
    id: "sales",
    label: "Sales",
    icon: "sales",
    submitLabel: "Talk to sales",
    replyLine: "Replies within 4 hours",
    fields: [
      NAME,
      EMAIL,
      {
        name: "companySize",
        label: "Company size",
        type: "select",
        required: true,
        requiredMessage: "Pick a company size.",
        options: [
          { value: "", label: "Select a range" },
          { value: "1-10", label: "1–10 people" },
          { value: "11-50", label: "11–50 people" },
          { value: "51-200", label: "51–200 people" },
          { value: "201-1000", label: "201–1,000 people" },
          { value: "1000+", label: "1,000+ people" },
        ],
        half: true,
      },
      {
        name: "teamSize",
        label: "Seats needed",
        type: "number",
        placeholder: "12",
        required: true,
        requiredMessage: "Roughly how many seats?",
        validate: (v) => (!/^\d+$/.test(v.trim()) || Number(v) < 1 ? "Use a whole number, 1 or more." : undefined),
        half: true,
      },
      { ...MESSAGE, label: "What are you hoping to solve?", placeholder: "Current setup, timeline, must-haves…" },
    ],
  },
  {
    id: "support",
    label: "Support",
    icon: "support",
    submitLabel: "Open a ticket",
    replyLine: "Replies within 2 hours",
    fields: [
      NAME,
      EMAIL,
      {
        name: "accountId",
        label: "Account ID",
        placeholder: "AC-104238",
        pattern: /^[A-Za-z]{2}-\d{4,8}$/,
        patternMessage: "Account IDs look like AC-104238.",
        hint: "Settings → Billing. Optional, but it speeds things up.",
      },
      {
        name: "severity",
        label: "Severity",
        type: "segmented",
        options: [
          { value: "low", label: "Low" },
          { value: "normal", label: "Normal" },
          { value: "high", label: "High" },
          { value: "urgent", label: "Urgent" },
        ],
        defaultValue: "normal",
      },
      { ...MESSAGE, label: "What's going on?", placeholder: "What you expected, what happened, and when it started." },
    ],
  },
  {
    id: "press",
    label: "Press",
    icon: "press",
    submitLabel: "Send press inquiry",
    replyLine: "Replies within 1 business day",
    fields: [
      NAME,
      EMAIL,
      { name: "outlet", label: "Publication", placeholder: "The Weekly Ledger", required: true, requiredMessage: "Which outlet are you writing for?", half: true },
      {
        name: "deadline",
        label: "Deadline",
        type: "select",
        options: ["No fixed deadline", "Within 48 hours", "This week", "This month"],
        defaultValue: "No fixed deadline",
        half: true,
      },
      { ...MESSAGE, label: "Your questions", placeholder: "Angle, questions, and who you'd like to speak with." },
    ],
  },
  {
    id: "other",
    label: "Other",
    icon: "other",
    submitLabel: "Send message",
    replyLine: "Replies within 2 business days",
    fields: [NAME, EMAIL, { name: "subject", label: "Subject", placeholder: "Partnership, feedback, hello…" }, MESSAGE],
  },
];

const DEFAULT_CHANNELS: ParleyChannel[] = [
  { kind: "email", label: "Email", value: "hi@halden.example", detail: "Answered by a person, never a bot" },
  { kind: "chat", label: "Live chat", value: "halden.example/chat", href: "#", detail: "Weekdays 8:00–18:00 PT" },
  { kind: "phone", label: "Phone", value: "+1 (415) 555-0142", detail: "Priority line for Business plans" },
];

const DEFAULT_OFFICE: ParleyOffice = {
  label: "Visit the studio",
  lines: ["418 Alder Street, Floor 3", "Portland, OR 97204"],
  detail: "Tue–Thu, 10:00–17:00 · by appointment",
  href: "#",
};

/* ---------------------------------------------------------------- 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 normOptions(options: ParleyField["options"]): ParleyOption[] {
  return (options ?? []).map((o) => (typeof o === "string" ? { value: o, label: o } : o));
}

function initialValue(f: ParleyField): string {
  if (f.defaultValue != null) return f.defaultValue;
  if (f.type === "segmented") return normOptions(f.options)[0]?.value ?? "";
  return "";
}

function validateField(f: ParleyField, raw: string | undefined, values: Record<string, string>): string | undefined {
  const value = (raw ?? "").trim();
  if (!value) return f.required ? f.requiredMessage ?? `${f.label} is required.` : undefined;
  if (f.type === "email" && !EMAIL_RE.test(value)) return "That doesn't look like an email address.";
  if (f.pattern && !f.pattern.test(value)) return f.patternMessage ?? "Check the format of this field.";
  return f.validate?.(value, values) ?? undefined;
}

async function writeClipboard(text: string): Promise<boolean> {
  try {
    if (navigator.clipboard?.writeText) {
      await navigator.clipboard.writeText(text);
      return true;
    }
  } catch {
    /* fall through to the legacy path */
  }
  try {
    const ta = document.createElement("textarea");
    ta.value = text;
    ta.setAttribute("readonly", "");
    ta.style.position = "fixed";
    ta.style.opacity = "0";
    document.body.appendChild(ta);
    ta.select();
    const ok = document.execCommand("copy");
    ta.remove();
    return ok;
  } catch {
    return false;
  }
}

/* ---------------------------------------------------------------- glyphs */

const stroke = {
  fill: "none",
  stroke: "currentColor",
  strokeWidth: 1.7,
  strokeLinecap: "round" as const,
  strokeLinejoin: "round" as const,
};

const TOPIC_GLYPHS: Record<ParleyTopicGlyph, React.ReactNode> = {
  sales: <path d="M4 17.5 9.5 12l3.5 3.5L20 8.5M15 8.5h5v5" />,
  support: (
    <>
      <circle cx="12" cy="12" r="8" />
      <circle cx="12" cy="12" r="3.4" />
      <path d="m6.4 6.4 3.2 3.2M14.4 14.4l3.2 3.2M17.6 6.4l-3.2 3.2M9.6 14.4l-3.2 3.2" />
    </>
  ),
  press: (
    <>
      <path d="M5.5 5h10A1.5 1.5 0 0 1 17 6.5V18a1.5 1.5 0 0 0 1.5 1.5H6A2.5 2.5 0 0 1 3.5 17V7A2 2 0 0 1 5.5 5Z" />
      <path d="M17 9.5h2A1.5 1.5 0 0 1 20.5 11v7a1.5 1.5 0 0 1-3 0M7 9h6M7 12.5h6M7 16h3.5" />
    </>
  ),
  other: (
    <path d="M5 5.5h14a1.5 1.5 0 0 1 1.5 1.5v8.5A1.5 1.5 0 0 1 19 17h-8l-4.5 3.5V17H5a1.5 1.5 0 0 1-1.5-1.5V7A1.5 1.5 0 0 1 5 5.5Z" />
  ),
};

const CHANNEL_GLYPHS: Record<ParleyChannelKind, React.ReactNode> = {
  email: (
    <>
      <rect x="3.5" y="5.5" width="17" height="13" rx="2" />
      <path d="m4 7 8 6 8-6" />
    </>
  ),
  chat: (
    <>
      <path d="M4.5 6.5A2 2 0 0 1 6.5 4.5h11a2 2 0 0 1 2 2v7a2 2 0 0 1-2 2H11l-4 3.5v-3.5h-.5a2 2 0 0 1-2-2Z" />
      <path d="M8.5 10h.01M12 10h.01M15.5 10h.01" strokeWidth="2.4" />
    </>
  ),
  phone: (
    <path d="M6.5 3.8h2.6l1.4 4-2 1.3a10.5 10.5 0 0 0 6.4 6.4l1.3-2 4 1.4v2.6a2 2 0 0 1-2.2 2A16 16 0 0 1 4.5 6a2 2 0 0 1 2-2.2Z" />
  ),
  other: (
    <>
      <circle cx="12" cy="12" r="8.5" />
      <path d="M3.5 12h17M12 3.5c2.4 2.4 3.5 5.2 3.5 8.5s-1.1 6.1-3.5 8.5c-2.4-2.4-3.5-5.2-3.5-8.5S9.6 5.9 12 3.5Z" />
    </>
  ),
};

function Glyph({ children, className }: { children: React.ReactNode; className?: string }) {
  return (
    <svg aria-hidden viewBox="0 0 24 24" className={cn("size-4 shrink-0", className)} {...stroke}>
      {children}
    </svg>
  );
}

function Spinner() {
  return (
    <svg aria-hidden viewBox="0 0 24 24" className="size-4 motion-safe:animate-spin" fill="none">
      <circle cx="12" cy="12" r="9" stroke="currentColor" strokeOpacity="0.25" strokeWidth="3" />
      <path d="M21 12a9 9 0 0 0-9-9" stroke="currentColor" strokeWidth="3" strokeLinecap="round" />
    </svg>
  );
}

/* ---------------------------------------------------------------- auto height */

/** Animates its own height to match its content (field enter/leave, form ↔ success). */
function AutoHeight({ children, instant }: { children: React.ReactNode; instant: boolean }) {
  const innerRef = React.useRef<HTMLDivElement>(null);
  const [height, setHeight] = React.useState<number | "auto">("auto");

  React.useEffect(() => {
    const el = innerRef.current;
    if (!el || typeof ResizeObserver === "undefined") return;
    const ro = new ResizeObserver(() => setHeight(el.offsetHeight));
    ro.observe(el);
    return () => ro.disconnect();
  }, []);

  return (
    <motion.div
      initial={false}
      animate={{ height }}
      transition={instant ? INSTANT : SPRING}
      className="overflow-hidden"
    >
      <div ref={innerRef} className="flow-root">
        {children}
      </div>
    </motion.div>
  );
}

/* ---------------------------------------------------------------- copy button */

function CopyButton({
  text,
  label,
  reducedMotion,
  onCopied,
}: {
  text: string;
  label: string;
  reducedMotion: boolean;
  onCopied: (label: string, ok: boolean) => void;
}) {
  const [copied, setCopied] = React.useState(false);
  const timer = React.useRef<number | undefined>(undefined);
  React.useEffect(() => () => window.clearTimeout(timer.current), []);

  const swap: Transition = reducedMotion ? { duration: 0.12 } : { duration: 0.22, ease: EASE };

  return (
    <button
      type="button"
      aria-label={copied ? `${label} copied` : `Copy ${label}`}
      title={copied ? "Copied" : "Copy"}
      onClick={async () => {
        const ok = await writeClipboard(text);
        onCopied(label, ok);
        if (!ok) return;
        setCopied(true);
        window.clearTimeout(timer.current);
        timer.current = window.setTimeout(() => setCopied(false), 1800);
      }}
      className={cn(
        "relative grid size-8 shrink-0 place-items-center rounded-lg border transition-colors duration-200",
        "focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--parley-accent)] focus-visible:ring-offset-2 focus-visible:ring-offset-neutral-950",
        copied
          ? "border-[color-mix(in_srgb,var(--parley-accent)_45%,transparent)] bg-[color-mix(in_srgb,var(--parley-accent)_14%,transparent)] text-[var(--parley-accent)]"
          : "border-white/[0.08] bg-white/[0.03] text-white/55 hover:border-white/15 hover:text-white"
      )}
    >
      <AnimatePresence initial={false} mode="popLayout">
        {copied ? (
          <motion.svg
            key="check"
            aria-hidden
            viewBox="0 0 24 24"
            className="size-4"
            {...stroke}
            strokeWidth={2.1}
            initial={reducedMotion ? { opacity: 0 } : { opacity: 0, scale: 0.6, rotate: -20 }}
            animate={{ opacity: 1, scale: 1, rotate: 0 }}
            exit={reducedMotion ? { opacity: 0 } : { opacity: 0, scale: 0.6 }}
            transition={swap}
          >
            <motion.path
              d="M5 12.5l4.5 4.5L19 7.5"
              initial={reducedMotion ? false : { pathLength: 0 }}
              animate={{ pathLength: 1 }}
              transition={reducedMotion ? INSTANT : { duration: 0.32, ease: EASE, delay: 0.05 }}
            />
          </motion.svg>
        ) : (
          <motion.svg
            key="copy"
            aria-hidden
            viewBox="0 0 24 24"
            className="size-4"
            {...stroke}
            initial={reducedMotion ? { opacity: 0 } : { opacity: 0, scale: 0.6, rotate: 20 }}
            animate={{ opacity: 1, scale: 1, rotate: 0 }}
            exit={reducedMotion ? { opacity: 0 } : { opacity: 0, scale: 0.6 }}
            transition={swap}
          >
            <rect x="8.5" y="8.5" width="11" height="11" rx="2.2" />
            <path d="M15.5 8.5V6.2a1.7 1.7 0 0 0-1.7-1.7H6.2a1.7 1.7 0 0 0-1.7 1.7v7.6a1.7 1.7 0 0 0 1.7 1.7h2.3" />
          </motion.svg>
        )}
      </AnimatePresence>
    </button>
  );
}

/* ---------------------------------------------------------------- location plate */

/** Abstract street grid + pulsing pin. Pure SVG, no map library. */
function LocationPlate({ accent, reducedMotion, shown }: { accent: string; reducedMotion: boolean; shown: boolean }) {
  const streets = "rgba(255,255,255,0.075)";
  const major = "rgba(255,255,255,0.13)";
  return (
    <svg aria-hidden viewBox="0 0 320 132" preserveAspectRatio="xMidYMid slice" className="block h-full w-full">
      <defs>
        <radialGradient id="parley-plate-glow" cx="58%" cy="52%" r="50%">
          <stop offset="0%" stopColor={accent} stopOpacity="0.16" />
          <stop offset="100%" stopColor={accent} stopOpacity="0" />
        </radialGradient>
        <linearGradient id="parley-plate-fade" x1="0" x2="0" y1="0" y2="1">
          <stop offset="0.55" stopColor="#0a0a0a" stopOpacity="0" />
          <stop offset="1" stopColor="#0a0a0a" stopOpacity="0.75" />
        </linearGradient>
      </defs>
      <rect width="320" height="132" fill="#0d0d0f" />
      <rect width="320" height="132" fill="url(#parley-plate-glow)" />
      {/* River */}
      <path d="M-10 112 C 60 96, 92 128, 160 116 S 270 92, 330 104" fill="none" stroke={accent} strokeOpacity="0.13" strokeWidth="11" />
      {/* Park block */}
      <rect x="104" y="30" width="44" height="30" rx="3" fill={accent} fillOpacity="0.07" />
      {/* Minor grid */}
      <g stroke={streets} strokeWidth="1.2">
        {[16, 44, 70, 98].map((y) => (
          <line key={`h${y}`} x1="0" x2="320" y1={y} y2={y} />
        ))}
        {[28, 64, 100, 152, 196, 236, 280].map((x) => (
          <line key={`v${x}`} x1={x} x2={x} y1="0" y2="132" />
        ))}
      </g>
      {/* Arterials */}
      <g stroke={major} strokeLinecap="round">
        <line x1="0" x2="320" y1="70" y2="70" strokeWidth="2.6" />
        <line x1="152" x2="152" y1="0" y2="132" strokeWidth="2.6" />
        <line x1="-6" x2="326" y1="126" y2="-6" strokeWidth="4" />
      </g>
      <rect width="320" height="132" fill="url(#parley-plate-fade)" />
      {/* Pin */}
      <g transform="translate(188 64)">
        {!reducedMotion ? (
          <>
            <circle r="5" fill={accent} className="parley-ping" />
            <circle r="5" fill={accent} className="parley-ping [animation-delay:1.1s]" />
          </>
        ) : null}
        <ellipse cx="0" cy="1" rx="5" ry="2" fill="#000" opacity="0.5" />
        <motion.g
          initial={false}
          animate={shown ? { y: 0, opacity: 1 } : { y: -14, opacity: 0 }}
          transition={reducedMotion ? INSTANT : { type: "spring", duration: 0.6, bounce: 0, delay: 0.55 }}
        >
          <path
            d="M0 0 C -1.2 -4, -8 -8.5, -8 -15 a 8 8 0 0 1 16 0 C 8 -8.5, 1.2 -4, 0 0 Z"
            fill={accent}
            stroke="rgba(0,0,0,0.35)"
            strokeWidth="0.8"
          />
          <circle cy="-15" r="3" fill={readableOn(accent)} fillOpacity="0.85" />
        </motion.g>
      </g>
    </svg>
  );
}

/* ---------------------------------------------------------------- field */

interface FieldProps {
  field: ParleyField;
  id: string;
  value: string;
  error?: string;
  shake: number;
  reducedMotion: boolean;
  registerFocus: (name: string, el: HTMLElement | null) => void;
  onChange: (name: string, value: string) => void;
  onBlur: (name: string) => void;
}

const inputBase =
  "w-full rounded-xl border bg-white/[0.03] px-3.5 text-sm text-white placeholder:text-white/30 transition-[border-color,background-color,box-shadow] duration-200 focus:outline-none focus:bg-white/[0.05] focus:border-[var(--parley-accent)] focus:shadow-[0_0_0_3px_color-mix(in_srgb,var(--parley-accent)_18%,transparent)]";

function Field({ field, id, value, error, shake, reducedMotion, registerFocus, onChange, onBlur }: FieldProps) {
  const [scope, animate] = useAnimate<HTMLDivElement>();
  const errorId = `${id}-error`;
  const hintId = `${id}-hint`;
  const type = field.type ?? "text";
  const describedBy = [error ? errorId : null, field.hint ? hintId : null].filter(Boolean).join(" ") || undefined;
  const borderTone = error ? "border-red-400/55" : "border-white/[0.09] hover:border-white/[0.16]";

  React.useEffect(() => {
    if (!shake || reducedMotion || !scope.current) return;
    animate(scope.current, { x: [0, -7, 6, -4, 3, -1, 0] }, { duration: 0.42, ease: "easeOut" });
  }, [shake, reducedMotion, animate, scope]);

  const options = normOptions(field.options);
  const segRefs = React.useRef<(HTMLButtonElement | null)[]>([]);
  const selectedIndex = Math.max(
    0,
    options.findIndex((o) => o.value === value)
  );

  let control: React.ReactNode;
  if (type === "textarea") {
    control = (
      <textarea
        ref={(el) => registerFocus(field.name, el)}
        id={id}
        name={field.name}
        rows={field.rows ?? 4}
        placeholder={field.placeholder}
        value={value}
        onChange={(e) => onChange(field.name, e.target.value)}
        onBlur={() => onBlur(field.name)}
        aria-invalid={error ? true : undefined}
        aria-required={field.required || undefined}
        aria-describedby={describedBy}
        className={cn(inputBase, borderTone, "block min-h-24 resize-y py-2.5 leading-relaxed")}
      />
    );
  } else if (type === "select") {
    control = (
      <div className="relative">
        <select
          ref={(el) => registerFocus(field.name, el)}
          id={id}
          name={field.name}
          value={value}
          onChange={(e) => onChange(field.name, e.target.value)}
          onBlur={() => onBlur(field.name)}
          aria-invalid={error ? true : undefined}
          aria-required={field.required || undefined}
          aria-describedby={describedBy}
          className={cn(inputBase, borderTone, "h-11 cursor-pointer appearance-none pr-9", !value && "text-white/40")}
        >
          {options.map((o) => (
            <option key={o.value} value={o.value} disabled={o.value === "" && field.required} className="bg-neutral-900 text-white">
              {o.label}
            </option>
          ))}
        </select>
        <svg
          aria-hidden
          viewBox="0 0 24 24"
          className="pointer-events-none absolute top-1/2 right-3 size-4 -translate-y-1/2 text-white/40"
          {...stroke}
        >
          <path d="m7 10 5 5 5-5" />
        </svg>
      </div>
    );
  } else if (type === "segmented") {
    const move = (to: number) => {
      const i = (to + options.length) % options.length;
      onChange(field.name, options[i].value);
      segRefs.current[i]?.focus();
    };
    control = (
      <div
        role="radiogroup"
        aria-labelledby={`${id}-label`}
        aria-describedby={describedBy}
        className="grid gap-1 rounded-xl border border-white/[0.09] bg-white/[0.03] p-1"
        style={{ gridTemplateColumns: `repeat(${options.length}, minmax(0, 1fr))` }}
      >
        {options.map((o, i) => {
          const checked = i === selectedIndex;
          return (
            <button
              key={o.value}
              ref={(el) => {
                segRefs.current[i] = el;
                if (checked) registerFocus(field.name, el);
              }}
              type="button"
              role="radio"
              aria-checked={checked}
              tabIndex={checked ? 0 : -1}
              onClick={() => onChange(field.name, o.value)}
              onBlur={() => onBlur(field.name)}
              onKeyDown={(e) => {
                if (e.key === "ArrowRight" || e.key === "ArrowDown") {
                  e.preventDefault();
                  move(i + 1);
                } else if (e.key === "ArrowLeft" || e.key === "ArrowUp") {
                  e.preventDefault();
                  move(i - 1);
                } else if (e.key === "Home") {
                  e.preventDefault();
                  move(0);
                } else if (e.key === "End") {
                  e.preventDefault();
                  move(options.length - 1);
                }
              }}
              className={cn(
                "relative isolate flex h-9 min-w-0 items-center justify-center gap-1.5 rounded-lg px-1 text-[13px] font-medium transition-colors duration-200",
                "focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--parley-accent)]",
                checked ? "text-white" : "text-white/50 hover:text-white/80"
              )}
            >
              {checked ? (
                <motion.span
                  layoutId={`${id}-seg`}
                  transition={reducedMotion ? INSTANT : SPRING}
                  className="absolute inset-0 -z-10 rounded-lg border border-white/[0.1] bg-white/[0.08]"
                />
              ) : null}
              {o.value === "urgent" || o.value === "high" ? (
                <span
                  aria-hidden
                  className={cn("size-1.5 shrink-0 rounded-full", o.value === "urgent" ? "bg-rose-400" : "bg-amber-300")}
                />
              ) : null}
              <span className="truncate">{o.label}</span>
            </button>
          );
        })}
      </div>
    );
  } else {
    control = (
      <input
        ref={(el) => registerFocus(field.name, el)}
        id={id}
        name={field.name}
        type={type === "number" ? "text" : type}
        inputMode={type === "number" ? "numeric" : type === "email" ? "email" : type === "tel" ? "tel" : undefined}
        autoComplete={field.autoComplete ?? "off"}
        placeholder={field.placeholder}
        value={value}
        onChange={(e) => onChange(field.name, e.target.value)}
        onBlur={() => onBlur(field.name)}
        aria-invalid={error ? true : undefined}
        aria-required={field.required || undefined}
        aria-describedby={describedBy}
        className={cn(inputBase, borderTone, "h-11", type === "number" && "tabular-nums")}
      />
    );
  }

  return (
    <div ref={scope} className="flex flex-col gap-1.5">
      <div className="flex items-baseline justify-between gap-3">
        {type === "segmented" ? (
          <span id={`${id}-label`} className="text-[13px] font-medium text-white/75">
            {field.label}
          </span>
        ) : (
          <label id={`${id}-label`} htmlFor={id} className="text-[13px] font-medium text-white/75">
            {field.label}
          </label>
        )}
        {!field.required && type !== "segmented" && type !== "select" ? (
          <span className="text-[11px] text-white/30">Optional</span>
        ) : null}
      </div>
      {control}
      <AnimatePresence initial={false}>
        {error ? (
          <motion.p
            key="err"
            id={errorId}
            initial={reducedMotion ? { opacity: 0 } : { opacity: 0, y: -4 }}
            animate={{ opacity: 1, y: 0 }}
            exit={{ opacity: 0 }}
            transition={{ duration: 0.18 }}
            className="flex items-center gap-1.5 text-xs text-red-300"
          >
            <svg aria-hidden viewBox="0 0 24 24" className="size-3.5 shrink-0" {...stroke} strokeWidth={2}>
              <circle cx="12" cy="12" r="8.5" />
              <path d="M12 7.8v5M12 16.2h.01" />
            </svg>
            {error}
          </motion.p>
        ) : field.hint ? (
          <p id={hintId} className="text-xs text-white/35">
            {field.hint}
          </p>
        ) : null}
      </AnimatePresence>
    </div>
  );
}

/* ---------------------------------------------------------------- main */

type Phase = "idle" | "pending" | "error" | "success";

/**
 * Parley — a contact section that routes before it asks. A radiogroup of
 * topic chips sits atop the form; picking one reshapes it — fields glide in
 * and out with layout animation, values carry across topics by field name,
 * and the submit label and expected-reply line change with the route.
 * Validation runs on blur with a short shake and inline messages; an async
 * `onSubmit` drives pending → success (a drawn check with "Send another") or
 * an inline error that keeps every value. A side column holds channel cards
 * with copy-to-clipboard (the icon morphs to a check) and an office card with
 * an abstract SVG street plate and pulsing pin. Reduced motion: crossfades
 * only, no shake, no pulse.
 */
export function Parley({
  eyebrow = "Contact",
  title = "Talk to the right person",
  subtitle = "Pick a topic and we'll route you straight to the team that can help — no ticket mazes, no bots.",
  topics = DEFAULT_TOPICS,
  defaultTopic,
  topic: topicProp,
  onTopicChange,
  onSubmit,
  channels = DEFAULT_CHANNELS,
  office = DEFAULT_OFFICE,
  accentColor = DEFAULT_ACCENT,
  ref,
  className,
  style,
  ...props
}: ParleyProps) {
  const reducedMotion = useReducedMotion();
  const uid = React.useId().replace(/[:]/g, "");
  const rootRef = React.useRef<HTMLElement>(null);
  const successRef = React.useRef<HTMLHeadingElement>(null);
  const focusMap = React.useRef(new Map<string, HTMLElement>());
  const chipRefs = React.useRef<(HTMLButtonElement | null)[]>([]);

  const fallbackTopic = defaultTopic ?? topics[0]?.id ?? "";
  const [innerTopic, setInnerTopic] = React.useState(fallbackTopic);
  const topicId = topicProp ?? innerTopic;
  const current = topics.find((t) => t.id === topicId) ?? topics[0];
  const fields = React.useMemo(() => current?.fields ?? [], [current]);

  const seedValues = React.useCallback(() => {
    const v: Record<string, string> = {};
    for (const t of topics) for (const f of t.fields) if (!(f.name in v)) v[f.name] = initialValue(f);
    return v;
  }, [topics]);

  const [values, setValues] = React.useState<Record<string, string>>(seedValues);
  const [touched, setTouched] = React.useState<Record<string, boolean>>({});
  const [shakes, setShakes] = React.useState<Record<string, number>>({});
  const [phase, setPhase] = React.useState<Phase>("idle");
  const [submitError, setSubmitError] = React.useState<string | null>(null);
  const [announce, setAnnounce] = React.useState("");
  const [sentTopic, setSentTopic] = React.useState<ParleyTopic | null>(null);
  const pendingRef = React.useRef(false);
  const focusAfter = React.useRef<"success" | "first" | null>(null);

  // Designed intro: once, the first time the section is in view.
  const [seen, setSeen] = React.useState(false);
  const [run, setRun] = React.useState(0);
  React.useEffect(() => {
    const el = rootRef.current;
    if (!el) return;
    const io = new IntersectionObserver(
      (entries) => {
        if (entries.some((e) => e.isIntersecting)) {
          setSeen(true);
          io.disconnect();
        }
      },
      { threshold: 0.15 }
    );
    io.observe(el);
    return () => io.disconnect();
  }, []);
  const shown = reducedMotion || seen;

  const accentInk = readableOn(accentColor);
  const pending = phase === "pending";

  const errors = React.useMemo(() => {
    const out: Record<string, string | undefined> = {};
    for (const f of fields) if (touched[f.name]) out[f.name] = validateField(f, values[f.name], values);
    return out;
  }, [fields, touched, values]);

  const selectTopic = React.useCallback(
    (id: string) => {
      if (id === topicId) return;
      const next = topics.find((t) => t.id === id);
      if (!next) return;
      if (topicProp == null) setInnerTopic(id);
      onTopicChange?.(id);
      setSubmitError(null);
      if (phase === "error") setPhase("idle");
      const added = next.fields.filter((f) => !fields.some((g) => g.name === f.name)).length;
      setAnnounce(`${next.label} form${added ? `, ${added} new field${added === 1 ? "" : "s"}` : ""}. ${next.replyLine ?? ""}`);
    },
    [topicId, topics, topicProp, onTopicChange, phase, fields]
  );

  const registerFocus = React.useCallback((name: string, el: HTMLElement | null) => {
    if (el) focusMap.current.set(name, el);
  }, []);

  const focusFirst = React.useCallback(() => {
    const first = fields[0];
    if (first) focusMap.current.get(first.name)?.focus({ preventScroll: false });
  }, [fields]);

  const handleChange = React.useCallback((name: string, value: string) => {
    setValues((v) => ({ ...v, [name]: value }));
  }, []);

  const handleBlur = React.useCallback(
    (name: string) => {
      const f = fields.find((x) => x.name === name);
      if (!f) return;
      // Don't flag untouched empty fields just for tabbing through — submit catches those.
      const value = values[name] ?? "";
      if (!touched[name] && !value.trim()) return;
      setTouched((t) => ({ ...t, [name]: true }));
      const err = validateField(f, value, values);
      // Shake when a new problem appears, not on every blur of an already-flagged field.
      if (err && err !== errors[name]) setShakes((s) => ({ ...s, [name]: (s[name] ?? 0) + 1 }));
    },
    [fields, values, touched, errors]
  );

  const reset = React.useCallback(() => {
    pendingRef.current = false;
    setValues(seedValues());
    setTouched({});
    setShakes({});
    setSubmitError(null);
    setPhase("idle");
    setSentTopic(null);
    if (topicProp == null) setInnerTopic(fallbackTopic);
    else if (topicProp !== fallbackTopic) onTopicChange?.(fallbackTopic);
  }, [seedValues, topicProp, fallbackTopic, onTopicChange]);

  const sendAnother = () => {
    // Keep who they are; clear what they said.
    const seeded = seedValues();
    setValues((v) => ({ ...seeded, name: v.name ?? "", email: v.email ?? "" }));
    setTouched({});
    setSubmitError(null);
    setPhase("idle");
    focusAfter.current = "first";
  };

  const replay = React.useCallback(() => {
    setSeen(true);
    setRun((r) => r + 1);
  }, []);

  React.useImperativeHandle(ref, () => ({ reset, replay, setTopic: selectTopic, focus: focusFirst }), [
    reset,
    replay,
    selectTopic,
    focusFirst,
  ]);

  React.useEffect(() => {
    if (focusAfter.current === "success" && phase === "success") {
      successRef.current?.focus({ preventScroll: true });
      focusAfter.current = null;
    }
  }, [phase]);

  // "Send another": the form mounts only after the success card exits, so focus from its ref.
  const formRef = React.useCallback(
    (el: HTMLFormElement | null) => {
      if (!el || focusAfter.current !== "first") return;
      focusAfter.current = null;
      requestAnimationFrame(() => focusFirst());
    },
    [focusFirst]
  );

  const handleSubmit = async (event: React.FormEvent<HTMLFormElement>) => {
    event.preventDefault();
    if (pendingRef.current || !current) return;
    const invalid = fields.filter((f) => validateField(f, values[f.name], values));
    if (invalid.length) {
      setTouched((t) => {
        const next = { ...t };
        for (const f of fields) next[f.name] = true;
        return next;
      });
      setShakes((s) => {
        const next = { ...s };
        for (const f of invalid) next[f.name] = (next[f.name] ?? 0) + 1;
        return next;
      });
      setAnnounce(`${invalid.length} field${invalid.length === 1 ? " needs" : "s need"} attention.`);
      focusMap.current.get(invalid[0].name)?.focus();
      return;
    }
    const payload: Record<string, string> = {};
    for (const f of fields) payload[f.name] = (values[f.name] ?? "").trim();
    pendingRef.current = true;
    setSubmitError(null);
    setPhase("pending");
    setAnnounce("Sending…");
    try {
      await onSubmit?.(payload, current.id);
      pendingRef.current = false;
      setSentTopic(current);
      focusAfter.current = "success";
      setPhase("success");
      setAnnounce(`Message sent. ${current.replyLine ?? ""}`);
    } catch (err) {
      pendingRef.current = false;
      const msg = err instanceof Error && err.message ? err.message : "Something went wrong. Your message is still here — try again.";
      setSubmitError(msg);
      setPhase("error");
      setAnnounce("");
    }
  };

  const onCopied = React.useCallback((label: string, ok: boolean) => {
    setAnnounce(ok ? `${label} copied to clipboard.` : `Couldn't copy ${label}.`);
  }, []);

  /* ---- chip radiogroup keyboard ---- */
  const topicIndex = Math.max(0, topics.findIndex((t) => t.id === current?.id));
  const moveChip = (to: number) => {
    const i = (to + topics.length) % topics.length;
    selectTopic(topics[i].id);
    chipRefs.current[i]?.focus();
  };

  /* ---- intro choreography ---- */
  const enter = (delay: number) =>
    reducedMotion
      ? { initial: false as const, animate: { opacity: 1, y: 0 } }
      : {
          initial: { opacity: 0, y: 14 },
          animate: shown ? { opacity: 1, y: 0 } : { opacity: 0, y: 14 },
          transition: { duration: 0.55, ease: EASE, delay },
        };

  const fieldT: Transition = reducedMotion ? { duration: 0.15 } : SPRING;
  const focusRing =
    "focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--parley-accent)] focus-visible:ring-offset-2 focus-visible:ring-offset-neutral-950";

  const hasSide = channels.length > 0 || office !== false;
  const officeCopy = office ? office.copyValue ?? office.lines.join(", ") : "";
  const firstName = (values.name ?? "").trim().split(/\s+/)[0];

  return (
    <section
      ref={rootRef}
      data-crucible="parley"
      aria-labelledby={`${uid}-title`}
      className={cn("@container relative isolate w-full text-white", className)}
      style={{ ["--parley-accent" as string]: accentColor, ...style }}
      {...props}
    >
      <style>{`@keyframes parley-ping{0%{transform:scale(1);opacity:.55}100%{transform:scale(4.2);opacity:0}}.parley-ping{transform-box:fill-box;transform-origin:center;animation:parley-ping 2.2s cubic-bezier(0,0,.2,1) infinite}`}</style>

      <div key={run} className="mx-auto w-full max-w-6xl px-4 py-10 @xl:px-8 @xl:py-14">
        {/* Header */}
        <motion.header {...enter(0)} className="max-w-xl">
          <p className="inline-flex items-center gap-2 text-xs font-medium tracking-[0.16em] text-[var(--parley-accent)] uppercase">
            <span aria-hidden className="h-px w-5 bg-[var(--parley-accent)] opacity-70" />
            {eyebrow}
          </p>
          <h2 id={`${uid}-title`} className="mt-3 text-3xl font-semibold tracking-tight text-balance text-white @xl:text-4xl">
            {title}
          </h2>
          {subtitle ? <p className="mt-3 max-w-lg text-[15px] leading-relaxed text-pretty text-white/50">{subtitle}</p> : null}
        </motion.header>

        <div className={cn("mt-10 grid gap-5", hasSide && "@3xl:grid-cols-[minmax(0,1.5fr)_minmax(0,1fr)] @3xl:gap-6")}>
          {/* Form card */}
          <motion.div
            {...enter(0.08)}
            className="@container/form relative rounded-2xl border border-white/[0.08] bg-white/[0.025] shadow-[0_1px_0_0_rgba(255,255,255,0.04)_inset,0_24px_60px_-30px_rgba(0,0,0,0.8)]"
          >
            {/* top hairline glow */}
            <div
              aria-hidden
              className="pointer-events-none absolute inset-x-8 top-0 h-px"
              style={{ background: `linear-gradient(90deg, transparent, color-mix(in srgb, ${accentColor} 55%, transparent), transparent)` }}
            />
            <AutoHeight instant={reducedMotion}>
              <AnimatePresence initial={false} mode="wait">
                {phase === "success" ? (
                  <motion.div
                    key="success"
                    initial={reducedMotion ? { opacity: 0 } : { opacity: 0, scale: 0.98 }}
                    animate={{ opacity: 1, scale: 1 }}
                    exit={{ opacity: 0 }}
                    transition={reducedMotion ? { duration: 0.15 } : { duration: 0.35, ease: EASE }}
                    className="flex min-h-[420px] flex-col items-center justify-center px-6 py-14 text-center"
                  >
                    <svg aria-hidden viewBox="0 0 64 64" className="size-16" fill="none">
                      <circle cx="32" cy="32" r="30" fill={accentColor} fillOpacity="0.08" />
                      <motion.circle
                        cx="32"
                        cy="32"
                        r="27"
                        stroke={accentColor}
                        strokeWidth="2"
                        strokeLinecap="round"
                        transform="rotate(-90 32 32)"
                        initial={reducedMotion ? false : { pathLength: 0 }}
                        animate={{ pathLength: 1 }}
                        transition={reducedMotion ? INSTANT : { duration: 0.55, ease: EASE }}
                      />
                      <motion.path
                        d="M21 33l7.5 7.5L44 25"
                        stroke={accentColor}
                        strokeWidth="3"
                        strokeLinecap="round"
                        strokeLinejoin="round"
                        initial={reducedMotion ? false : { pathLength: 0 }}
                        animate={{ pathLength: 1 }}
                        transition={reducedMotion ? INSTANT : { duration: 0.35, ease: EASE, delay: 0.38 }}
                      />
                    </svg>
                    <h3
                      ref={successRef}
                      tabIndex={-1}
                      className="mt-6 text-xl font-semibold tracking-tight text-white focus:outline-none"
                    >
                      {firstName ? `Thanks, ${firstName} — message sent` : "Message sent"}
                    </h3>
                    <p className="mt-2 max-w-xs text-sm leading-relaxed text-white/50">
                      {sentTopic?.successMessage ??
                        `It's with our ${sentTopic?.label.toLowerCase() ?? ""} team. ${sentTopic?.replyLine ? `${sentTopic.replyLine}.` : ""}`}
                    </p>
                    {values.email ? (
                      <p className="mt-4 rounded-full border border-white/[0.08] bg-white/[0.03] px-3 py-1 text-xs text-white/55">
                        Reply goes to <span className="text-white/80">{values.email}</span>
                      </p>
                    ) : null}
                    <button
                      type="button"
                      onClick={sendAnother}
                      className={cn(
                        "mt-7 inline-flex items-center gap-1.5 rounded-md text-sm font-medium text-[var(--parley-accent)] underline-offset-4 hover:underline",
                        focusRing
                      )}
                    >
                      Send another
                      <Glyph className="size-3.5">
                        <path d="M5 12h14m-5-5 5 5-5 5" />
                      </Glyph>
                    </button>
                  </motion.div>
                ) : (
                  <motion.form
                    key="form"
                    ref={formRef}
                    noValidate
                    onSubmit={handleSubmit}
                    aria-labelledby={`${uid}-title`}
                    initial={reducedMotion ? { opacity: 0 } : { opacity: 0, y: 8 }}
                    animate={{ opacity: 1, y: 0 }}
                    exit={{ opacity: 0 }}
                    transition={reducedMotion ? { duration: 0.15 } : { duration: 0.3, ease: EASE }}
                    className="p-4 @md:p-6"
                  >
                    {/* Topic chips */}
                    <div id={`${uid}-topic-label`} className="mb-2.5 text-[13px] font-medium text-white/75">
                      What&apos;s this about?
                    </div>
                    <div
                      role="radiogroup"
                      aria-labelledby={`${uid}-topic-label`}
                      className="grid grid-cols-2 gap-1 rounded-xl border border-white/[0.08] bg-black/30 p-1 @sm/form:grid-cols-4"
                      style={topics.length !== 4 ? { gridTemplateColumns: `repeat(${Math.min(topics.length, 4)}, minmax(0, 1fr))` } : undefined}
                    >
                      {topics.map((t, i) => {
                        const checked = i === topicIndex;
                        const icon =
                          t.icon != null && typeof t.icon !== "string" ? (
                            t.icon
                          ) : (
                            <Glyph>{TOPIC_GLYPHS[(t.icon as ParleyTopicGlyph) ?? "other"] ?? TOPIC_GLYPHS.other}</Glyph>
                          );
                        return (
                          <button
                            key={t.id}
                            ref={(el) => {
                              chipRefs.current[i] = el;
                            }}
                            type="button"
                            role="radio"
                            aria-checked={checked}
                            tabIndex={checked ? 0 : -1}
                            disabled={pending}
                            onClick={() => selectTopic(t.id)}
                            onKeyDown={(e) => {
                              if (e.key === "ArrowRight" || e.key === "ArrowDown") {
                                e.preventDefault();
                                moveChip(i + 1);
                              } else if (e.key === "ArrowLeft" || e.key === "ArrowUp") {
                                e.preventDefault();
                                moveChip(i - 1);
                              } else if (e.key === "Home") {
                                e.preventDefault();
                                moveChip(0);
                              } else if (e.key === "End") {
                                e.preventDefault();
                                moveChip(topics.length - 1);
                              }
                            }}
                            className={cn(
                              "relative isolate flex h-10 min-w-0 items-center justify-center gap-2 rounded-lg px-2 text-sm font-medium transition-colors duration-200 disabled:cursor-not-allowed",
                              "focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--parley-accent)]",
                              checked ? "" : "text-white/50 hover:bg-white/[0.04] hover:text-white/85"
                            )}
                            style={checked ? { color: accentInk } : undefined}
                          >
                            {checked ? (
                              <motion.span
                                layoutId={`${uid}-chip`}
                                transition={reducedMotion ? INSTANT : SPRING}
                                className="absolute inset-0 -z-10 rounded-lg"
                                style={{
                                  backgroundColor: accentColor,
                                  boxShadow: `0 6px 20px -8px ${accentColor}, inset 0 1px 0 rgba(255,255,255,0.25)`,
                                }}
                              />
                            ) : null}
                            {icon}
                            <span className="truncate">{t.label}</span>
                          </button>
                        );
                      })}
                    </div>

                    {/* Fields */}
                    <fieldset disabled={pending} className="relative mt-6 grid min-w-0 grid-cols-1 gap-x-4 gap-y-5 @sm/form:grid-cols-2">
                      <legend className="sr-only">{current?.label} details</legend>
                      <AnimatePresence initial={false} mode="popLayout">
                        {fields.map((f) => (
                          <motion.div
                            key={f.name}
                            layout={reducedMotion ? false : "position"}
                            initial={reducedMotion ? { opacity: 0 } : { opacity: 0, y: 10, filter: "blur(4px)" }}
                            animate={{ opacity: 1, y: 0, filter: "blur(0px)" }}
                            exit={reducedMotion ? { opacity: 0 } : { opacity: 0, scale: 0.97, filter: "blur(4px)" }}
                            transition={fieldT}
                            className={cn("min-w-0", f.half ? "@sm/form:col-span-1" : "@sm/form:col-span-2")}
                          >
                            <Field
                              field={f}
                              id={`${uid}-${f.name}`}
                              value={values[f.name] ?? ""}
                              error={errors[f.name]}
                              shake={shakes[f.name] ?? 0}
                              reducedMotion={reducedMotion}
                              registerFocus={registerFocus}
                              onChange={handleChange}
                              onBlur={handleBlur}
                            />
                          </motion.div>
                        ))}
                      </AnimatePresence>
                    </fieldset>

                    {/* Submit error */}
                    <AnimatePresence initial={false}>
                      {phase === "error" && submitError ? (
                        <motion.div
                          key="submit-error"
                          role="alert"
                          initial={{ opacity: 0 }}
                          animate={{ opacity: 1 }}
                          exit={{ opacity: 0 }}
                          className="mt-5 flex items-start gap-2.5 rounded-xl border border-red-400/25 bg-red-500/[0.07] px-3.5 py-3 text-sm text-red-200"
                        >
                          <Glyph className="mt-0.5 text-red-300">
                            <path d="M12 4 21 19.5H3Z" />
                            <path d="M12 10v4M12 17h.01" />
                          </Glyph>
                          <span>{submitError}</span>
                        </motion.div>
                      ) : null}
                    </AnimatePresence>

                    {/* Footer: reply line + submit */}
                    <div className="mt-6 flex flex-col-reverse gap-4 border-t border-white/[0.07] pt-5 @sm/form:flex-row @sm/form:items-center @sm/form:justify-between">
                      <p className="flex items-center gap-2 text-xs text-white/45">
                        <span aria-hidden className="relative flex size-2">
                          {!reducedMotion ? (
                            <span className="absolute inline-flex size-full animate-ping rounded-full bg-emerald-400 opacity-50 [animation-duration:2.4s]" />
                          ) : null}
                          <span className="relative inline-flex size-2 rounded-full bg-emerald-400" />
                        </span>
                        <AnimatePresence initial={false} mode="popLayout">
                          <motion.span
                            key={current?.replyLine ?? "none"}
                            initial={reducedMotion ? { opacity: 0 } : { opacity: 0, y: 6 }}
                            animate={{ opacity: 1, y: 0 }}
                            exit={reducedMotion ? { opacity: 0 } : { opacity: 0, y: -6 }}
                            transition={{ duration: 0.25, ease: EASE }}
                            className="tabular-nums"
                          >
                            {current?.replyLine ?? "We read every message"}
                          </motion.span>
                        </AnimatePresence>
                      </p>
                      <motion.button
                        type="submit"
                        layout={reducedMotion ? false : true}
                        transition={reducedMotion ? INSTANT : SPRING}
                        aria-disabled={pending || undefined}
                        aria-busy={pending || undefined}
                        className={cn(
                          "inline-flex h-11 shrink-0 items-center justify-center gap-2 overflow-hidden rounded-xl px-5 text-sm font-semibold whitespace-nowrap transition-[filter] hover:brightness-110 active:brightness-95",
                          focusRing
                        )}
                        style={{
                          backgroundColor: accentColor,
                          color: accentInk,
                          boxShadow: `0 10px 30px -12px ${accentColor}, inset 0 1px 0 rgba(255,255,255,0.3)`,
                        }}
                      >
                        <AnimatePresence initial={false} mode="popLayout">
                          <motion.span
                            key={pending ? "pending" : current?.submitLabel ?? "Send message"}
                            layout={reducedMotion ? false : "position"}
                            initial={reducedMotion ? { opacity: 0 } : { opacity: 0, y: 10 }}
                            animate={{ opacity: 1, y: 0 }}
                            exit={reducedMotion ? { opacity: 0 } : { opacity: 0, y: -10 }}
                            transition={{ duration: 0.22, ease: EASE }}
                            className="inline-flex items-center gap-2"
                          >
                            {pending ? <Spinner /> : null}
                            {pending ? "Sending…" : current?.submitLabel ?? "Send message"}
                            {!pending ? (
                              <Glyph className="size-3.5">
                                <path d="M5 12h14m-5-5 5 5-5 5" />
                              </Glyph>
                            ) : null}
                          </motion.span>
                        </AnimatePresence>
                      </motion.button>
                    </div>
                  </motion.form>
                )}
              </AnimatePresence>
            </AutoHeight>
          </motion.div>

          {/* Side column */}
          {hasSide ? (
            <div className="grid content-start gap-3 @xl:grid-cols-2 @3xl:grid-cols-1">
              {channels.map((c, i) => {
                const kind = c.kind ?? "other";
                const href = c.href ?? (kind === "email" ? `mailto:${c.value}` : kind === "phone" ? `tel:${c.value.replace(/[^\d+]/g, "")}` : undefined);
                return (
                  <motion.div
                    key={`${c.label}-${c.value}`}
                    {...enter(0.16 + i * 0.06)}
                    className="group flex items-center gap-3.5 rounded-2xl @xl:col-start-1 @3xl:col-start-auto border border-white/[0.07] bg-white/[0.02] p-3.5 transition-colors duration-200 hover:border-white/[0.12] hover:bg-white/[0.035]"
                  >
                    <span
                      aria-hidden
                      className="grid size-10 shrink-0 place-items-center rounded-xl border border-white/[0.08] bg-white/[0.03] text-white/70 transition-colors duration-200 group-hover:text-[var(--parley-accent)]"
                    >
                      <Glyph className="size-[18px]">{CHANNEL_GLYPHS[kind]}</Glyph>
                    </span>
                    <div className="min-w-0 flex-1">
                      <p className="text-[13px] font-medium text-white/85">{c.label}</p>
                      {href ? (
                        <a
                          href={href}
                          className={cn(
                            "block truncate rounded-sm text-sm text-white/55 tabular-nums transition-colors hover:text-[var(--parley-accent)]",
                            focusRing
                          )}
                        >
                          {c.value}
                        </a>
                      ) : (
                        <p className="truncate text-sm text-white/55 tabular-nums">{c.value}</p>
                      )}
                      {c.detail ? <p className="mt-0.5 text-xs leading-snug text-pretty text-white/30">{c.detail}</p> : null}
                    </div>
                    <CopyButton
                      text={c.copyValue ?? c.value}
                      label={c.label}
                      reducedMotion={reducedMotion}
                      onCopied={onCopied}
                    />
                  </motion.div>
                );
              })}

              {office ? (
                <motion.div
                  {...enter(0.16 + channels.length * 0.06)}
                  className="flex flex-col overflow-hidden rounded-2xl border border-white/[0.07] bg-white/[0.02] @xl:col-start-2 @xl:row-start-1 @xl:row-span-3 @3xl:col-start-auto @3xl:row-start-auto @3xl:row-span-1"
                >
                  <div className="relative h-28 flex-1 border-b border-white/[0.06] @3xl:flex-none">
                    <LocationPlate accent={accentColor} reducedMotion={reducedMotion} shown={shown} />
                  </div>
                  <div className="flex items-start gap-3.5 p-3.5">
                    <div className="min-w-0 flex-1">
                      <p className="text-[13px] font-medium text-white/85">{office.label ?? "Visit the studio"}</p>
                      <address className="mt-0.5 text-sm leading-snug text-white/55 not-italic">
                        {office.lines.map((l) => (
                          <span key={l} className="block">
                            {l}
                          </span>
                        ))}
                      </address>
                      {office.detail ? <p className="mt-1 text-xs text-white/30">{office.detail}</p> : null}
                      {office.href ? (
                        <a
                          href={office.href}
                          className={cn(
                            "mt-2 inline-flex items-center gap-1 rounded-sm text-xs font-medium text-[var(--parley-accent)] underline-offset-4 hover:underline",
                            focusRing
                          )}
                        >
                          Get directions
                          <Glyph className="size-3">
                            <path d="M7 17 17 7M9 7h8v8" />
                          </Glyph>
                        </a>
                      ) : null}
                    </div>
                    <CopyButton
                      text={officeCopy}
                      label={office.label ? `${office.label} address` : "Address"}
                      reducedMotion={reducedMotion}
                      onCopied={onCopied}
                    />
                  </div>
                </motion.div>
              ) : null}
            </div>
          ) : null}
        </div>
      </div>

      <p role="status" aria-live="polite" className="sr-only">
        {announce}
      </p>
    </section>
  );
}
```
