diff --git a/docs/content/javascripts/pkg-search.js b/docs/content/javascripts/pkg-search.js
new file mode 100644
index 00000000..fb6087b4
--- /dev/null
+++ b/docs/content/javascripts/pkg-search.js
@@ -0,0 +1,226 @@
+/*
+ * Scoped package search.
+ *
+ * Every generated package-overview page (see docs/gen_pages.py) carries a
+ * `
` marker. This
+ * turns each into an in-page search box that queries ONLY that package's subtree
+ * of the site search index (site/search.json) — classes, functions, attributes,
+ * and prose under `data-scope`, deep-linked to their anchors.
+ *
+ * The full index is fetched lazily (first focus) and cached across widgets. While
+ * a query is active the card grid is hidden and ranked results take its place;
+ * clearing the box restores the grid. Everything is wrapped in try/catch and
+ * degrades to the plain card grid if anything goes wrong or JS is disabled.
+ */
+(function () {
+ "use strict";
+
+ var MAX_RESULTS = 40;
+
+ // --- shared index (fetched once) -----------------------------------------
+
+ var indexPromise = null;
+
+ function siteBase() {
+ try {
+ var cfg = JSON.parse(document.getElementById("__config").textContent);
+ return String(cfg.base || ".").replace(/\/?$/, "/");
+ } catch (e) {
+ return "./";
+ }
+ }
+ var BASE = siteBase();
+
+ function loadIndex() {
+ if (!indexPromise) {
+ indexPromise = fetch(new URL(BASE + "search.json", location.href).href)
+ .then(function (r) { return r.json(); })
+ .then(function (d) { return (d && d.items) || []; })
+ .catch(function () { return []; });
+ }
+ return indexPromise;
+ }
+
+ // --- helpers --------------------------------------------------------------
+
+ var SCRATCH = document.createElement("div");
+ function stripTags(html) {
+ if (!html) return "";
+ SCRATCH.innerHTML = html;
+ return (SCRATCH.textContent || "").replace(/\s+/g, " ").trim();
+ }
+
+ function escapeHtml(s) {
+ return s.replace(/[&<>"]/g, function (c) {
+ return { "&": "&", "<": "<", ">": ">", '"': """ }[c];
+ });
+ }
+
+ function resolve(location_) {
+ try {
+ return new URL(BASE + location_, location.href).href;
+ } catch (e) {
+ return location_;
+ }
+ }
+
+ // Build a ~160-char window of `text` around the first matched term, with every
+ // term wrapped in
. Operates on already-stripped plain text.
+ function snippet(text, terms) {
+ if (!text) return "";
+ var lower = text.toLowerCase();
+ var at = -1;
+ for (var i = 0; i < terms.length; i++) {
+ var p = lower.indexOf(terms[i]);
+ if (p !== -1 && (at === -1 || p < at)) at = p;
+ }
+ var start = at === -1 ? 0 : Math.max(0, at - 40);
+ var slice = text.slice(start, start + 160);
+ if (start > 0) slice = "… " + slice;
+ if (start + 160 < text.length) slice = slice + " …";
+
+ var out = escapeHtml(slice);
+ terms.forEach(function (t) {
+ if (!t) return;
+ var re = new RegExp("(" + t.replace(/[.*+?^${}()|[\]\\]/g, "\\$&") + ")", "ig");
+ out = out.replace(re, "$1");
+ });
+ return out;
+ }
+
+ function rank(items, scope, selfPage, terms) {
+ var results = [];
+ for (var i = 0; i < items.length; i++) {
+ var it = items[i];
+ var loc = it.location || "";
+ if (loc.indexOf(scope) !== 0) continue; // outside this subtree
+ if (loc === selfPage || loc.indexOf(selfPage + "#") === 0) continue; // self
+
+ var title = stripTags(it.title);
+ var text = stripTags(it.text);
+ var hayTitle = title.toLowerCase();
+ var hayText = text.toLowerCase();
+
+ var score = 0, ok = true;
+ for (var t = 0; t < terms.length; t++) {
+ var inTitle = hayTitle.indexOf(terms[t]) !== -1;
+ var inText = hayText.indexOf(terms[t]) !== -1;
+ if (!inTitle && !inText) { ok = false; break; }
+ score += inTitle ? 10 : 1;
+ }
+ if (!ok) continue;
+
+ results.push({ href: resolve(loc), title: title, text: text,
+ path: it.path || [], score: score });
+ }
+ results.sort(function (a, b) {
+ return b.score - a.score || a.title.length - b.title.length;
+ });
+ return results.slice(0, MAX_RESULTS);
+ }
+
+ // --- widget ---------------------------------------------------------------
+
+ function mount(widget) {
+ if (widget.__pkgWired) return;
+ widget.__pkgWired = true;
+
+ var scope = widget.getAttribute("data-scope") || "";
+ var label = widget.getAttribute("data-label") || "this package";
+ var selfPage = scope + "index.html";
+ var grid = widget.parentElement
+ ? widget.parentElement.querySelector(".grid.cards")
+ : null;
+
+ var input = document.createElement("input");
+ input.type = "search";
+ input.className = "pkg-search__input";
+ input.placeholder = "Search " + label + "…";
+ input.setAttribute("aria-label", "Search " + label);
+
+ var results = document.createElement("div");
+ results.className = "pkg-search__results";
+ results.hidden = true;
+
+ widget.appendChild(input);
+ widget.appendChild(results);
+
+ function showGrid(show) {
+ if (grid) grid.style.display = show ? "" : "none";
+ }
+
+ function render(list, terms, q) {
+ if (!list.length) {
+ results.innerHTML =
+ 'No matches for “' +
+ escapeHtml(q) + "” in " + escapeHtml(label) + ".
";
+ return;
+ }
+ var html = [''];
+ list.forEach(function (r) {
+ var crumb = r.path.length
+ ? '' +
+ escapeHtml(r.path.join(" / ")) + ""
+ : "";
+ var snip = r.text
+ ? '' + snippet(r.text, terms) + ""
+ : "";
+ html.push(
+ '- ' +
+ '' + escapeHtml(r.title) + "" +
+ crumb + snip + "
"
+ );
+ });
+ html.push("
");
+ results.innerHTML = html.join("");
+ }
+
+ var scheduled = false;
+ function schedule() {
+ if (scheduled) return;
+ scheduled = true;
+ requestAnimationFrame(function () {
+ scheduled = false;
+ run();
+ });
+ }
+
+ function run() {
+ var q = input.value.trim();
+ if (!q) {
+ results.hidden = true;
+ results.innerHTML = "";
+ showGrid(true);
+ return;
+ }
+ var terms = q.toLowerCase().split(/\s+/).filter(Boolean);
+ showGrid(false);
+ results.hidden = false;
+ loadIndex().then(function (items) {
+ if (input.value.trim() !== q) return; // superseded by newer keystroke
+ render(rank(items, scope, selfPage, terms), terms, q);
+ });
+ }
+
+ input.addEventListener("focus", loadIndex, { once: true });
+ input.addEventListener("input", schedule);
+ input.addEventListener("keydown", function (e) {
+ if (e.key === "Escape") { input.value = ""; run(); }
+ });
+ }
+
+ function start() {
+ try {
+ var widgets = document.querySelectorAll(".pkg-search[data-scope]");
+ for (var i = 0; i < widgets.length; i++) mount(widgets[i]);
+ } catch (e) {
+ /* leave the card grid as-is */
+ }
+ }
+
+ if (document.readyState === "loading") {
+ document.addEventListener("DOMContentLoaded", start);
+ } else {
+ start();
+ }
+})();
diff --git a/docs/content/stylesheets/openswarm.css b/docs/content/stylesheets/openswarm.css
index a47ef073..b4b46ed4 100644
--- a/docs/content/stylesheets/openswarm.css
+++ b/docs/content/stylesheets/openswarm.css
@@ -133,3 +133,95 @@
.md-search__form {
border-radius: 0.4rem;
}
+
+/* ----------------------------------------------------------------------------
+ * Scoped package search (pkg-search.js) — the in-page box on overview pages
+ * ------------------------------------------------------------------------- */
+/* Empty when JS is off or a package is small: collapse it so it leaves no gap. */
+.md-typeset .pkg-search:empty {
+ display: none;
+}
+
+.md-typeset .pkg-search {
+ margin: 0 0 1.2em;
+}
+
+.md-typeset .pkg-search__input {
+ width: 100%;
+ box-sizing: border-box;
+ padding: 0.55em 0.8em;
+ font-size: 0.78rem;
+ color: var(--md-default-fg-color);
+ background: var(--md-default-bg-color);
+ border: 1px solid var(--md-default-fg-color--lightest);
+ border-radius: 0.4rem;
+ transition: border-color 120ms, box-shadow 120ms;
+}
+
+.md-typeset .pkg-search__input::placeholder {
+ color: var(--md-default-fg-color--lighter);
+}
+
+.md-typeset .pkg-search__input:focus {
+ outline: none;
+ border-color: var(--md-accent-fg-color);
+ box-shadow: 0 0 0 2px var(--md-accent-fg-color--transparent);
+}
+
+.md-typeset .pkg-search__results {
+ margin-top: 0.6em;
+}
+
+.md-typeset .pkg-search__list {
+ list-style: none;
+ margin: 0;
+ padding: 0;
+}
+
+.md-typeset .pkg-search__list > li {
+ margin: 0;
+ border-bottom: 1px solid var(--md-default-fg-color--lightest);
+}
+
+.md-typeset .pkg-search__list > li > a {
+ display: block;
+ padding: 0.55em 0.4em;
+ border-radius: 0.3rem;
+}
+
+.md-typeset .pkg-search__list > li > a:hover {
+ background: var(--md-accent-fg-color--transparent);
+}
+
+.md-typeset .pkg-search__title {
+ display: block;
+ font-weight: 600;
+ color: var(--md-typeset-a-color);
+}
+
+.md-typeset .pkg-search__crumb {
+ display: block;
+ font-size: 0.66rem;
+ color: var(--md-default-fg-color--lighter);
+ margin-top: 0.1em;
+}
+
+.md-typeset .pkg-search__snip {
+ display: block;
+ font-size: 0.72rem;
+ color: var(--md-default-fg-color--light);
+ margin-top: 0.2em;
+}
+
+.md-typeset .pkg-search__snip mark {
+ background: var(--md-accent-fg-color--transparent);
+ color: inherit;
+ padding: 0 0.1em;
+ border-radius: 0.15em;
+}
+
+.md-typeset .pkg-search__empty {
+ font-size: 0.74rem;
+ color: var(--md-default-fg-color--light);
+ margin: 0.4em 0;
+}
diff --git a/docs/gen_pages.py b/docs/gen_pages.py
index e5d015ce..399ac531 100644
--- a/docs/gen_pages.py
+++ b/docs/gen_pages.py
@@ -226,6 +226,11 @@ def _dest_reference(py: Path) -> Path:
_SUBPKG_ICON = ":material-folder:"
_MODULE_ICON = ":material-file-code:"
+# Below this many children a scoped-search box is more clutter than help, so the
+# overview is just the card grid. The widget itself is wired by
+# content/javascripts/pkg-search.js, keyed off the emitted ``.pkg-search`` marker.
+_SEARCH_MIN_CHILDREN = 6
+
def _module_docstring(py: Path) -> str:
"""Return ``py``'s module-level docstring (stripped), or '' when absent.
@@ -304,6 +309,17 @@ def _render_package_overview(py: Path) -> str:
if pkg_doc:
out += [pkg_doc, ""]
+ # Scoped-search marker: pkg-search.js fills this with an input that searches
+ # only this package's subtree of the site index (data-scope is the URL prefix
+ # every descendant page shares). Gated on child count so tiny packages stay
+ # clean; degrades to an empty (CSS-hidden) div when JS is off.
+ if len(subpackages) + len(modules) >= _SEARCH_MIN_CHILDREN:
+ scope = "/".join(parts) + "/"
+ out += [
+ f'',
+ "",
+ ]
+
cards = [_card(_SUBPKG_ICON, n, link, s, "Subpackage") for n, link, s in subpackages]
cards += [_card(_MODULE_ICON, n, link, s, "Module") for n, link, s in modules]
diff --git a/docs/zensical.toml b/docs/zensical.toml
index b122d98b..f0cdcbb2 100644
--- a/docs/zensical.toml
+++ b/docs/zensical.toml
@@ -26,7 +26,7 @@ extra_css = ["stylesheets/openswarm.css"]
# Seed the search modal with a few "suggested pages" while the query is empty, so
# it isn't blank on open (command-palette style). See the script header for how it
# slots into the modal's shadow DOM.
-extra_javascript = ["javascripts/search-suggestions.js"]
+extra_javascript = ["javascripts/search-suggestions.js", "javascripts/pkg-search.js"]
# Drop the "Made with Zensical" attribution from the footer (copyright.html
# skips the generator block when this is false).