lvwerra HF Staff commited on
Commit
34252e5
·
verified ·
1 Parent(s): adc659d

topic layout: drop right rail — Contents + Open questions as collapsibles at top, Open questions + Sources-cited references at bottom; add figure/SVG rendering support

Browse files
Files changed (2) hide show
  1. app.js +153 -192
  2. styles.css +98 -45
app.js CHANGED
@@ -26,8 +26,10 @@ 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
  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
@@ -190,29 +192,6 @@ function setupMarked() {
190
  renderer(tok) { return citationHTML(tok.id); },
191
  }],
192
  });
193
- // Cross-references between topics are authored as bare `category/node` code
194
- // spans (e.g. `preference-data/ai-feedback-data`) rather than markdown links.
195
- // Turn each into a live hop to that topic — but only when the page exists, so
196
- // genuine code spans (`preference/DPO`, planned-but-unwritten nodes) stay plain.
197
- // (Markdown-link cross-refs are handled separately by rewriteInternalLinks;
198
- // this href is already a `#/` route, so that pass leaves it untouched.)
199
- marked.use({
200
- renderer: {
201
- // marked@13 hands codespan the already-HTML-escaped code text as a plain
202
- // string (the {text} token arg is a marked-v16 API) — so read it directly
203
- // and don't re-escape, or every cross-ref silently falls through to plain.
204
- codespan(code) {
205
- const m = /^([a-z0-9][a-z0-9-]*\/[a-z0-9][a-z0-9-]*)$/.exec(code);
206
- if (m) {
207
- const path = `topics/${m[1]}.md`;
208
- if (state.pages.some(p => p.path === path)) {
209
- return `<a class="xref" href="#/topic/${encodeURIComponent(path)}" title="Topic: ${m[1]}"><code>${code}</code></a>`;
210
- }
211
- }
212
- return false; // fall back to marked's default code-span rendering
213
- },
214
- },
215
- });
216
  marked.setOptions({ gfm: true, breaks: false });
217
  }
218
 
@@ -290,56 +269,36 @@ function enhanceProse(root, currentPath) {
290
  if (h.tagName !== 'H4') toc.push({ slug, text: txt, level: h.tagName === 'H2' ? 2 : 3 });
291
  });
292
  if (window.hljs) $$('pre code', root).forEach(c => { try { hljs.highlightElement(c); } catch (e) {} });
 
293
  rewriteInternalLinks(root, currentPath); // markdown links to topics/sources → in-app routes
294
  linkifySectionRefs(root, secMap); // §N / §N.M → jump to that section
295
  acronymHovers(root); // known acronyms get a hover definition
296
  // remaining external links open in a new tab
297
  $$('a[href^="http"]', root).forEach(a => { a.target = '_blank'; a.rel = 'noopener'; });
298
- highlightDocMatches(root, ($('#navSearch') || {}).value); // mark active search terms in the article
299
  return toc;
300
  }
301
-
302
- // In-document search highlighting: <mark> occurrences of the active query in the
303
- // rendered article. Cleared when the search is cleared (see clearNavSearch).
304
- function highlightDocMatches(root, query) {
305
- clearDocHighlights(root);
306
- const q = (query || '').trim().toLowerCase();
307
- if (!root || q.length < 2) return; // skip empty / single-char to avoid noise
308
- walkTextNodes(root, (node) => {
309
- const text = node.nodeValue, lc = text.toLowerCase();
310
- let idx = lc.indexOf(q);
311
- if (idx < 0) return;
312
- const frag = document.createDocumentFragment();
313
- let last = 0;
314
- while (idx >= 0) {
315
- if (idx > last) frag.append(text.slice(last, idx));
316
- const mark = document.createElement('mark');
317
- mark.className = 'search-hl';
318
- mark.textContent = text.slice(idx, idx + q.length);
319
- frag.append(mark);
320
- last = idx + q.length;
321
- idx = lc.indexOf(q, last);
322
- }
323
- if (last < text.length) frag.append(text.slice(last));
324
- node.replaceWith(frag);
325
- }, { SCRIPT: 1, STYLE: 1, PRE: 1 }); // search highlights headings/inline-code/links too; only fenced code, scripts & KaTeX (via classList) are skipped
326
- }
327
- function clearDocHighlights(root) {
328
- root = root || $('#prose');
329
- if (!root) return;
330
- const marks = root.querySelectorAll('mark.search-hl');
331
- marks.forEach(m => m.replaceWith(document.createTextNode(m.textContent)));
332
- if (marks.length) root.normalize(); // merge split text nodes so re-highlighting stays clean
333
- }
334
- // (Re)highlight both the article body and the aside (open questions, toc) for query q.
335
- function refreshDocHighlights(q) {
336
- highlightDocMatches($('#prose'), q);
337
- highlightDocMatches($('#aside'), q);
338
  }
339
 
340
  // Walk visible text nodes under root, skipping code/math/links/headings/abbr.
341
- function walkTextNodes(root, fn, skip) {
342
- const SKIP = skip || { CODE: 1, PRE: 1, A: 1, ABBR: 1, SCRIPT: 1, STYLE: 1, H1: 1, H2: 1, H3: 1, H4: 1, H5: 1, H6: 1 };
343
  const w = document.createTreeWalker(root, NodeFilter.SHOW_TEXT, {
344
  acceptNode(n) {
345
  if (!n.nodeValue.trim()) return NodeFilter.FILTER_REJECT;
@@ -500,7 +459,7 @@ async function softRefresh() {
500
  ============================================================ */
501
  function layoutAside(on) { $('#layout').classList.toggle('has-aside', !!on); }
502
  function setView(html) { $('#view').innerHTML = html; }
503
- function setAside(html) { $('#aside').innerHTML = html || ''; highlightDocMatches($('#aside'), ($('#navSearch') || {}).value); }
504
  function showError(msg) {
505
  layoutAside(false);
506
  setView(`<div class="error-box">⚠ ${esc(msg)}<br><br><a class="btn" href="#/">← back to home</a></div>`);
@@ -751,43 +710,68 @@ async function renderTopic(path) {
751
  const title = fm.title || meta.title || path.split('/').pop().replace(/\.md$/, '');
752
  const cat = meta.parent || path.split('/').slice(-2, -1)[0] || '';
753
 
754
- layoutAside(true);
755
  setView(`
756
- <div class="crumbs"><a href="#/">Wiki</a><span class="sep">/</span>
757
- <span>${esc(catDisplay(cat))}</span></div>
758
- <div class="doc-head">
759
- <div class="doc-kicker">Topic Article<button class="share-btn" data-share title="Copy a link to this page">🔗 copy link</button></div>
760
- <h1 class="doc-title">${esc(title)}</h1>
761
- <div class="doc-meta-row">
762
- ${maturityBadge(fm.maturity)}
763
- ${fm.sources ? `<span class="doc-sub">${fm.sources.length} source${fm.sources.length === 1 ? '' : 's'} cited</span>` : ''}
764
  </div>
 
 
 
 
765
  </div>
766
- <div class="prose" id="prose">${renderBody(body)}</div>
767
- ${footerHTML()}
768
  `);
769
  wireShare();
770
  const toc = enhanceProse($('#prose'), path);
771
  bindInternalLinks($('#prose'));
772
 
773
- // Aside: cited sources, open questions, TOC, links
774
- let aside = '';
775
- if (fm.sources?.length) {
776
- aside += `<div class="panel"><div class="panel-title">Cited sources</div><div class="chips">` +
777
- fm.sources.map(id => sourceChip(id)).join('') + `</div></div>`;
778
- }
779
- if (fm.open_questions?.length) {
780
- aside += `<div class="panel"><div class="panel-title">Open questions</div><ul class="oq-list">` +
781
- fm.open_questions.map(q => `<li>${renderInline(q)}</li>`).join('') + `</ul></div>`;
782
- }
783
- aside += tocPanel(toc);
784
- aside += `<div class="panel"><div class="panel-title">This page</div>
785
- <div class="chips">
786
- <a class="chip ext" href="${esc(RESOLVE)}/${esc(path)}" target="_blank" rel="noopener">raw .md</a>
787
- <a class="chip ext" href="${esc(DATASET_BLOB)}/${esc(path)}" target="_blank" rel="noopener">on HF</a>
788
- </div></div>`;
789
- setAside(aside);
790
- initScrollSpy(toc);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
791
  }
792
 
793
  /* ── SOURCE RECORD ─────────────────────────────────────── */
@@ -994,27 +978,8 @@ function renderTopicsNav(list, q) {
994
  matches.forEach(n => {
995
  const it = el('div', 'nav-item');
996
  it.dataset.path = n.page.path;
997
- const base = `#/topic/${encodeURIComponent(n.page.path)}`;
998
- // All body occurrences of the query in this article (empty for a title-only match).
999
- const { infos, total } = q && (state.searchText.get(n.page.path) || '').includes(q)
1000
- ? bodyMatchInfos(n.page.path, q) : { infos: [], total: 0 };
1001
- const titleTarget = base + (infos[0] && infos[0].section ? `~${infos[0].section}` : '');
1002
-
1003
- const title = el('span', 'nav-item-title', highlightMatch(n.page.title || n.slug, q));
1004
- if (total > 1) title.append(el('span', 'nav-hit-count', String(total)));
1005
- it.append(title);
1006
- it.addEventListener('click', () => go(titleTarget)); // clicking the row → first hit / top
1007
-
1008
- if (infos.length) {
1009
- const snips = el('div', 'nav-snippets');
1010
- infos.forEach(h => {
1011
- const s = el('div', 'nav-snippet', h.snippet);
1012
- s.addEventListener('click', (e) => { e.stopPropagation(); go(base + (h.section ? `~${h.section}` : '')); });
1013
- snips.append(s);
1014
- });
1015
- if (total > infos.length) snips.append(el('div', 'nav-snippet more', `+${total - infos.length} more match${total - infos.length === 1 ? '' : 'es'}`));
1016
- it.append(snips);
1017
- }
1018
  body.append(it);
1019
  });
1020
  wrap.append(body);
@@ -1105,60 +1070,7 @@ function sortSources(items, mode) {
1105
  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));
1106
  return arr;
1107
  }
1108
- // A page matches on its title, its path, or anywhere in its (background-indexed)
1109
- // body. `q` is already lowercased by callers.
1110
- const matchPage = (p, q) =>
1111
- (p.title || '').toLowerCase().includes(q) ||
1112
- p.path.toLowerCase().includes(q) ||
1113
- (state.searchText.get(p.path) || '').includes(q);
1114
- // Every body occurrence of q in a page: a highlighted excerpt + its nearest heading,
1115
- // so the sidebar can list all in-document hits and each can jump to its own section.
1116
- // Returns { infos: [...], total } — total is the true count even when infos is capped.
1117
- function bodyMatchInfos(path, q, cap = 20) {
1118
- const raw = state.pageContent.get(path), lc = state.searchText.get(path);
1119
- if (raw == null || lc == null) return { infos: [], total: 0 };
1120
- const text = parseFrontmatter(raw).body; // same body the index was built from → indices align with lc and hits map to visible prose
1121
- const infos = []; let total = 0, i = lc.indexOf(q);
1122
- while (i >= 0) {
1123
- total++;
1124
- if (infos.length < cap) {
1125
- const start = Math.max(0, i - 30);
1126
- let clip = text.slice(start, i + q.length + 40)
1127
- .replace(/\[source:[^\]]*\]/g, ' ')
1128
- .replace(/[#>*_`~|]+/g, ' ')
1129
- .replace(/\s+/g, ' ').trim();
1130
- clip = (start > 0 ? '…' : '') + clip + '…';
1131
- infos.push({ snippet: highlightMatch(clip, q), section: headingSlugBefore(text, i) });
1132
- }
1133
- i = lc.indexOf(q, i + q.length);
1134
- }
1135
- return { infos, total };
1136
- }
1137
- // Slug of the last ##/###/#### heading before `pos`, replicating enhanceProse's
1138
- // slugify + de-dup (and skipping fenced code) so the id matches the rendered anchor.
1139
- function headingSlugBefore(md, pos) {
1140
- const seen = {};
1141
- let found = '', inFence = false, offset = 0;
1142
- for (const line of md.split('\n')) {
1143
- if (/^\s*(```|~~~)/.test(line)) inFence = !inFence;
1144
- else if (!inFence) {
1145
- const h = /^(#{2,4})\s+(.+?)\s*#*$/.exec(line);
1146
- if (h) {
1147
- let slug = h[2].trim().toLowerCase().replace(/[^\w\s-]/g, '').trim().replace(/\s+/g, '-').slice(0, 60) || 'sec';
1148
- if (seen[slug] != null) slug = `${slug}-${++seen[slug]}`; else seen[slug] = 0;
1149
- if (offset <= pos) found = slug; // last heading at/before the match wins
1150
- }
1151
- }
1152
- offset += line.length + 1; // +1 for the consumed '\n'
1153
- }
1154
- return found;
1155
- }
1156
- // Throttled nav re-render, used while background indexing fills in during a search.
1157
- let _navRefreshT = null;
1158
- function scheduleNavRefresh() {
1159
- if (_navRefreshT) return;
1160
- _navRefreshT = setTimeout(() => { _navRefreshT = null; if (($('#navSearch').value || '').trim()) renderNav(); }, 200);
1161
- }
1162
  const matchSource = (s, q) => (s.title || '').toLowerCase().includes(q) || s.id.toLowerCase().includes(q);
1163
  function toggleCat(cat) {
1164
  if (state.collapsed.has(cat)) state.collapsed.delete(cat); else state.collapsed.add(cat);
@@ -1180,6 +1092,30 @@ function highlightActive() {
1180
  Filters an in-memory index, so it stays instant at thousands of items.
1181
  ============================================================ */
1182
  // `text` and `q` must already be lowercased (callers pre-lower for speed).
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1183
  function highlightMatch(text, raw) {
1184
  const q = raw.trim();
1185
  if (!q) return esc(text);
@@ -1187,6 +1123,37 @@ function highlightMatch(text, raw) {
1187
  if (i < 0) return esc(text);
1188
  return esc(text.slice(0, i)) + '<b>' + esc(text.slice(i, i + q.length)) + '</b>' + esc(text.slice(i + q.length));
1189
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1190
 
1191
  /* ── mobile nav ───────────���────────────────────────────── */
1192
  function closeNav() { document.body.classList.remove('nav-open'); }
@@ -1235,6 +1202,7 @@ async function loadCore() {
1235
  }
1236
  try { state.taxonomy = taxT && window.jsyaml ? jsyaml.load(taxT) : null; } catch (e) { state.taxonomy = null; }
1237
  buildTaxonomy();
 
1238
  }
1239
  const SOURCES_CACHE_KEY = 'viz-sources-v1';
1240
  function setSources(items) {
@@ -1245,7 +1213,7 @@ function setSources(items) {
1245
  if (items.some(s => s.title)) state.sourceTitlesLoaded = true; // tree stage has none yet
1246
  }
1247
  function refreshSourceViews() {
1248
- updateCounts();
1249
  softRefresh(); // re-renders the current view (incl. the sources browser), upgrading titles/resolution
1250
  }
1251
  // Reverse the filename sanitization (`:`/`/` → `-`): `arxiv-1707.06347.md` → `arxiv:1707.06347`.
@@ -1326,6 +1294,13 @@ function buildTaxonomy() {
1326
  state.tax = cats;
1327
  }
1328
 
 
 
 
 
 
 
 
1329
  const catLabel = (c) => prettyTitle((c || '').replace(/-/g, ' '));
1330
  // Sentence-cased category for headings, e.g. "reward-modeling" → "Reward modeling".
1331
  const catDisplay = (c) => { const s = catLabel(c); return s.charAt(0).toUpperCase() + s.slice(1); };
@@ -1373,8 +1348,6 @@ async function ensureCitemap() {
1373
  ids.forEach(id => { if (!state.citedBy.has(id)) state.citedBy.set(id, new Set()); state.citedBy.get(id).add(path); });
1374
  state.refCount.set(path, refs);
1375
  state.wordCount.set(path, countWords(content));
1376
- state.searchText.set(path, parseFrontmatter(content).body.toLowerCase()); // body only (frontmatter/open-questions excluded) so every hit maps to visible prose
1377
- if (($('#navSearch').value || '').trim()) scheduleNavRefresh(); // live-fill an in-progress search
1378
  } catch (e) { /* skip unreadable article */ }
1379
  }
1380
  };
@@ -1427,30 +1400,18 @@ async function boot() {
1427
  $('#navScrim').addEventListener('click', closeNav);
1428
  $('#brandHome').addEventListener('click', () => go('#/'));
1429
  let st;
1430
- const searchBadge = $('#paletteOpen');
1431
- const focusNavSearch = () => { const s = $('#navSearch'); s.focus(); s.select(); };
1432
- const clearNavSearch = () => { const s = $('#navSearch'); s.value = ''; syncSearchBadge(); renderNav(); refreshDocHighlights(''); s.focus(); };
1433
- // The badge is a ⌘K focus hint while empty, and a ✕ clear button once you've typed.
1434
- const syncSearchBadge = () => {
1435
- const has = !!$('#navSearch').value;
1436
- searchBadge.textContent = has ? '✕' : '⌘K';
1437
- searchBadge.classList.toggle('is-clear', has);
1438
- searchBadge.title = has ? 'Clear search' : 'Focus search (⌘K)';
1439
- };
1440
- $('#navSearch').addEventListener('input', () => {
1441
- ensureCitemap(); // kick off body indexing on first keystroke so content matches fill in fast
1442
- syncSearchBadge();
1443
- clearTimeout(st); st = setTimeout(() => { renderNav(); refreshDocHighlights($('#navSearch').value); }, 120);
1444
- });
1445
  window.addEventListener('hashchange', route);
1446
 
1447
- // ⌘K (and "/") focus the top-left search — one box that filters titles + bodies.
1448
- searchBadge.addEventListener('click', () => { $('#navSearch').value ? clearNavSearch() : focusNavSearch(); });
 
 
 
1449
  window.addEventListener('keydown', (e) => {
1450
- if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === 'k') { e.preventDefault(); focusNavSearch(); return; }
1451
- if (e.key === 'Escape' && document.activeElement === $('#navSearch') && $('#navSearch').value) { clearNavSearch(); return; }
1452
  const typing = /^(input|textarea)$/i.test(document.activeElement?.tagName || '');
1453
- if (e.key === '/' && !typing) { e.preventDefault(); focusNavSearch(); }
1454
  });
1455
  initHovercards();
1456
 
 
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
 
192
  renderer(tok) { return citationHTML(tok.id); },
193
  }],
194
  });
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
195
  marked.setOptions({ gfm: true, breaks: false });
196
  }
197
 
 
269
  if (h.tagName !== 'H4') toc.push({ slug, text: txt, level: h.tagName === 'H2' ? 2 : 3 });
270
  });
271
  if (window.hljs) $$('pre code', root).forEach(c => { try { hljs.highlightElement(c); } catch (e) {} });
272
+ wrapFigures(root); // images / inline SVG → captioned <figure>
273
  rewriteInternalLinks(root, currentPath); // markdown links to topics/sources → in-app routes
274
  linkifySectionRefs(root, secMap); // §N / §N.M → jump to that section
275
  acronymHovers(root); // known acronyms get a hover definition
276
  // remaining external links open in a new tab
277
  $$('a[href^="http"]', root).forEach(a => { a.target = '_blank'; a.rel = 'noopener'; });
 
278
  return toc;
279
  }
280
+ // Present visuals as proper figures: a markdown image (alt → caption) or a
281
+ // block-level inline <svg> becomes a centered <figure> with an optional caption.
282
+ function wrapFigures(root) {
283
+ $$('img', root).forEach(img => {
284
+ if (img.closest('figure')) return;
285
+ const fig = el('figure', 'fig');
286
+ img.replaceWith(fig); fig.append(img);
287
+ const cap = img.getAttribute('alt');
288
+ if (cap) fig.append(el('figcaption', null, esc(cap)));
289
+ img.loading = 'lazy';
290
+ });
291
+ $$(':scope > svg, :scope > p > svg', root).forEach(svg => {
292
+ const host = svg.parentElement.tagName === 'P' ? svg.parentElement : svg;
293
+ if (host.closest && host.closest('figure')) return;
294
+ const fig = el('figure', 'fig');
295
+ host.replaceWith(fig); fig.append(svg);
296
+ });
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
297
  }
298
 
299
  // Walk visible text nodes under root, skipping code/math/links/headings/abbr.
300
+ function walkTextNodes(root, fn) {
301
+ const SKIP = { CODE: 1, PRE: 1, A: 1, ABBR: 1, SCRIPT: 1, STYLE: 1, H1: 1, H2: 1, H3: 1, H4: 1, H5: 1, H6: 1 };
302
  const w = document.createTreeWalker(root, NodeFilter.SHOW_TEXT, {
303
  acceptNode(n) {
304
  if (!n.nodeValue.trim()) return NodeFilter.FILTER_REJECT;
 
459
  ============================================================ */
460
  function layoutAside(on) { $('#layout').classList.toggle('has-aside', !!on); }
461
  function setView(html) { $('#view').innerHTML = html; }
462
+ function setAside(html) { $('#aside').innerHTML = html || ''; }
463
  function showError(msg) {
464
  layoutAside(false);
465
  setView(`<div class="error-box">⚠ ${esc(msg)}<br><br><a class="btn" href="#/">← back to home</a></div>`);
 
710
  const title = fm.title || meta.title || path.split('/').pop().replace(/\.md$/, '');
711
  const cat = meta.parent || path.split('/').slice(-2, -1)[0] || '';
712
 
713
+ layoutAside(false); // no right rail — single reading column
714
  setView(`
715
+ <div class="doc">
716
+ <div class="crumbs"><a href="#/">Wiki</a><span class="sep">/</span>
717
+ <span>${esc(catDisplay(cat))}</span></div>
718
+ <div class="doc-head">
719
+ <div class="doc-kicker">Topic Article<button class="share-btn" data-share title="Copy a link to this page">🔗 copy link</button></div>
720
+ <h1 class="doc-title">${esc(title)}</h1>
721
+ <div class="doc-meta-row">${maturityBadge(fm.maturity)}</div>
 
722
  </div>
723
+ <div id="docTop"></div>
724
+ <div class="prose" id="prose">${renderBody(body)}</div>
725
+ <div id="docBottom"></div>
726
+ ${footerHTML()}
727
  </div>
 
 
728
  `);
729
  wireShare();
730
  const toc = enhanceProse($('#prose'), path);
731
  bindInternalLinks($('#prose'));
732
 
733
+ // cited sources, in the order they first appear in the article
734
+ const citedIds = []; const seenCite = new Set();
735
+ $$('#prose .cite[data-src]').forEach(a => { const id = a.dataset.src; if (!seenCite.has(id)) { seenCite.add(id); citedIds.push(id); } });
736
+ const base = location.hash.split('~')[0];
737
+
738
+ // TOP: contents + open questions (both collapsed, unobtrusive)
739
+ let top = '';
740
+ if (toc.length) top += contentsDisclosure(toc, base);
741
+ if (fm.open_questions?.length) top += openQuestionsDisclosure(fm.open_questions, false);
742
+ $('#docTop').innerHTML = top;
743
+
744
+ // BOTTOM: open questions (expanded) + the references + this-page links
745
+ let bot = '';
746
+ if (fm.open_questions?.length) bot += openQuestionsDisclosure(fm.open_questions, true);
747
+ bot += referencesSection(citedIds.length ? citedIds : (fm.sources || []));
748
+ bot += `<div class="page-links"><a href="${esc(RESOLVE)}/${esc(path)}" target="_blank" rel="noopener">raw markdown</a><a href="${esc(DATASET_BLOB)}/${esc(path)}" target="_blank" rel="noopener">source on HF ↗</a></div>`;
749
+ $('#docBottom').innerHTML = bot;
750
+ }
751
+
752
+ // collapsible <details> blocks used in the article flow
753
+ function openQuestionsDisclosure(list, open) {
754
+ return `<details class="disclosure oq-d"${open ? ' open' : ''}>
755
+ <summary><span class="disc-ic">?</span> Open questions <span class="disc-n">${list.length}</span></summary>
756
+ <ul class="oq-list">${list.map(q => `<li>${renderInline(q)}</li>`).join('')}</ul>
757
+ </details>`;
758
+ }
759
+ function contentsDisclosure(toc, base) {
760
+ return `<details class="disclosure toc-d">
761
+ <summary><span class="disc-ic">≡</span> Contents <span class="disc-n">${toc.length}</span></summary>
762
+ <nav class="toc">${toc.map(t => `<a href="${base}~${t.slug}" class="${t.level === 3 ? 'h3' : ''}">${esc(t.text)}</a>`).join('')}</nav>
763
+ </details>`;
764
+ }
765
+ // references (sources cited), listed at the end of the article
766
+ function referencesSection(ids) {
767
+ if (!ids.length) return '';
768
+ return `<section class="refs-section"><h2 class="refs-h">Sources cited<span class="refs-n">${ids.length}</span></h2>
769
+ <ol class="refs-list">${ids.map(id => {
770
+ const s = state.srcById.get(id); const ext = externalUrl(id);
771
+ return `<li><a class="ref-id" href="#/source/${encodeURIComponent(id)}" data-src="${esc(id)}">${esc(id)}</a>` +
772
+ (s && s.title ? `<span class="ref-title">${esc(s.title)}</span>` : '') +
773
+ (ext ? ` <a class="ref-ext" href="${esc(ext)}" target="_blank" rel="noopener" title="open the original">↗</a>` : '') + `</li>`;
774
+ }).join('')}</ol></section>`;
775
  }
776
 
777
  /* ── SOURCE RECORD ─────────────────────────────────────── */
 
978
  matches.forEach(n => {
979
  const it = el('div', 'nav-item');
980
  it.dataset.path = n.page.path;
981
+ it.innerHTML = `${esc(n.page.title || n.slug)}`;
982
+ it.addEventListener('click', () => go(`#/topic/${encodeURIComponent(n.page.path)}`));
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
983
  body.append(it);
984
  });
985
  wrap.append(body);
 
1070
  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));
1071
  return arr;
1072
  }
1073
+ const matchPage = (p, q) => (p.title || '').toLowerCase().includes(q) || p.path.toLowerCase().includes(q);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1074
  const matchSource = (s, q) => (s.title || '').toLowerCase().includes(q) || s.id.toLowerCase().includes(q);
1075
  function toggleCat(cat) {
1076
  if (state.collapsed.has(cat)) state.collapsed.delete(cat); else state.collapsed.add(cat);
 
1092
  Filters an in-memory index, so it stays instant at thousands of items.
1093
  ============================================================ */
1094
  // `text` and `q` must already be lowercased (callers pre-lower for speed).
1095
+ function fuzzyScore(q, text) {
1096
+ const idx = text.indexOf(q);
1097
+ if (idx === 0) return 1000; // prefix match — best
1098
+ if (idx > 0) return 700 - Math.min(idx, 300);
1099
+ // subsequence fallback — start at the first occurrence of q[0]; bail cheaply if absent.
1100
+ let start = text.indexOf(q[0]);
1101
+ if (start < 0) return -1;
1102
+ let qi = 1, gaps = 0, last = start;
1103
+ for (let ti = start + 1; ti < text.length && qi < q.length; ti++) {
1104
+ if (text[ti] === q[qi]) { gaps += ti - last - 1; last = ti; qi++; }
1105
+ }
1106
+ return qi === q.length ? 300 - Math.min(gaps, 200) - Math.min(start, 50) : -1;
1107
+ }
1108
+ function paletteResults(raw) {
1109
+ const q = raw.toLowerCase().trim();
1110
+ if (!q) return state.palItems.slice(0, 40);
1111
+ const scored = [];
1112
+ for (const it of state.palItems) {
1113
+ const s = Math.max(fuzzyScore(q, it._l), fuzzyScore(q, it._s) - 60);
1114
+ if (s > -1) scored.push({ it, s });
1115
+ }
1116
+ scored.sort((a, b) => b.s - a.s || a.it.label.localeCompare(b.it.label));
1117
+ return scored.slice(0, 40).map(x => x.it);
1118
+ }
1119
  function highlightMatch(text, raw) {
1120
  const q = raw.trim();
1121
  if (!q) return esc(text);
 
1123
  if (i < 0) return esc(text);
1124
  return esc(text.slice(0, i)) + '<b>' + esc(text.slice(i, i + q.length)) + '</b>' + esc(text.slice(i + q.length));
1125
  }
1126
+ function renderPalette() {
1127
+ const q = $('#palInput').value;
1128
+ state.palFiltered = paletteResults(q);
1129
+ if (state.palSel >= state.palFiltered.length) state.palSel = 0;
1130
+ const box = $('#palResults');
1131
+ if (!state.palFiltered.length) { box.innerHTML = `<div class="pal-empty">No matches for “${esc(q)}”.</div>`; return; }
1132
+ box.innerHTML = state.palFiltered.map((it, i) => `
1133
+ <div class="pal-item${i === state.palSel ? ' sel' : ''}" data-i="${i}">
1134
+ <span class="pal-kind ${it.kind}">${it.kind}</span>
1135
+ <span class="pal-text"><span class="pal-label">${highlightMatch(it.label, q)}</span><span class="pal-sub">${esc(it.sub)}</span></span>
1136
+ </div>`).join('');
1137
+ $$('#palResults .pal-item').forEach(n => {
1138
+ n.addEventListener('mousemove', () => { if (state.palSel !== +n.dataset.i) { state.palSel = +n.dataset.i; markPalSel(); } });
1139
+ n.addEventListener('click', () => choosePal(+n.dataset.i));
1140
+ });
1141
+ }
1142
+ function markPalSel() { $$('#palResults .pal-item').forEach((n, i) => n.classList.toggle('sel', i === state.palSel)); }
1143
+ function scrollPalSel() { $$('#palResults .pal-item')[state.palSel]?.scrollIntoView({ block: 'nearest' }); }
1144
+ function choosePal(i) { const it = state.palFiltered[i]; if (it) { closePalette(); go(it.hash); } }
1145
+ function openPalette() {
1146
+ const sc = $('#palScrim'); sc.hidden = false;
1147
+ const inp = $('#palInput'); inp.value = ''; state.palSel = 0;
1148
+ renderPalette(); inp.focus();
1149
+ }
1150
+ function closePalette() { $('#palScrim').hidden = true; }
1151
+ function palKeydown(e) {
1152
+ if (e.key === 'ArrowDown') { e.preventDefault(); state.palSel = Math.min(state.palSel + 1, state.palFiltered.length - 1); markPalSel(); scrollPalSel(); }
1153
+ else if (e.key === 'ArrowUp') { e.preventDefault(); state.palSel = Math.max(state.palSel - 1, 0); markPalSel(); scrollPalSel(); }
1154
+ else if (e.key === 'Enter') { e.preventDefault(); choosePal(state.palSel); }
1155
+ else if (e.key === 'Escape') { e.preventDefault(); closePalette(); }
1156
+ }
1157
 
1158
  /* ── mobile nav ───────────���────────────────────────────── */
1159
  function closeNav() { document.body.classList.remove('nav-open'); }
 
1202
  }
1203
  try { state.taxonomy = taxT && window.jsyaml ? jsyaml.load(taxT) : null; } catch (e) { state.taxonomy = null; }
1204
  buildTaxonomy();
1205
+ buildPaletteIndex();
1206
  }
1207
  const SOURCES_CACHE_KEY = 'viz-sources-v1';
1208
  function setSources(items) {
 
1213
  if (items.some(s => s.title)) state.sourceTitlesLoaded = true; // tree stage has none yet
1214
  }
1215
  function refreshSourceViews() {
1216
+ buildPaletteIndex(); updateCounts();
1217
  softRefresh(); // re-renders the current view (incl. the sources browser), upgrading titles/resolution
1218
  }
1219
  // Reverse the filename sanitization (`:`/`/` → `-`): `arxiv-1707.06347.md` → `arxiv:1707.06347`.
 
1294
  state.tax = cats;
1295
  }
1296
 
1297
+ function buildPaletteIndex() {
1298
+ const mk = (kind, label, sub, hash) => ({ kind, label, sub, hash, _l: label.toLowerCase(), _s: sub.toLowerCase() });
1299
+ state.palItems = [
1300
+ ...state.pages.map(p => mk('topic', p.title || p.path, catLabel(p._cat || p.parent || ''), `#/topic/${encodeURIComponent(p.path)}`)),
1301
+ ...state.sources.map(s => mk('source', s.title || s.id, s.id, `#/source/${encodeURIComponent(s.id)}`)),
1302
+ ];
1303
+ }
1304
  const catLabel = (c) => prettyTitle((c || '').replace(/-/g, ' '));
1305
  // Sentence-cased category for headings, e.g. "reward-modeling" → "Reward modeling".
1306
  const catDisplay = (c) => { const s = catLabel(c); return s.charAt(0).toUpperCase() + s.slice(1); };
 
1348
  ids.forEach(id => { if (!state.citedBy.has(id)) state.citedBy.set(id, new Set()); state.citedBy.get(id).add(path); });
1349
  state.refCount.set(path, refs);
1350
  state.wordCount.set(path, countWords(content));
 
 
1351
  } catch (e) { /* skip unreadable article */ }
1352
  }
1353
  };
 
1400
  $('#navScrim').addEventListener('click', closeNav);
1401
  $('#brandHome').addEventListener('click', () => go('#/'));
1402
  let st;
1403
+ $('#navSearch').addEventListener('input', () => { clearTimeout(st); st = setTimeout(renderNav, 120); });
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1404
  window.addEventListener('hashchange', route);
1405
 
1406
+ // command palette
1407
+ $('#paletteOpen').addEventListener('click', openPalette);
1408
+ $('#palInput').addEventListener('input', renderPalette);
1409
+ $('#palInput').addEventListener('keydown', palKeydown);
1410
+ $('#palScrim').addEventListener('click', (e) => { if (e.target === $('#palScrim')) closePalette(); });
1411
  window.addEventListener('keydown', (e) => {
1412
+ if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === 'k') { e.preventDefault(); $('#palScrim').hidden ? openPalette() : closePalette(); return; }
 
1413
  const typing = /^(input|textarea)$/i.test(document.activeElement?.tagName || '');
1414
+ if (e.key === '/' && !typing && $('#palScrim').hidden) { e.preventDefault(); openPalette(); }
1415
  });
1416
  initHovercards();
1417
 
styles.css CHANGED
@@ -24,8 +24,6 @@
24
  --accent-deep: #0a275f;
25
  --accent-soft: #dde6f5;
26
  --accent-hover-row: #c8d6ee;
27
- --hl-bg: #0f3787; /* search-hit rectangle */
28
- --hl-ink: #ffffff;
29
  --good: #1c7c3f;
30
  --warn: #9a6b00;
31
  --danger: #a12d2d;
@@ -55,8 +53,6 @@
55
  --accent-deep: #b6cdf7;
56
  --accent-soft: #20304d;
57
  --accent-hover-row: #28395a;
58
- --hl-bg: #4f7fe0;
59
- --hl-ink: #ffffff;
60
  --good: #6fcf8f;
61
  --warn: #e0b15a;
62
  --danger: #e08585;
@@ -163,7 +159,7 @@ a:hover { color: var(--accent-deep); }
163
  content: "⌕"; position: absolute; left: 9px; top: 50%; transform: translateY(-50%);
164
  color: var(--muted-3); font-size: 15px;
165
  }
166
- /* the ⌘K badge on the search box: click (or ⌘K) → focus the search input */
167
  .search-k {
168
  position: absolute; right: 7px; top: 50%; transform: translateY(-50%);
169
  font-family: var(--mono); font-size: 10px; padding: 2px 6px; border-radius: 4px;
@@ -171,9 +167,6 @@ a:hover { color: var(--accent-deep); }
171
  cursor: pointer; transition: all .12s;
172
  }
173
  .search-k:hover { border-color: var(--accent); color: var(--accent-deep); }
174
- /* once a query is entered, the badge becomes a ✕ clear button */
175
- .search-k.is-clear { font-family: var(--sans); font-size: 12px; line-height: 1; padding: 3px 6.5px; }
176
- .search-k.is-clear:hover { background: var(--accent-soft); }
177
 
178
  .nav-group { margin-bottom: 14px; }
179
  .nav-group-title {
@@ -198,25 +191,6 @@ a:hover { color: var(--accent-deep); }
198
  display: block; font-family: var(--mono); font-size: 9.5px;
199
  color: var(--muted-4); margin-top: 1px; letter-spacing: 0.2px;
200
  }
201
- /* content-search: a matched-body excerpt under the topic name, query bolded */
202
- .nav-item .nav-item-title { display: block; }
203
- .nav-item .nav-item-title b { color: var(--accent-deep); font-weight: 600; }
204
- .nav-item .nav-snippets { display: block; margin-top: 2px; }
205
- .nav-item .nav-snippet {
206
- display: block; margin-top: 2px; padding: 2px 6px; font-size: 11px; line-height: 1.4;
207
- color: var(--muted-3); font-weight: 400; cursor: pointer;
208
- border-left: 2px solid var(--border); border-radius: 0 4px 4px 0;
209
- transition: background .12s, border-color .12s, color .12s;
210
- }
211
- .nav-item .nav-snippet:hover { background: var(--accent-soft); border-left-color: var(--accent); color: var(--ink); }
212
- .nav-item .nav-snippet b { color: var(--accent-deep); font-weight: 600; }
213
- .nav-item .nav-snippet.more { font-style: italic; cursor: default; border-left-color: transparent; color: var(--muted-4); }
214
- .nav-item .nav-snippet.more:hover { background: none; }
215
- .nav-hit-count {
216
- display: inline-block; margin-left: 5px; padding: 0 5px; font-family: var(--mono);
217
- font-size: 9px; line-height: 15px; vertical-align: 1px; border-radius: 8px;
218
- background: var(--accent-soft); color: var(--accent-deep); font-weight: 600;
219
- }
220
  .nav-empty { color: var(--muted-4); font-size: 11.5px; padding: 8px; font-style: italic; }
221
  .mini-spin { display: inline-block; width: 10px; height: 10px; vertical-align: -1px; margin-right: 4px;
222
  border: 1.5px solid var(--border); border-top-color: var(--accent); border-radius: 50%; animation: spin .7s linear infinite; }
@@ -234,6 +208,49 @@ a:hover { color: var(--accent-deep); }
234
  .crumbs .sep { color: var(--muted-5); }
235
 
236
  /* doc header */
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
237
  .doc-head { margin-bottom: 18px; padding-bottom: 16px; border-bottom: 1px solid var(--border); }
238
  .doc-kicker {
239
  font-family: var(--mono); font-size: 10.5px; letter-spacing: 1.5px;
@@ -338,23 +355,6 @@ a:hover { color: var(--accent-deep); }
338
  font-size: 12.8px; line-height: 1.55;
339
  }
340
  .prose pre code { background: none; border: none; padding: 0; font-size: inherit; color: var(--ink-2); }
341
- /* cross-reference: a `category/node` code span linked to its topic page */
342
- .prose a.xref { border-bottom: none; text-decoration: none; }
343
- .prose a.xref code {
344
- background: var(--accent-soft); border-color: transparent; color: var(--accent-deep);
345
- cursor: pointer; transition: background .12s, color .12s;
346
- }
347
- .prose a.xref:hover code { background: var(--accent); color: #fff; }
348
- [data-theme="dark"] .prose a.xref:hover code { color: #0b1220; }
349
- /* active-search term highlighting inside the article (cleared with the search) —
350
- bold, slightly larger blue text (no pill, so it doesn't read like a link chip) */
351
- mark.search-hl {
352
- background: var(--hl-bg); color: var(--hl-ink);
353
- font-weight: 600; border-radius: 3px;
354
- padding: 0.5px 3.5px; margin: 0 0.5px;
355
- box-shadow: 0 0 0 1px var(--hl-bg);
356
- -webkit-box-decoration-break: clone; box-decoration-break: clone;
357
- }
358
 
359
  /* blockquote */
360
  .prose blockquote {
@@ -595,6 +595,52 @@ mark.search-hl {
595
  }
596
  @media (min-width: 901px) { .nav-scrim { display: none !important; } }
597
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
598
 
599
  /* ── Collapsible taxonomy tree (topics sidebar) ────────── */
600
  .nav-cat { margin-bottom: 10px; }
@@ -768,7 +814,7 @@ body.book-mode .main { padding: 0; }
768
 
769
  /* ── Print (browser "Save as PDF") ─────────────────────── */
770
  @media print {
771
- .topbar, .sidebar, .aside, .nav-scrim, .book-toolbar, #hovercard, #toast, .foot, .menu-toggle { display: none !important; }
772
  html, body { background: #fff; color: #000; }
773
  .layout, .main, body.book-mode .main { display: block; padding: 0; margin: 0; }
774
  .book { max-width: none; margin: 0; padding: 0; color: #111; font-size: 10.5pt; }
@@ -787,4 +833,11 @@ body.book-mode .main { padding: 0; }
787
  .cite .cite-mark { display: none; }
788
  a.secref { color: #111; border: none; }
789
  .prose abbr.ac { border: none; }
 
 
 
 
 
 
 
790
  }
 
24
  --accent-deep: #0a275f;
25
  --accent-soft: #dde6f5;
26
  --accent-hover-row: #c8d6ee;
 
 
27
  --good: #1c7c3f;
28
  --warn: #9a6b00;
29
  --danger: #a12d2d;
 
53
  --accent-deep: #b6cdf7;
54
  --accent-soft: #20304d;
55
  --accent-hover-row: #28395a;
 
 
56
  --good: #6fcf8f;
57
  --warn: #e0b15a;
58
  --danger: #e08585;
 
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;
 
167
  cursor: pointer; transition: all .12s;
168
  }
169
  .search-k:hover { border-color: var(--accent); color: var(--accent-deep); }
 
 
 
170
 
171
  .nav-group { margin-bottom: 14px; }
172
  .nav-group-title {
 
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; }
 
208
  .crumbs .sep { color: var(--muted-5); }
209
 
210
  /* doc header */
211
+ /* single reading column for topic articles (no right rail) */
212
+ .doc { max-width: 820px; margin: 0 auto; }
213
+ .doc .prose { max-width: none; }
214
+
215
+ /* collapsible disclosures used in the article flow (Contents, Open questions) */
216
+ .disclosure { border: 1px solid var(--border); border-radius: 9px; background: var(--bg-card); margin: 14px 0; overflow: hidden; }
217
+ .disclosure > summary {
218
+ list-style: none; cursor: pointer; padding: 10px 14px; display: flex; align-items: center; gap: 8px;
219
+ font-family: var(--mono); font-size: 12px; font-weight: 600; letter-spacing: 0.4px; text-transform: uppercase; color: var(--ink-3);
220
+ }
221
+ .disclosure > summary::-webkit-details-marker { display: none; }
222
+ .disclosure > summary:hover { color: var(--ink); }
223
+ .disclosure > summary::after { content: "▸"; margin-left: auto; color: var(--muted-4); transition: transform .15s; }
224
+ .disclosure[open] > summary::after { transform: rotate(90deg); }
225
+ .disclosure .disc-ic { color: var(--accent); font-weight: 700; }
226
+ .disclosure .disc-n { color: var(--muted-4); font-weight: 500; }
227
+ .disclosure.oq-d { border-left: 3px solid var(--accent); }
228
+ .disclosure .oq-list, .disclosure .toc { padding: 2px 16px 14px; }
229
+ .disclosure .toc { display: block; }
230
+ .disclosure .toc a { display: block; font-size: 12.5px; color: var(--muted); padding: 3px 0 3px 10px; border-left: 2px solid var(--border-soft); }
231
+ .disclosure .toc a.h3 { padding-left: 22px; font-size: 11.5px; }
232
+ .disclosure .toc a:hover { color: var(--accent); border-left-color: var(--accent); }
233
+
234
+ /* references (sources cited), at the end of the article */
235
+ .refs-section { margin: 40px 0 8px; padding-top: 20px; border-top: 1px solid var(--border); }
236
+ .refs-h { font-family: var(--mono); font-size: 15px; font-weight: 600; color: var(--ink); margin-bottom: 12px; display: flex; align-items: baseline; gap: 8px; }
237
+ .refs-h .refs-n { font-size: 11px; color: var(--muted-4); font-weight: 500; }
238
+ .refs-list { list-style: decimal; margin: 0 0 0 26px; padding: 0; }
239
+ .refs-list li { margin: 5px 0; font-size: 13px; line-height: 1.5; }
240
+ .refs-list li::marker { color: var(--muted-4); font-family: var(--mono); font-size: 11px; }
241
+ .ref-id { font-family: var(--mono); font-size: 11.5px; color: var(--accent); border-bottom: none; }
242
+ .ref-id:hover { border-bottom: 1px solid var(--accent); }
243
+ .ref-title { color: var(--ink-3); margin-left: 8px; }
244
+ .ref-ext { color: var(--muted-4); border-bottom: none; }
245
+ .page-links { margin: 22px 0 0; display: flex; gap: 16px; font-family: var(--mono); font-size: 11px; }
246
+ .page-links a { color: var(--muted-3); }
247
+ .page-links a:hover { color: var(--accent); }
248
+
249
+ /* figures — images, inline SVG diagrams, generated plots */
250
+ .prose .fig { margin: 22px 0; text-align: center; }
251
+ .prose .fig img, .prose .fig svg { max-width: 100%; height: auto; border-radius: 8px; }
252
+ .prose .fig figcaption { font-size: 12px; color: var(--muted-2); margin-top: 8px; line-height: 1.45; text-align: center; }
253
+
254
  .doc-head { margin-bottom: 18px; padding-bottom: 16px; border-bottom: 1px solid var(--border); }
255
  .doc-kicker {
256
  font-family: var(--mono); font-size: 10.5px; letter-spacing: 1.5px;
 
355
  font-size: 12.8px; line-height: 1.55;
356
  }
357
  .prose pre code { background: none; border: none; padding: 0; font-size: inherit; color: var(--ink-2); }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
358
 
359
  /* blockquote */
360
  .prose blockquote {
 
595
  }
596
  @media (min-width: 901px) { .nav-scrim { display: none !important; } }
597
 
598
+ /* ── command palette ───────────────────────────────────── */
599
+ .palette-foot kbd, .pal-esc {
600
+ font-family: var(--mono); font-size: 10px; padding: 1.5px 5px; border-radius: 4px;
601
+ border: 1px solid var(--border); background: var(--bg-soft); color: var(--muted-2); line-height: 1.4;
602
+ }
603
+
604
+ .palette-scrim {
605
+ position: fixed; inset: 0; z-index: 200; background: rgba(15,18,22,0.42);
606
+ backdrop-filter: blur(2px); display: flex; align-items: flex-start; justify-content: center;
607
+ padding: 12vh 16px 16px;
608
+ }
609
+ .palette-scrim[hidden] { display: none; }
610
+ .palette {
611
+ width: 100%; max-width: 580px; background: var(--bg-card); border: 1px solid var(--border);
612
+ border-radius: 12px; box-shadow: 0 12px 48px rgba(0,0,0,0.28); overflow: hidden;
613
+ display: flex; flex-direction: column; max-height: 70vh;
614
+ }
615
+ .palette-search { display: flex; align-items: center; gap: 10px; padding: 12px 15px; border-bottom: 1px solid var(--border); }
616
+ .pal-icon { color: var(--muted-3); font-size: 17px; }
617
+ .palette-input {
618
+ flex: 1; border: none; background: none; outline: none; color: var(--ink);
619
+ font-family: var(--sans); font-size: 15px; font-weight: 300;
620
+ }
621
+ .palette-results { overflow-y: auto; padding: 6px; }
622
+ .pal-item {
623
+ display: flex; align-items: center; gap: 10px; padding: 8px 10px; border-radius: 7px; cursor: pointer;
624
+ }
625
+ .pal-item.sel { background: var(--accent-soft); }
626
+ .pal-kind {
627
+ flex: 0 0 auto; font-family: var(--mono); font-size: 9px; font-weight: 600; text-transform: uppercase;
628
+ letter-spacing: 0.5px; padding: 2px 6px; border-radius: 4px; width: 50px; text-align: center;
629
+ }
630
+ .pal-kind.topic { background: var(--accent-soft); color: var(--accent-deep); }
631
+ .pal-kind.source { background: var(--bg-soft); color: var(--muted-2); border: 1px solid var(--border-soft); }
632
+ .pal-text { flex: 1; min-width: 0; }
633
+ .pal-label { font-size: 13.5px; color: var(--ink); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
634
+ .pal-label b { color: var(--accent-deep); font-weight: 600; }
635
+ .pal-sub { font-family: var(--mono); font-size: 10px; color: var(--muted-4); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
636
+ .pal-empty { padding: 24px; text-align: center; color: var(--muted-3); font-size: 13px; }
637
+ .palette-foot {
638
+ display: flex; gap: 14px; align-items: center; padding: 8px 14px; border-top: 1px solid var(--border-soft);
639
+ font-size: 10.5px; color: var(--muted-3); font-family: var(--mono); flex-wrap: wrap;
640
+ }
641
+ .pal-dot { display: inline-block; width: 7px; height: 7px; border-radius: 50%; vertical-align: middle; margin: 0 2px 0 6px; }
642
+ .pal-dot.pal-t { background: var(--accent); }
643
+ .pal-dot.pal-s { background: var(--muted-4); }
644
 
645
  /* ── Collapsible taxonomy tree (topics sidebar) ────────── */
646
  .nav-cat { margin-bottom: 10px; }
 
814
 
815
  /* ── Print (browser "Save as PDF") ─────────────────────── */
816
  @media print {
817
+ .topbar, .sidebar, .aside, .nav-scrim, .book-toolbar, .palette-scrim, #hovercard, #toast, .foot, .menu-toggle { display: none !important; }
818
  html, body { background: #fff; color: #000; }
819
  .layout, .main, body.book-mode .main { display: block; padding: 0; margin: 0; }
820
  .book { max-width: none; margin: 0; padding: 0; color: #111; font-size: 10.5pt; }
 
833
  .cite .cite-mark { display: none; }
834
  a.secref { color: #111; border: none; }
835
  .prose abbr.ac { border: none; }
836
+ /* single-article print: show collapsed disclosures + drop their chrome */
837
+ .disclosure { border: none; }
838
+ .disclosure > summary { padding-left: 0; }
839
+ .disclosure > summary::after { display: none; }
840
+ .disclosure > *:not(summary) { display: block !important; }
841
+ .page-links { display: none; }
842
+ .prose .fig { break-inside: avoid; }
843
  }