diff --git a/build-tools/static/md/mkdocs/docs/css/agda-hover.css b/build-tools/static/md/mkdocs/docs/css/agda-hover.css new file mode 100644 index 000000000..6a741fad7 --- /dev/null +++ b/build-tools/static/md/mkdocs/docs/css/agda-hover.css @@ -0,0 +1,134 @@ +/* ========================================================================== + Agda Hover Tooltip (clean + compact) + -------------------------------------------------------------------------- + Tuning knobs: + - --agda-font-base: base font size used for most text in the tooltip + - --agda-font-code: font size for code snippet + - --agda-line-base: line-height for body text + - --agda-line-code: line-height for code snippet + - --agda-width: max width of the tooltip + - --agda-ch: max code line width in characters (for readability) + ========================================================================== */ + +/* === Theme + size tokens ================================================= */ +:root, +[data-md-color-scheme="default"] { + /* Sizes (make smaller/larger here) */ + --agda-font-base: 0.72rem; /* overall base text size (very compact) */ + --agda-font-code: 0.70rem; /* code size (very compact) */ + --agda-line-base: 1.05rem; /* base line-height */ + --agda-line-code: 1.06rem; /* code line-height */ + --agda-width: min(34rem, 90vw); /* tooltip max width */ + --agda-ch: 68ch; /* max code line width */ + + /* Colors (light) */ + --agda-tip-bg: #ffffff; + --agda-tip-fg: #1f2328; + --agda-tip-border: rgba(0,0,0,.10); + --agda-tip-link: #0969da; /* reserved if links added later */ + --agda-tip-codebg: rgba(127,127,127,.06); + --agda-shadow: 0 6px 16px rgba(0,0,0,.14), 0 2px 6px rgba(0,0,0,.10); +} + +[data-md-color-scheme="slate"] { + /* Colors (dark) */ + --agda-tip-bg: #1f2430; + --agda-tip-fg: #f4f6f8; + --agda-tip-border: rgba(255,255,255,.14); + --agda-tip-link: #8ab4ff; + --agda-tip-codebg: rgba(255,255,255,.04); + --agda-shadow: 0 6px 16px rgba(0,0,0,.25), 0 2px 6px rgba(0,0,0,.18); +} + +/* === Tooltip container =================================================== */ +.agda-tooltip { + position: absolute; + z-index: 2000; + max-width: var(--agda-width); + background: var(--agda-tip-bg); + color: var(--agda-tip-fg); + border: 1px solid var(--agda-tip-border); + border-radius: .35rem; + box-shadow: var(--agda-shadow); + opacity: 1; + transform: none; + + /* Hover-only behavior: no interactions inside the box */ + pointer-events: none; + /* To allow selection/copy, flip to 'auto' */ +} + +.agda-tooltip.hidden { + opacity: 0; + /* Tooltip is purely informational; hide by opacity. */ + /* (JS is already preventing pointer events when hidden.) */ +} + +/* === Inner layout ======================================================== */ +.agda-tooltip .agda-tooltip-inner { + padding: .36rem .48rem; /* very tight padding */ + font-size: var(--agda-font-base); + line-height: var(--agda-line-base); +} + +/* === Header line (kind · name · module) ================================= */ +.agda-tip-head { + display: flex; + align-items: baseline; + gap: .32rem; + margin-bottom: .16rem; + font-weight: 600; +} + +.agda-kind { + font-size: calc(var(--agda-font-base) - 0.06rem); /* tiny label */ + letter-spacing: .04em; + text-transform: uppercase; + opacity: .72; +} + +.agda-name { + font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, + "Liberation Mono", "Courier New", monospace; + font-size: calc(var(--agda-font-base) - 0.02rem); + font-weight: 700; +} + +.agda-module { + margin-left: auto; + font-size: calc(var(--agda-font-base) - 0.08rem); + opacity: .55; +} + +/* === Code preview ======================================================== */ +.agda-tip-snippet { + margin: .10rem 0 0 0; + padding: .30rem .40rem; + border-radius: .28rem; + background: var(--agda-tip-codebg); + + font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, + "Liberation Mono", "Courier New", monospace; + font-size: var(--agda-font-code); + line-height: var(--agda-line-code); + + /* Keep Agda formatting exactly. If you prefer wrapping, change to 'pre-wrap'. */ + white-space: pre; + + /* Width hygiene */ + max-width: var(--agda-ch); + overflow-x: auto; /* allow horizontal scroll for long types */ + overflow-y: hidden; +} + +/* When no snippet could be extracted */ +.agda-tip-empty { + margin: .12rem 0 0 0; + font-size: calc(var(--agda-font-base) - 0.04rem); + opacity: .85; +} + +/* === Legacy / unused bits (kept inert) ================================== */ +/* If older CSS defines these, keep them invisible to avoid layout churn. */ +.agda-tip-actions { display: none !important; } +.agda-open-def { display: none !important; } diff --git a/build-tools/static/md/mkdocs/docs/js/agda-hover.js b/build-tools/static/md/mkdocs/docs/js/agda-hover.js new file mode 100644 index 000000000..41e74d2c5 --- /dev/null +++ b/build-tools/static/md/mkdocs/docs/js/agda-hover.js @@ -0,0 +1,344 @@ +// === Agda hover definitions =============================================== +// Works on Agda-generated HTML embedded in Markdown, i.e., inside
.
+// Shows a tooltip on hover with (1) token info, (2) link to full definition,
+// and (best-effort) a short snippet from the target page near the anchor.
+
+(function () {
+ const HOVER_SELECTOR = 'pre.Agda a[href]'; // limit to Agda pre blocks
+ const HOVER_DELAY_MS = 120; // small debounce to avoid flicker
+ const MAX_SNIPPET_CHARS = 220; // keep tooltips compact
+ const cache = new Map(); // href -> { html, title, className, module, snippet }
+
+ let tooltipEl = null;
+ let hideTimer = null;
+ let showTimer = null;
+
+ function createTooltip() {
+ if (tooltipEl) return tooltipEl;
+ tooltipEl = document.createElement('div');
+ tooltipEl.className = 'agda-tooltip hidden';
+ tooltipEl.setAttribute('role', 'tooltip');
+ tooltipEl.innerHTML = '';
+ document.body.appendChild(tooltipEl);
+ return tooltipEl;
+ }
+
+ function positionTooltip(evt, el) {
+ const pad = 12;
+ const vw = window.innerWidth;
+ const vh = window.innerHeight;
+ const rect = el.getBoundingClientRect();
+ const tipRect = tooltipEl.getBoundingClientRect();
+ let top = rect.bottom + window.scrollY + pad;
+ let left = evt.clientX + window.scrollX + pad;
+
+ // if overflowing right edge, shift left
+ if (left + tipRect.width > window.scrollX + vw - pad) {
+ left = Math.max(window.scrollX + pad, vw + window.scrollX - tipRect.width - pad);
+ }
+ // if too low, pop above
+ if (top + tipRect.height > window.scrollY + vh - pad) {
+ top = rect.top + window.scrollY - tipRect.height - pad;
+ }
+ tooltipEl.style.top = `${top}px`;
+ tooltipEl.style.left = `${left}px`;
+ }
+
+ function sanitize(text) {
+ // very small helper: we only ever insert our own strings; still, be safe
+ const div = document.createElement('div');
+ div.textContent = text == null ? '' : String(text);
+ return div.innerHTML;
+ }
+
+ function inferTokenInfo(a) {
+ // Try to infer token info from the Agda link classes + URL
+ const classes = (a.getAttribute('class') || '').split(/\s+/).filter(Boolean);
+ const agdaClass = classes.find(c => /^(Function|Field|Datatype|Record|Module|Primitive|InductiveConstructor|Operator)$/.test(c)) || 'Symbol';
+ const href = a.getAttribute('href') || '';
+ const url = new URL(href, window.location.href);
+ const moduleFile = (url.pathname.split('/').pop() || '').replace(/\.html$/,'');
+ const moduleName = moduleFile || 'Module';
+ // The visible label (basename) is often adjacent; fall back to text
+ const basename = a.textContent.trim() || moduleName;
+ const anchor = url.hash ? url.hash.slice(1) : '';
+ return { href: url.href, basename, agdaClass, moduleName, anchor };
+ }
+
+ async function fetchSnippet(href, anchor) {
+ try {
+ const res = await fetch(href, { credentials: 'same-origin' });
+ if (!res.ok) throw new Error(`HTTP ${res.status}`);
+ const html = await res.text();
+
+ const dom = new DOMParser().parseFromString(html, 'text/html');
+
+ // 1) Find the anchor element, then its closest Agda
+ const target = anchor ? dom.getElementById(anchor) : null;
+ const pre = target ? target.closest('pre.Agda') : dom.querySelector('pre.Agda');
+ if (!pre) return { snippet: '' };
+
+ // 2) Compute the anchor's character offset within that
+ const index = target ? charIndexWithin(pre, target) : -1;
+
+ // 3) Extract a multi-line definition block that surrounds the anchor
+ const text = pre.textContent || '';
+ const { block } = extractDefinitionBlock(text, index, {
+ // -- tweakable knobs --
+ minLines: 2, // default: 2 lines
+ maxLines: 14, // default: 14 lines
+ maxChars: 1200, // hard cap to keep tooltips snappy
+ includeUpwardSignature: true,
+ });
+
+ return { snippet: block.trim() };
+ } catch (_e) {
+ return { snippet: '' };
+ }
+ }
+ // async function fetchSnippet(href, anchor) {
+ // try {
+ // const res = await fetch(href, { credentials: 'same-origin' });
+ // if (!res.ok) throw new Error(`HTTP ${res.status}`);
+ // const html = await res.text();
+
+ // // Best-effort: parse the target page & grab nearby code around the anchor
+ // const dom = new DOMParser().parseFromString(html, 'text/html');
+ // const target = anchor ? dom.getElementById(anchor) : null;
+ // const pre = target ? target.closest('pre.Agda') : dom.querySelector('pre.Agda');
+
+ // if (!pre) return { snippet: '' };
+
+ // // Convert the to text and extract a compact snippet around the anchor id
+ // const preText = pre.textContent || '';
+ // if (!preText) return { snippet: '' };
+
+ // let snippet = preText;
+ // // Try to bias toward the area containing the anchor id text (Agda puts ids on , not text;
+ // // so we approximate by taking the middle of the if we don't find anything)
+ // const mid = Math.floor(preText.length / 2);
+ // const start = Math.max(0, mid - Math.floor(MAX_SNIPPET_CHARS / 2));
+ // snippet = preText.slice(start, start + MAX_SNIPPET_CHARS).trim();
+
+ // // Normalize whitespace; keep “code” flavor
+ // snippet = snippet.replace(/\s+\n/g, '\n').replace(/\n{3,}/g, '\n\n');
+
+ // return { snippet };
+ // } catch (_e) {
+ // return { snippet: '' };
+ // }
+ // }
+
+ /**
+ * Compute the character index of `node` relative to the start of `root`,
+ * by using a DOM Range. Falls back to -1 if anything fails.
+ */
+ function charIndexWithin(root, node) {
+ try {
+ const r = document.createRange();
+ r.setStart(root, 0);
+ r.setEnd(node, 0);
+ return r.toString().length;
+ } catch {
+ return -1;
+ }
+ }
+
+ /**
+ * Heuristically extract a "definition block" around `anchorIndex`.
+ * Strategy:
+ * - Split text into lines, track cumulative offsets.
+ * - Find the line containing anchorIndex (or mid if -1).
+ * - Expand upward to include header/signature lines:
+ * - previous non-empty lines that are less-indented than the current line (headers),
+ * - or that look like a type signature (contains ':' or 'record'/'data'/'module').
+ * - Expand downward while:
+ * - line is indented >= firstDefIndent (continuation/clauses),
+ * - OR it’s a clause/where/with line,
+ * - stop at first blank line following content.
+ * - Enforce min/max lines and maxChars guardrails.
+ */
+ function extractDefinitionBlock(text, anchorIndex, opts) {
+ const {
+ minLines = 2,
+ maxLines = 12,
+ maxChars = 1000,
+ includeUpwardSignature = true,
+ } = opts || {};
+
+ const lines = text.split(/\r?\n/);
+ const lens = lines.map(l => l.length + 1); // +1 for newline
+ const starts = new Array(lines.length);
+ let acc = 0;
+ for (let i = 0; i < lines.length; i++) {
+ starts[i] = acc;
+ acc += lens[i];
+ }
+
+ // Find anchor line (or fallback to middle line)
+ let iAnchor = 0;
+ if (anchorIndex >= 0) {
+ for (let i = 0; i < starts.length; i++) {
+ const s = starts[i];
+ const e = s + Math.max(0, lens[i] - 1); // exclude trailing newline
+ if (anchorIndex >= s && anchorIndex <= e) { iAnchor = i; break; }
+ if (anchorIndex < s) { iAnchor = Math.max(0, i - 1); break; }
+ if (i === starts.length - 1) iAnchor = i;
+ }
+ } else {
+ iAnchor = Math.floor(lines.length / 2);
+ }
+
+ // Helpers
+ const indent = l => (l.match(/^\s*/)?.[0]?.length ?? 0);
+ const isBlank = l => /^\s*$/.test(l);
+ const looksLikeHeader = l =>
+ /\brecord\b|\bdata\b|\bmodule\b|\bconstructor\b/.test(l) || /:/.test(l);
+ const looksLikeClause = l =>
+ /\bwhere\b|\bwith\b|^\s*\|/.test(l) || /:=|=\s*$|→|→\s*$/.test(l);
+
+ // 1) Seed line: the one with anchor
+ let start = iAnchor, end = iAnchor;
+
+ // 2) Expand upward for headers/signatures (optional)
+ if (includeUpwardSignature) {
+ let i = iAnchor - 1;
+ while (i >= 0) {
+ const l = lines[i];
+ if (isBlank(l)) { i--; continue; }
+ if (looksLikeHeader(l) || indent(l) <= indent(lines[start])) {
+ start = i; i--;
+ } else {
+ break;
+ }
+ if (end - start + 1 >= maxLines) break;
+ }
+ }
+
+ // 3) Expand downward for continuation/clauses
+ const firstIndent = indent(lines[start]);
+ let sawContent = false;
+ for (let i = end + 1; i < lines.length; i++) {
+ const l = lines[i];
+ if (isBlank(l)) {
+ if (sawContent) { end = i - 1; break; }
+ // if blank before any content, skip it but don't count it
+ continue;
+ }
+ const ind = indent(l);
+ if (ind >= firstIndent || looksLikeClause(l)) {
+ end = i;
+ sawContent = true;
+ } else {
+ // hit a new top-level thing
+ break;
+ }
+ if (end - start + 1 >= maxLines) break;
+ }
+
+ // 4) Ensure at least minLines (extend down a bit if needed)
+ while (end - start + 1 < minLines && end + 1 < lines.length && !isBlank(lines[end + 1])) {
+ end++;
+ }
+
+ // 5) Compose block + enforce maxChars (trim softly on line boundary)
+ let blockLines = lines.slice(start, end + 1);
+ let block = blockLines.join('\n');
+ if (block.length > maxChars) {
+ // trim to nearest earlier newline before maxChars
+ const cut = block.lastIndexOf('\n', maxChars);
+ block = block.slice(0, cut > 0 ? cut : maxChars) + '\n…';
+ }
+
+ return { block, start, end, iAnchor };
+ }
+
+ function renderTooltipContent(info, snippet) {
+ const { basename, agdaClass, moduleName } = info;
+
+ const header = `
+ ${sanitize(agdaClass)}
+ ${sanitize(basename)}
+ · ${sanitize(moduleName)}
+ `;
+
+ const body = snippet
+ ? `${sanitize(snippet)}
`
+ : `No preview available.`;
+
+ return header + body; // ⟵ no footer
+ }
+
+ function showTooltip(evt, a, content) {
+ const el = createTooltip();
+ el.querySelector('.agda-tooltip-inner').innerHTML = content;
+ el.classList.remove('hidden');
+ positionTooltip(evt, a);
+ }
+
+ function hideTooltipSoon() {
+ if (!tooltipEl) return;
+ tooltipEl.classList.add('hidden');
+ }
+
+ async function handleEnter(evt) {
+ const a = evt.currentTarget;
+ clearTimeout(hideTimer);
+ clearTimeout(showTimer);
+
+ showTimer = setTimeout(async () => {
+ const key = a.href;
+ let data = cache.get(key);
+
+ const info = inferTokenInfo(a);
+ if (!data) {
+ // Render a fast “skeleton” immediately
+ const skeleton = renderTooltipContent(info, '');
+ showTooltip(evt, a, skeleton);
+
+ // Fetch snippet (once)
+ const { snippet } = await fetchSnippet(info.href, info.anchor);
+ data = { info, snippet };
+ cache.set(key, data);
+ }
+
+ const html = renderTooltipContent(info, data.snippet);
+ showTooltip(evt, a, html);
+ }, HOVER_DELAY_MS);
+ }
+
+ function handleLeave() {
+ clearTimeout(showTimer);
+ hideTimer = setTimeout(hideTooltipSoon, 60);
+ }
+
+ function handleMove(evt) {
+ if (!tooltipEl || tooltipEl.classList.contains('hidden')) return;
+ const a = evt.currentTarget;
+ positionTooltip(evt, a);
+ }
+
+ function wireUp() {
+ const anchors = document.querySelectorAll(HOVER_SELECTOR);
+ anchors.forEach(a => {
+ // Skip non-HTTP links
+ const href = a.getAttribute('href');
+ if (!href || href.startsWith('#') || href.startsWith('mailto:')) return;
+
+ a.addEventListener('mouseenter', handleEnter, { passive: true });
+ a.addEventListener('mouseleave', handleLeave, { passive: true });
+ a.addEventListener('mousemove', handleMove, { passive: true });
+ });
+
+ // Keep tooltip from flickering if user hovers the tooltip itself
+ createTooltip();
+ tooltipEl.addEventListener('mouseenter', () => clearTimeout(hideTimer));
+ tooltipEl.addEventListener('mouseleave', hideTooltipSoon);
+ }
+
+ if (document.readyState === 'loading') {
+ document.addEventListener('DOMContentLoaded', wireUp);
+ } else {
+ wireUp();
+ }
+})();
diff --git a/build-tools/static/md/mkdocs/mkdocs.yml b/build-tools/static/md/mkdocs/mkdocs.yml
index e4331f2d3..bd0967a61 100644
--- a/build-tools/static/md/mkdocs/mkdocs.yml
+++ b/build-tools/static/md/mkdocs/mkdocs.yml
@@ -71,6 +71,7 @@ extra_css:
# auto-generated by Agda fls backend (load after katex.css)
# - css/Agda.css
- css/custom.css
+ - css/agda-hover.css
extra_javascript:
# KaTeX JavaScript
@@ -82,6 +83,7 @@ extra_javascript:
- AgdaKaTeX.js
- https://cdn.jsdelivr.net/npm/chart.js
- js/custom.js
+ - js/agda-hover.js
markdown_extensions:
- admonition # call-out boxes like !!! note