docs: full-text search with snippets + peek a11y (0.93.0)

the docs search only matched titles/folders, so searching a term that lives in
a doc's body (e.g. 'heartbeat') returned nothing — you had to already know
which doc to open.

- search now matches title AND body across all docs; results show the title
  plus a context snippet with the query highlighted (<mark>)
- doc bodies are fetched once on first search and cached (invalidated when the
  doc list changes); in-flight searches cancel on a new query
- clicking a result opens the doc (and updates the URL); clearing returns the
  folder tree
- a11y: the homepage API peek used role=tablist/tab without tabpanels (an
  incomplete ARIA pattern) — switched to a labeled button group with aria-pressed
- verified live: 'heartbeat' -> 3 body matches with highlighted snippets,
  click opens Connect-your-broker, clear restores the tree
This commit is contained in:
artheru 2026-06-10 14:08:19 +08:00
parent ce9d2336f7
commit 66ccd54d21
5 changed files with 75 additions and 18 deletions

View File

@ -17,7 +17,7 @@
@media (prefers-reduced-motion: reduce) { .app-loading-gem { animation: none; transform: rotate(45deg); } } @media (prefers-reduced-motion: reduce) { .app-loading-gem { animation: none; transform: rotate(45deg); } }
</style> </style>
<meta name="description" content="amerc is the agent mercenary tavern — hire, run, publish, and remotely deliver AI agents across software boundaries. No infrastructure, just skills." /> <meta name="description" content="amerc is the agent mercenary tavern — hire, run, publish, and remotely deliver AI agents across software boundaries. No infrastructure, just skills." />
<meta name="version" content="0.92.0-apipeek" /> <meta name="version" content="0.93.0-docsearch" />
<link rel="icon" href="/favicon.svg" type="image/svg+xml" /> <link rel="icon" href="/favicon.svg" type="image/svg+xml" />
<link rel="icon" href="/app-192.png" type="image/png" sizes="192x192" /> <link rel="icon" href="/app-192.png" type="image/png" sizes="192x192" />
<link rel="apple-touch-icon" href="/apple-touch-icon.png" /> <link rel="apple-touch-icon" href="/apple-touch-icon.png" />

View File

@ -1,6 +1,6 @@
{ {
"name": "amerc-site", "name": "amerc-site",
"version": "0.92.0-apipeek", "version": "0.93.0-docsearch",
"private": true, "private": true,
"type": "module", "type": "module",
"scripts": { "scripts": {

View File

@ -25,6 +25,16 @@ function highlightCode(code) {
// URL-safe slug from a doc title, for shareable #/<slug> doc links. // URL-safe slug from a doc title, for shareable #/<slug> doc links.
const slugify = (s) => String(s || '').toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '') || 'doc'; const slugify = (s) => String(s || '').toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '') || 'doc';
// A context snippet around a full-text match, markdown stripped, query <mark>ed.
function makeSnippet(body, idx, query) {
const start = Math.max(0, idx - 48);
const end = Math.min(body.length, idx + query.length + 64);
const raw = body.slice(start, end).replace(/[#>*`~_[\]]/g, '').replace(/\s+/g, ' ').trim();
const esc = (s) => s.replace(/[&<>]/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;' }[c]));
const re = new RegExp(`(${query.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')})`, 'ig');
return (start > 0 ? '…' : '') + esc(raw).replace(re, '<mark>$1</mark>') + (end < body.length ? '…' : '');
}
function DocsWorkspace({ canEdit }) { function DocsWorkspace({ canEdit }) {
const [docs, setDocs] = useState([]); const [docs, setDocs] = useState([]);
const [sel, setSel] = useState(null); const [sel, setSel] = useState(null);
@ -34,8 +44,10 @@ function DocsWorkspace({ canEdit }) {
const [err, setErr] = useState(''); const [err, setErr] = useState('');
const [q, setQ] = useState(''); const [q, setQ] = useState('');
const [navOpen, setNavOpen] = useState(false); const [navOpen, setNavOpen] = useState(false);
const [results, setResults] = useState(null);
const renderRef = useRef(null); const renderRef = useRef(null);
const pendingSection = useRef(null); const pendingSection = useRef(null);
const bodiesRef = useRef(null);
const loadList = useCallback(async () => { const loadList = useCallback(async () => {
try { try {
@ -121,6 +133,30 @@ function DocsWorkspace({ canEdit }) {
const el = renderRef.current?.querySelector(`#${CSS.escape(s)}`); if (el) el.scrollIntoView({ block: 'start' }); const el = renderRef.current?.querySelector(`#${CSS.escape(s)}`); if (el) el.scrollIntoView({ block: 'start' });
})); }));
}, [docHtml]); }, [docHtml]);
// full-text search: fetch all doc bodies once, then match title + body, with snippets
useEffect(() => { bodiesRef.current = null; }, [docs]);
useEffect(() => {
const query = q.trim();
if (!query) { setResults(null); return; }
let live = true;
(async () => {
if (!bodiesRef.current) {
const all = await Promise.all(docs.map((d) => api(`/docs/${d.id}`).then((r) => [d.id, r.doc.body || '']).catch(() => [d.id, ''])));
if (!live) return;
bodiesRef.current = Object.fromEntries(all);
}
const ql = query.toLowerCase();
const res = [];
for (const d of docs) {
const body = bodiesRef.current[d.id] || '';
const inMeta = (d.title + ' ' + (d.folder || '')).toLowerCase().includes(ql);
const idx = body.toLowerCase().indexOf(ql);
if (inMeta || idx >= 0) res.push({ ...d, snippet: idx >= 0 ? makeSnippet(body, idx, query) : '' });
}
if (live) setResults(res);
})();
return () => { live = false; };
}, [q, docs]);
const create = async () => { const create = async () => {
const title = window.prompt('Document title', 'New document'); if (!title) return; const title = window.prompt('Document title', 'New document'); if (!title) return;
@ -129,9 +165,8 @@ function DocsWorkspace({ canEdit }) {
const save = async () => { try { await api(`/docs/${sel}`, { method: 'PATCH', body: draft }); await loadList(); setDoc({ ...doc, ...draft }); setEditing(false); } catch (e) { setErr(e.message); } }; const save = async () => { try { await api(`/docs/${sel}`, { method: 'PATCH', body: draft }); await loadList(); setDoc({ ...doc, ...draft }); setEditing(false); } catch (e) { setErr(e.message); } };
const del = async () => { if (!window.confirm('Delete document?')) return; try { await api(`/docs/${sel}`, { method: 'DELETE' }); setSel(null); loadList(); } catch (e) { setErr(e.message); } }; const del = async () => { if (!window.confirm('Delete document?')) return; try { await api(`/docs/${sel}`, { method: 'DELETE' }); setSel(null); loadList(); } catch (e) { setErr(e.message); } };
const filtered = docs.filter((d) => (d.title + ' ' + (d.folder || '')).toLowerCase().includes(q.toLowerCase()));
const folders = {}; const folders = {};
filtered.forEach((d) => { (folders[d.folder || ''] = folders[d.folder || ''] || []).push(d); }); docs.forEach((d) => { (folders[d.folder || ''] = folders[d.folder || ''] || []).push(d); });
return ( return (
<div className="docs"> <div className="docs">
@ -145,17 +180,30 @@ function DocsWorkspace({ canEdit }) {
<input placeholder="search docs…" value={q} onChange={(e) => setQ(e.target.value)} /> <input placeholder="search docs…" value={q} onChange={(e) => setQ(e.target.value)} />
{canEdit && <button className="cs-btn cs-primary" onClick={create}>+ New</button>} {canEdit && <button className="cs-btn cs-primary" onClick={create}>+ New</button>}
</div> </div>
<div className="docs-tree"> {q.trim() ? (
{Object.keys(folders).sort().map((f) => ( <div className="docs-results">
<div key={f} className="docs-folder"> {results === null && <p className="cs-hint">Searching</p>}
<div className="docs-folder-name">{f || '📁 root'}</div> {results !== null && !results.length && <p className="cs-hint">No matches for {q.trim()}.</p>}
{folders[f].map((d) => ( {results !== null && results.map((d) => (
<button key={d.id} className={`docs-item${sel === d.id ? ' on' : ''}`} onClick={() => openDoc(d)}>{d.title}</button> <button key={d.id} className={`docs-result${sel === d.id ? ' on' : ''}`} onClick={() => openDoc(d)}>
))} <span className="docs-result-title">{d.title}</span>
</div> {d.snippet && <span className="docs-result-snip" dangerouslySetInnerHTML={{ __html: d.snippet }} />}
))} </button>
{!filtered.length && <p className="cs-hint">No documents yet. Create one humans and agents (via API key) can both read & write here.</p>} ))}
</div> </div>
) : (
<div className="docs-tree">
{Object.keys(folders).sort().map((f) => (
<div key={f} className="docs-folder">
<div className="docs-folder-name">{f || '📁 root'}</div>
{folders[f].map((d) => (
<button key={d.id} className={`docs-item${sel === d.id ? ' on' : ''}`} onClick={() => openDoc(d)}>{d.title}</button>
))}
</div>
))}
{!docs.length && <p className="cs-hint">No documents yet. Create one humans and agents (via API key) can both read & write here.</p>}
</div>
)}
</div> </div>
</aside> </aside>
<section className="docs-main"> <section className="docs-main">

View File

@ -10,7 +10,7 @@ import { api } from './api.js';
const Menu = ({ size = 20 }) => (<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" aria-hidden="true"><line x1="3" y1="6" x2="21" y2="6" /><line x1="3" y1="12" x2="21" y2="12" /><line x1="3" y1="18" x2="21" y2="18" /></svg>); const Menu = ({ size = 20 }) => (<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" aria-hidden="true"><line x1="3" y1="6" x2="21" y2="6" /><line x1="3" y1="12" x2="21" y2="12" /><line x1="3" y1="18" x2="21" y2="18" /></svg>);
const X = ({ size = 20 }) => (<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" aria-hidden="true"><line x1="18" y1="6" x2="6" y2="18" /><line x1="6" y1="6" x2="18" y2="18" /></svg>); const X = ({ size = 20 }) => (<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" aria-hidden="true"><line x1="18" y1="6" x2="6" y2="18" /><line x1="6" y1="6" x2="18" y2="18" /></svg>);
const RELEASE = '0.92.0-apipeek'; const RELEASE = '0.93.0-docsearch';
const NAV = [ const NAV = [
{ id: 'home', label: 'Tavern' }, { id: 'home', label: 'Tavern' },
@ -264,9 +264,9 @@ function ApiPeek() {
const cur = PEEK.find((x) => x.id === ep); const cur = PEEK.find((x) => x.id === ep);
return ( return (
<div className="hf-peek"> <div className="hf-peek">
<div className="hf-peek-tabs" role="tablist" aria-label="API endpoint"> <div className="hf-peek-tabs" role="group" aria-label="API endpoint">
{PEEK.map((e) => ( {PEEK.map((e) => (
<button key={e.id} type="button" role="tab" aria-selected={ep === e.id} className={`hf-peek-tab${ep === e.id ? ' on' : ''}`} onClick={() => setEp(e.id)}>{e.label}</button> <button key={e.id} type="button" aria-pressed={ep === e.id} className={`hf-peek-tab${ep === e.id ? ' on' : ''}`} onClick={() => setEp(e.id)}>{e.label}</button>
))} ))}
</div> </div>
<div className="hf-peek-term"> <div className="hf-peek-term">

View File

@ -1538,3 +1538,12 @@ button {
.docs-anchor { opacity: 0; margin-left: 9px; padding: 0 2px; background: none; border: none; color: #46c8e0; cursor: pointer; font-size: 0.8em; font-weight: 600; text-decoration: none; vertical-align: middle; transition: opacity 0.12s; } .docs-anchor { opacity: 0; margin-left: 9px; padding: 0 2px; background: none; border: none; color: #46c8e0; cursor: pointer; font-size: 0.8em; font-weight: 600; text-decoration: none; vertical-align: middle; transition: opacity 0.12s; }
.docs-render h2:hover .docs-anchor, .docs-render h3:hover .docs-anchor, .docs-anchor:focus-visible { opacity: 0.85; } .docs-render h2:hover .docs-anchor, .docs-render h3:hover .docs-anchor, .docs-anchor:focus-visible { opacity: 0.85; }
.docs-anchor:hover { opacity: 1; } .docs-anchor:hover { opacity: 1; }
/* docs full-text search results (0.93) */
.docs-results { padding: 8px; display: flex; flex-direction: column; gap: 2px; }
.docs-result { display: flex; flex-direction: column; gap: 3px; text-align: left; background: none; border: none; padding: 8px 10px; border-radius: 8px; cursor: pointer; }
.docs-result:hover { background: #131c2b; }
.docs-result.on { background: #1a2740; }
.docs-result-title { color: #eaf2ff; font-size: 13.5px; font-weight: 600; }
.docs-result-snip { color: #8595ab; font-size: 12px; line-height: 1.45; }
.docs-result-snip mark { background: rgba(39, 216, 255, 0.22); color: #d6f0f8; border-radius: 2px; padding: 0 1px; }