thomwolf HF Staff commited on
Commit
f35e14b
·
verified ·
1 Parent(s): 903172a

Search inside article bodies from the top-left box; ⌘K focuses it

Browse files

The top-left search now matches article *content*, not just titles.

- matchPage also tests each article's body, indexed for free during the existing ensureCitemap() background pass (state.searchText — no new fetches). A body-only match shows a one-line highlighted snippet under the topic so it's clear why and where it matched.
- Responsive: typing kicks off indexing immediately and results fill in as articles get indexed.
- Cmd/Ctrl-K (and '/') and the Cmd-K badge now just focus the top-left search — one box for everything. The separate command palette (which felt broken) is removed entirely: DOM, JS (paletteResults / renderPalette / fuzzyScore / buildPaletteIndex / open-close-palette / palKeydown), and CSS.

Verified in-browser: 48 article bodies indexed; 'variance' surfaces 19 topics each with a bolded snippet (e.g. MDP Formulation, whose title lacks the word); title search still works; Cmd-K / '/' / badge all focus the box.

Files changed (3) hide show
  1. app.js +46 -77
  2. index.html +2 -15
  3. styles.css +9 -48
app.js CHANGED
@@ -26,10 +26,8 @@ const state = {
26
  collapsed: new Set(JSON.parse(localStorage.getItem('viz-collapsed') || '[]')),
27
  treeCollapsed: new Set(JSON.parse(localStorage.getItem('viz-tree-collapsed') || '[]')),
28
  srcNs: null, // selected source-namespace filter (null = all)
29
- palItems: [], // command-palette index
30
- palFiltered: [],
31
- palSel: 0,
32
  pageContent: new Map(), // path -> article markdown (cached)
 
33
  citedBy: new Map(), // sourceId -> Set(topic path) that cite it
34
  citeCount: new Map(), // sourceId -> # distinct articles citing it
35
  wordCount: new Map(), // topic path -> prose word count
@@ -957,7 +955,9 @@ function renderTopicsNav(list, q) {
957
  matches.forEach(n => {
958
  const it = el('div', 'nav-item');
959
  it.dataset.path = n.page.path;
960
- it.innerHTML = `${esc(n.page.title || n.slug)}`;
 
 
961
  it.addEventListener('click', () => go(`#/topic/${encodeURIComponent(n.page.path)}`));
962
  body.append(it);
963
  });
@@ -1049,7 +1049,36 @@ function sortSources(items, mode) {
1049
  else arr.sort((a, b) => (state.citeCount.get(b.id) || 0) - (state.citeCount.get(a.id) || 0) || (a.title || a.id).localeCompare(b.title || b.id));
1050
  return arr;
1051
  }
1052
- const matchPage = (p, q) => (p.title || '').toLowerCase().includes(q) || p.path.toLowerCase().includes(q);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1053
  const matchSource = (s, q) => (s.title || '').toLowerCase().includes(q) || s.id.toLowerCase().includes(q);
1054
  function toggleCat(cat) {
1055
  if (state.collapsed.has(cat)) state.collapsed.delete(cat); else state.collapsed.add(cat);
@@ -1071,30 +1100,6 @@ function highlightActive() {
1071
  Filters an in-memory index, so it stays instant at thousands of items.
1072
  ============================================================ */
1073
  // `text` and `q` must already be lowercased (callers pre-lower for speed).
1074
- function fuzzyScore(q, text) {
1075
- const idx = text.indexOf(q);
1076
- if (idx === 0) return 1000; // prefix match — best
1077
- if (idx > 0) return 700 - Math.min(idx, 300);
1078
- // subsequence fallback — start at the first occurrence of q[0]; bail cheaply if absent.
1079
- let start = text.indexOf(q[0]);
1080
- if (start < 0) return -1;
1081
- let qi = 1, gaps = 0, last = start;
1082
- for (let ti = start + 1; ti < text.length && qi < q.length; ti++) {
1083
- if (text[ti] === q[qi]) { gaps += ti - last - 1; last = ti; qi++; }
1084
- }
1085
- return qi === q.length ? 300 - Math.min(gaps, 200) - Math.min(start, 50) : -1;
1086
- }
1087
- function paletteResults(raw) {
1088
- const q = raw.toLowerCase().trim();
1089
- if (!q) return state.palItems.slice(0, 40);
1090
- const scored = [];
1091
- for (const it of state.palItems) {
1092
- const s = Math.max(fuzzyScore(q, it._l), fuzzyScore(q, it._s) - 60);
1093
- if (s > -1) scored.push({ it, s });
1094
- }
1095
- scored.sort((a, b) => b.s - a.s || a.it.label.localeCompare(b.it.label));
1096
- return scored.slice(0, 40).map(x => x.it);
1097
- }
1098
  function highlightMatch(text, raw) {
1099
  const q = raw.trim();
1100
  if (!q) return esc(text);
@@ -1102,37 +1107,6 @@ function highlightMatch(text, raw) {
1102
  if (i < 0) return esc(text);
1103
  return esc(text.slice(0, i)) + '<b>' + esc(text.slice(i, i + q.length)) + '</b>' + esc(text.slice(i + q.length));
1104
  }
1105
- function renderPalette() {
1106
- const q = $('#palInput').value;
1107
- state.palFiltered = paletteResults(q);
1108
- if (state.palSel >= state.palFiltered.length) state.palSel = 0;
1109
- const box = $('#palResults');
1110
- if (!state.palFiltered.length) { box.innerHTML = `<div class="pal-empty">No matches for “${esc(q)}”.</div>`; return; }
1111
- box.innerHTML = state.palFiltered.map((it, i) => `
1112
- <div class="pal-item${i === state.palSel ? ' sel' : ''}" data-i="${i}">
1113
- <span class="pal-kind ${it.kind}">${it.kind}</span>
1114
- <span class="pal-text"><span class="pal-label">${highlightMatch(it.label, q)}</span><span class="pal-sub">${esc(it.sub)}</span></span>
1115
- </div>`).join('');
1116
- $$('#palResults .pal-item').forEach(n => {
1117
- n.addEventListener('mousemove', () => { if (state.palSel !== +n.dataset.i) { state.palSel = +n.dataset.i; markPalSel(); } });
1118
- n.addEventListener('click', () => choosePal(+n.dataset.i));
1119
- });
1120
- }
1121
- function markPalSel() { $$('#palResults .pal-item').forEach((n, i) => n.classList.toggle('sel', i === state.palSel)); }
1122
- function scrollPalSel() { $$('#palResults .pal-item')[state.palSel]?.scrollIntoView({ block: 'nearest' }); }
1123
- function choosePal(i) { const it = state.palFiltered[i]; if (it) { closePalette(); go(it.hash); } }
1124
- function openPalette() {
1125
- const sc = $('#palScrim'); sc.hidden = false;
1126
- const inp = $('#palInput'); inp.value = ''; state.palSel = 0;
1127
- renderPalette(); inp.focus();
1128
- }
1129
- function closePalette() { $('#palScrim').hidden = true; }
1130
- function palKeydown(e) {
1131
- if (e.key === 'ArrowDown') { e.preventDefault(); state.palSel = Math.min(state.palSel + 1, state.palFiltered.length - 1); markPalSel(); scrollPalSel(); }
1132
- else if (e.key === 'ArrowUp') { e.preventDefault(); state.palSel = Math.max(state.palSel - 1, 0); markPalSel(); scrollPalSel(); }
1133
- else if (e.key === 'Enter') { e.preventDefault(); choosePal(state.palSel); }
1134
- else if (e.key === 'Escape') { e.preventDefault(); closePalette(); }
1135
- }
1136
 
1137
  /* ── mobile nav ────────────────────────────────────────── */
1138
  function closeNav() { document.body.classList.remove('nav-open'); }
@@ -1181,7 +1155,6 @@ async function loadCore() {
1181
  }
1182
  try { state.taxonomy = taxT && window.jsyaml ? jsyaml.load(taxT) : null; } catch (e) { state.taxonomy = null; }
1183
  buildTaxonomy();
1184
- buildPaletteIndex();
1185
  }
1186
  const SOURCES_CACHE_KEY = 'viz-sources-v1';
1187
  function setSources(items) {
@@ -1192,7 +1165,7 @@ function setSources(items) {
1192
  if (items.some(s => s.title)) state.sourceTitlesLoaded = true; // tree stage has none yet
1193
  }
1194
  function refreshSourceViews() {
1195
- buildPaletteIndex(); updateCounts();
1196
  softRefresh(); // re-renders the current view (incl. the sources browser), upgrading titles/resolution
1197
  }
1198
  // Reverse the filename sanitization (`:`/`/` → `-`): `arxiv-1707.06347.md` → `arxiv:1707.06347`.
@@ -1273,13 +1246,6 @@ function buildTaxonomy() {
1273
  state.tax = cats;
1274
  }
1275
 
1276
- function buildPaletteIndex() {
1277
- const mk = (kind, label, sub, hash) => ({ kind, label, sub, hash, _l: label.toLowerCase(), _s: sub.toLowerCase() });
1278
- state.palItems = [
1279
- ...state.pages.map(p => mk('topic', p.title || p.path, catLabel(p._cat || p.parent || ''), `#/topic/${encodeURIComponent(p.path)}`)),
1280
- ...state.sources.map(s => mk('source', s.title || s.id, s.id, `#/source/${encodeURIComponent(s.id)}`)),
1281
- ];
1282
- }
1283
  const catLabel = (c) => prettyTitle((c || '').replace(/-/g, ' '));
1284
  // Sentence-cased category for headings, e.g. "reward-modeling" → "Reward modeling".
1285
  const catDisplay = (c) => { const s = catLabel(c); return s.charAt(0).toUpperCase() + s.slice(1); };
@@ -1327,6 +1293,8 @@ async function ensureCitemap() {
1327
  ids.forEach(id => { if (!state.citedBy.has(id)) state.citedBy.set(id, new Set()); state.citedBy.get(id).add(path); });
1328
  state.refCount.set(path, refs);
1329
  state.wordCount.set(path, countWords(content));
 
 
1330
  } catch (e) { /* skip unreadable article */ }
1331
  }
1332
  };
@@ -1379,18 +1347,19 @@ async function boot() {
1379
  $('#navScrim').addEventListener('click', closeNav);
1380
  $('#brandHome').addEventListener('click', () => go('#/'));
1381
  let st;
1382
- $('#navSearch').addEventListener('input', () => { clearTimeout(st); st = setTimeout(renderNav, 120); });
 
 
 
1383
  window.addEventListener('hashchange', route);
1384
 
1385
- // command palette
1386
- $('#paletteOpen').addEventListener('click', openPalette);
1387
- $('#palInput').addEventListener('input', renderPalette);
1388
- $('#palInput').addEventListener('keydown', palKeydown);
1389
- $('#palScrim').addEventListener('click', (e) => { if (e.target === $('#palScrim')) closePalette(); });
1390
  window.addEventListener('keydown', (e) => {
1391
- if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === 'k') { e.preventDefault(); $('#palScrim').hidden ? openPalette() : closePalette(); return; }
1392
  const typing = /^(input|textarea)$/i.test(document.activeElement?.tagName || '');
1393
- if (e.key === '/' && !typing && $('#palScrim').hidden) { e.preventDefault(); openPalette(); }
1394
  });
1395
  initHovercards();
1396
 
 
26
  collapsed: new Set(JSON.parse(localStorage.getItem('viz-collapsed') || '[]')),
27
  treeCollapsed: new Set(JSON.parse(localStorage.getItem('viz-tree-collapsed') || '[]')),
28
  srcNs: null, // selected source-namespace filter (null = all)
 
 
 
29
  pageContent: new Map(), // path -> article markdown (cached)
30
+ searchText: new Map(), // topic path -> lowercased body, for nav content search
31
  citedBy: new Map(), // sourceId -> Set(topic path) that cite it
32
  citeCount: new Map(), // sourceId -> # distinct articles citing it
33
  wordCount: new Map(), // topic path -> prose word count
 
955
  matches.forEach(n => {
956
  const it = el('div', 'nav-item');
957
  it.dataset.path = n.page.path;
958
+ const snip = q && bodyOnlyMatch(n.page, q) ? bodySnippet(n.page.path, q) : '';
959
+ it.innerHTML = `<span class="nav-item-title">${esc(n.page.title || n.slug)}</span>` +
960
+ (snip ? `<span class="nav-snippet">${snip}</span>` : '');
961
  it.addEventListener('click', () => go(`#/topic/${encodeURIComponent(n.page.path)}`));
962
  body.append(it);
963
  });
 
1049
  else arr.sort((a, b) => (state.citeCount.get(b.id) || 0) - (state.citeCount.get(a.id) || 0) || (a.title || a.id).localeCompare(b.title || b.id));
1050
  return arr;
1051
  }
1052
+ // A page matches on its title, its path, or anywhere in its (background-indexed)
1053
+ // body. `q` is already lowercased by callers.
1054
+ const matchPage = (p, q) =>
1055
+ (p.title || '').toLowerCase().includes(q) ||
1056
+ p.path.toLowerCase().includes(q) ||
1057
+ (state.searchText.get(p.path) || '').includes(q);
1058
+ // True when *only* the body matched — those items get a snippet so it's clear why.
1059
+ const bodyOnlyMatch = (p, q) =>
1060
+ !(p.title || '').toLowerCase().includes(q) && !p.path.toLowerCase().includes(q) &&
1061
+ (state.searchText.get(p.path) || '').includes(q);
1062
+ // A one-line excerpt around the first body hit, lightly de-marked, query bolded.
1063
+ function bodySnippet(path, q) {
1064
+ const text = state.pageContent.get(path), lc = state.searchText.get(path);
1065
+ if (!text || !lc) return '';
1066
+ const i = lc.indexOf(q);
1067
+ if (i < 0) return '';
1068
+ const start = Math.max(0, i - 30);
1069
+ let clip = text.slice(start, i + q.length + 40)
1070
+ .replace(/\[source:[^\]]*\]/g, ' ')
1071
+ .replace(/[#>*_`~|]+/g, ' ')
1072
+ .replace(/\s+/g, ' ').trim();
1073
+ clip = (start > 0 ? '…' : '') + clip + '…';
1074
+ return highlightMatch(clip, q);
1075
+ }
1076
+ // Throttled nav re-render, used while background indexing fills in during a search.
1077
+ let _navRefreshT = null;
1078
+ function scheduleNavRefresh() {
1079
+ if (_navRefreshT) return;
1080
+ _navRefreshT = setTimeout(() => { _navRefreshT = null; if (($('#navSearch').value || '').trim()) renderNav(); }, 200);
1081
+ }
1082
  const matchSource = (s, q) => (s.title || '').toLowerCase().includes(q) || s.id.toLowerCase().includes(q);
1083
  function toggleCat(cat) {
1084
  if (state.collapsed.has(cat)) state.collapsed.delete(cat); else state.collapsed.add(cat);
 
1100
  Filters an in-memory index, so it stays instant at thousands of items.
1101
  ============================================================ */
1102
  // `text` and `q` must already be lowercased (callers pre-lower for speed).
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1103
  function highlightMatch(text, raw) {
1104
  const q = raw.trim();
1105
  if (!q) return esc(text);
 
1107
  if (i < 0) return esc(text);
1108
  return esc(text.slice(0, i)) + '<b>' + esc(text.slice(i, i + q.length)) + '</b>' + esc(text.slice(i + q.length));
1109
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1110
 
1111
  /* ── mobile nav ────────────────────────────────────────── */
1112
  function closeNav() { document.body.classList.remove('nav-open'); }
 
1155
  }
1156
  try { state.taxonomy = taxT && window.jsyaml ? jsyaml.load(taxT) : null; } catch (e) { state.taxonomy = null; }
1157
  buildTaxonomy();
 
1158
  }
1159
  const SOURCES_CACHE_KEY = 'viz-sources-v1';
1160
  function setSources(items) {
 
1165
  if (items.some(s => s.title)) state.sourceTitlesLoaded = true; // tree stage has none yet
1166
  }
1167
  function refreshSourceViews() {
1168
+ updateCounts();
1169
  softRefresh(); // re-renders the current view (incl. the sources browser), upgrading titles/resolution
1170
  }
1171
  // Reverse the filename sanitization (`:`/`/` → `-`): `arxiv-1707.06347.md` → `arxiv:1707.06347`.
 
1246
  state.tax = cats;
1247
  }
1248
 
 
 
 
 
 
 
 
1249
  const catLabel = (c) => prettyTitle((c || '').replace(/-/g, ' '));
1250
  // Sentence-cased category for headings, e.g. "reward-modeling" → "Reward modeling".
1251
  const catDisplay = (c) => { const s = catLabel(c); return s.charAt(0).toUpperCase() + s.slice(1); };
 
1293
  ids.forEach(id => { if (!state.citedBy.has(id)) state.citedBy.set(id, new Set()); state.citedBy.get(id).add(path); });
1294
  state.refCount.set(path, refs);
1295
  state.wordCount.set(path, countWords(content));
1296
+ state.searchText.set(path, content.toLowerCase()); // for nav content search
1297
+ if (($('#navSearch').value || '').trim()) scheduleNavRefresh(); // live-fill an in-progress search
1298
  } catch (e) { /* skip unreadable article */ }
1299
  }
1300
  };
 
1347
  $('#navScrim').addEventListener('click', closeNav);
1348
  $('#brandHome').addEventListener('click', () => go('#/'));
1349
  let st;
1350
+ $('#navSearch').addEventListener('input', () => {
1351
+ ensureCitemap(); // kick off body indexing on first keystroke so content matches fill in fast
1352
+ clearTimeout(st); st = setTimeout(renderNav, 120);
1353
+ });
1354
  window.addEventListener('hashchange', route);
1355
 
1356
+ // ⌘K (and "/") focus the top-left search — one box that filters titles + bodies.
1357
+ const focusNavSearch = () => { const s = $('#navSearch'); s.focus(); s.select(); };
1358
+ $('#paletteOpen').addEventListener('click', focusNavSearch);
 
 
1359
  window.addEventListener('keydown', (e) => {
1360
+ if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === 'k') { e.preventDefault(); focusNavSearch(); return; }
1361
  const typing = /^(input|textarea)$/i.test(document.activeElement?.tagName || '');
1362
+ if (e.key === '/' && !typing) { e.preventDefault(); focusNavSearch(); }
1363
  });
1364
  initHovercards();
1365
 
index.html CHANGED
@@ -63,8 +63,8 @@
63
  <aside class="sidebar" id="sidebar">
64
  <div class="side-head">Topics</div>
65
  <div class="search-wrap">
66
- <input class="search" id="navSearch" type="text" placeholder="Search topics…" autocomplete="off">
67
- <kbd class="search-k" id="paletteOpen" title="Search everything — topics &amp; sources (⌘K)">⌘K</kbd>
68
  </div>
69
  <nav id="navList"></nav>
70
  </aside>
@@ -76,19 +76,6 @@
76
  <aside class="aside" id="aside"></aside>
77
  </div>
78
 
79
- <!-- Command palette: universal jump to any topic or source -->
80
- <div class="palette-scrim" id="palScrim" hidden>
81
- <div class="palette" role="dialog" aria-label="Search">
82
- <div class="palette-search">
83
- <span class="pal-icon">⌕</span>
84
- <input id="palInput" class="palette-input" type="text" placeholder="Jump to a topic or source…" autocomplete="off" spellcheck="false">
85
- <kbd class="pal-esc">esc</kbd>
86
- </div>
87
- <div id="palResults" class="palette-results"></div>
88
- <div class="palette-foot"><span><kbd>↑</kbd><kbd>↓</kbd> navigate</span><span><kbd>↵</kbd> open</span><span><span class="pal-dot pal-t"></span> topic <span class="pal-dot pal-s"></span> source</span></div>
89
- </div>
90
- </div>
91
-
92
  <script defer src="app.js"></script>
93
  </body>
94
  </html>
 
63
  <aside class="sidebar" id="sidebar">
64
  <div class="side-head">Topics</div>
65
  <div class="search-wrap">
66
+ <input class="search" id="navSearch" type="text" placeholder="Search topics &amp; text…" autocomplete="off">
67
+ <kbd class="search-k" id="paletteOpen" title="Focus search (⌘K)">⌘K</kbd>
68
  </div>
69
  <nav id="navList"></nav>
70
  </aside>
 
76
  <aside class="aside" id="aside"></aside>
77
  </div>
78
 
 
 
 
 
 
 
 
 
 
 
 
 
 
79
  <script defer src="app.js"></script>
80
  </body>
81
  </html>
styles.css CHANGED
@@ -159,7 +159,7 @@ a:hover { color: var(--accent-deep); }
159
  content: "⌕"; position: absolute; left: 9px; top: 50%; transform: translateY(-50%);
160
  color: var(--muted-3); font-size: 15px;
161
  }
162
- /* the one global-search affordance: click (or ⌘K) → command palette */
163
  .search-k {
164
  position: absolute; right: 7px; top: 50%; transform: translateY(-50%);
165
  font-family: var(--mono); font-size: 10px; padding: 2px 6px; border-radius: 4px;
@@ -191,6 +191,13 @@ a:hover { color: var(--accent-deep); }
191
  display: block; font-family: var(--mono); font-size: 9.5px;
192
  color: var(--muted-4); margin-top: 1px; letter-spacing: 0.2px;
193
  }
 
 
 
 
 
 
 
194
  .nav-empty { color: var(--muted-4); font-size: 11.5px; padding: 8px; font-style: italic; }
195
  .mini-spin { display: inline-block; width: 10px; height: 10px; vertical-align: -1px; margin-right: 4px;
196
  border: 1.5px solid var(--border); border-top-color: var(--accent); border-radius: 50%; animation: spin .7s linear infinite; }
@@ -560,52 +567,6 @@ a:hover { color: var(--accent-deep); }
560
  }
561
  @media (min-width: 901px) { .nav-scrim { display: none !important; } }
562
 
563
- /* ── command palette ───────────────────────────────────── */
564
- .palette-foot kbd, .pal-esc {
565
- font-family: var(--mono); font-size: 10px; padding: 1.5px 5px; border-radius: 4px;
566
- border: 1px solid var(--border); background: var(--bg-soft); color: var(--muted-2); line-height: 1.4;
567
- }
568
-
569
- .palette-scrim {
570
- position: fixed; inset: 0; z-index: 200; background: rgba(15,18,22,0.42);
571
- backdrop-filter: blur(2px); display: flex; align-items: flex-start; justify-content: center;
572
- padding: 12vh 16px 16px;
573
- }
574
- .palette-scrim[hidden] { display: none; }
575
- .palette {
576
- width: 100%; max-width: 580px; background: var(--bg-card); border: 1px solid var(--border);
577
- border-radius: 12px; box-shadow: 0 12px 48px rgba(0,0,0,0.28); overflow: hidden;
578
- display: flex; flex-direction: column; max-height: 70vh;
579
- }
580
- .palette-search { display: flex; align-items: center; gap: 10px; padding: 12px 15px; border-bottom: 1px solid var(--border); }
581
- .pal-icon { color: var(--muted-3); font-size: 17px; }
582
- .palette-input {
583
- flex: 1; border: none; background: none; outline: none; color: var(--ink);
584
- font-family: var(--sans); font-size: 15px; font-weight: 300;
585
- }
586
- .palette-results { overflow-y: auto; padding: 6px; }
587
- .pal-item {
588
- display: flex; align-items: center; gap: 10px; padding: 8px 10px; border-radius: 7px; cursor: pointer;
589
- }
590
- .pal-item.sel { background: var(--accent-soft); }
591
- .pal-kind {
592
- flex: 0 0 auto; font-family: var(--mono); font-size: 9px; font-weight: 600; text-transform: uppercase;
593
- letter-spacing: 0.5px; padding: 2px 6px; border-radius: 4px; width: 50px; text-align: center;
594
- }
595
- .pal-kind.topic { background: var(--accent-soft); color: var(--accent-deep); }
596
- .pal-kind.source { background: var(--bg-soft); color: var(--muted-2); border: 1px solid var(--border-soft); }
597
- .pal-text { flex: 1; min-width: 0; }
598
- .pal-label { font-size: 13.5px; color: var(--ink); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
599
- .pal-label b { color: var(--accent-deep); font-weight: 600; }
600
- .pal-sub { font-family: var(--mono); font-size: 10px; color: var(--muted-4); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
601
- .pal-empty { padding: 24px; text-align: center; color: var(--muted-3); font-size: 13px; }
602
- .palette-foot {
603
- display: flex; gap: 14px; align-items: center; padding: 8px 14px; border-top: 1px solid var(--border-soft);
604
- font-size: 10.5px; color: var(--muted-3); font-family: var(--mono); flex-wrap: wrap;
605
- }
606
- .pal-dot { display: inline-block; width: 7px; height: 7px; border-radius: 50%; vertical-align: middle; margin: 0 2px 0 6px; }
607
- .pal-dot.pal-t { background: var(--accent); }
608
- .pal-dot.pal-s { background: var(--muted-4); }
609
 
610
  /* ── Collapsible taxonomy tree (topics sidebar) ────────── */
611
  .nav-cat { margin-bottom: 10px; }
@@ -779,7 +740,7 @@ body.book-mode .main { padding: 0; }
779
 
780
  /* ── Print (browser "Save as PDF") ─────────────────────── */
781
  @media print {
782
- .topbar, .sidebar, .aside, .nav-scrim, .book-toolbar, .palette-scrim, #hovercard, #toast, .foot, .menu-toggle { display: none !important; }
783
  html, body { background: #fff; color: #000; }
784
  .layout, .main, body.book-mode .main { display: block; padding: 0; margin: 0; }
785
  .book { max-width: none; margin: 0; padding: 0; color: #111; font-size: 10.5pt; }
 
159
  content: "⌕"; position: absolute; left: 9px; top: 50%; transform: translateY(-50%);
160
  color: var(--muted-3); font-size: 15px;
161
  }
162
+ /* the ⌘K badge on the search box: click (or ⌘K) → focus the search input */
163
  .search-k {
164
  position: absolute; right: 7px; top: 50%; transform: translateY(-50%);
165
  font-family: var(--mono); font-size: 10px; padding: 2px 6px; border-radius: 4px;
 
191
  display: block; font-family: var(--mono); font-size: 9.5px;
192
  color: var(--muted-4); margin-top: 1px; letter-spacing: 0.2px;
193
  }
194
+ /* content-search: a matched-body excerpt under the topic name, query bolded */
195
+ .nav-item .nav-item-title { display: block; }
196
+ .nav-item .nav-snippet {
197
+ display: block; margin-top: 2px; font-size: 11px; line-height: 1.4;
198
+ color: var(--muted-3); font-weight: 400;
199
+ }
200
+ .nav-item .nav-snippet b { color: var(--accent-deep); font-weight: 600; }
201
  .nav-empty { color: var(--muted-4); font-size: 11.5px; padding: 8px; font-style: italic; }
202
  .mini-spin { display: inline-block; width: 10px; height: 10px; vertical-align: -1px; margin-right: 4px;
203
  border: 1.5px solid var(--border); border-top-color: var(--accent); border-radius: 50%; animation: spin .7s linear infinite; }
 
567
  }
568
  @media (min-width: 901px) { .nav-scrim { display: none !important; } }
569
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
570
 
571
  /* ── Collapsible taxonomy tree (topics sidebar) ────────── */
572
  .nav-cat { margin-bottom: 10px; }
 
740
 
741
  /* ── Print (browser "Save as PDF") ─────────────────────── */
742
  @media print {
743
+ .topbar, .sidebar, .aside, .nav-scrim, .book-toolbar, #hovercard, #toast, .foot, .menu-toggle { display: none !important; }
744
  html, body { background: #fff; color: #000; }
745
  .layout, .main, body.book-mode .main { display: block; padding: 0; margin: 0; }
746
  .book { max-width: none; margin: 0; padding: 0; color: #111; font-size: 10.5pt; }