Skip to content

Commit c23a0e3

Browse files
committed
feat: find things faster, and hand the site to your agent
Finding, for people: a command palette on ⌘K (or /) from every page, results grouped by kind as you type, arrow keys and Enter, recent queries, and typed prefixes (attr:, metric:, spec:, otlp:) that narrow. The ranking moved into site/lib/search-client.ts and is shared with /search, which now highlights matched terms and shows a deprecated name's successor inline; a segment of an id counts ("status" finds http.response.status_code) and a term of four letters matches fuzzily. The 404 page reads the path and proposes the closest rows, so a typo in an attribute URL is one click from the right page. Finding, for agents: a `search` MCP tool that takes a question in plain words and ranks everything the site knows - both registries, metrics, signals, spec and OTLP requirements, wire messages - identifiers over prose, deprecated rows held back unless the question is about them. A new /agents page carries the install line for Claude Code, Codex, Cursor, VS Code and any other client, the live tool list, and prompts to paste; llms.txt and the README point at it.
1 parent 69fddf8 commit c23a0e3

15 files changed

Lines changed: 1107 additions & 118 deletions

File tree

‎README.md‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -119,8 +119,10 @@ straight off the CDN. `/mcp` is a public, read-only MCP server (streamable HTTP,
119119
session state) whose tools read those same asset files through the Worker's `ASSETS` binding — so
120120
the MCP answers and the pages cannot drift apart.
121121

122+
`search` is the front door: a question in plain words, ranked over everything the site knows.
122123
The tool that earns its keep is `check_attribute_names`: give it the attribute keys a codebase
123-
emits and it reports which are deprecated, renamed, or absent from the registry.
124+
emits and it reports which are deprecated, renamed, or absent from the registry. `/agents` is the
125+
human-readable version of this section, with install snippets per client and prompts to paste.
124126

125127
## Severity
126128

‎site/components/Palette.astro‎

Lines changed: 287 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,287 @@
1+
---
2+
/**
3+
* The command palette: ⌘K (or `/`) from any page, results grouped by kind as
4+
* you type, arrow keys and Enter. Loads the search index on first open and
5+
* shares the ranking with /search, so a query lands on the same row either
6+
* way. Without JavaScript the header field is still a form that submits to
7+
* /search.
8+
*/
9+
---
10+
11+
<dialog id="palette" class="palette" aria-label="Search">
12+
<div class="mx-auto mt-[12vh] w-[min(40rem,calc(100%-2rem))] overflow-hidden rounded-xl border border-rule bg-surface shadow-2xl">
13+
<label class="flex h-12 items-center gap-3 border-b border-rule px-4">
14+
<svg class="h-4 w-4 shrink-0 text-muted" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="square" aria-hidden="true">
15+
<circle cx="11" cy="11" r="7" />
16+
<path d="M20 20l-3.5-3.5" />
17+
</svg>
18+
<input
19+
id="palette-input"
20+
type="text"
21+
placeholder="Search attributes, metrics, requirements, OTLP…"
22+
autocomplete="off"
23+
autocorrect="off"
24+
spellcheck="false"
25+
aria-controls="palette-list"
26+
aria-autocomplete="list"
27+
role="combobox"
28+
aria-expanded="true"
29+
class="h-full w-full bg-transparent font-mono text-[14px] text-ink outline-none placeholder:font-sans placeholder:text-faint"
30+
/>
31+
<kbd class="hidden shrink-0 rounded border border-rule px-1.5 py-0.5 font-mono text-[10px] text-muted sm:inline">esc</kbd>
32+
</label>
33+
34+
<div id="palette-list" role="listbox" class="max-h-[min(60vh,32rem)] overflow-y-auto overscroll-contain py-1.5"></div>
35+
36+
<div class="flex flex-wrap items-center gap-x-4 gap-y-1 border-t border-rule px-4 py-2 text-[11px] text-faint">
37+
<span><kbd class="font-mono">↑↓</kbd> move</span>
38+
<span><kbd class="font-mono">↵</kbd> open</span>
39+
<span class="hidden sm:inline">
40+
Prefix with <code class="code-chip">attr:</code> <code class="code-chip">metric:</code>
41+
<code class="code-chip">spec:</code> <code class="code-chip">otlp:</code> to narrow
42+
</span>
43+
<a href="/agents" class="ml-auto no-underline hover:underline">Use with your agent →</a>
44+
</div>
45+
</div>
46+
</dialog>
47+
48+
<script>
49+
import {
50+
GROUP_LABEL,
51+
STATIC_PAGES,
52+
TYPE_RANK,
53+
highlight,
54+
index,
55+
loadIndex,
56+
recentQueries,
57+
rememberQuery,
58+
search,
59+
type Indexed,
60+
} from "../lib/search-client.ts"
61+
62+
const dialog = document.getElementById("palette") as HTMLDialogElement
63+
const input = document.getElementById("palette-input") as HTMLInputElement
64+
const list = document.getElementById("palette-list") as HTMLElement
65+
const trigger = document.querySelector<HTMLInputElement>("[data-palette-trigger]")
66+
const kbd = document.querySelector<HTMLElement>("[data-palette-kbd]")
67+
68+
if (kbd && !/Mac|iPhone|iPad/.test(navigator.platform)) kbd.textContent = "Ctrl K"
69+
70+
let entries: Indexed[] | null = null
71+
let selected = 0
72+
let rows: HTMLElement[] = []
73+
const PAGES = STATIC_PAGES.map(index)
74+
const PER_GROUP = 6
75+
const TOTAL = 28
76+
77+
const open = (seed = "") => {
78+
if (dialog.open) return
79+
dialog.showModal()
80+
document.body.style.overflow = "hidden"
81+
input.value = seed
82+
render()
83+
input.focus()
84+
input.select()
85+
if (!entries) {
86+
loadIndex().then((loaded) => {
87+
entries = loaded
88+
if (dialog.open) render()
89+
})
90+
}
91+
}
92+
93+
const close = () => {
94+
dialog.close()
95+
document.body.style.overflow = ""
96+
}
97+
98+
const el = (tag: string, className: string, text?: string) => {
99+
const node = document.createElement(tag)
100+
node.className = className
101+
if (text !== undefined) node.textContent = text
102+
return node
103+
}
104+
105+
const heading = (label: string) => el("div", "eyebrow px-4 pt-3 pb-1", label)
106+
107+
const row = (entry: Indexed, terms: readonly string[]) => {
108+
const a = document.createElement("a")
109+
a.href = entry.url
110+
a.setAttribute("role", "option")
111+
a.className =
112+
"palette-row mx-1.5 flex items-baseline gap-x-3 rounded-lg px-2.5 py-1.5 no-underline transition-colors hover:bg-elevated aria-selected:bg-elevated"
113+
const id = el("span", entry.type === "page" || entry.type === "domain" ? "shrink-0 text-[13.5px] font-medium text-ink" : "shrink-0 font-mono text-[13px] font-medium text-ink")
114+
id.innerHTML = highlight(entry.id, terms)
115+
a.append(id)
116+
if (entry.renamedTo) a.append(el("span", "shrink-0 font-mono text-[12px] text-muted", `→ ${entry.renamedTo}`))
117+
const text = el("span", "min-w-0 truncate text-[12.5px] text-muted")
118+
text.innerHTML = highlight(entry.text, terms)
119+
a.append(text)
120+
if (entry.meta && entry.type !== "page") {
121+
const meta = el("span", "ml-auto hidden shrink-0 font-mono text-[10.5px] text-faint sm:inline", entry.meta.split(" · ")[0] ?? "")
122+
a.append(meta)
123+
}
124+
a.addEventListener("click", () => rememberQuery(input.value))
125+
return a
126+
}
127+
128+
const linkRow = (label: string, href: string, hint: string) => {
129+
const a = document.createElement("a")
130+
a.href = href
131+
a.setAttribute("role", "option")
132+
a.className =
133+
"palette-row mx-1.5 flex items-baseline gap-x-3 rounded-lg px-2.5 py-1.5 text-[13px] no-underline transition-colors hover:bg-elevated aria-selected:bg-elevated"
134+
a.append(el("span", "text-ink", label), el("span", "text-[12px] text-muted", hint))
135+
return a
136+
}
137+
138+
const select = (i: number) => {
139+
if (rows.length === 0) return
140+
selected = (i + rows.length) % rows.length
141+
rows.forEach((r, n) => r.setAttribute("aria-selected", String(n === selected)))
142+
rows[selected]?.scrollIntoView({ block: "nearest" })
143+
}
144+
145+
function render() {
146+
const raw = input.value.trim()
147+
list.replaceChildren()
148+
149+
if (raw === "") {
150+
const recent = recentQueries()
151+
if (recent.length > 0) {
152+
list.append(heading("Recent"))
153+
for (const q of recent) {
154+
const b = document.createElement("button")
155+
b.type = "button"
156+
b.setAttribute("role", "option")
157+
b.className =
158+
"palette-row mx-1.5 flex w-[calc(100%-0.75rem)] items-baseline gap-x-3 rounded-lg px-2.5 py-1.5 text-left font-mono text-[13px] text-ink transition-colors hover:bg-elevated aria-selected:bg-elevated"
159+
b.textContent = q
160+
b.addEventListener("click", () => {
161+
input.value = q
162+
render()
163+
input.focus()
164+
})
165+
list.append(b)
166+
}
167+
}
168+
list.append(heading("Pages"))
169+
for (const page of PAGES) list.append(row(page, []))
170+
list.append(heading("Try"))
171+
for (const [q, hint] of [
172+
["http status", "an attribute by what it holds"],
173+
["db.statement", "a deprecated name, and what replaced it"],
174+
["spec: retry", "what the specification requires"],
175+
["metric: memory usage", "instruments and units"],
176+
] as const) {
177+
const b = document.createElement("button")
178+
b.type = "button"
179+
b.setAttribute("role", "option")
180+
b.className =
181+
"palette-row mx-1.5 flex w-[calc(100%-0.75rem)] items-baseline gap-x-3 rounded-lg px-2.5 py-1.5 text-left transition-colors hover:bg-elevated aria-selected:bg-elevated"
182+
b.append(el("span", "font-mono text-[13px] text-ink", q), el("span", "text-[12px] text-muted", hint))
183+
b.addEventListener("click", () => {
184+
input.value = q
185+
render()
186+
input.focus()
187+
})
188+
list.append(b)
189+
}
190+
} else if (!entries) {
191+
list.append(el("p", "m-0 px-4 py-6 text-center text-[13px] text-muted", "Loading the index…"))
192+
} else {
193+
const result = search(entries, raw, { limit: 400 })
194+
const groups = new Map<string, Indexed[]>()
195+
let shown = 0
196+
for (const hit of result.hits) {
197+
const bucket = groups.get(hit.type) ?? []
198+
if (bucket.length >= PER_GROUP || shown >= TOTAL) continue
199+
bucket.push(hit)
200+
groups.set(hit.type, bucket)
201+
shown++
202+
}
203+
const ordered = [...groups.entries()].sort(([a], [b]) => (TYPE_RANK[a] ?? 9) - (TYPE_RANK[b] ?? 9))
204+
for (const [type, hits] of ordered) {
205+
const count = result.perType[type] ?? hits.length
206+
list.append(heading(`${GROUP_LABEL[type] ?? type}${count > hits.length ? ` · ${count}` : ""}`))
207+
for (const hit of hits) list.append(row(hit, result.query.terms))
208+
}
209+
if (result.total === 0) {
210+
list.append(el("p", "m-0 px-4 py-6 text-center text-[13px] text-muted", "Nothing matches. Fewer words, or a different spelling?"))
211+
}
212+
if (result.total > shown || result.total > 0) {
213+
list.append(heading(""))
214+
list.append(
215+
linkRow(
216+
result.total > shown ? `All ${result.total} results` : "Open in search",
217+
`/search?q=${encodeURIComponent(raw)}`,
218+
"with filters and full descriptions",
219+
),
220+
)
221+
}
222+
}
223+
224+
rows = Array.from(list.querySelectorAll<HTMLElement>(".palette-row"))
225+
selected = 0
226+
select(0)
227+
}
228+
229+
input.addEventListener("input", render)
230+
231+
input.addEventListener("keydown", (event) => {
232+
if (event.key === "ArrowDown") {
233+
event.preventDefault()
234+
select(selected + 1)
235+
} else if (event.key === "ArrowUp") {
236+
event.preventDefault()
237+
select(selected - 1)
238+
} else if (event.key === "Enter") {
239+
event.preventDefault()
240+
const target = rows[selected]
241+
if (target) {
242+
rememberQuery(input.value)
243+
target.click()
244+
} else if (input.value.trim()) {
245+
rememberQuery(input.value)
246+
location.href = `/search?q=${encodeURIComponent(input.value.trim())}`
247+
}
248+
}
249+
})
250+
251+
dialog.addEventListener("close", () => {
252+
document.body.style.overflow = ""
253+
})
254+
// A click on the backdrop lands on the dialog itself, not on the panel.
255+
dialog.addEventListener("click", (event) => {
256+
if (event.target === dialog) close()
257+
})
258+
259+
// The header field is a real form for no-JS readers; with JS it is the trigger.
260+
if (trigger) {
261+
trigger.addEventListener("focus", () => {
262+
trigger.blur()
263+
open()
264+
})
265+
trigger.addEventListener("click", () => open())
266+
}
267+
268+
document.addEventListener("keydown", (event) => {
269+
const target = event.target as HTMLElement | null
270+
const typing = target && /^(INPUT|TEXTAREA|SELECT)$/.test(target.tagName)
271+
if ((event.metaKey || event.ctrlKey) && event.key.toLowerCase() === "k") {
272+
event.preventDefault()
273+
dialog.open ? close() : open()
274+
return
275+
}
276+
// `/` is the reflex on any reference site. On /search and /attributes it
277+
// goes to the page's own field, which has filters the palette does not.
278+
if (event.key === "/" && !typing && !event.metaKey && !event.ctrlKey && !event.altKey) {
279+
const field = document.getElementById("q")
280+
event.preventDefault()
281+
if (field instanceof HTMLInputElement) {
282+
field.focus()
283+
field.select()
284+
} else open()
285+
}
286+
})
287+
</script>

‎site/layouts/Shell.astro‎

Lines changed: 7 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
import "../styles/global.css"
33
import MapleMark from "../components/MapleMark.astro"
44
import SemconvMark from "../components/SemconvMark.astro"
5+
import Palette from "../components/Palette.astro"
56
import { SITE } from "../lib/site.ts"
67
78
interface Props {
@@ -21,6 +22,7 @@ const nav = [
2122
{ href: "/spec", label: "Spec" },
2223
{ href: "/otlp", label: "OTLP" },
2324
{ href: "/diff", label: "Compare" },
25+
{ href: "/agents", label: "Agents" },
2426
{ href: "/about", label: "About" },
2527
]
2628
const isActive = (href: string) => (href === "/" ? path === "/" : path.startsWith(href))
@@ -94,18 +96,21 @@ const isActive = (href: string) => (href === "/" ? path === "/" : path.startsWit
9496
id="site-search"
9597
type="search"
9698
name="q"
97-
placeholder="Search everything"
99+
placeholder="Search"
100+
data-palette-trigger
98101
aria-label="Search"
99102
autocomplete="off"
100103
spellcheck="false"
101104
class="h-full w-full bg-transparent pl-7 pr-7 text-xs text-ink outline-none placeholder:text-muted"
102105
/>
103-
<kbd class="pointer-events-none absolute right-2 hidden font-mono text-[10px] font-medium tracking-widest text-muted/70 sm:inline">/</kbd>
106+
<kbd class="pointer-events-none absolute right-2 hidden font-mono text-[10px] font-medium tracking-widest text-muted/70 sm:inline" data-palette-kbd>⌘K</kbd>
104107
</label>
105108
</form>
106109
</div>
107110
</header>
108111

112+
<Palette />
113+
109114
<main id="main" tabindex="-1" class:list={["mx-auto px-5 py-10 outline-none", wide ? "max-w-[86rem]" : "max-w-[62rem]"]}>
110115
<slot />
111116
</main>
@@ -153,19 +158,6 @@ const isActive = (href: string) => (href === "/" ? path === "/" : path.startsWit
153158
</div>
154159
</div>
155160
</footer>
156-
<script>
157-
// `/` focuses search, the reflex on any reference site.
158-
document.addEventListener("keydown", (event) => {
159-
const target = event.target as HTMLElement | null
160-
if (event.key !== "/" || event.metaKey || event.ctrlKey || event.altKey) return
161-
if (target && /^(INPUT|TEXTAREA|SELECT)$/.test(target.tagName)) return
162-
const field = document.getElementById("q") ?? document.getElementById("site-search")
163-
if (!(field instanceof HTMLInputElement)) return
164-
event.preventDefault()
165-
field.focus()
166-
field.select()
167-
})
168-
</script>
169161
</body>
170162
</html>
171163

0 commit comments

Comments
 (0)