diff --git a/src/app/globals.css b/src/app/globals.css index 586a2fef..81a809be 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -330,6 +330,19 @@ textarea:focus-visible { border-radius: 4px; } +/* cmdk command palette input — opts out of the universal focus ring. + The dialog already provides visual focus context (modal backdrop + + centered card); a hard accent outline on the input itself reads as + a validation error rather than a focus state, which is the look the + designer flagged as "pas pro". The input still receives focus and + keyboard nav, just without the harsh outline. */ +[cmdk-input]:focus, +[cmdk-input]:focus-visible { + outline: none !important; + outline-offset: 0; + box-shadow: none !important; +} + /* Skip-link target. Hidden until keyboard focus brings it onto the page, then snaps to the top-left so a screen-reader / keyboard user can bypass the nav. */ diff --git a/src/components/search/search-dialog.tsx b/src/components/search/search-dialog.tsx index 28464364..ff4e1492 100644 --- a/src/components/search/search-dialog.tsx +++ b/src/components/search/search-dialog.tsx @@ -2,7 +2,7 @@ import { Command } from "cmdk"; import Fuse from "fuse.js"; -import { Search, X } from "lucide-react"; +import { ArrowRight, Search } from "lucide-react"; import { useRouter } from "next/navigation"; import { useEffect, useMemo, useRef, useState } from "react"; import { useSearch } from "@/components/search/search-provider"; @@ -18,6 +18,16 @@ const KIND_ORDER: SearchKind[] = [ "Page", ]; +const KIND_LABEL: Record = { + Benchmark: "Benchmarks", + Product: "Products", + Compare: "Compare", + Alternative: "Alternatives", + Answer: "Answers", + Chain: "Chains", + Page: "Pages", +}; + /** * Hardcoded "popular benches" shown when the query is empty. No * analytics involved, just an editorial pick of high-traffic specs @@ -30,6 +40,14 @@ const POPULAR_BENCH_SLUGS = [ "rpc-capabilities", ]; +function Kbd({ children }: { children: React.ReactNode }) { + return ( + + {children} + + ); +} + export default function SearchDialog() { const { items, close: onClose } = useSearch(); const router = useRouter(); @@ -83,7 +101,7 @@ export default function SearchDialog() { .filter((it): it is SearchItem => Boolean(it)); return popular; } - return fuse.search(q, { limit: 8 }).map((r) => r.item); + return fuse.search(q, { limit: 12 }).map((r) => r.item); }, [query, fuse, items]); const grouped = useMemo(() => { @@ -109,14 +127,14 @@ export default function SearchDialog() { return (
e.stopPropagation()} > -
- + {/* Input row — no border on the input itself, focus ring removed, + search icon left, ESC kbd right. */} +
+
- + {headerLabel && ( -
+
{headerLabel}
)} {showEmpty && ( - - No results for "{trimmed}". Try searching by bench name, - provider, chain, or keyword. + + No results for “{trimmed}”. +
+ + Try a bench name, provider, or chain. +
)} {grouped.map(({ kind, list }) => ( 0 ? kind : undefined} - className="px-2 [&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:pt-2 [&_[cmdk-group-heading]]:pb-1 [&_[cmdk-group-heading]]:text-[10px] [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group-heading]]:uppercase [&_[cmdk-group-heading]]:tracking-[0.18em] [&_[cmdk-group-heading]]:text-ink-faint" + heading={trimmed.length > 0 ? KIND_LABEL[kind] : undefined} + className="[&_[cmdk-group-heading]]:px-3 [&_[cmdk-group-heading]]:pt-3 [&_[cmdk-group-heading]]:pb-1 [&_[cmdk-group-heading]]:text-[10px] [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group-heading]]:uppercase [&_[cmdk-group-heading]]:tracking-[0.14em] [&_[cmdk-group-heading]]:text-ink-faint" > {list.map((it) => ( go(it.url)} - className="flex items-start gap-3 rounded px-2 py-2 cursor-pointer text-sm aria-selected:bg-paper-soft" + className="group flex items-center gap-3 rounded-lg px-3 py-2.5 cursor-pointer text-sm aria-selected:bg-paper-soft transition-colors" > - - {it.kind} - - - {it.title} + + + {KIND_LABEL[it.kind].replace(/s$/, "")} + + · + + {it.title} + {it.description && ( - + {it.description} )} + ))} ))} -
- Enter to go - Esc to close + {/* Footer — subtle separator, kbd-driven hints. */} +
+
+ + + + navigate + + + + open + +
+ + esc + close +
diff --git a/src/lib/search/buildIndex.ts b/src/lib/search/buildIndex.ts index bec9813a..fcd00793 100644 --- a/src/lib/search/buildIndex.ts +++ b/src/lib/search/buildIndex.ts @@ -116,9 +116,19 @@ const STATIC_PAGES: Array<{ url: string; title: string; description: string; tag }, ]; +/** Strip `{{best_name}}` / `{{best_p50}}` / `{{p50:slug}}` etc. tokens + * from raw YAML editorial copy. The search index is built at build time + * with no Prom data, so template tokens can't be resolved here. Leaving + * them raw ships `{{best_name}} leads ...` into the search dialog + * description preview, which reads as broken. */ +function stripTemplates(s: string): string { + return s.replace(/\{\{[^}]+\}\}/g, "").replace(/\s+/g, " ").trim(); +} + function truncate(s: string | undefined, n = 150): string | undefined { if (!s) return undefined; - const clean = s.replace(/\s+/g, " ").trim(); + const clean = stripTemplates(s); + if (!clean) return undefined; if (clean.length <= n) return clean; return `${clean.slice(0, n - 1).trimEnd()}…`; } @@ -146,12 +156,18 @@ export const buildSearchIndex = cache(async function buildSearchIndex(): Promise // ── Benchmarks ────────────────────────────────────────────────── for (const spec of specs) { if (spec.status === "draft") continue; + // Prefer subtitle over seo_description when the latter contains + // template tokens. The index is built without Prom data so we can't + // resolve `{{best_name}}` here, and showing the YAML with the tokens + // stripped leaves a sentence with gaps ("leads ... at (p50, 24h)"). + const hasTokens = spec.seo_description?.includes("{{"); + const description = hasTokens ? spec.subtitle : (spec.seo_description ?? spec.subtitle); items.push({ id: `bench:${spec.slug}`, kind: "Benchmark", title: spec.title, url: `/benchmarks/${spec.slug}`, - description: truncate(spec.seo_description ?? spec.subtitle), + description: truncate(description), tags: [spec.category, spec.slug], }); }