{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "wizard-dovetail",
  "title": "Wizard Dovetail",
  "description": "A multi-step form whose completed steps physically interlock: each step contributes an SVG dovetail chip to a rail, sliding in to mate on a spring when valid, or bouncing off the joint and returning when it isn't.",
  "dependencies": [],
  "files": [
    {
      "path": "registry/core/wizard-dovetail/component.tsx",
      "content": "\"use client\";\n\nimport type { CSSProperties } from \"react\";\nimport { useEffect, useId, useRef, useState } from \"react\";\n\n// ---------------------------------------------------------------------------\n// DovetailRun — a multi-step form whose completed steps physically interlock.\n// Each step is a real labelled <fieldset>; validating it on Next/Submit\n// either SEATS its rail chip — a small SVG shape cut with dovetail edges\n// (a flared tail on its trailing edge, a matching socket notch on its\n// leading edge) that slides in from outside the rail on ease-out-expo, the\n// final 12px settling on a spring with slight overshoot so the joint reads\n// as mated — or REJECTS it: the same chip travels toward the joint,\n// decelerates, bounces off it (a decaying two-swing recoil) and fades back\n// out while per-field errors render below the form and an aria-live summary\n// announces what still needs fixing. A step's chip only ever sits in the\n// rail while its fields are genuinely valid; editing a seated step back into\n// invalidity un-seats it — the dashed socket outline is the constant \"gap\"\n// that shows exactly where work remains. Zero deps, DOM + SVG + CSS only.\n// ---------------------------------------------------------------------------\n\nexport type DovetailField = {\n  id: string;\n  label: string;\n  type?: \"text\" | \"email\" | \"tel\" | \"number\";\n  required?: boolean;\n  placeholder?: string;\n  autoComplete?: string;\n  /** return an error message for a non-empty value, or null/undefined if fine */\n  validate?: (value: string) => string | null | undefined;\n};\n\nexport type DovetailStep = {\n  id: string;\n  title: string;\n  fields: DovetailField[];\n};\n\nexport interface DovetailRunProps {\n  /** the wizard steps, in order */\n  steps: DovetailStep[];\n  /** seed field values by field id, e.g. to demonstrate a pre-filled step */\n  defaultValues?: Record<string, string>;\n  /** label for the final step's submit button */\n  submitLabel?: string;\n  /** called with all field values, keyed by field id, once the final step submits */\n  onComplete?: (values: Record<string, string>) => void;\n  /** extra classes merged onto the rendered root element */\n  className?: string;\n}\n\n// chip geometry — one shared shape, module-level so it's identical (and\n// deterministic across renders/screenshots) for every chip. A dovetail\n// tail flares OUT past the trailing (right) edge; the socket is the same\n// trapezoid cut IN from the leading (left) edge, so an identical neighbor's\n// tail nests into it.\nconst CHIP_W = 104;\nconst CHIP_H = 34;\nconst DEPTH = 6; // how far the tail protrudes / the socket cuts in\nconst FLARE = 3; // extra half-width at the tip vs the base — the flare\nconst TOOTH_H = 14; // vertical extent of the tooth, centered\nconst TOP_Y = CHIP_H / 2 - TOOTH_H / 2;\nconst BOT_Y = CHIP_H / 2 + TOOTH_H / 2;\nconst SVG_W = CHIP_W + DEPTH;\nconst TRAVEL_PX = 40; // distance a chip flies in from before it seats/rejects\nconst SEAT_MS = 650;\nconst REJECT_MS = 700;\n\n// full closed outline — used for both the fill and the default-colour stroke\nconst FULL_D = [\n  `M0,0`,\n  `L${CHIP_W},0`,\n  `L${CHIP_W},${TOP_Y}`,\n  `L${CHIP_W + DEPTH},${TOP_Y - FLARE}`,\n  `L${CHIP_W + DEPTH},${BOT_Y + FLARE}`,\n  `L${CHIP_W},${BOT_Y}`,\n  `L${CHIP_W},${CHIP_H}`,\n  `L0,${CHIP_H}`,\n  `L0,${BOT_Y}`,\n  `L${DEPTH},${BOT_Y + FLARE}`,\n  `L${DEPTH},${TOP_Y - FLARE}`,\n  `L0,${TOP_Y}`,\n  \"Z\",\n].join(\" \");\n\n// just the leading (left/pin) edge — drawn again on top so it alone can be\n// recoloured to --ns-accent on focus-within, without touching the fill path.\nconst LEFT_EDGE_D = [\n  `M0,${CHIP_H}`,\n  `L0,${BOT_Y}`,\n  `L${DEPTH},${BOT_Y + FLARE}`,\n  `L${DEPTH},${TOP_Y - FLARE}`,\n  `L0,${TOP_Y}`,\n  `L0,0`,\n].join(\" \");\n\nfunction useReducedMotion() {\n  const [reduced, setReduced] = useState(false);\n  useEffect(() => {\n    const mq = window.matchMedia(\"(prefers-reduced-motion: reduce)\");\n    setReduced(mq.matches);\n    const onChange = () => setReduced(mq.matches);\n    mq.addEventListener(\"change\", onChange);\n    return () => mq.removeEventListener(\"change\", onChange);\n  }, []);\n  return reduced;\n}\n\ntype StepStatus = \"pending\" | \"complete\";\ntype AnimPhase = \"idle\" | \"seat\" | \"reject\";\n\nfunction RailSlot({\n  step,\n  index,\n  status,\n  isCurrent,\n  animPhase,\n}: {\n  step: DovetailStep;\n  index: number;\n  status: StepStatus;\n  isCurrent: boolean;\n  animPhase: AnimPhase;\n}) {\n  const animating = animPhase !== \"idle\";\n  const showChip = status === \"complete\" || animating;\n  const stateWord =\n    status === \"complete\" ? \"complete\" : isCurrent ? \"current\" : \"not started\";\n  const travelStyle = { \"--travel\": `${TRAVEL_PX}px` } as CSSProperties;\n\n  return (\n    <li\n      aria-current={isCurrent ? \"step\" : undefined}\n      className=\"relative shrink-0\"\n      style={{ width: CHIP_W, height: CHIP_H }}\n    >\n      <span className=\"sr-only\">\n        Step {index + 1}, {step.title}, {stateWord}\n      </span>\n      <svg\n        width={SVG_W}\n        height={CHIP_H}\n        viewBox={`0 0 ${SVG_W} ${CHIP_H}`}\n        className=\"absolute left-0 top-0 overflow-visible\"\n        aria-hidden\n        focusable=\"false\"\n      >\n        {/* the socket outline — always present; an empty dashed one IS the\n            visible gap that shows a step hasn't joined yet */}\n        <path\n          d={FULL_D}\n          fill=\"none\"\n          stroke=\"var(--border)\"\n          strokeWidth={1}\n          strokeDasharray={status === \"complete\" ? undefined : \"3 3\"}\n        />\n        {showChip ? (\n          <g\n            data-dovetail-seated={\n              status === \"complete\" && !animating ? \"true\" : undefined\n            }\n            className={\n              animPhase === \"seat\"\n                ? \"ns-dove-seat\"\n                : animPhase === \"reject\"\n                  ? \"ns-dove-reject\"\n                  : undefined\n            }\n            style={animating ? travelStyle : undefined}\n          >\n            <path d={FULL_D} fill=\"var(--surface)\" stroke=\"var(--border)\" strokeWidth={1} />\n          </g>\n        ) : null}\n        {isCurrent ? (\n          <path\n            data-current-pin\n            d={LEFT_EDGE_D}\n            fill=\"none\"\n            stroke=\"var(--border)\"\n            strokeWidth={1}\n            className=\"ns-dove-pin\"\n          />\n        ) : null}\n      </svg>\n      <span className=\"pointer-events-none absolute inset-0 flex items-center justify-center truncate px-2 text-center font-mono text-[10px] tracking-wide text-foreground\">\n        {step.title}\n      </span>\n    </li>\n  );\n}\n\nexport function DovetailRun({\n  steps,\n  defaultValues,\n  submitLabel = \"Submit\",\n  onComplete,\n  className = \"\",\n}: DovetailRunProps) {\n  const uid = useId();\n  const reduced = useReducedMotion();\n  const [currentIndex, setCurrentIndex] = useState(0);\n  const [values, setValues] = useState<Record<string, string>>(\n    () => defaultValues ?? {}\n  );\n  const [stepStatus, setStepStatus] = useState<StepStatus[]>(() =>\n    steps.map(() => \"pending\")\n  );\n  const [animPhase, setAnimPhase] = useState<Record<number, AnimPhase>>({});\n  const [errors, setErrors] = useState<Record<string, string>>({});\n  const [done, setDone] = useState(false);\n\n  const fieldRefs = useRef<Record<string, HTMLInputElement | null>>({});\n  const timeouts = useRef<number[]>([]);\n  const prevIndexRef = useRef(currentIndex);\n\n  useEffect(\n    () => () => {\n      timeouts.current.forEach((t) => window.clearTimeout(t));\n    },\n    []\n  );\n\n  // focus the new step's first field when Next actually advances — never on\n  // first mount, and never on Back (a deliberate return needs no yank).\n  useEffect(() => {\n    if (prevIndexRef.current !== currentIndex && currentIndex > prevIndexRef.current) {\n      const first = steps[currentIndex]?.fields[0];\n      if (first) fieldRefs.current[first.id]?.focus();\n    }\n    prevIndexRef.current = currentIndex;\n    // eslint-disable-next-line react-hooks/exhaustive-deps\n  }, [currentIndex]);\n\n  function playAnim(index: number, phase: \"seat\" | \"reject\") {\n    if (reduced) return;\n    setAnimPhase((p) => ({ ...p, [index]: phase }));\n    const ms = phase === \"seat\" ? SEAT_MS : REJECT_MS;\n    const t = window.setTimeout(() => {\n      setAnimPhase((p) => ({ ...p, [index]: \"idle\" }));\n    }, ms);\n    timeouts.current.push(t);\n  }\n\n  function validateStep(step: DovetailStep, vals: Record<string, string>) {\n    const next: Record<string, string> = {};\n    for (const field of step.fields) {\n      const value = (vals[field.id] ?? \"\").trim();\n      if (field.required && !value) {\n        next[field.id] = `${field.label} is required.`;\n        continue;\n      }\n      if (value && field.type === \"email\" && !/^[^\\s@]+@[^\\s@]+\\.[^\\s@]+$/.test(value)) {\n        next[field.id] = `Enter a valid ${field.label.toLowerCase()}.`;\n        continue;\n      }\n      if (value && field.validate) {\n        const msg = field.validate(value);\n        if (msg) next[field.id] = msg;\n      }\n    }\n    return next;\n  }\n\n  function handleFieldChange(id: string, value: string) {\n    setValues((v) => ({ ...v, [id]: value }));\n    setErrors((e) => {\n      if (!(id in e)) return e;\n      const { [id]: _drop, ...rest } = e;\n      return rest;\n    });\n  }\n\n  function handleSubmitStep(e: React.FormEvent<HTMLFormElement>) {\n    e.preventDefault();\n    if ((animPhase[currentIndex] ?? \"idle\") !== \"idle\") return;\n\n    const step = steps[currentIndex];\n    const stepErrors = validateStep(step, values);\n\n    if (Object.keys(stepErrors).length > 0) {\n      setErrors(stepErrors);\n      if (stepStatus[currentIndex] === \"complete\") {\n        setStepStatus((s) => s.map((v, i) => (i === currentIndex ? \"pending\" : v)));\n      }\n      playAnim(currentIndex, \"reject\");\n      const firstInvalid = step.fields.find((f) => stepErrors[f.id]);\n      if (firstInvalid) fieldRefs.current[firstInvalid.id]?.focus();\n      return;\n    }\n\n    setErrors({});\n    setStepStatus((s) => s.map((v, i) => (i === currentIndex ? \"complete\" : v)));\n    playAnim(currentIndex, \"seat\");\n\n    if (currentIndex === steps.length - 1) {\n      setDone(true);\n      onComplete?.(values);\n    } else {\n      setCurrentIndex((i) => i + 1);\n    }\n  }\n\n  function goBack() {\n    if (currentIndex === 0) return;\n    setErrors({});\n    setDone(false);\n    setCurrentIndex((i) => i - 1);\n  }\n\n  const step = steps[currentIndex];\n  const errorEntries = Object.entries(errors);\n  const errorId = `${uid}-errors`;\n\n  return (\n    <div className={className}>\n      <style>{`\n@keyframes ns-dove-seat {\n  0%   { transform: translateX(var(--travel)); opacity: 0; animation-timing-function: cubic-bezier(0.16,1,0.3,1); }\n  18%  { opacity: 1; }\n  72%  { transform: translateX(12px); animation-timing-function: cubic-bezier(0.34,1.56,0.64,1); }\n  100% { transform: translateX(0); opacity: 1; }\n}\n@keyframes ns-dove-reject {\n  0%   { transform: translateX(var(--travel)); opacity: 0; animation-timing-function: cubic-bezier(0.16,1,0.3,1); }\n  15%  { opacity: 1; }\n  55%  { transform: translateX(0px); animation-timing-function: linear; }\n  62%  { transform: translateX(4px); }\n  69%  { transform: translateX(-1.5px); }\n  76%  { transform: translateX(2px); }\n  83%  { transform: translateX(0px); animation-timing-function: cubic-bezier(0.4,0,1,1); }\n  92%  { opacity: 1; }\n  100% { transform: translateX(var(--travel)); opacity: 0; }\n}\n.ns-dove-seat { animation: ns-dove-seat ${SEAT_MS}ms both; }\n.ns-dove-reject { animation: ns-dove-reject ${REJECT_MS}ms both; }\n.ns-dove-root:focus-within [data-current-pin] { stroke: var(--ns-accent); }\n@media (prefers-reduced-motion: reduce) {\n  .ns-dove-seat, .ns-dove-reject { animation: none; }\n}\n`}</style>\n\n      <div className=\"ns-dove-root rounded-md border border-border bg-background p-4\">\n        {/* the rail — decorative summary of the joined run; a real list, but\n            no interactive control lives inside it, so Tab never enters it */}\n        <ol aria-label=\"Steps\" className=\"mb-5 flex gap-0 overflow-x-auto pb-1\">\n          {steps.map((s, i) => (\n            <RailSlot\n              key={s.id}\n              step={s}\n              index={i}\n              status={stepStatus[i]}\n              isCurrent={i === currentIndex}\n              animPhase={animPhase[i] ?? \"idle\"}\n            />\n          ))}\n        </ol>\n\n        {done ? (\n          <div role=\"status\" className=\"rounded-sm border border-border bg-surface p-4\">\n            <p className=\"font-mono text-xs tracking-wide text-ns-muted\">ALL STEPS JOINED</p>\n            <p className=\"mt-1 text-sm text-foreground\">\n              Every step seated cleanly — the run is complete.\n            </p>\n          </div>\n        ) : (\n          <form onSubmit={handleSubmitStep} noValidate>\n            <fieldset>\n              <legend className=\"text-sm font-semibold text-foreground\">{step.title}</legend>\n\n              {errorEntries.length > 0 ? (\n                <p\n                  id={errorId}\n                  role=\"alert\"\n                  aria-live=\"assertive\"\n                  data-dovetail-errors\n                  className=\"mt-3 rounded-sm border border-border bg-surface px-3 py-2 font-mono text-xs text-foreground\"\n                >\n                  {errorEntries.length === 1\n                    ? \"1 error: \"\n                    : `${errorEntries.length} errors: `}\n                  {errorEntries.map(([, msg]) => msg).join(\" \")}\n                </p>\n              ) : null}\n\n              <div className=\"mt-3 space-y-3\">\n                {step.fields.map((field) => {\n                  const inputId = `${uid}-${step.id}-${field.id}`;\n                  const fieldErrorId = `${inputId}-error`;\n                  const hasError = Boolean(errors[field.id]);\n                  return (\n                    <div key={field.id}>\n                      <label\n                        htmlFor={inputId}\n                        className=\"block font-mono text-[11px] uppercase tracking-wide text-ns-muted\"\n                      >\n                        {field.label}\n                        {field.required ? \" *\" : \"\"}\n                      </label>\n                      <input\n                        id={inputId}\n                        ref={(el) => {\n                          fieldRefs.current[field.id] = el;\n                        }}\n                        type={field.type ?? \"text\"}\n                        value={values[field.id] ?? \"\"}\n                        placeholder={field.placeholder}\n                        autoComplete={field.autoComplete}\n                        aria-invalid={hasError || undefined}\n                        aria-describedby={hasError ? fieldErrorId : undefined}\n                        onChange={(e) => handleFieldChange(field.id, e.target.value)}\n                        className={\n                          \"mt-1 w-full rounded-sm border bg-surface px-2.5 py-1.5 text-sm text-foreground outline-none transition-colors focus-visible:border-ns-accent \" +\n                          (hasError ? \"border-foreground\" : \"border-border\")\n                        }\n                      />\n                      {hasError ? (\n                        <p id={fieldErrorId} className=\"mt-1 font-mono text-xs text-ns-muted\">\n                          {errors[field.id]}\n                        </p>\n                      ) : null}\n                    </div>\n                  );\n                })}\n              </div>\n\n              <div className=\"mt-4 flex items-center justify-end gap-2\">\n                {currentIndex > 0 ? (\n                  <button\n                    type=\"button\"\n                    onClick={goBack}\n                    className=\"inline-flex items-center rounded-sm border border-border px-3 py-1.5 text-sm text-foreground transition-colors hover:bg-surface focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-ns-accent\"\n                  >\n                    Back\n                  </button>\n                ) : null}\n                <button\n                  type=\"submit\"\n                  data-dovetail-next\n                  className=\"inline-flex items-center rounded-sm bg-ns-accent px-3 py-1.5 text-sm font-medium text-white transition-colors hover:bg-ns-accent-hover focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-ns-accent\"\n                >\n                  {currentIndex === steps.length - 1 ? submitLabel : \"Next\"}\n                </button>\n              </div>\n            </fieldset>\n          </form>\n        )}\n      </div>\n    </div>\n  );\n}\n",
      "type": "registry:ui",
      "target": "components/ui/wizard-dovetail.tsx"
    }
  ],
  "cssVars": {
    "theme": {
      "color-ns-muted": "var(--ns-muted)",
      "color-ns-accent": "var(--ns-accent)",
      "color-ns-accent-hover": "var(--ns-accent-hover)",
      "color-surface": "var(--surface)"
    },
    "light": {
      "ns-muted": "#4d4d4d",
      "ns-accent": "#006bff",
      "ns-accent-hover": "#0059d1",
      "surface": "#fafafa"
    },
    "dark": {
      "ns-muted": "#8f8f8f",
      "surface": "#171717"
    }
  },
  "meta": {
    "collection": "core",
    "tags": [
      "form",
      "wizard",
      "multi-step",
      "checkout",
      "onboarding",
      "validation",
      "svg",
      "accessibility"
    ],
    "instruction": "Build a multi-step form (`steps`: {id, title, fields}[], each field {id, label, type, required, placeholder, autoComplete, validate}) where only one step's fields are interactive at a time — a real `<fieldset>`/`<legend>` region per step — while a rail above it accumulates a physical summary of every completed step. Each rail slot is a fixed-size SVG shape (104x34) cut with dovetail edges: a flared trapezoid tail protrudes past its trailing (right) edge, and an identical trapezoid notch is cut into its leading (left) edge as a socket, both drawn from one shared path-generator so an adjacent slot's tail would nest exactly into this one's socket — the socket is DEEPER-wide than its mouth (the classic dovetail profile), which is what makes the joint read as something that resists being pulled straight apart rather than a plain rounded chip. Every slot always renders its socket outline (1px --border stroke, dashed while not yet joined) as a constant, visible 'not yet joined' gap; a step's fields validating on Next/Submit fills that slot with a solid --surface chip animated in via a CSS keyframe (translateX from 40px to 0 plus a fade-in) — ease-out-expo for the bulk of the travel, then the final 12px settles on a spring-shaped cubic-bezier(0.34,1.56,0.64,1) with a small overshoot past the seat before resting, so the joint visibly mates; on failed validation the same chip flies in, decelerates to the joint, then recoils off it — a decaying two-swing 4px bounce — and fades back out to its start offset, leaving the socket empty/dashed exactly where the outstanding field is. A step's status can regress: editing an already-seated step's fields back into invalidity and resubmitting un-seats its chip (back to the dashed socket) before the same reject animation plays — the rail never shows a joint that isn't currently true. The step currently being worked has its rail slot's pin (left/socket) edge redrawn on top in --ns-accent, but ONLY while focus is genuinely inside the form (a `:focus-within` rule scoped to a shared wrapper around both the rail and the fieldset) — moving focus elsewhere reverts it to --border, so the accent never reads as a static label. On invalid submit, a `role=\"alert\" aria-live=\"assertive\"` summary above the fields lists every failing field's message, each field also gets its own error paragraph directly below it via `aria-describedby` plus `aria-invalid`, and focus moves straight to the first invalid field — the live summary and the moved focus fire together, not gated behind the ~700ms bounce animation finishing. Next and Back are real buttons (`type=\"submit\"` — the form has `noValidate` and does its own validation on submit so Enter in any field submits the step honestly; Back is `type=\"button\"` and only shown past the first step, never validates, never un-seats a step just from viewing it). The rail is one `<ol aria-label=\"Steps\">`; each `<li>` carries `aria-current=\"step\"` only for the active index and a visually-hidden 'Step N, {title}, {complete|current|not started}' string — the SVG itself is `aria-hidden`, so the rail's only tab stops are none: it contributes zero interactive elements and tab order flows straight from one field to the next to Back/Next, never detouring into the decorative rail. The last step's button label is the `submitLabel` prop (default 'Submit'); completing it replaces the fieldset with a plain resting confirmation panel and calls `onComplete(values)`, while the rail keeps showing every seated chip. Under prefers-reduced-motion the seat/reject keyframes are stripped entirely (checked via matchMedia with a change listener, plus a CSS media-query belt-and-suspenders) — a step's chip appears already seated or the socket stays empty, immediately, with no travel, bounce, or fade, fully legible and usable either way. Every color is `var(--background|--surface|--border|--foreground|--ns-muted|--ns-accent)`, zero hex, both themes render; zero dependencies, DOM + SVG + CSS only, no canvas. Differs from progress-narrated (a determinate progress bar whose fill narrates continuous phase completion with a typed caption and a milestone ledger docking below the track) by being validity-gated and discrete rather than continuous: wizard-dovetail's steps don't advance a percentage, they either mechanically join the rail or get physically rejected back to the form with the gap left exactly where the invalid field is. Differs from drill-down-spines (drill-down navigation where a pushed level compresses into a permanently clickable spine you can pop back into) by having no navigation history to click back into — the rail is a read-only, non-interactive summary of validity, not a set of live route targets."
  },
  "type": "registry:ui"
}