{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "search-results",
  "title": "Search Results",
  "description": "A four-state search results page — query echo, keyword highlighting, faceted type/recency/tag filters whose counts are the real post-click result count, and pagination that snaps back to page 1 on every filter change.",
  "dependencies": [
    "zod",
    "lucide-react"
  ],
  "registryDependencies": [
    "utils",
    "https://ui.zyeon.ai/r/search-highlight.json"
  ],
  "files": [
    {
      "path": "src/registry/blocks/search-results.tsx",
      "content": "\"use client\"\n\nimport * as React from \"react\"\nimport { ChevronLeft, ChevronRight, ListFilter, SearchX } from \"lucide-react\"\nimport { cn } from \"@/lib/utils\"\nimport { SearchHighlight } from \"@/components/ui/search-highlight\"\nimport type { SearchResultsData, SearchResultsItem } from \"./search-results.contract\"\n\nconst DAY_MS = 86_400_000\n\n/**\n * The recency facet. The ranges are **nested** (24h ⊂ week ⊂ month ⊂ year), and multi-selecting\n * nested ranges is meaningless, so this dimension is single-select; \"Any time\" is an explicit\n * value of it rather than the implicit \"nothing picked\" state.\n */\nconst RECENCY_OPTIONS = [\n  { id: \"any\", label: \"Any time\" },\n  { id: \"day\", label: \"Past 24 hours\" },\n  { id: \"week\", label: \"Past week\" },\n  { id: \"month\", label: \"Past month\" },\n  { id: \"year\", label: \"Past year\" },\n] as const\n\ntype RecencyId = (typeof RECENCY_OPTIONS)[number][\"id\"]\n\nconst RECENCY_DAYS: Record<RecencyId, number> = {\n  any: 0,\n  day: 1,\n  month: 30,\n  week: 7,\n  year: 365,\n}\n\n/**\n * The full state of one filtering pass. Facet semantics: **OR within a dimension, AND across\n * dimensions** — (Guide or Video) and (tag theming or tokens) and (past week).\n */\ninterface FacetState {\n  recency: RecencyId\n  tags: string[]\n  types: string[]\n}\n\nconst NO_FILTERS: FacetState = { recency: \"any\", tags: [], types: [] }\n\nconst pulse = \"animate-pulse rounded bg-muted motion-reduce:animate-none\"\n\n/** quiet period before announcing counts: query is a prop, the host re-renders on every keystroke — don't announce each one */\nconst ANNOUNCE_DELAY_MS = 600\n\nfunction plural(count: number, noun: string) {\n  return `${count} ${noun}${count === 1 ? \"\" : \"s\"}`\n}\n\n/** order-preserving dedupe: facet option order = first appearance in the data, so ordering the data orders the options */\nfunction uniqueInOrder(values: string[]) {\n  const seen = new Set<string>()\n  const out: string[] = []\n  for (const value of values) {\n    if (seen.has(value)) continue\n    seen.add(value)\n    out.push(value)\n  }\n  return out\n}\n\nfunction toggleValue(values: string[], value: string) {\n  return values.includes(value) ? values.filter(entry => entry !== value) : [...values, value]\n}\n\n/**\n * The single predicate. Facet counts, the result list and the empty check all go through it —\n * counts and results come from the same code, so the number next to an option and the number\n * of rows you actually get after clicking it can never disagree.\n */\nfunction matchesFacets(item: SearchResultsItem, state: FacetState, queriedAtMs: number) {\n  // OR within a dimension: picking Guide and Video keeps both; an untouched dimension doesn't narrow anything\n  if (state.types.length > 0 && !state.types.includes(item.type)) return false\n  if (state.tags.length > 0 && !item.tags.some(tag => state.tags.includes(tag))) return false\n  if (state.recency !== \"any\") {\n    const updatedAtMs = Date.parse(item.updatedAt)\n    // an item with an unparseable timestamp can't be passed off as recent: it only shows under Any time\n    if (!Number.isFinite(updatedAtMs)) return false\n    if (updatedAtMs < queriedAtMs - RECENCY_DAYS[state.recency] * DAY_MS) return false\n  }\n  return true\n}\n\n/**\n * 1 … 4 5 6 7 8 … 12 — first and last are always there, with a contiguous window centred on the\n * current page (near an edge it grows on the other side, so the number of page keys never jumps\n * around). A gap of exactly one page is drawn as that page instead of an ellipsis: a \"…\" hiding a\n * single page costs more clicks than one extra button.\n */\nconst PAGE_WINDOW_SPAN = 5\n\nfunction buildPageWindow(current: number, count: number): (number | \"gap\")[] {\n  const end = Math.min(count, Math.max(1, current - Math.floor(PAGE_WINDOW_SPAN / 2)) + PAGE_WINDOW_SPAN - 1)\n  const start = Math.max(1, end - PAGE_WINDOW_SPAN + 1)\n  const pages = new Set<number>([1, count])\n  for (let page = start; page <= end; page++) pages.add(page)\n\n  const out: (number | \"gap\")[] = []\n  let previous = 0\n  for (const page of [...pages].sort((a, b) => a - b)) {\n    if (previous > 0 && page - previous === 2) out.push(previous + 1)\n    else if (previous > 0 && page - previous > 2) out.push(\"gap\")\n    out.push(page)\n    previous = page\n  }\n  return out\n}\n\nconst RELATIVE_STEPS: [Intl.RelativeTimeFormatUnit, number][] = [\n  [\"year\", 365 * DAY_MS],\n  [\"month\", 30 * DAY_MS],\n  [\"week\", 7 * DAY_MS],\n  [\"day\", DAY_MS],\n  [\"hour\", 3_600_000],\n  [\"minute\", 60_000],\n]\n\n/**\n * Relative time. The reference point is queriedAt from the data, not Date.now() — reading the\n * clock during render gives the server and the client two different answers (hydration mismatch)\n * and makes screenshot diffs unstable.\n * Truncating toward zero: an item labelled \"6 days ago\" is guaranteed to still sit inside the\n * \"Past week\" window, so the two never contradict each other.\n */\nfunction relativeLabel(updatedAtMs: number, queriedAtMs: number, formatter: Intl.RelativeTimeFormat) {\n  const delta = updatedAtMs - queriedAtMs\n  const distance = Math.abs(delta)\n  for (const [unit, span] of RELATIVE_STEPS) {\n    if (distance >= span) return formatter.format(Math.trunc(delta / span), unit)\n  }\n  return formatter.format(0, \"second\")\n}\n\nfunction FacetOption({\n  checked,\n  count,\n  inputType,\n  label,\n  name,\n  onSelect,\n}: {\n  checked: boolean\n  count: number\n  inputType: \"checkbox\" | \"radio\"\n  label: string\n  name?: string\n  onSelect: () => void\n}) {\n  return (\n    <li className=\"min-w-0\">\n      <label\n        className={cn(\n          \"flex min-w-0 cursor-pointer items-start gap-2 rounded-md px-2 py-1.5 text-sm transition-colors\",\n          \"hover:bg-muted/60 has-[:focus-visible]:ring-2 has-[:focus-visible]:ring-ring motion-reduce:transition-none\",\n          checked && \"font-medium\",\n        )}\n      >\n        <input\n          checked={checked}\n          className=\"mt-0.5 size-4 shrink-0 cursor-pointer accent-primary focus-visible:outline-none\"\n          name={name}\n          onChange={onSelect}\n          type={inputType}\n        />\n        {/* min-w-0 + wrap-anywhere: a very long space-less label wraps here instead of blowing out the sidebar */}\n        <span className=\"min-w-0 flex-1 wrap-anywhere\">{label}</span>\n        <span className=\"shrink-0 text-xs tabular-nums text-muted-foreground\">{count}</span>\n        {/*\n          say what the number means, otherwise a screen reader reads out a bare digit.\n          clicking the already-selected option in a radio group changes nothing, so its number is\n          \"shown now\", not \"if removed\" — the wording follows the control type instead of lying\n          to screen-reader users.\n        */}\n        <span className=\"sr-only\">\n          {checked\n            ? inputType === \"radio\"\n              ? \"results shown now\"\n              : \"results if removed\"\n            : \"results if selected\"}\n        </span>\n      </label>\n    </li>\n  )\n}\n\nfunction FacetGroup({ children, legend }: { children: React.ReactNode; legend: string }) {\n  return (\n    // min-w-0: fieldset defaults to min-inline-size:min-content — without the override, content widens it on narrow screens\n    <fieldset className=\"min-w-0 border-0 p-0\">\n      <legend className=\"mb-1 px-2 text-xs font-semibold tracking-wide text-muted-foreground uppercase\">\n        {legend}\n      </legend>\n      <ul className=\"flex min-w-0 flex-col\">{children}</ul>\n    </fieldset>\n  )\n}\n\nexport interface SearchResultsProps extends SearchResultsData {\n  /** results per page, default 5; illegal values (0 / NaN / negative) clamp to 1 so the list can't compute Infinity pages */\n  pageSize?: number\n  /** BCP-47 locale for Intl; pass it explicitly so server and client don't format times by different defaults */\n  locale?: string\n  onRetry?: () => void\n  /** wire this up and the suggested queries in the zero-result panel render as clickable; without it they stay static advice, no fake buttons */\n  onSuggest?: (query: string) => void\n  className?: string\n}\n\nexport function SearchResults({\n  className,\n  items,\n  locale = \"en-US\",\n  onRetry,\n  onSuggest,\n  pageSize = 5,\n  query,\n  queriedAt,\n  status,\n  suggestions = [],\n}: SearchResultsProps) {\n  const baseId = React.useId()\n  const [facets, setFacets] = React.useState<FacetState>(NO_FILTERS)\n  const [page, setPage] = React.useState(1)\n  const [filtersOpen, setFiltersOpen] = React.useState(false)\n\n  const queriedAtMs = React.useMemo(() => Date.parse(queriedAt), [queriedAt])\n  const recencyEnabled = Number.isFinite(queriedAtMs)\n  const size = Number.isFinite(pageSize) ? Math.max(1, Math.floor(pageSize)) : 5\n\n  /**\n   * A new query or a new result set goes back to page 1. Without it a user parked on page 3 stares\n   * at a blank page — the new results are only 4 rows, so page 3 holds nothing.\n   * The signature is a string of ids rather than the array reference: hosts routinely build the\n   * array inline during render, and comparing by reference would reset on every render (which\n   * means pagination never works).\n   */\n  const dataKey = `${query}|${queriedAt}|${items.map(item => item.id).join(\",\")}`\n  const [previousDataKey, setPreviousDataKey] = React.useState(dataKey)\n  if (previousDataKey !== dataKey) {\n    setPreviousDataKey(dataKey)\n    setPage(1)\n  }\n\n  const typeOptions = React.useMemo(() => uniqueInOrder(items.map(item => item.type)), [items])\n  const tagOptions = React.useMemo(() => uniqueInOrder(items.flatMap(item => item.tags)), [items])\n\n  /**\n   * Stale-selection guard: after the host swaps in a new batch of results, a previously selected\n   * type / tag may no longer exist in the data. Without this the component keeps filtering by a\n   * value the user can neither see nor unclick.\n   * When queriedAt doesn't parse the whole recency group isn't rendered, so its selection falls\n   * back to any as well.\n   */\n  const active = React.useMemo<FacetState>(\n    () => ({\n      recency: recencyEnabled ? facets.recency : \"any\",\n      tags: facets.tags.filter(tag => tagOptions.includes(tag)),\n      types: facets.types.filter(type => typeOptions.includes(type)),\n    }),\n    [facets, recencyEnabled, tagOptions, typeOptions],\n  )\n\n  const filtersActive = active.types.length > 0 || active.tags.length > 0 || active.recency !== \"any\"\n  const activeCount = active.types.length + active.tags.length + (active.recency === \"any\" ? 0 : 1)\n\n  const filtered = React.useMemo(\n    () => items.filter(item => matchesFacets(item, active, queriedAtMs)),\n    [active, items, queriedAtMs],\n  )\n\n  /**\n   * A facet count is \"how many rows are left after you click this\", not a static number that\n   * ignores the current filters. It runs the list's own matchesFacets: **apply this click to the\n   * current state**, then count again. Every number is therefore a promise about the next click,\n   * checkable one by one in any state.\n   */\n  const countAfter = React.useCallback(\n    (next: FacetState) =>\n      items.reduce((total, item) => total + (matchesFacets(item, next, queriedAtMs) ? 1 : 0), 0),\n    [items, queriedAtMs],\n  )\n\n  const typeCounts = React.useMemo(\n    () => typeOptions.map(type => countAfter({ ...active, types: toggleValue(active.types, type) })),\n    [active, countAfter, typeOptions],\n  )\n  const tagCounts = React.useMemo(\n    () => tagOptions.map(tag => countAfter({ ...active, tags: toggleValue(active.tags, tag) })),\n    [active, countAfter, tagOptions],\n  )\n  const recencyCounts = React.useMemo(\n    () => RECENCY_OPTIONS.map(option => countAfter({ ...active, recency: option.id })),\n    [active, countAfter],\n  )\n\n  const pageCount = Math.max(1, Math.ceil(filtered.length / size))\n  // clamp the page during render: when a filter turns 5 pages into 2, sitting on page 4 shouldn't show an empty list\n  const currentPage = Math.min(Math.max(1, page), pageCount)\n  const offset = (currentPage - 1) * size\n  const visible = filtered.slice(offset, offset + size)\n\n  // any filter change returns to page 1 — a separate matter from the clamp above: page 3 may still be\n  // legal under the new results, but someone who just changed the criteria wants the most relevant\n  // rows, not rows 11–15.\n  const updateFacets = React.useCallback((next: (current: FacetState) => FacetState) => {\n    setFacets(next)\n    setPage(1)\n  }, [])\n\n  const clearFilters = React.useCallback(() => {\n    setFacets(NO_FILTERS)\n    setPage(1)\n  }, [])\n\n  const relativeFormatter = React.useMemo(\n    () => new Intl.RelativeTimeFormat(locale, { numeric: \"auto\" }),\n    [locale],\n  )\n  // without a parseable queriedAt, relative time has no anchor (everything would read \"now\", which is\n  // a lie), so fall back to absolute dates; the timezone is pinned to UTC or server and client render\n  // two different dates\n  const absoluteFormatter = React.useMemo(\n    () => new Intl.DateTimeFormat(locale, { dateStyle: \"medium\", timeZone: \"UTC\" }),\n    [locale],\n  )\n\n  const trimmedQuery = query.trim()\n  const forQuery = trimmedQuery ? ` for “${trimmedQuery}”` : \"\"\n  const rangeLabel =\n    filtered.length > size\n      ? `Results ${offset + 1}–${offset + visible.length} of ${filtered.length}`\n      : plural(filtered.length, \"result\")\n  const summary = `${rangeLabel}${forQuery}${filtersActive ? ` · filtered from ${items.length}` : \"\"}`\n\n  const showEmpty = status === \"empty\" || (status === \"ready\" && items.length === 0)\n  const showResults = status === \"ready\" && items.length > 0\n\n  /**\n   * Count announcements write textContent imperatively instead of going through setState — the\n   * debounce is just a rescheduled timeout, so the component never re-renders merely to announce.\n   * The first mount stays silent (the ref holds the previous message and identical messages are\n   * skipped), so StrictMode's double invoke can't announce out of nowhere either.\n   */\n  const liveRef = React.useRef<HTMLParagraphElement>(null)\n  const announcement =\n    status === \"loading\"\n      ? `Searching${forQuery}`\n      : status === \"error\"\n        ? \"The search request failed\"\n        : showEmpty\n          ? `No results${forQuery}`\n          : summary\n  const lastAnnouncementRef = React.useRef(announcement)\n  React.useEffect(() => {\n    if (lastAnnouncementRef.current === announcement) return\n    lastAnnouncementRef.current = announcement\n    const node = liveRef.current\n    if (!node) return\n    const timer = window.setTimeout(() => {\n      node.textContent = announcement\n    }, ANNOUNCE_DELAY_MS)\n    return () => window.clearTimeout(timer)\n  }, [announcement])\n\n  const panelClasses = \"flex flex-col items-center gap-3 rounded-xl border bg-card px-6 py-14 text-center\"\n  const buttonClasses = cn(\n    \"cursor-pointer rounded-md border px-3 py-1.5 text-sm transition-colors hover:bg-muted\",\n    \"focus-visible:ring-2 focus-visible:ring-ring focus-visible:outline-none motion-reduce:transition-none\",\n  )\n\n  return (\n    <section\n      aria-labelledby={`${baseId}-heading`}\n      className={cn(\"flex w-full min-w-0 flex-col gap-5\", className)}\n    >\n      {/* permanent live region: the effect above writes into it on a delay, so typing doesn't announce per keystroke */}\n      <p aria-live=\"polite\" className=\"sr-only\" ref={liveRef} role=\"status\" />\n\n      <header className=\"flex min-w-0 flex-col gap-1\">\n        <h2\n          className=\"min-w-0 text-lg font-semibold tracking-tight wrap-anywhere\"\n          id={`${baseId}-heading`}\n        >\n          {trimmedQuery ? `Results for “${trimmedQuery}”` : \"Search results\"}\n        </h2>\n        {showResults && <p className=\"text-sm text-muted-foreground\">{summary}</p>}\n        {status === \"loading\" && <div aria-hidden=\"true\" className={cn(\"h-4 w-48\", pulse)} />}\n      </header>\n\n      {status === \"loading\" && (\n        <div\n          aria-hidden=\"true\"\n          className=\"grid min-w-0 gap-6 lg:grid-cols-[minmax(0,15rem)_minmax(0,1fr)] lg:gap-8\"\n        >\n          <div className=\"hidden min-w-0 flex-col gap-5 lg:flex\">\n            {[4, 5].map(rows => (\n              <div className=\"flex flex-col gap-2\" key={rows}>\n                <div className={cn(\"h-3 w-20\", pulse)} />\n                {Array.from({ length: rows }, (_, row) => (\n                  <div className={cn(\"h-4 w-full\", pulse)} key={row} />\n                ))}\n              </div>\n            ))}\n          </div>\n          <div className=\"flex min-w-0 flex-col gap-6\">\n            {Array.from({ length: 4 }, (_, row) => (\n              <div className=\"flex flex-col gap-2\" key={row}>\n                <div className={cn(\"h-3 w-40\", pulse)} />\n                <div className={cn(\"h-5 w-3/4 max-w-96\", pulse)} />\n                <div className={cn(\"h-3 w-full\", pulse)} />\n                <div className={cn(\"h-3 w-5/6\", pulse)} />\n              </div>\n            ))}\n          </div>\n        </div>\n      )}\n\n      {status === \"error\" && (\n        <div className={panelClasses}>\n          <p className=\"text-sm font-medium\">The search request failed</p>\n          <p className=\"text-sm text-muted-foreground\">\n            The index didn’t answer. Your query is unchanged — try it again.\n          </p>\n          {onRetry && (\n            <button className={buttonClasses} onClick={onRetry} type=\"button\">\n              Try again\n            </button>\n          )}\n        </div>\n      )}\n\n      {/*\n        zero-result state one: **the query itself matched nothing**. There is no filter to clear\n        here, so the way out is rewriting the query (spelling / broader words / synonyms) plus the\n        suggested queries that came with the data.\n      */}\n      {showEmpty && (\n        <div className={panelClasses}>\n          <SearchX aria-hidden=\"true\" className=\"size-6 text-muted-foreground\" />\n          <p className=\"text-sm font-medium\">No results{forQuery}</p>\n          <p className=\"text-sm text-muted-foreground\">\n            Nothing in the index matches this search — no filter is hiding anything.\n          </p>\n          <ul className=\"flex list-disc flex-col gap-1 text-left text-sm text-muted-foreground\">\n            <li>Check every word for typos.</li>\n            <li>Use fewer, more general keywords.</li>\n            <li>Try a synonym — “theme” instead of “skin”.</li>\n          </ul>\n          {suggestions.length > 0 && onSuggest && (\n            <div className=\"flex flex-wrap items-center justify-center gap-2\">\n              <span className=\"text-sm text-muted-foreground\">Did you mean</span>\n              {suggestions.map(suggestion => (\n                <button\n                  className={cn(\n                    \"cursor-pointer rounded-full border px-3 py-1 text-sm font-medium transition-colors\",\n                    \"hover:bg-muted focus-visible:ring-2 focus-visible:ring-ring focus-visible:outline-none\",\n                    \"motion-reduce:transition-none\",\n                  )}\n                  key={suggestion}\n                  onClick={() => onSuggest(suggestion)}\n                  type=\"button\"\n                >\n                  {suggestion}\n                </button>\n              ))}\n            </div>\n          )}\n        </div>\n      )}\n\n      {showResults && (\n        <div className=\"grid min-w-0 gap-6 lg:grid-cols-[minmax(0,15rem)_minmax(0,1fr)] lg:items-start lg:gap-8\">\n          <div className=\"flex min-w-0 flex-col gap-3\">\n            <button\n              aria-controls={`${baseId}-facets`}\n              aria-expanded={filtersOpen}\n              className={cn(\n                \"inline-flex cursor-pointer items-center justify-center gap-2 rounded-lg border px-3 py-2 text-sm transition-colors lg:hidden\",\n                \"hover:bg-muted focus-visible:ring-2 focus-visible:ring-ring focus-visible:outline-none motion-reduce:transition-none\",\n              )}\n              onClick={() => setFiltersOpen(open => !open)}\n              type=\"button\"\n            >\n              <ListFilter aria-hidden=\"true\" className=\"size-4\" />\n              Filters\n              {activeCount > 0 && (\n                <span className=\"rounded-full bg-primary px-1.5 text-xs font-medium text-primary-foreground tabular-nums\">\n                  {activeCount}\n                </span>\n              )}\n            </button>\n\n            {/* collapsed uses display:none (hidden), not visually-hidden — a visually hidden\n                checkbox is still tabbable, which is an invisible keyboard trap */}\n            <div\n              className={cn(\"min-w-0 flex-col gap-5 lg:flex\", filtersOpen ? \"flex\" : \"hidden\")}\n              id={`${baseId}-facets`}\n            >\n              <div className=\"flex min-w-0 flex-col gap-1 px-2\">\n                <div className=\"flex items-center justify-between gap-2\">\n                  <span className=\"text-sm font-semibold\">Filters</span>\n                  {filtersActive && (\n                    <button\n                      className={cn(\n                        \"cursor-pointer rounded-sm text-xs text-muted-foreground underline-offset-4 transition-colors\",\n                        \"hover:text-foreground hover:underline focus-visible:ring-2 focus-visible:ring-ring focus-visible:outline-none\",\n                        \"motion-reduce:transition-none\",\n                      )}\n                      onClick={clearFilters}\n                      type=\"button\"\n                    >\n                      Clear all\n                    </button>\n                  )}\n                </div>\n                <p className=\"text-xs text-muted-foreground\">\n                  Each number is the result count after that click.\n                </p>\n              </div>\n\n              <FacetGroup legend=\"Type\">\n                {typeOptions.map((type, index) => (\n                  <FacetOption\n                    checked={active.types.includes(type)}\n                    count={typeCounts[index]}\n                    inputType=\"checkbox\"\n                    key={type}\n                    label={type}\n                    onSelect={() =>\n                      updateFacets(current => ({ ...current, types: toggleValue(current.types, type) }))\n                    }\n                  />\n                ))}\n              </FacetGroup>\n\n              {recencyEnabled && (\n                <FacetGroup legend=\"Last updated\">\n                  {RECENCY_OPTIONS.map((option, index) => (\n                    <FacetOption\n                      checked={active.recency === option.id}\n                      count={recencyCounts[index]}\n                      inputType=\"radio\"\n                      key={option.id}\n                      label={option.label}\n                      name={`${baseId}-recency`}\n                      onSelect={() => updateFacets(current => ({ ...current, recency: option.id }))}\n                    />\n                  ))}\n                </FacetGroup>\n              )}\n\n              {tagOptions.length > 0 && (\n                <FacetGroup legend=\"Tags\">\n                  {tagOptions.map((tag, index) => (\n                    <FacetOption\n                      checked={active.tags.includes(tag)}\n                      count={tagCounts[index]}\n                      inputType=\"checkbox\"\n                      key={tag}\n                      label={tag}\n                      onSelect={() =>\n                        updateFacets(current => ({ ...current, tags: toggleValue(current.tags, tag) }))\n                      }\n                    />\n                  ))}\n                </FacetGroup>\n              )}\n            </div>\n          </div>\n\n          <div className=\"flex min-w-0 flex-col gap-6\">\n            {visible.length === 0 ? (\n              /*\n                zero-result state two: **the results are still there, the filters are hiding them**.\n                The wording is deliberately different from the one above, and it offers a way out\n                that actually works (the same clearFilters as the sidebar).\n              */\n              <div className={cn(panelClasses, \"border-dashed\")}>\n                <p className=\"text-sm font-medium\">No results match these filters</p>\n                <p className=\"text-sm text-muted-foreground\">\n                  All {plural(items.length, \"result\")}\n                  {forQuery} are still here — this filter combination just excludes every one of them.\n                </p>\n                <button className={buttonClasses} onClick={clearFilters} type=\"button\">\n                  Clear all filters\n                </button>\n              </div>\n            ) : (\n              // <ol>: results are ranked by relevance, so the order carries meaning; start makes page 2 count from 6\n              <ol className=\"flex min-w-0 list-none flex-col gap-6\" start={offset + 1}>\n                {visible.map(item => {\n                  const updatedAtMs = Date.parse(item.updatedAt)\n                  return (\n                    <li className=\"flex min-w-0 flex-col gap-1.5\" key={item.id}>\n                      {item.breadcrumb.length > 0 && (\n                        // wrap-anywhere + min-w-0: a long space-less URL segment wraps here instead of breaking the layout\n                        <p className=\"flex min-w-0 flex-wrap items-center gap-x-1.5 text-xs text-muted-foreground wrap-anywhere\">\n                          {item.breadcrumb.map((segment, index) => (\n                            <React.Fragment key={`${segment}-${index}`}>\n                              {index > 0 && <span aria-hidden=\"true\">/</span>}\n                              <span className=\"min-w-0\">{segment}</span>\n                            </React.Fragment>\n                          ))}\n                        </p>\n                      )}\n\n                      <h3 className=\"min-w-0 text-base font-medium tracking-tight wrap-anywhere\">\n                        <a\n                          className={cn(\n                            \"rounded-sm hover:underline\",\n                            \"focus-visible:ring-2 focus-visible:ring-ring focus-visible:outline-none\",\n                          )}\n                          href={item.href}\n                        >\n                          {/* highlighting is structural: SearchHighlight splits the text into text\n                              nodes + <mark>, never dangerouslySetInnerHTML — markup inside a snippet\n                              can't get through */}\n                          <SearchHighlight mode=\"words\" query={query} text={item.title} />\n                        </a>\n                      </h3>\n\n                      <p className=\"min-w-0 text-sm text-muted-foreground wrap-anywhere\">\n                        <SearchHighlight mode=\"words\" query={query} text={item.snippet} />\n                      </p>\n\n                      <div className=\"flex min-w-0 flex-wrap items-center gap-x-2 gap-y-1 text-xs text-muted-foreground\">\n                        {/* max-w-full + wrap-anywhere: type names and tags are free-form host strings,\n                            and one long space-less string pushes this whole row past the viewport\n                            (measured: 54px of overflow at 375px) */}\n                        <span className=\"max-w-full min-w-0 rounded-full border px-2 py-0.5 font-medium wrap-anywhere\">\n                          {item.type}\n                        </span>\n                        {Number.isFinite(updatedAtMs) ? (\n                          <time dateTime={item.updatedAt}>\n                            Updated{\" \"}\n                            {recencyEnabled\n                              ? relativeLabel(updatedAtMs, queriedAtMs, relativeFormatter)\n                              : absoluteFormatter.format(updatedAtMs)}\n                          </time>\n                        ) : (\n                          <span className=\"italic\">Update date unavailable</span>\n                        )}\n                        {item.tags.map(tag => {\n                          const selected = active.tags.includes(tag)\n                          return (\n                            // a tag that looks clickable has to be clickable: it toggles the same tag facet as the sidebar\n                            <button\n                              aria-pressed={selected}\n                              className={cn(\n                                \"max-w-full min-w-0 cursor-pointer rounded-full px-2 py-0.5 transition-colors wrap-anywhere\",\n                                \"focus-visible:ring-2 focus-visible:ring-ring focus-visible:outline-none motion-reduce:transition-none\",\n                                selected\n                                  ? \"bg-primary/15 font-medium text-foreground\"\n                                  : \"bg-muted hover:bg-muted-foreground/20\",\n                              )}\n                              key={tag}\n                              onClick={() =>\n                                updateFacets(current => ({\n                                  ...current,\n                                  tags: toggleValue(current.tags, tag),\n                                }))\n                              }\n                              type=\"button\"\n                            >\n                              {tag}\n                            </button>\n                          )\n                        })}\n                      </div>\n                    </li>\n                  )\n                })}\n              </ol>\n            )}\n\n            {pageCount > 1 && (\n              <nav\n                aria-label=\"Search results pages\"\n                className=\"flex flex-wrap items-center justify-between gap-3 border-t pt-4\"\n              >\n                <p className=\"text-xs text-muted-foreground tabular-nums\">\n                  Page {currentPage} of {pageCount}\n                </p>\n                <ul className=\"flex flex-wrap items-center gap-1\">\n                  <li>\n                    {/*\n                      the end-of-range page buttons use aria-disabled rather than native disabled:\n                      native disabled makes the browser blur them immediately, so clicking through\n                      to the last page drops focus onto <body>.\n                    */}\n                    <button\n                      aria-disabled={currentPage === 1 || undefined}\n                      aria-label=\"Previous page\"\n                      className={cn(\n                        \"inline-flex size-8 cursor-pointer items-center justify-center rounded-md border transition-colors\",\n                        \"focus-visible:ring-2 focus-visible:ring-ring focus-visible:outline-none motion-reduce:transition-none\",\n                        currentPage === 1 ? \"cursor-not-allowed opacity-50\" : \"hover:bg-muted\",\n                      )}\n                      onClick={() => {\n                        if (currentPage > 1) setPage(currentPage - 1)\n                      }}\n                      type=\"button\"\n                    >\n                      <ChevronLeft aria-hidden=\"true\" className=\"size-4\" />\n                    </button>\n                  </li>\n                  {buildPageWindow(currentPage, pageCount).map((entry, index) =>\n                    entry === \"gap\" ? (\n                      <li\n                        aria-hidden=\"true\"\n                        className=\"px-1 text-sm text-muted-foreground\"\n                        key={`gap-${index}`}\n                      >\n                        …\n                      </li>\n                    ) : (\n                      <li key={entry}>\n                        <button\n                          aria-current={entry === currentPage ? \"page\" : undefined}\n                          aria-label={`Page ${entry}`}\n                          className={cn(\n                            \"inline-flex size-8 cursor-pointer items-center justify-center rounded-md border text-sm tabular-nums transition-colors\",\n                            \"focus-visible:ring-2 focus-visible:ring-ring focus-visible:outline-none motion-reduce:transition-none\",\n                            entry === currentPage\n                              ? \"border-primary bg-primary font-medium text-primary-foreground\"\n                              : \"hover:bg-muted\",\n                          )}\n                          onClick={() => setPage(entry)}\n                          type=\"button\"\n                        >\n                          {entry}\n                        </button>\n                      </li>\n                    ),\n                  )}\n                  <li>\n                    <button\n                      aria-disabled={currentPage === pageCount || undefined}\n                      aria-label=\"Next page\"\n                      className={cn(\n                        \"inline-flex size-8 cursor-pointer items-center justify-center rounded-md border transition-colors\",\n                        \"focus-visible:ring-2 focus-visible:ring-ring focus-visible:outline-none motion-reduce:transition-none\",\n                        currentPage === pageCount ? \"cursor-not-allowed opacity-50\" : \"hover:bg-muted\",\n                      )}\n                      onClick={() => {\n                        if (currentPage < pageCount) setPage(currentPage + 1)\n                      }}\n                      type=\"button\"\n                    >\n                      <ChevronRight aria-hidden=\"true\" className=\"size-4\" />\n                    </button>\n                  </li>\n                </ul>\n              </nav>\n            )}\n          </div>\n        </div>\n      )}\n    </section>\n  )\n}\n\nexport default SearchResults\n",
      "type": "registry:block"
    },
    {
      "path": "src/registry/blocks/search-results.contract.ts",
      "content": "import { z } from \"zod\"\n\n/**\n * Interaction-honesty red line: a result must point at a real destination, and dead anchors\n * (\"#\" / \"#top\") are rejected at parse time — a result row that does nothing when clicked is\n * worse than no result at all.\n */\nconst hrefSchema = z\n  .string()\n  .min(1)\n  .refine(href => href.trim() !== \"\" && !href.trim().startsWith(\"#\"), {\n    message: 'href must point at a real destination — dead anchors like \"#\" are rejected',\n  })\n\nexport const searchResultsItemSchema = z.object({\n  id: z.string().min(1),\n  /** result title; matched terms are highlighted structurally by the component, and long titles wrap rather than truncate */\n  title: z.string().min(1),\n  href: hrefSchema,\n  /**\n   * The snippet, **plain text**. The component renders it as text nodes (highlighting comes from\n   * <mark> structure, never dangerouslySetInnerHTML), so markup like \"<em>…</em>\" coming back from\n   * the backend is displayed verbatim — strip the markup server-side and let query do the\n   * highlighting.\n   */\n  snippet: z.string(),\n  /** breadcrumb segments, e.g. [\"Docs\", \"Guides\", \"Theming\"]; an empty array skips the row */\n  breadcrumb: z.array(z.string().min(1)),\n  /** value for the type facet (free-form string, e.g. \"Guide\" / \"API reference\"); the options are derived from the data */\n  type: z.string().min(1),\n  /** values for the tag facet; one result can carry several tags */\n  tags: z.array(z.string().min(1)),\n  /** ISO 8601 timestamp. The recency facet measures against queriedAt, not Date.now() */\n  updatedAt: z.iso.datetime({ offset: true }),\n})\n\nexport const searchResultsSchema = z\n  .object({\n    status: z.enum([\"loading\", \"empty\", \"error\", \"ready\"]),\n    /** the raw query the user typed: echoed back in the header and used as the highlight source */\n    query: z.string(),\n    /**\n     * When this search ran (ISO). The recency facet is anchored to the **data** rather than\n     * Date.now(), so the same payload lands in the same buckets on the server and the client,\n     * today and tomorrow — which is what makes screenshots and tests reproducible.\n     */\n    queriedAt: z.iso.datetime({ offset: true }),\n    /** the full result set, already ranked by relevance; paging happens inside the component, the host doesn't slice */\n    items: z.array(searchResultsItemSchema),\n    /** alternative queries for the zero-result state (\"did you mean\"); only clickable when onSuggest is passed */\n    suggestions: z.array(z.string().min(1)).optional(),\n  })\n  // every refine below carries its own existence guard: zod runs **all** refines, a failing one\n  // doesn't stop the next, so dereferencing items[0].id in the second would throw a TypeError on a\n  // malformed payload — safeParse blows up and the caller never even gets an error object.\n  .refine(data => data.status !== \"ready\" || (Array.isArray(data.items) && data.items.length > 0), {\n    message: '\"ready\" with zero items is the empty state — send status \"empty\" instead',\n    path: [\"items\"],\n  })\n  .refine(data => data.status !== \"empty\" || (Array.isArray(data.items) && data.items.length === 0), {\n    message: '\"empty\" must not carry items — send status \"ready\" instead',\n    path: [\"items\"],\n  })\n  .refine(\n    data => {\n      if (!Array.isArray(data.items)) return true\n      const ids = data.items.map(item => item?.id).filter(id => typeof id === \"string\")\n      return new Set(ids).size === ids.length\n    },\n    {\n      message: \"result ids must be unique — duplicates collapse rows and break React keys\",\n      path: [\"items\"],\n    },\n  )\n\nexport type SearchResultsItem = z.infer<typeof searchResultsItemSchema>\nexport type SearchResultsData = z.infer<typeof searchResultsSchema>\n",
      "type": "registry:block"
    }
  ],
  "type": "registry:block"
}
