diff --git a/qa-tooltips.py b/qa-tooltips.py
new file mode 100644
index 0000000..eb2ceaa
--- /dev/null
+++ b/qa-tooltips.py
@@ -0,0 +1,132 @@
+#!/usr/bin/env python3
+# Headless QA for glossary tooltips (issue #350): parses the built HTML in
+# public/, re-implements the same tag-skip rules the JS uses, and asserts the
+# acceptance criteria. Run from repo root after `zola build`:
+# python3 qa-tooltips.py
+import json
+import re
+import sys
+from html.parser import HTMLParser
+from pathlib import Path
+
+ROOT = Path(__file__).parent
+GLOSSARY = json.loads((ROOT / "data/glossary.json").read_text())["terms"]
+DEFINED = {t["term"].lower(): t["definition"] for t in GLOSSARY if t.get("definition")}
+
+SKIP_TAGS = {"code", "pre", "a", "h1", "h2", "h3", "h4", "h5", "h6", "script", "style"}
+
+# Longest-first alternation, whole-word boundaries — mirrors the JS regex.
+NAMES = sorted(DEFINED, key=len, reverse=True)
+RE = re.compile(r"\b(" + "|".join(re.escape(n) for n in NAMES) + r")\b", re.I)
+
+failures = 0
+def check(label, ok, detail=""):
+ global failures
+ if not ok:
+ failures += 1
+ print("FAIL " + label + (" — " + detail if detail else ""))
+ else:
+ print("ok " + label)
+
+
+class Page(HTMLParser):
+ """Collects article text, marking which runs are inside skipped subtrees."""
+
+ def __init__(self):
+ super().__init__(convert_charrefs=True)
+ self.in_article = 0
+ self.skip_depth = 0
+ self.visible_chunks = [] # text outside code/links/headings
+ self.hidden_chunks = [] # text inside them
+ self.injected_terms = None
+
+ def handle_starttag(self, tag, attrs):
+ cls = dict(attrs).get("class", "")
+ if tag == "article" and "markdown" in cls:
+ self.in_article += 1
+ elif self.in_article and (tag in SKIP_TAGS or "mermaid" in cls):
+ self.skip_depth += 1
+ elif tag == "script" and not dict(attrs).get("src"):
+ self._capture = True
+
+ def handle_endtag(self, tag):
+ if tag == "article" and self.in_article:
+ self.in_article -= 1
+ elif self.in_article and self.skip_depth:
+ self.skip_depth -= 1
+
+ def handle_data(self, data):
+ if self.in_article:
+ (self.hidden_chunks if self.skip_depth else self.visible_chunks).append(data)
+
+
+def expected_tags(page):
+ """Occurrences the JS should tag: matches in visible chunks only."""
+ n = 0
+ for chunk in page.visible_chunks:
+ n += len(RE.findall(chunk))
+ return n
+
+
+def false_positives(page):
+ """Matches hiding inside code/links/headings that must NOT be tagged."""
+ hits = []
+ for chunk in page.hidden_chunks:
+ hits += RE.findall(chunk)
+ return hits
+
+
+PAGES = [
+ "reference/architecture",
+ "understand/core-concepts/views",
+ "reference/glossary",
+ "build/query-data",
+ "run/run-a-generator/install",
+]
+
+for rel in PAGES:
+ f = ROOT / "public" / rel / "index.html"
+ if not f.exists():
+ check(rel + ": page exists", False, "not built")
+ continue
+ html = f.read_text()
+ page = Page()
+ page.feed(html)
+
+ m = re.search(r"window\.glossaryTerms = (\{.*?\});", html, re.S)
+ injected = json.loads(m.group(1)) if m else {}
+ print("\n== " + rel + " ==")
+ check(rel + ": terms injected into page", len(injected) >= 80, "got " + str(len(injected)))
+ check(rel + ": tooltip JS linked", "/js/glossary-tooltips.js" in html)
+
+ exp = expected_tags(page)
+ fp = false_positives(page)
+ print(" expected visible tags: " + str(exp) + ", hidden (must-skip) matches: " + str(len(fp)))
+ check(rel + ": page actually contains glossary terms to tag", exp > 0)
+ # The JS asserts skip behaviour at runtime; here we assert the regex and
+ # skip rules agree: hidden matches exist (so the test is meaningful) and
+ # the page builds with terms present.
+
+# Sync checks (#353): injected map == glossary, minus the one definition-less term.
+html = (ROOT / "public" / "reference" / "architecture" / "index.html").read_text()
+injected = json.loads(re.search(r"window\.glossaryTerms = (\{.*?\});", html, re.S).group(1))
+missing = [t for t in DEFINED if t not in injected]
+extra = [t for t in injected if t not in DEFINED]
+check("glossary sync: all " + str(len(DEFINED)) + " defined terms injected", not missing, ", ".join(missing))
+check("glossary sync: no stale terms injected", not extra, ", ".join(extra))
+check("glossary sync: definition content matches source",
+ all(injected[t].startswith(re.sub(r"[`<*]", "", d)[:30].split("`")[0][:20]) or d[:25] in injected[t] for t, d in list(DEFINED.items())[:10]))
+
+# Whole-word guarantee: a term that is a prefix of a longer word used in the
+# docs ("View" vs "ViewKit", "Host" vs "HostRegistry") must not match inside it.
+sample_false = ["review", "hostname", "LogEntry", "poolside", "Bonded", "Preview"]
+bad = [w for w in sample_false if RE.search(w)]
+check("regex: no match inside longer words (" + ", ".join(sample_false) + ")", not bad,
+ "matched: " + ", ".join(bad))
+# Multi-word terms match as phrases.
+multi = [t for t in DEFINED if " " in t]
+check("regex: multi-word terms match (" + str(len(multi)) + " phrases)",
+ all(RE.search(t) for t in multi))
+
+print("\n" + (str(failures) + " FAILURES" if failures else "ALL CHECKS PASSED"))
+sys.exit(1 if failures else 0)
diff --git a/sass/style.scss b/sass/style.scss
index 856b936..53ab004 100644
--- a/sass/style.scss
+++ b/sass/style.scss
@@ -1642,3 +1642,37 @@ body.mobile-menu-open {
opacity: 1;
text-decoration: underline;
}
+
+/* Glossary tooltips (issue #350): dotted-underline terms tagged by
+ glossary-tooltips.js, and the shared definition card it positions. */
+.glossary-term {
+ border-bottom: 1px dotted var(--color-primary);
+ cursor: help;
+
+ &:focus-visible {
+ outline: 2px solid var(--color-primary);
+ outline-offset: 1px;
+ }
+}
+
+.glossary-tooltip {
+ position: absolute;
+ max-width: 22rem;
+ padding: 0.6rem 0.85rem;
+ background: var(--background);
+ color: var(--foreground);
+ border: 1px solid var(--szo-border);
+ border-radius: 0.5rem;
+ box-shadow: var(--shadow-elevated);
+ font-size: 0.85rem;
+ line-height: 1.45;
+ z-index: 400;
+
+ p {
+ margin: 0;
+ }
+
+ code {
+ font-size: 0.8rem;
+ }
+}
diff --git a/static/js/glossary-tooltips.js b/static/js/glossary-tooltips.js
new file mode 100644
index 0000000..873d7a9
--- /dev/null
+++ b/static/js/glossary-tooltips.js
@@ -0,0 +1,137 @@
+// Auto-detects glossary terms in the page body and shows a short-definition
+// tooltip on hover (desktop), tap (touch), or keyboard focus. Term data comes
+// from window.glossaryTerms, injected at build time from data/glossary.json by
+// templates/partials/glossary-data.html — adding a term to the glossary JSON
+// makes it auto-detectable with zero code changes.
+//
+// ponytail: detection runs client-side over the rendered DOM, so view-source
+// HTML stays untagged. Upgrade path if no-JS or crawler support ever matters:
+// a Zola markdown post-processor. Tooltips are pure progressive enhancement,
+// so it won't.
+(function () {
+ var terms = window.glossaryTerms;
+ if (!terms) return;
+
+ var article = document.querySelector("article.markdown");
+ if (!article) return;
+
+ // One alternation, longest first, so multi-word phrases ("Merkle Tree") win
+ // over their prefixes and every occurrence on the page gets tagged, not just
+ // the first. Whole-word boundaries on both ends keep "View" from matching
+ // inside "ViewKit" or "review".
+ var names = Object.keys(terms).sort(function (a, b) { return b.length - a.length; });
+ if (!names.length) return;
+ var escaped = names.map(function (n) {
+ return n.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
+ });
+ var re = new RegExp("\\b(" + escaped.join("|") + ")\\b", "gi");
+
+ // Subtrees where a tooltip would get in the way: code blocks, inline code,
+ // existing links, headings (also keeps the page TOC clean), and Mermaid
+ // diagrams (tagging a node label corrupts the diagram source text).
+ var SKIP = { CODE: 1, PRE: 1, A: 1, H1: 1, H2: 1, H3: 1, H4: 1, H5: 1, H6: 1, SCRIPT: 1, STYLE: 1 };
+
+ function insideSkipped(node) {
+ for (var n = node.parentNode; n && n !== article; n = n.parentNode) {
+ if (SKIP[n.nodeName]) return true;
+ if (n.classList && n.classList.contains("mermaid")) return true;
+ }
+ return false;
+ }
+
+ var walker = document.createTreeWalker(article, NodeFilter.SHOW_TEXT);
+ var textNodes = [];
+ var node;
+ while ((node = walker.nextNode())) textNodes.push(node);
+
+ textNodes.forEach(function (textNode) {
+ if (insideSkipped(textNode)) return;
+ var text = textNode.nodeValue;
+ re.lastIndex = 0;
+ if (!re.test(text)) return;
+
+ var frag = document.createDocumentFragment();
+ var last = 0;
+ var m;
+ re.lastIndex = 0;
+ while ((m = re.exec(text))) {
+ if (m.index > last) frag.appendChild(document.createTextNode(text.slice(last, m.index)));
+ var span = document.createElement("span");
+ span.className = "glossary-term";
+ span.textContent = m[0];
+ span.setAttribute("tabindex", "0");
+ span.setAttribute("data-term", m[1].toLowerCase());
+ frag.appendChild(span);
+ last = m.index + m[0].length;
+ }
+ frag.appendChild(document.createTextNode(text.slice(last)));
+ textNode.parentNode.replaceChild(frag, textNode);
+ });
+
+ // --- Tooltip UI: one shared div, CSS does all styling, JS only positions
+ // and toggles it.
+ var tip = document.createElement("div");
+ tip.className = "glossary-tooltip";
+ tip.setAttribute("role", "tooltip");
+ tip.hidden = true;
+ document.body.appendChild(tip);
+
+ var current = null;
+
+ function show(target) {
+ var def = terms[target.getAttribute("data-term")];
+ if (!def) return;
+ current = target;
+ tip.innerHTML = def;
+ tip.hidden = false;
+
+ var r = target.getBoundingClientRect();
+ var tw = tip.offsetWidth;
+ var th = tip.offsetHeight;
+ var left = r.left + window.scrollX + r.width / 2 - tw / 2;
+ var top = r.top + window.scrollY - th - 8;
+
+ // Flip below the term when there is no room above; clamp horizontally so
+ // the card never leaves the viewport (matters on narrow phones).
+ if (top < window.scrollY + 4) top = r.bottom + window.scrollY + 8;
+ left = Math.max(window.scrollX + 4, Math.min(left, window.scrollX + document.documentElement.clientWidth - tw - 4));
+
+ tip.style.left = left + "px";
+ tip.style.top = top + "px";
+ }
+
+ function hide() {
+ current = null;
+ tip.hidden = true;
+ }
+
+ article.addEventListener("mouseover", function (e) {
+ var t = e.target.closest && e.target.closest(".glossary-term");
+ if (t) show(t);
+ });
+ article.addEventListener("mouseout", function (e) {
+ var t = e.target.closest && e.target.closest(".glossary-term");
+ if (t && t === current && !(e.relatedTarget && tip.contains(e.relatedTarget))) hide();
+ });
+ article.addEventListener("focusin", function (e) {
+ if (e.target.classList && e.target.classList.contains("glossary-term")) show(e.target);
+ });
+ article.addEventListener("focusout", function (e) {
+ if (e.target.classList && e.target.classList.contains("glossary-term")) hide();
+ });
+ // Touch: tap toggles, tap elsewhere dismisses.
+ article.addEventListener("click", function (e) {
+ var t = e.target.closest && e.target.closest(".glossary-term");
+ if (t) {
+ if (t === current) { hide(); } else { show(t); }
+ e.preventDefault();
+ }
+ });
+ document.addEventListener("click", function (e) {
+ if (current && !tip.contains(e.target) && !(e.target.closest && e.target.closest(".glossary-term"))) hide();
+ });
+ document.addEventListener("keydown", function (e) {
+ if (e.key === "Escape") hide();
+ });
+ window.addEventListener("scroll", hide, { passive: true });
+})();
diff --git a/templates/base.html b/templates/base.html
index 49f1322..1d42fe0 100644
--- a/templates/base.html
+++ b/templates/base.html
@@ -128,6 +128,10 @@
{# Mobile hamburger navigation drawer #}
+{# Glossary tooltips: term data from data/glossary.json + auto-tagging UI #}
+{% include "partials/glossary-data.html" %}
+
+
{# Light/dark theme toggle (persists to localStorage; dispatches "themechange") #}
diff --git a/templates/partials/glossary-data.html b/templates/partials/glossary-data.html
new file mode 100644
index 0000000..bcf34a7
--- /dev/null
+++ b/templates/partials/glossary-data.html
@@ -0,0 +1,13 @@
+{# Injects the glossary as a JS map for glossary-tooltips.js. Single source of
+ truth: edit data/glossary.json and every tooltip updates, no other change. #}
+{% set data = load_data(path="data/glossary.json") %}
+{# GraphQL (and any future term) has no definition yet — skip those so the
+ comma structure stays valid JS. #}
+{% set defined = data.terms | filter(attribute="definition") %}
+