# Docs Site

> A documentation site with a top nav, collapsible sidebar, code samples whose language choice follows you across the page, a table of contents that tracks your scroll, ⌘K search over pages and headings, and prev and next cards.

A shadcn/ui block from the [@devl](https://devl.dev/?utm_source=shadcn.exchange) registry, listed on [shadcn.exchange](https://shadcn.exchange/r/devl/docs-site).

- Page, with a live preview: https://shadcn.exchange/r/devl/docs-site
- Screenshot: https://shadcn.exchange/shot/devl/docs-site/light.png
- Registry item: https://devl.dev/r/docs-site.json
- Categories: screen, Dev
- Listed: 2026-10-28, source last changed 2026-10-28

## Install

```bash
npx shadcn@latest add https://shadcn.exchange/r/devl/docs-site.json
```

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

## Dependencies

- npm packages: `lucide-react`, `motion`
- Registry items: `utils`, `https://coss.com/ui/r/button.json`, `https://coss.com/ui/r/kbd.json`

## Files

### `registry/devl/docs-site.tsx`

```tsx
"use client";

import { AnimatePresence, LayoutGroup, MotionConfig, motion } from "motion/react";
import {
  ArrowLeftIcon,
  ArrowRightIcon,
  CheckIcon,
  ChevronDownIcon,
  ChevronRightIcon,
  CopyIcon,
  CornerDownLeftIcon,
  FileTextIcon,
  HashIcon,
  InfoIcon,
  MenuIcon,
  PencilIcon,
  SearchIcon,
  ThumbsDownIcon,
  ThumbsUpIcon,
  TriangleAlertIcon,
  XIcon,
} from "lucide-react";
import {
  Fragment,
  type KeyboardEvent,
  type ReactNode,
  useEffect,
  useLayoutEffect,
  useMemo,
  useRef,
  useState,
} from "react";
import { Button } from "@/components/ui/button";
import { Kbd } from "@/components/ui/kbd";
import { cn } from "@/lib/utils";

export type DocsLang = "node" | "python" | "go" | "curl";

export type DocsBlock =
  | { type: "p"; text: string }
  | { type: "list"; items: string[] }
  | { type: "steps"; items: string[] }
  | { type: "callout"; tone: "info" | "warning"; title: string; text: string }
  | { type: "code"; samples: Partial<Record<DocsLang, string>>; title?: string }
  | { type: "table"; head: string[]; rows: string[][] };

export interface DocsSection {
  id: string;
  title: string;
  blocks: DocsBlock[];
}

export interface DocsPage {
  id: string;
  title: string;
  group: string;
  lead: string;
  updated: string;
  minutes: number;
  sections: DocsSection[];
}

export interface DocsSiteProps {
  product?: string;
  version?: string;
  pages?: DocsPage[];
  initialPage?: string;
  dashboardHref?: string;
  editHref?: (pageId: string) => string;
  className?: string;
}

const defaultPages: DocsPage[] = [
  {
    id: "introduction",
    title: "Introduction",
    group: "Getting started",
    lead: "Kestrel delivers events from your product to your customers' endpoints, with retries, signatures and a delivery log they can trust.",
    updated: "Oct 2",
    minutes: 2,
    sections: [
      {
        id: "what-you-can-build",
        title: "What you can build",
        blocks: [
          { type: "p", text: "Anything your customers would otherwise poll for. Teams use Kestrel to send order updates to warehouses, sync CRM records, trigger builds and page on-call when a job fails." },
          { type: "list", items: ["Outgoing webhooks with per-customer endpoints", "A hosted portal where customers manage their own endpoints", "Delivery logs, replays and alerts without building them yourself"] },
        ],
      },
      {
        id: "how-it-fits",
        title: "How it fits together",
        blocks: [
          { type: "p", text: "Your backend publishes an event once. Kestrel fans it out to every subscribed endpoint, signs each request, retries failures and records every attempt." },
        ],
      },
    ],
  },
  {
    id: "quickstart",
    title: "Quickstart",
    group: "Getting started",
    lead: "Send your first webhook in about five minutes.",
    updated: "Oct 18",
    minutes: 5,
    sections: [
      {
        id: "install",
        title: "Install the SDK",
        blocks: [
          {
            type: "code",
            samples: { node: "npm install @kestrel/node", python: "pip install kestrel", go: "go get kestrel.dev/go/v2", curl: "# No install needed. Every SDK call maps to one HTTP request." },
          },
        ],
      },
      {
        id: "send-event",
        title: "Send an event",
        blocks: [
          { type: "p", text: "Publish an event with a type and a JSON payload. Every endpoint subscribed to that type receives it." },
          {
            type: "code",
            samples: {
              node: `const kestrel = new Kestrel(process.env.KESTREL_KEY);\n\nawait kestrel.events.publish({\n  type: "order.shipped",\n  data: { orderId: "ord_9QX2", carrier: "Parcelwise" },\n});`,
              python: `kestrel = Kestrel(os.environ["KESTREL_KEY"])\n\nkestrel.events.publish(\n    type="order.shipped",\n    data={"order_id": "ord_9QX2", "carrier": "Parcelwise"},\n)`,
              go: `client := kestrel.New(os.Getenv("KESTREL_KEY"))\n\nclient.Events.Publish(ctx, kestrel.Event{\n\tType: "order.shipped",\n\tData: map[string]any{"orderId": "ord_9QX2"},\n})`,
              curl: `curl https://api.kestrel.dev/v2/events \\\n  -H "Authorization: Bearer $KESTREL_KEY" \\\n  -d type=order.shipped \\\n  -d data[orderId]=ord_9QX2`,
            },
          },
          { type: "callout", tone: "info", title: "Use a test key first", text: "Keys starting with kst_test_ deliver to test endpoints only, so you can try this without touching customer traffic." },
        ],
      },
    ],
  },
  {
    id: "authentication",
    title: "Authentication",
    group: "Getting started",
    lead: "Every API request is authenticated with a secret key from your dashboard.",
    updated: "Sep 12",
    minutes: 2,
    sections: [
      {
        id: "api-keys",
        title: "API keys",
        blocks: [
          {
            type: "table",
            head: ["Prefix", "Environment", "Can deliver to"],
            rows: [
              ["kst_live_", "Production", "Live endpoints"],
              ["kst_test_", "Test", "Test endpoints and the local CLI"],
            ],
          },
          { type: "callout", tone: "warning", title: "Keep secret keys on the server", text: "Anyone with a secret key can publish events as you. Never ship one in a browser bundle or a mobile app." },
        ],
      },
    ],
  },
  {
    id: "signatures",
    title: "Verifying signatures",
    group: "Webhooks",
    lead: "Kestrel signs every request so your customers can check it came from you and wasn't changed on the way.",
    updated: "Oct 9",
    minutes: 3,
    sections: [
      {
        id: "how-signing-works",
        title: "How signing works",
        blocks: [
          { type: "p", text: "Each request carries a Kestrel-Signature header: a timestamp and an HMAC-SHA256 of the timestamp and raw body, keyed with the endpoint's signing secret." },
        ],
      },
      {
        id: "verify",
        title: "Verify in your handler",
        blocks: [
          {
            type: "code",
            samples: {
              node: `const event = kestrel.webhooks.verify(\n  rawBody,\n  req.headers["kestrel-signature"],\n  process.env.ENDPOINT_SECRET,\n);`,
              python: `event = kestrel.webhooks.verify(\n    raw_body,\n    request.headers["Kestrel-Signature"],\n    os.environ["ENDPOINT_SECRET"],\n)`,
              go: `event, err := kestrel.Verify(body, r.Header.Get("Kestrel-Signature"), secret)`,
            },
          },
        ],
      },
    ],
  },
  {
    id: "retries",
    title: "Retries and dead letters",
    group: "Webhooks",
    lead: "When an endpoint fails, Kestrel retries with backoff, then parks the event in a dead-letter queue, so nothing is lost and one bad endpoint never holds up the rest.",
    updated: "Oct 22",
    minutes: 4,
    sections: [
      {
        id: "how-retries-work",
        title: "How retries work",
        blocks: [
          {
            type: "callout",
            tone: "info",
            title: "Changed in v2.4",
            text: "Events now move to the dead-letter queue after three failed attempts. Before v2.4 they were retried for up to 24 hours.",
          },
          { type: "p", text: "A delivery fails when the endpoint answers with anything other than a 2xx, takes longer than 15 seconds, or can't be reached. Each retry waits longer than the last, with jitter so a recovering endpoint isn't hit by every event at once." },
          {
            type: "table",
            head: ["Attempt", "Sent after", "If it fails"],
            rows: [
              ["1", "Immediately", "Retry in about 1 minute"],
              ["2", "~1 minute", "Retry in about 10 minutes"],
              ["3", "~10 minutes", "Move to the dead-letter queue"],
            ],
          },
          { type: "p", text: "An endpoint that answers 410 Gone is disabled straight away and its pending events are dead-lettered." },
        ],
      },
      {
        id: "dead-letter-queue",
        title: "The dead-letter queue",
        blocks: [
          { type: "p", text: "Dead-lettered events keep their payload, every attempt and the last error for 7 days. List them per endpoint to see what went wrong." },
          {
            type: "code",
            title: "List dead letters for an endpoint",
            samples: {
              node: `const parked = await kestrel.deadLetters.list({\n  endpointId: "ep_8fK21",\n  limit: 20,\n});\n\nfor (const letter of parked.data) {\n  console.log(letter.eventId, letter.lastError);\n}`,
              python: `parked = kestrel.dead_letters.list(endpoint_id="ep_8fK21", limit=20)\n\nfor letter in parked.data:\n    print(letter.event_id, letter.last_error)`,
              go: `parked, err := client.DeadLetters.List(ctx, &kestrel.DeadLetterParams{\n\tEndpointID: "ep_8fK21",\n\tLimit:      20,\n})`,
              curl: `curl "https://api.kestrel.dev/v2/dead_letters?endpoint_id=ep_8fK21&limit=20" \\\n  -H "Authorization: Bearer $KESTREL_KEY"`,
            },
          },
        ],
      },
      {
        id: "replaying",
        title: "Replaying events",
        blocks: [
          {
            type: "steps",
            items: [
              "Fix the endpoint, or wait until your customer confirms it's back.",
              "Open Deliveries, filter by Dead-lettered, and select the events to send again.",
              "Choose Replay. Each event is delivered once more and counted as a new attempt.",
            ],
          },
          {
            type: "code",
            title: "Replay from code",
            samples: {
              node: `await kestrel.deadLetters.replay("dl_3Hc9Ve");`,
              python: `kestrel.dead_letters.replay("dl_3Hc9Ve")`,
              go: `err := client.DeadLetters.Replay(ctx, "dl_3Hc9Ve")`,
              curl: `curl -X POST https://api.kestrel.dev/v2/dead_letters/dl_3Hc9Ve/replay \\\n  -H "Authorization: Bearer $KESTREL_KEY"`,
            },
          },
          {
            type: "callout",
            tone: "warning",
            title: "Replays can arrive out of order",
            text: "A replayed event may land after newer events for the same object. Compare the event's created timestamp before overwriting state.",
          },
        ],
      },
      {
        id: "limits",
        title: "Limits",
        blocks: [
          {
            type: "table",
            head: ["Limit", "Value"],
            rows: [
              ["Attempts per event", "3"],
              ["Request timeout", "15 seconds"],
              ["Dead-letter retention", "7 days"],
              ["Replay rate", "50 events/s per endpoint"],
            ],
          },
        ],
      },
    ],
  },
  {
    id: "replay-guide",
    title: "Bulk replays",
    group: "Webhooks",
    lead: "Send a whole window of events again after an outage, without flooding the endpoint.",
    updated: "Oct 22",
    minutes: 3,
    sections: [
      {
        id: "choose-window",
        title: "Choose a window",
        blocks: [
          { type: "p", text: "Pick a start and end time and an optional event type. Kestrel shows how many events match before anything is sent." },
          {
            type: "code",
            samples: {
              node: `await kestrel.replays.create({\n  endpointId: "ep_8fK21",\n  from: "2026-10-21T09:00:00Z",\n  to: "2026-10-21T09:40:00Z",\n});`,
              curl: `curl https://api.kestrel.dev/v2/replays \\\n  -H "Authorization: Bearer $KESTREL_KEY" \\\n  -d endpoint_id=ep_8fK21 \\\n  -d from=2026-10-21T09:00:00Z \\\n  -d to=2026-10-21T09:40:00Z`,
            },
          },
        ],
      },
      {
        id: "pacing",
        title: "Pacing",
        blocks: [{ type: "p", text: "Bulk replays are paced at the endpoint's replay rate and pause automatically if the endpoint starts failing again." }],
      },
    ],
  },
  {
    id: "events-api",
    title: "Events",
    group: "API reference",
    lead: "The Event object, and how to list and retrieve events.",
    updated: "Oct 14",
    minutes: 3,
    sections: [
      {
        id: "event-object",
        title: "The Event object",
        blocks: [
          {
            type: "table",
            head: ["Field", "Type", "Description"],
            rows: [
              ["id", "string", "Unique ID, prefixed evt_"],
              ["type", "string", "Dot-separated name, e.g. order.shipped"],
              ["data", "object", "Your payload, up to 256 KB"],
              ["created", "timestamp", "When you published it"],
            ],
          },
        ],
      },
      {
        id: "list-events",
        title: "List events",
        blocks: [
          {
            type: "code",
            samples: {
              node: `const events = await kestrel.events.list({ type: "order.shipped" });`,
              python: `events = kestrel.events.list(type="order.shipped")`,
              curl: `curl "https://api.kestrel.dev/v2/events?type=order.shipped" \\\n  -H "Authorization: Bearer $KESTREL_KEY"`,
            },
          },
        ],
      },
    ],
  },
  {
    id: "deliveries-api",
    title: "Deliveries",
    group: "API reference",
    lead: "Every attempt to send an event to an endpoint, with status, timing and response.",
    updated: "Oct 14",
    minutes: 2,
    sections: [
      {
        id: "delivery-object",
        title: "The Delivery object",
        blocks: [
          {
            type: "table",
            head: ["Field", "Type", "Description"],
            rows: [
              ["id", "string", "Unique ID, prefixed dlv_"],
              ["status", "enum", "succeeded, failed or dead_lettered"],
              ["attempt", "integer", "1 to 3"],
              ["duration_ms", "integer", "Time until the endpoint answered"],
            ],
          },
        ],
      },
    ],
  },
];

const LANGS: { id: DocsLang; label: string }[] = [
  { id: "node", label: "Node.js" },
  { id: "python", label: "Python" },
  { id: "go", label: "Go" },
  { id: "curl", label: "cURL" },
];

const navLinks = ["Guides", "API reference"] as const;

const ease = [0.23, 1, 0.32, 1] as const;
const spring = { type: "spring", stiffness: 520, damping: 42 } as const;

const KEYWORDS =
  /^(const|let|var|await|async|for|of|in|new|return|import|from|if|else|func|err|nil|range|def|print|True|False|None|curl)$/;

function highlight(code: string): ReactNode[] {
  const out: ReactNode[] = [];
  const re = /((?:\/\/|#).*$)|("(?:[^"\\]|\\.)*"|'(?:[^'\\]|\\.)*'|`(?:[^`\\]|\\.)*`)|(\s-[A-Za-z]\b|\s--?[a-z-]+)|(\b\d[\d_.]*\b)|([A-Za-z_$][\w$]*)/gm;
  let last = 0;
  let k = 0;
  for (let m = re.exec(code); m; m = re.exec(code)) {
    if (m.index > last) out.push(code.slice(last, m.index));
    const tok = m[0];
    if (m[1] && (m[1].startsWith("//") || /(^|\s)#/.test(code.slice(Math.max(0, m.index - 1), m.index + 1))))
      out.push(<span className="text-muted-foreground italic" key={k++}>{tok}</span>);
    else if (m[2]) out.push(<span className="text-emerald-700 dark:text-emerald-300" key={k++}>{tok}</span>);
    else if (m[3]) out.push(<span className="text-sky-700 dark:text-sky-300" key={k++}>{tok}</span>);
    else if (m[4]) out.push(<span className="text-amber-700 dark:text-amber-300" key={k++}>{tok}</span>);
    else if (m[5] && KEYWORDS.test(tok)) out.push(<span className="text-violet-700 dark:text-violet-300" key={k++}>{tok}</span>);
    else if (m[5] && /^[A-Z]/.test(tok)) out.push(<span className="text-sky-700 dark:text-sky-300" key={k++}>{tok}</span>);
    else if (m[5] && code[re.lastIndex] === "(") out.push(<span className="text-blue-700 dark:text-blue-300" key={k++}>{tok}</span>);
    else out.push(tok);
    last = re.lastIndex;
  }
  if (last < code.length) out.push(code.slice(last));
  return out;
}

function CodeTabs({ samples, title, lang, onLang }: { samples: Partial<Record<DocsLang, string>>; title?: string; lang: DocsLang; onLang: (l: DocsLang) => void }) {
  const available = LANGS.filter((l) => samples[l.id]);
  const current = samples[lang] ? lang : available[0].id;
  const [copied, setCopied] = useState(false);
  useEffect(() => {
    if (!copied) return;
    const id = window.setTimeout(() => setCopied(false), 1600);
    return () => window.clearTimeout(id);
  }, [copied]);
  const single = available.length === 1 && !title;
  return (
    <div className="overflow-hidden rounded-xl border bg-card shadow-xs/5">
      <div className="flex h-10 items-center gap-1 border-b bg-muted/40 ps-1.5 pe-1.5">
        {title && <span className="hidden min-w-0 flex-1 truncate ps-1.5 pe-2 font-medium text-muted-foreground text-xs @2xl:block">{title}</span>}
        <div className="flex min-w-0 gap-0.5 overflow-x-auto [scrollbar-width:none]" role="tablist">
          {single ? (
            <span className="px-1.5 font-mono text-muted-foreground text-xs">terminal</span>
          ) : (
            available.map((l) => (
              <button
                aria-selected={current === l.id}
                className={cn(
                  "relative h-7 shrink-0 cursor-pointer rounded-md px-2 font-medium text-xs outline-none focus-visible:ring-2 focus-visible:ring-ring",
                  current === l.id ? "text-foreground" : "text-muted-foreground hover:text-foreground",
                )}
                key={l.id}
                onClick={() => onLang(l.id)}
                role="tab"
                type="button"
              >
                {current === l.id && <span className="absolute inset-0 rounded-md bg-background shadow-xs/5 ring-1 ring-border dark:bg-accent dark:ring-0" />}
                <span className="relative">{l.label}</span>
              </button>
            ))
          )}
        </div>
        <button
          aria-label={copied ? "Copied" : "Copy code"}
          className={cn("ms-auto flex size-8 shrink-0 cursor-pointer items-center justify-center rounded-md text-muted-foreground outline-none transition-[color,background-color,scale] duration-150 ease-out hover:bg-accent hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring active:scale-90", title && "@2xl:ms-1")}
          onClick={() => {
            navigator.clipboard?.writeText(samples[current] ?? "").catch(() => {});
            setCopied(true);
          }}
          type="button"
        >
          <AnimatePresence initial={false} mode="popLayout">
            <motion.span animate={{ opacity: 1, scale: 1 }} exit={{ opacity: 0, scale: 0.8 }} initial={{ opacity: 0, scale: 0.8 }} key={copied ? "y" : "n"} transition={{ duration: 0.12 }}>
              {copied ? <CheckIcon className="size-3.5 text-success" /> : <CopyIcon className="size-3.5" />}
            </motion.span>
          </AnimatePresence>
        </button>
      </div>
      <pre className="overflow-x-auto px-4 py-3 font-mono text-[12.5px] leading-6 [scrollbar-width:thin]">
        <code>{highlight(samples[current] ?? "")}</code>
      </pre>
    </div>
  );
}

function Inline({ text }: { text: string }) {
  const parts = text.split(/(kst_live_|kst_test_|Kestrel-Signature|evt_|dlv_|order\.shipped|410 Gone|2xx)/g);
  return (
    <>
      {parts.map((p, i) =>
        i % 2 === 1 ? (
          <code className="rounded-md border bg-muted px-1 py-px font-mono text-[0.8125rem]" key={i}>
            {p}
          </code>
        ) : (
          <Fragment key={i}>{p}</Fragment>
        ),
      )}
    </>
  );
}

function Block({ block, lang, onLang }: { block: DocsBlock; lang: DocsLang; onLang: (l: DocsLang) => void }) {
  switch (block.type) {
    case "p":
      return (
        <p className="text-pretty text-[15px] text-foreground/85 leading-7">
          <Inline text={block.text} />
        </p>
      );
    case "list":
      return (
        <ul className="flex flex-col gap-1 ps-1">
          {block.items.map((it) => (
            <li className="flex gap-3 text-[15px] leading-7" key={it}>
              <span className="mt-[13px] size-1 shrink-0 rounded-full bg-muted-foreground/70" />
              <span className="text-foreground/85">{it}</span>
            </li>
          ))}
        </ul>
      );
    case "steps":
      return (
        <ol className="relative flex flex-col gap-3 before:absolute before:top-3 before:bottom-3 before:left-3 before:w-px before:bg-border">
          {block.items.map((it, i) => (
            <li className="relative flex gap-3" key={it}>
              <span className="z-10 flex size-6 shrink-0 items-center justify-center rounded-full border bg-background font-medium text-xs tabular-nums">{i + 1}</span>
              <span className="text-pretty text-[15px] text-foreground/85 leading-6">{it}</span>
            </li>
          ))}
        </ol>
      );
    case "callout": {
      const Icon = block.tone === "info" ? InfoIcon : TriangleAlertIcon;
      return (
        <div
          className={cn(
            "flex gap-3 rounded-xl border px-4 py-3.5",
            block.tone === "info" ? "border-info/16 bg-info/4 dark:border-info/14 dark:bg-info/6" : "border-warning/20 bg-warning/5 dark:border-warning/14 dark:bg-warning/6",
          )}
          role="note"
        >
          <Icon className={cn("mt-[3px] size-4 shrink-0", block.tone === "info" ? "text-info-foreground" : "text-warning-foreground")} />
          <div className="min-w-0">
            <div className="font-medium">{block.title}</div>
            <p className="mt-1 text-pretty text-muted-foreground leading-6">
              <Inline text={block.text} />
            </p>
          </div>
        </div>
      );
    }
    case "code":
      return <CodeTabs lang={lang} onLang={onLang} samples={block.samples} title={block.title} />;
    case "table":
      return (
        <div className="overflow-x-auto rounded-xl border [scrollbar-width:thin]">
          <table className="w-full min-w-[420px] text-left text-sm">
            <thead className="bg-muted/30">
              <tr>
                {block.head.map((h) => (
                  <th className="border-b px-3.5 py-2 font-medium text-muted-foreground text-xs" key={h}>
                    {h}
                  </th>
                ))}
              </tr>
            </thead>
            <tbody>
              {block.rows.map((r) => (
                <tr className="border-b last:border-b-0" key={r.join()}>
                  {r.map((c, i) => (
                    <td className={cn("px-3.5 py-2 align-baseline leading-6", i === 0 ? "font-mono text-[0.8125rem] text-foreground" : "text-foreground/85")} key={c}>
                      {c}
                    </td>
                  ))}
                </tr>
              ))}
            </tbody>
          </table>
        </div>
      );
  }
}

interface SearchHit {
  id: string;
  page: DocsPage;
  section?: DocsSection;
}

function SearchDialog({ pages, onPick, onClose }: { pages: DocsPage[]; onPick: (page: string, section?: string) => void; onClose: () => void }) {
  const [query, setQuery] = useState("");
  const [active, setActive] = useState(0);
  const inputRef = useRef<HTMLInputElement>(null);
  useEffect(() => inputRef.current?.focus(), []);
  const hits = useMemo<SearchHit[]>(() => {
    const q = query.trim().toLowerCase();
    const all: SearchHit[] = pages.flatMap((p) => [
      { id: p.id, page: p },
      ...p.sections.map((s) => ({ id: `${p.id}#${s.id}`, page: p, section: s })),
    ]);
    if (!q) return all.filter((h) => !h.section).slice(0, 8);
    const score = (h: SearchHit) => {
      const title = (h.section ? h.section.title : h.page.title).toLowerCase();
      if (title.startsWith(q)) return 0;
      if (title.includes(q)) return 1;
      if (h.page.title.toLowerCase().includes(q)) return 2;
      const body = h.section ? h.section.blocks.map((b) => ("text" in b ? b.text : "")).join(" ") : h.page.lead;
      return body.toLowerCase().includes(q) ? 3 : 9;
    };
    return all
      .map((h) => ({ h, s: score(h) }))
      .filter((x) => x.s < 9)
      .sort((a, b) => a.s - b.s)
      .slice(0, 10)
      .map((x) => x.h);
  }, [pages, query]);

  const mark = (text: string) => {
    const q = query.trim();
    if (!q) return text;
    const i = text.toLowerCase().indexOf(q.toLowerCase());
    if (i < 0) return text;
    return (
      <>
        {text.slice(0, i)}
        <mark className="rounded-sm bg-warning/24 text-foreground">{text.slice(i, i + q.length)}</mark>
        {text.slice(i + q.length)}
      </>
    );
  };

  return (
    <motion.div
      animate={{ opacity: 1 }}
      className="absolute inset-0 z-50 flex items-start justify-center bg-black/24 px-3 pt-16"
      exit={{ opacity: 0 }}
      initial={{ opacity: 0 }}
      onMouseDown={(e) => e.target === e.currentTarget && onClose()}
      transition={{ duration: 0.12 }}
    >
      <motion.div
        animate={{ opacity: 1, scale: 1 }}
        aria-label="Search documentation"
        aria-modal="true"
        className="flex max-h-[440px] w-full max-w-xl origin-top flex-col overflow-hidden rounded-2xl border bg-popover shadow-lg/10"
        exit={{ opacity: 0, scale: 0.98 }}
        initial={{ opacity: 0, scale: 0.98 }}
        role="dialog"
        transition={{ duration: 0.14, ease }}
      >
        <div className="flex items-center gap-2 border-b px-3">
          <SearchIcon className="size-4 shrink-0 text-muted-foreground" />
          <input
            aria-label="Search the docs"
            className="h-12 min-w-0 flex-1 bg-transparent text-sm outline-none placeholder:text-muted-foreground"
            onChange={(e) => {
              setQuery(e.target.value);
              setActive(0);
            }}
            onKeyDown={(e) => {
              if (e.key === "ArrowDown") {
                e.preventDefault();
                setActive((a) => Math.min(hits.length - 1, a + 1));
              } else if (e.key === "ArrowUp") {
                e.preventDefault();
                setActive((a) => Math.max(0, a - 1));
              } else if (e.key === "Enter" && hits[active]) {
                onPick(hits[active].page.id, hits[active].section?.id);
              } else if (e.key === "Escape") {
                e.stopPropagation();
                onClose();
              }
            }}
            placeholder="Search guides and API reference…"
            ref={inputRef}
            value={query}
          />
          <Kbd>Esc</Kbd>
        </div>
        <div className="min-h-0 flex-1 overflow-y-auto p-1.5" role="listbox">
          {hits.length === 0 ? (
            <div className="px-4 py-10 text-center text-muted-foreground text-sm">
              Nothing for “{query}”. Try “retry”, “signature” or “replay”.
            </div>
          ) : (
            <>
              {!query && <div className="px-2.5 pt-1.5 pb-1 font-medium text-muted-foreground text-xs">Pages</div>}
              {hits.map((h, i) => (
                <div
                  aria-selected={i === active}
                  className={cn("flex cursor-pointer items-center gap-3 rounded-lg px-2.5 py-2", i === active && "bg-accent")}
                  key={h.id}
                  onClick={() => onPick(h.page.id, h.section?.id)}
                  onMouseMove={() => setActive(i)}
                  role="option"
                >
                  <span className="flex size-7 shrink-0 items-center justify-center rounded-lg border bg-card text-muted-foreground">
                    {h.section ? <HashIcon className="size-3.5" /> : <FileTextIcon className="size-3.5" />}
                  </span>
                  <span className="min-w-0 flex-1">
                    <span className="block truncate font-medium">{mark(h.section ? h.section.title : h.page.title)}</span>
                    <span className="block truncate text-muted-foreground text-xs">
                      {h.page.group}
                      {h.section ? ` › ${h.page.title}` : ""}
                    </span>
                  </span>
                  {i === active && <CornerDownLeftIcon className="size-3.5 shrink-0 text-muted-foreground" />}
                </div>
              ))}
            </>
          )}
        </div>
      </motion.div>
    </motion.div>
  );
}

export function DocsSite({
  product = "Kestrel",
  version = "v2.4",
  pages = defaultPages,
  initialPage = "retries",
  dashboardHref,
  editHref,
  className,
}: DocsSiteProps) {
  const [pageId, setPageId] = useState(initialPage);
  const [lang, setLang] = useState<DocsLang>("node");
  const [activeSection, setActiveSection] = useState<string | null>(null);
  const [search, setSearch] = useState(false);
  const [drawer, setDrawer] = useState(false);
  const [tocOpen, setTocOpen] = useState(false);
  const [feedback, setFeedback] = useState<Record<string, "yes" | "no">>({});
  const [closedGroups, setClosedGroups] = useState<Set<string>>(() => new Set());
  const scrollRef = useRef<HTMLDivElement>(null);
  const pendingSection = useRef<string | null>(null);

  const page = pages.find((p) => p.id === pageId) ?? pages[0];
  const index = pages.indexOf(page);
  const prev = pages[index - 1];
  const next = pages[index + 1];
  const groups = Array.from(new Set(pages.map((p) => p.group)));

  const scrollToSection = (id: string, smooth = true) => {
    const box = scrollRef.current;
    const el = box?.querySelector<HTMLElement>(`[data-section="${id}"]`);
    if (box && el) box.scrollTo({ top: el.offsetTop - 16, behavior: smooth ? "smooth" : "auto" });
  };

  const go = (id: string, section?: string) => {
    setSearch(false);
    setDrawer(false);
    setTocOpen(false);
    if (id === pageId) {
      if (section) scrollToSection(section);
      else scrollRef.current?.scrollTo({ top: 0, behavior: "smooth" });
      return;
    }
    pendingSection.current = section ?? null;
    setPageId(id);
  };

  // biome-ignore lint/correctness/useExhaustiveDependencies: runs once per page change
  useLayoutEffect(() => {
    const box = scrollRef.current;
    if (!box) return;
    if (pendingSection.current) {
      scrollToSection(pendingSection.current, false);
      pendingSection.current = null;
    } else box.scrollTop = 0;
    setActiveSection(page.sections[0]?.id ?? null);
  }, [pageId]);

  const onScroll = () => {
    const box = scrollRef.current;
    if (!box) return;
    let current = page.sections[0]?.id ?? null;
    for (const s of page.sections) {
      const el = box.querySelector<HTMLElement>(`[data-section="${s.id}"]`);
      if (el && el.offsetTop - 120 <= box.scrollTop) current = s.id;
    }
    if (box.scrollTop + box.clientHeight >= box.scrollHeight - 4) current = page.sections[page.sections.length - 1]?.id ?? current;
    if (current !== activeSection) setActiveSection(current);
  };

  const onKeyDown = (e: KeyboardEvent<HTMLDivElement>) => {
    if (search) return;
    if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === "k") {
      e.preventDefault();
      setSearch(true);
      return;
    }
    const t = e.target as HTMLElement;
    if (t.tagName === "INPUT" || t.tagName === "TEXTAREA" || e.metaKey || e.ctrlKey || e.altKey) return;
    if (e.key === "/") {
      e.preventDefault();
      setSearch(true);
    } else if (e.key === "Escape") {
      setDrawer(false);
      setTocOpen(false);
    } else if (e.key === "[" && prev) go(prev.id);
    else if (e.key === "]" && next) go(next.id);
  };

  const sidebar = (
    <nav aria-label="Documentation" className="flex flex-col gap-6 px-3 py-5">
      {groups.map((g) => {
        const closed = closedGroups.has(g);
        return (
          <div key={g}>
            <button
              aria-expanded={!closed}
              className="group/g flex h-6 w-full cursor-pointer items-center justify-between rounded-md px-2.5 mb-1 text-left font-medium text-foreground text-xs outline-none focus-visible:ring-2 focus-visible:ring-ring"
              onClick={() =>
                setClosedGroups((c) => {
                  const n = new Set(c);
                  if (n.has(g)) n.delete(g);
                  else n.add(g);
                  return n;
                })
              }
              type="button"
            >
              {g}
              <ChevronDownIcon className={cn("size-3.5 text-muted-foreground/70 transition-[rotate,color] duration-200 group-hover/g:text-foreground", closed && "-rotate-90")} />
            </button>
            <AnimatePresence initial={false}>
              {!closed && (
                <motion.ul
                  animate={{ height: "auto", opacity: 1 }}
                  className="flex flex-col overflow-hidden"
                  exit={{ height: 0, opacity: 0 }}
                  initial={{ height: 0, opacity: 0 }}
                  transition={{ duration: 0.2, ease }}
                >
                  {pages
                    .filter((p) => p.group === g)
                    .map((p) => {
                      const on = p.id === page.id;
                      return (
                        <li key={p.id}>
                          <button
                            aria-current={on ? "page" : undefined}
                            className={cn(
                              "relative flex h-8 w-full cursor-pointer items-center rounded-lg px-2.5 text-left text-[13px] outline-none transition-colors focus-visible:ring-2 focus-visible:ring-ring",
                              on ? "font-medium text-foreground" : "text-muted-foreground hover:bg-accent/60 hover:text-foreground",
                            )}
                            onClick={() => go(p.id)}
                            type="button"
                          >
                            {on && <motion.span className="absolute inset-0 rounded-lg bg-accent" layoutId="docs-nav-pill" transition={spring} />}
                            <span className="relative truncate">{p.title}</span>
                            {p.id === "retries" && <span className="relative ms-auto rounded-full bg-info/12 px-1.5 font-medium text-[10px] text-info-foreground">New</span>}
                          </button>
                        </li>
                      );
                    })}
                </motion.ul>
              )}
            </AnimatePresence>
          </div>
        );
      })}
    </nav>
  );

  const toc = (
    <LayoutGroup id="docs-toc">
      <ul className="relative flex flex-col border-s">
        {page.sections.map((s) => {
          const on = s.id === activeSection;
          return (
            <li className="relative" key={s.id}>
              {on && <motion.span className="absolute inset-y-1 -left-px w-0.5 rounded-full bg-foreground" layoutId="toc-rail" transition={spring} />}
              <button
                className={cn(
                  "w-full cursor-pointer rounded-e-md py-1.5 ps-3.5 pe-2 text-left text-[13px] outline-none transition-colors focus-visible:ring-2 focus-visible:ring-ring",
                  on ? "font-medium text-foreground" : "text-muted-foreground hover:text-foreground",
                )}
                onClick={() => go(page.id, s.id)}
                type="button"
              >
                {s.title}
              </button>
            </li>
          );
        })}
      </ul>
    </LayoutGroup>
  );

  return (
    <MotionConfig reducedMotion="user">
      <div
        className={cn("@container relative isolate flex h-[760px] w-full flex-col overflow-hidden bg-background text-foreground text-sm outline-none", className)}
        onKeyDown={onKeyDown}
        tabIndex={-1}
      >
        <header className="flex h-14 shrink-0 items-center gap-3 border-b px-3 @3xl:px-5">
          <Button aria-label="Open navigation" className="@4xl:hidden" onClick={() => setDrawer(true)} size="icon-sm" variant="ghost">
            <MenuIcon />
          </Button>
          <a className="flex shrink-0 items-center gap-2 rounded-md outline-none focus-visible:ring-2 focus-visible:ring-ring" href="#top" onClick={(e) => { e.preventDefault(); go("introduction"); }}>
            <svg aria-hidden className="size-6" viewBox="0 0 24 24">
              <rect className="fill-foreground" height="24" rx="7" width="24" />
              <path className="stroke-background" d="M7 17 12 7l5 10M9.2 13h5.6" fill="none" strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" />
            </svg>
            <span className="font-heading font-semibold tracking-tight">{product}</span>
            <span className="hidden text-muted-foreground @md:inline">Docs</span>
          </a>
          <span className="hidden rounded-md border px-1.5 py-0.5 font-mono text-[11px] text-muted-foreground @xl:inline">{version}</span>
          <LayoutGroup id="docs-top">
            <nav aria-label="Sections" className="ms-2 hidden items-center gap-1 @4xl:flex">
              {navLinks.map((l) => {
                const on = (l === "API reference" && page.group === "API reference") || (l === "Guides" && page.group !== "API reference");
                return (
                  <button
                    aria-current={on ? "page" : undefined}
                    className={cn(
                      "relative flex h-14 cursor-pointer items-center px-2.5 outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-inset",
                      on ? "font-medium text-foreground" : "text-muted-foreground hover:text-foreground",
                    )}
                    key={l}
                    onClick={() => go(l === "API reference" ? (pages.find((p) => p.group === "API reference")?.id ?? pages[0].id) : pages[0].id)}
                    type="button"
                  >
                    {l}
                    {on && <motion.span className="absolute inset-x-2.5 -bottom-px h-0.5 rounded-full bg-foreground" layoutId="docs-top-line" transition={spring} />}
                  </button>
                );
              })}
            </nav>
          </LayoutGroup>
          <div className="ms-auto flex items-center gap-2">
            <button
              className="flex h-8 cursor-pointer items-center gap-2 rounded-lg border bg-background px-2.5 text-muted-foreground shadow-xs/5 outline-none transition-colors hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring @xl:w-56 dark:bg-input/32"
              onClick={() => setSearch(true)}
              type="button"
            >
              <SearchIcon className="size-4" />
              <span className="hidden flex-1 text-left @xl:inline">Search docs</span>
              <Kbd className="hidden @xl:inline-flex">⌘K</Kbd>
            </button>
            {dashboardHref && (
              <Button className="hidden @2xl:inline-flex" render={<a href={dashboardHref} rel="noreferrer" target="_blank" />} size="sm" variant="outline">
                Dashboard
              </Button>
            )}
          </div>
        </header>

        <div className="flex min-h-0 flex-1">
          <aside aria-label="Pages" className="hidden w-60 shrink-0 overflow-y-auto border-e [scrollbar-width:thin] @4xl:block">
            {sidebar}
          </aside>

          <div className="min-w-0 flex-1 overflow-y-auto [scroll-padding-top:16px] [scrollbar-width:thin]" onScroll={onScroll} ref={scrollRef}>
            <div className="mx-auto flex max-w-[1040px] gap-10 px-4 pt-8 pb-16 @3xl:px-10">
              <motion.article
                animate={{ opacity: 1, y: 0 }}
                className="min-w-0 max-w-[680px] flex-1"
                initial={{ opacity: 0, y: 6 }}
                key={page.id}
                transition={{ duration: 0.2, ease }}
              >
                <div className="flex items-center gap-1.5 text-muted-foreground text-xs">
                  <span>{page.group}</span>
                  <ChevronRightIcon className="size-3" />
                  <span className="text-foreground">{page.title}</span>
                </div>
                <h1 className="mt-3 text-balance font-heading font-semibold text-3xl tracking-tight">{page.title}</h1>
                <p className="mt-3 text-pretty text-[17px] text-muted-foreground leading-7">{page.lead}</p>
                <div className="mt-4 flex items-center gap-3 text-muted-foreground text-xs">
                  <span>Updated {page.updated}</span>
                  <span className="size-0.5 rounded-full bg-muted-foreground" />
                  <span>{page.minutes} min read</span>
                </div>

                {page.sections.length > 1 && (
                  <div className="mt-6 rounded-xl border @6xl:hidden">
                    <button
                      aria-expanded={tocOpen}
                      className="flex h-10 w-full cursor-pointer items-center justify-between rounded-xl px-3.5 font-medium outline-none focus-visible:ring-2 focus-visible:ring-ring"
                      onClick={() => setTocOpen((o) => !o)}
                      type="button"
                    >
                      On this page
                      <ChevronDownIcon className={cn("size-4 text-muted-foreground transition-transform duration-200", tocOpen && "rotate-180")} />
                    </button>
                    <AnimatePresence initial={false}>
                      {tocOpen && (
                        <motion.div
                          animate={{ height: "auto", opacity: 1 }}
                          className="overflow-hidden"
                          exit={{ height: 0, opacity: 0 }}
                          initial={{ height: 0, opacity: 0 }}
                          transition={{ duration: 0.2, ease }}
                        >
                          <div className="px-3.5 pb-3">{toc}</div>
                        </motion.div>
                      )}
                    </AnimatePresence>
                  </div>
                )}

                {page.sections.map((s) => (
                  <section className="mt-10 flex flex-col gap-4" data-section={s.id} key={s.id}>
                    <h2 className="group/h flex items-center gap-2 font-heading font-semibold text-xl tracking-tight">
                      {s.title}
                      <button
                        aria-label={`Link to ${s.title}`}
                        className="cursor-pointer rounded text-muted-foreground opacity-0 outline-none transition-opacity focus-visible:opacity-100 focus-visible:ring-2 focus-visible:ring-ring group-hover/h:opacity-100"
                        onClick={() => go(page.id, s.id)}
                        type="button"
                      >
                        <HashIcon className="size-4" />
                      </button>
                    </h2>
                    {s.blocks.map((b, i) => (
                      <Block block={b} key={i} lang={lang} onLang={setLang} />
                    ))}
                  </section>
                ))}

                <div className="mt-12 flex flex-wrap items-center gap-3 border-t pt-6">
                  <AnimatePresence initial={false} mode="wait">
                    {feedback[page.id] ? (
                      <motion.p animate={{ opacity: 1 }} className="text-muted-foreground" exit={{ opacity: 0 }} initial={{ opacity: 0 }} key="thanks" transition={{ duration: 0.15 }}>
                        {feedback[page.id] === "yes" ? "Thanks! Glad it helped." : "Thanks. We read every one of these and fix the page."}
                      </motion.p>
                    ) : (
                      <motion.div animate={{ opacity: 1 }} className="flex items-center gap-3" exit={{ opacity: 0 }} initial={{ opacity: 0 }} key="ask" transition={{ duration: 0.15 }}>
                        <span className="text-muted-foreground">Was this page helpful?</span>
                        <Button onClick={() => setFeedback((f) => ({ ...f, [page.id]: "yes" }))} size="sm" variant="outline">
                          <ThumbsUpIcon />
                          Yes
                        </Button>
                        <Button onClick={() => setFeedback((f) => ({ ...f, [page.id]: "no" }))} size="sm" variant="outline">
                          <ThumbsDownIcon />
                          No
                        </Button>
                      </motion.div>
                    )}
                  </AnimatePresence>
                  {editHref && (
                    <a
                      className="ms-auto flex items-center gap-1.5 rounded text-muted-foreground text-xs outline-none hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring @6xl:hidden"
                      href={editHref(page.id)}
                      rel="noreferrer"
                      target="_blank"
                    >
                      <PencilIcon className="size-3.5" />
                      Edit this page
                    </a>
                  )}
                </div>

                <div className="mt-6 grid grid-cols-1 gap-3 @xl:grid-cols-2">
                  {prev ? (
                    <button
                      className="group/pn flex cursor-pointer flex-col items-start gap-1 rounded-xl border p-4 text-left outline-none transition-[border-color,scale] duration-150 ease-out hover:border-foreground/20 focus-visible:ring-2 focus-visible:ring-ring active:scale-[0.98]"
                      onClick={() => go(prev.id)}
                      type="button"
                    >
                      <span className="flex items-center gap-1.5 text-muted-foreground text-xs">
                        <ArrowLeftIcon className="size-3.5 transition-transform duration-150 group-hover/pn:-translate-x-0.5" />
                        Previous <Kbd className="h-4 text-[10px]">[</Kbd>
                      </span>
                      <span className="font-medium">{prev.title}</span>
                    </button>
                  ) : (
                    <span className="hidden @xl:block" />
                  )}
                  {next && (
                    <button
                      className="group/pn flex cursor-pointer flex-col items-end gap-1 rounded-xl border p-4 text-right outline-none transition-[border-color,scale] duration-150 ease-out hover:border-foreground/20 focus-visible:ring-2 focus-visible:ring-ring active:scale-[0.98]"
                      onClick={() => go(next.id)}
                      type="button"
                    >
                      <span className="flex items-center gap-1.5 text-muted-foreground text-xs">
                        <Kbd className="h-4 text-[10px]">]</Kbd> Next
                        <ArrowRightIcon className="size-3.5 transition-transform duration-150 group-hover/pn:translate-x-0.5" />
                      </span>
                      <span className="font-medium">{next.title}</span>
                    </button>
                  )}
                </div>
              </motion.article>

              <aside aria-label="On this page" className="sticky top-8 hidden h-fit w-52 shrink-0 @6xl:block">
                <div className="mb-2 font-medium text-xs">On this page</div>
                {toc}
                <div className="mt-6 flex flex-col gap-2 border-t pt-4 text-muted-foreground text-xs">
                  {editHref && (
                    <a
                      className="flex items-center gap-1.5 rounded outline-none hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring"
                      href={editHref(page.id)}
                      rel="noreferrer"
                      target="_blank"
                    >
                      <PencilIcon className="size-3.5" />
                      Edit this page
                    </a>
                  )}
                  <button
                    className="flex cursor-pointer items-center gap-1.5 rounded text-left outline-none hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring"
                    onClick={() => scrollRef.current?.scrollTo({ top: 0, behavior: "smooth" })}
                    type="button"
                  >
                    <ArrowLeftIcon className="size-3.5 rotate-90" />
                    Back to top
                  </button>
                </div>
              </aside>
            </div>
          </div>
        </div>

        <AnimatePresence>
          {drawer && (
            <>
              <motion.div
                animate={{ opacity: 1 }}
                aria-hidden
                className="absolute inset-0 z-40 bg-black/32 @4xl:hidden"
                exit={{ opacity: 0 }}
                initial={{ opacity: 0 }}
                onClick={() => setDrawer(false)}
              />
              <motion.div
                animate={{ x: 0 }}
                aria-label="Documentation"
                aria-modal="true"
                className="absolute inset-y-2 left-2 z-50 flex w-[272px] max-w-[calc(100%-3rem)] flex-col overflow-hidden rounded-2xl border bg-background shadow-lg/10 @4xl:hidden"
                exit={{ x: "-110%" }}
                initial={{ x: "-110%" }}
                role="dialog"
                transition={{ type: "spring", stiffness: 420, damping: 40 }}
              >
                <div className="flex h-12 items-center justify-between border-b ps-4 pe-2">
                  <span className="font-medium">
                    {product} Docs <span className="font-mono text-muted-foreground text-xs">{version}</span>
                  </span>
                  <Button aria-label="Close navigation" onClick={() => setDrawer(false)} size="icon-sm" variant="ghost">
                    <XIcon />
                  </Button>
                </div>
                <div className="min-h-0 flex-1 overflow-y-auto">{sidebar}</div>
              </motion.div>
            </>
          )}
        </AnimatePresence>

        <AnimatePresence>{search && <SearchDialog onClose={() => setSearch(false)} onPick={go} pages={pages} />}</AnimatePresence>
      </div>
    </MotionConfig>
  );
}
```
