From ea0cf27b7561eb7f83f32829139d16cf93839c31 Mon Sep 17 00:00:00 2001 From: Joost de Valk Date: Tue, 30 Jun 2026 10:33:57 +0200 Subject: [PATCH] feat(search): migrate to Pagefind Component UI MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace the hand-rolled + Default UI search with Pagefind's Component UI for both the global ⌘K modal and the /search/ page, plus a mobile search button in the header. - Lazy-load the component bundle on first open to keep ~46 kB off visitors who never search; CSP-safe (external module, no inline JS). - Preserve full-fidelity Plausible analytics via the component's typed event API (search/results), reading the exact result count instead of scraping the rendered DOM. - Keep our own global ⌘K and "/" shortcuts; the component owns the modal, focus, ARIA combobox and keyboard navigation. - Theme the widget to our design tokens in light and dark: map --pf-* on html[lang] to outrank the component's :root defaults, and force color-scheme: inherit past its host reset so light-dark() flips. - Style matched terms with an accent pill and set excerpt length to 30 with multi-line wrapping for more context. - Point the WebMCP open_search tool at the new modal driver. Co-Authored-By: Claude Opus 4.8 (1M context) --- public/search-init.js | 102 --------- public/search-overlay.js | 200 ------------------ public/search-page.js | 86 ++++++++ public/search.js | 144 +++++++++++++ public/trusted-types-policy.js | 2 +- src/components/SiteHeader.astro | 26 ++- .../2026-06-30-search-component-ui.md | 7 + src/layouts/BaseLayout.astro | 58 ++--- src/pages/search.astro | 23 +- src/pages/webmcp.js.ts | 26 +-- src/styles/global.css | 145 +++++++------ 11 files changed, 387 insertions(+), 432 deletions(-) delete mode 100644 public/search-init.js delete mode 100644 public/search-overlay.js create mode 100644 public/search-page.js create mode 100644 public/search.js create mode 100644 src/content/changelog/2026-06-30-search-component-ui.md diff --git a/public/search-init.js b/public/search-init.js deleted file mode 100644 index d6ce2d25..00000000 --- a/public/search-init.js +++ /dev/null @@ -1,102 +0,0 @@ -// Initialise Pagefind UI without inline scripts so our strict CSP holds. -// The bundle at /pagefind/pagefind-ui.js exposes PagefindUI on window. -(function () { - // Pagefind renders a plain type=text input with its own clear button. Decorate - // it for mobile keyboards — a Search-labelled enter key, the search keyboard - // layout, and no autocapitalise/autocorrect on a query. We deliberately leave - // it as type=text: type=search would add a *second*, native clear button on top - // of Pagefind's. See /spec/accessibility/mobile-form-inputs/. - function tuneSearchInput(scope) { - var input = scope.querySelector("input"); - if (!input) return; - input.setAttribute("inputmode", "search"); - input.setAttribute("enterkeyhint", "search"); - input.setAttribute("autocapitalize", "none"); - input.setAttribute("autocorrect", "off"); - input.setAttribute("spellcheck", "false"); - } - // Report searches to Plausible: the term, the result count Pagefind shows, - // and whether it matched anything. Debounced so we log settled queries, not - // every keystroke. The count is read from Pagefind's own message, matching - // " result(s)" specifically so a numeric query (e.g. "http2") is never - // mistaken for a count. Production only — window.plausible is undefined on - // the dev server. CSP-safe: lives in this external file. - function trackSearch(scope, surface) { - var input = scope.querySelector("input"); - if (!input) return; - var timer; - input.addEventListener("input", function () { - clearTimeout(timer); - timer = setTimeout(function () { - if (typeof window.plausible !== "function") return; - var term = input.value.trim().toLowerCase(); - if (term.length < 2) return; - var msg = scope.querySelector(".pagefind-ui__message"); - if (!msg) return; - var text = msg.textContent || ""; - var count = null; - var m = text.match(/([\d,]+)\s+results?\b/i); - if (m) count = parseInt(m[1].replace(/,/g, ""), 10); - else if (/no results/i.test(text)) count = 0; - if (count === null) return; // transient state, e.g. "Searching…" - window.plausible("Search", { - props: { - term: term, - results: count, - found: count > 0, - surface: surface, - }, - }); - }, 800); - }); - } - function init() { - var mount = document.getElementById("search"); - if (!mount) return; - if (typeof window.PagefindUI !== "function") { - var p = document.createElement("p"); - p.className = "text-sm text-ink-600"; - p.appendChild(document.createTextNode("Search index is built during ")); - var code = document.createElement("code"); - code.textContent = "npm run build"; - p.appendChild(code); - p.appendChild( - document.createTextNode( - ". It is unavailable on the dev server — try the deployed site.", - ), - ); - mount.replaceChildren(p); - return; - } - new window.PagefindUI({ - element: "#search", - showSubResults: true, - showImages: false, - resetStyles: false, - pageSize: 8, - excerptLength: 24, - processTerm: function (term) { - return term.toLowerCase(); - }, - }); - - tuneSearchInput(mount); - trackSearch(mount, "page"); - - // Pre-fill from ?q= - var url = new URL(window.location.href); - var q = url.searchParams.get("q"); - if (q) { - var input = mount.querySelector("input"); - if (input) { - input.value = q; - input.dispatchEvent(new Event("input", { bubbles: true })); - } - } - } - if (document.readyState === "loading") { - document.addEventListener("DOMContentLoaded", init, { once: true }); - } else { - init(); - } -})(); diff --git a/public/search-overlay.js b/public/search-overlay.js deleted file mode 100644 index 3c824ce0..00000000 --- a/public/search-overlay.js +++ /dev/null @@ -1,200 +0,0 @@ -// ⌘K / Ctrl-K global search overlay. -// Lazy-loads /pagefind/pagefind-ui.{css,js} on first open, then mounts the -// Pagefind UI inside a native . No inline scripts so our strict CSP -// stays intact. Falls back to navigating to /search/ if Pagefind can't load -// (typically: the dev server, where the index doesn't exist). -(function () { - var dialog = document.getElementById("search-overlay"); - if (!dialog) return; - var mount = document.getElementById("search-overlay-mount"); - - // Report searches to Plausible: the term, the result count Pagefind shows, - // and whether it matched anything. Debounced so we log settled queries, not - // every keystroke. The count is read from Pagefind's own message, matching - // " result(s)" specifically so a numeric query (e.g. "http2") is never - // mistaken for a count. Production only — window.plausible is undefined on - // the dev server. CSP-safe: lives in this external file. - function trackSearch(scope, surface) { - var input = scope.querySelector("input"); - if (!input) return; - var timer; - input.addEventListener("input", function () { - clearTimeout(timer); - timer = setTimeout(function () { - if (typeof window.plausible !== "function") return; - var term = input.value.trim().toLowerCase(); - if (term.length < 2) return; - var msg = scope.querySelector(".pagefind-ui__message"); - if (!msg) return; - var text = msg.textContent || ""; - var count = null; - var m = text.match(/([\d,]+)\s+results?\b/i); - if (m) count = parseInt(m[1].replace(/,/g, ""), 10); - else if (/no results/i.test(text)) count = 0; - if (count === null) return; // transient state, e.g. "Searching…" - window.plausible("Search", { - props: { - term: term, - results: count, - found: count > 0, - surface: surface, - }, - }); - }, 800); - }); - } - var closeButton = dialog.querySelector("[data-search-close]"); - var triggers = document.querySelectorAll("[data-search-trigger]"); - var loaded = false; - var initialised = false; - - function loadAssets() { - return new Promise(function (resolve, reject) { - if (window.PagefindUI) return resolve(); - if (!document.querySelector("link[data-pagefind-css]")) { - var link = document.createElement("link"); - link.rel = "stylesheet"; - link.href = "/pagefind/pagefind-ui.css"; - link.setAttribute("data-pagefind-css", ""); - document.head.appendChild(link); - } - var script = document.createElement("script"); - script.src = "/pagefind/pagefind-ui.js"; - script.async = true; - script.onload = function () { - resolve(); - }; - script.onerror = function () { - reject(new Error("pagefind-ui.js failed to load")); - }; - document.head.appendChild(script); - }); - } - - function init() { - if (initialised || !mount || typeof window.PagefindUI !== "function") - return; - new window.PagefindUI({ - element: "#search-overlay-mount", - showSubResults: true, - showImages: false, - resetStyles: false, - pageSize: 6, - excerptLength: 20, - processTerm: function (term) { - return term.toLowerCase(); - }, - }); - // Pagefind renders a plain type=text input with its own clear button. Decorate - // it for mobile keyboards — a Search-labelled enter key, the search keyboard - // layout, and no autocapitalise/autocorrect on a query. We deliberately leave - // it as type=text: type=search would add a *second*, native clear button on top - // of Pagefind's. See /spec/accessibility/mobile-form-inputs/. - var input = mount.querySelector("input"); - if (input) { - input.setAttribute("inputmode", "search"); - input.setAttribute("enterkeyhint", "search"); - input.setAttribute("autocapitalize", "none"); - input.setAttribute("autocorrect", "off"); - input.setAttribute("spellcheck", "false"); - } - trackSearch(mount, "overlay"); - initialised = true; - } - - function focusInput() { - requestAnimationFrame(function () { - var input = dialog.querySelector("input"); - if (input) { - input.focus(); - input.select(); - } - }); - } - - function open(prefill) { - if (loaded === false) { - loaded = true; - loadAssets() - .then(function () { - init(); - focusInput(); - }) - .catch(function () { - window.location.href = "/search/"; - }); - } - if (typeof dialog.showModal === "function") { - if (!dialog.open) dialog.showModal(); - } else { - // Older browsers — graceful navigation fallback. - window.location.href = "/search/"; - return; - } - if (initialised) focusInput(); - if (prefill) { - var input = dialog.querySelector("input"); - if (input) { - input.value = prefill; - input.dispatchEvent(new Event("input", { bubbles: true })); - } - } - } - - function close() { - if (dialog.open) dialog.close(); - } - - // Trigger: any element with data-search-trigger opens the overlay. - triggers.forEach(function (t) { - t.addEventListener("click", function (ev) { - ev.preventDefault(); - open(); - }); - }); - - // ⌘K / Ctrl-K from anywhere. - document.addEventListener("keydown", function (ev) { - var isCmdK = ev.key === "k" && (ev.metaKey || ev.ctrlKey); - if (isCmdK) { - ev.preventDefault(); - if (dialog.open) close(); - else open(); - return; - } - // "/" focus shortcut, like GitHub — only when not already in an input. - if (ev.key === "/" && !dialog.open) { - var target = ev.target; - var inField = - target && - (target.tagName === "INPUT" || - target.tagName === "TEXTAREA" || - target.isContentEditable); - if (!inField) { - ev.preventDefault(); - open(); - } - } - }); - - if (closeButton) { - closeButton.addEventListener("click", function () { - close(); - }); - } - - // Click outside the inner panel closes. - dialog.addEventListener("click", function (ev) { - if (ev.target === dialog) close(); - }); - - // Clicking a result navigates and should close the overlay. - dialog.addEventListener( - "click", - function (ev) { - var a = ev.target.closest("a"); - if (a && a.href) close(); - }, - true, - ); -})(); diff --git a/public/search-page.js b/public/search-page.js new file mode 100644 index 00000000..b0a95ef4 --- /dev/null +++ b/public/search-page.js @@ -0,0 +1,86 @@ +// Initialiser for the standalone /search/ page. The Pagefind Component UI +// custom elements (loaded eagerly here) drive the search; this script only +// wires Plausible analytics and the ?q= deep-link. No inline scripts, so the +// strict CSP holds. On the dev server the /pagefind/ bundle doesn't exist, so +// the components never upgrade — we detect that and show a build hint. +(function () { + var INSTANCE = "page"; + + function showBuildHint() { + var mount = document.getElementById("search"); + if (!mount) return; + var p = document.createElement("p"); + p.className = "text-sm text-ink-600"; + p.appendChild(document.createTextNode("Search index is built during ")); + var code = document.createElement("code"); + code.textContent = "npm run build"; + p.appendChild(code); + p.appendChild( + document.createTextNode( + ". It is unavailable on the dev server — try the deployed site.", + ), + ); + mount.replaceChildren(p); + } + + // Report searches to Plausible: term, exact result count, and whether it + // matched. The Component UI exposes a typed event API on the search instance, + // so we read the count straight off the `results` event. Debounced so we log + // settled queries. Production only — window.plausible is undefined in dev. + function wireAnalytics(inst) { + var term = ""; + var timer; + inst.on("search", function (t) { + term = (t || "").trim().toLowerCase(); + }); + inst.on("results", function (r) { + var count = + r && typeof r.unfilteredResultCount === "number" + ? r.unfilteredResultCount + : null; + clearTimeout(timer); + timer = setTimeout(function () { + if (typeof window.plausible !== "function") return; + if (term.length < 2 || count === null) return; + window.plausible("Search", { + props: { + term: term, + results: count, + found: count > 0, + surface: "page", + }, + }); + }, 800); + }); + } + + function prefillFromQuery() { + var q = new URL(window.location.href).searchParams.get("q"); + if (!q) return; + var input = document.querySelector('pagefind-input[instance="page"] input'); + if (!input) input = document.querySelector("#search input"); + if (input) { + input.value = q; + input.dispatchEvent(new Event("input", { bubbles: true })); + } + } + + function init() { + if (!window.PagefindComponents) return showBuildHint(); + var inst = + window.PagefindComponents.getInstanceManager().getInstance(INSTANCE); + wireAnalytics(inst); + prefillFromQuery(); + } + + // Race the module's custom-element registration against a timeout: if the + // bundle 404s (dev server) the elements never define and we show the hint. + Promise.race([ + customElements.whenDefined("pagefind-input"), + new Promise(function (resolve, reject) { + setTimeout(function () { + reject(new Error("timeout")); + }, 4000); + }), + ]).then(init, showBuildHint); +})(); diff --git a/public/search.js b/public/search.js new file mode 100644 index 00000000..6d3390dd --- /dev/null +++ b/public/search.js @@ -0,0 +1,144 @@ +// ⌘K / Ctrl-K global search modal, built on the Pagefind Component UI +// (). Lazy-loads /pagefind/pagefind-component-ui.{css,js} on +// first open so the ~46 kB bundle never lands on visitors who don't search. +// No inline scripts, so the strict CSP stays intact. Falls back to navigating +// to /search/ if the bundle can't load (typically: the dev server, where the +// Pagefind index doesn't exist yet). +(function () { + var modal = document.querySelector("pagefind-modal"); + if (!modal) return; + + var triggers = document.querySelectorAll("[data-search-trigger]"); + var loading = false; + var ready = false; + + function loadAssets() { + return new Promise(function (resolve, reject) { + if (window.PagefindComponents) return resolve(); + if (!document.querySelector("link[data-pagefind-css]")) { + var link = document.createElement("link"); + link.rel = "stylesheet"; + link.href = "/pagefind/pagefind-component-ui.css"; + link.setAttribute("data-pagefind-css", ""); + document.head.appendChild(link); + } + var script = document.createElement("script"); + script.type = "module"; + script.src = "/pagefind/pagefind-component-ui.js"; + script.onload = function () { + // The custom elements register asynchronously after the module + // evaluates; wait for the modal to be defined before opening it. + customElements.whenDefined("pagefind-modal").then(resolve, reject); + }; + script.onerror = function () { + reject(new Error("pagefind-component-ui.js failed to load")); + }; + document.head.appendChild(script); + }); + } + + // Report searches to Plausible: the term, the result count, and whether it + // matched anything. The Component UI exposes a typed event API on the search + // instance — `search` carries the term, `results` carries the result set — + // so we read the exact count straight off the instance instead of scraping + // the rendered DOM. Debounced so we log settled queries, not every keystroke. + // Production only — window.plausible is undefined on the dev server. + function wireAnalytics() { + if (!window.PagefindComponents) return; + var inst = + window.PagefindComponents.getInstanceManager().getInstance("default"); + if (!inst || inst.__swAnalytics__) return; + inst.__swAnalytics__ = true; + var term = ""; + var timer; + inst.on("search", function (t) { + term = (t || "").trim().toLowerCase(); + }); + inst.on("results", function (r) { + var count = + r && typeof r.unfilteredResultCount === "number" + ? r.unfilteredResultCount + : null; + clearTimeout(timer); + timer = setTimeout(function () { + if (typeof window.plausible !== "function") return; + if (term.length < 2 || count === null) return; + window.plausible("Search", { + props: { + term: term, + results: count, + found: count > 0, + surface: "overlay", + }, + }); + }, 800); + }); + } + + function open(prefill) { + if (ready) { + modal.open(); + if (prefill) prefillSearch(prefill); + return; + } + if (loading) return; + loading = true; + loadAssets() + .then(function () { + ready = true; + wireAnalytics(); + modal.open(); + if (prefill) prefillSearch(prefill); + }) + .catch(function () { + var url = "/search/"; + if (prefill) url += "?q=" + encodeURIComponent(prefill); + window.location.href = url; + }); + } + + // The modal renders its into the light DOM, so we can drive it + // directly — set the value and dispatch a native input event the searchbox + // already listens for. Used by the WebMCP open_search tool's prefill. + function prefillSearch(q) { + requestAnimationFrame(function () { + var input = modal.querySelector("input"); + if (!input) return; + input.value = q; + input.dispatchEvent(new Event("input", { bubbles: true })); + }); + } + window.__swOpenSearch = open; + + // Trigger: any element with data-search-trigger opens the modal. + triggers.forEach(function (t) { + t.addEventListener("click", function (ev) { + ev.preventDefault(); + open(); + }); + }); + + // ⌘K / Ctrl-K toggles from anywhere; "/" opens, GitHub-style. The Component + // UI only registers shortcuts scoped to the open modal (arrow nav, esc), so + // these global bindings never collide with it. + document.addEventListener("keydown", function (ev) { + if (ev.key === "k" && (ev.metaKey || ev.ctrlKey)) { + ev.preventDefault(); + if (ready && modal.isOpen) modal.close(); + else open(); + return; + } + if (ev.key === "/" && !(ready && modal.isOpen)) { + var target = ev.target; + var inField = + target && + (target.tagName === "INPUT" || + target.tagName === "TEXTAREA" || + target.isContentEditable); + if (!inField) { + ev.preventDefault(); + open(); + } + } + }); +})(); diff --git a/public/trusted-types-policy.js b/public/trusted-types-policy.js index a3cbece9..74190171 100644 --- a/public/trusted-types-policy.js +++ b/public/trusted-types-policy.js @@ -6,7 +6,7 @@ // trusted typed value or the browser throws. // // Our own scripts touch no sinks, but the Pagefind search bundle does, in two ways: -// - pagefind-ui.js builds its results list with innerHTML (TrustedHTML sink). +// - pagefind-component-ui.js builds its results UI with innerHTML (TrustedHTML sink). // - pagefind.js loads its own JS/WASM chunks by assigning a script URL // (TrustedScriptURL sink). // A *default* policy is the only thing that can cover Pagefind, because its diff --git a/src/components/SiteHeader.astro b/src/components/SiteHeader.astro index 045485db..60227b66 100644 --- a/src/components/SiteHeader.astro +++ b/src/components/SiteHeader.astro @@ -68,7 +68,7 @@ const { pathname } = Astro.url; class="inline-flex h-6 cursor-pointer items-center gap-1.5 rounded-full border border-ink-300 bg-ink-100 px-3 text-xs font-medium text-ink-700 hover:border-ink-400 hover:text-ink-900" aria-label="Open search (Command-K)" aria-haspopup="dialog" - aria-controls="search-overlay" + aria-controls="search-modal" >
+
+
- -
-
- Search - -
-
-
- esc close - navigate - open -
-
-
- + { + /* Global ⌘K search modal — Pagefind Component UI. Inert until + /search.js lazy-loads the component bundle on first open; the + `:not(:defined)` rule in global.css keeps it hidden until then. */ + } + + + + + + + + + + + + + + diff --git a/src/pages/search.astro b/src/pages/search.astro index e367b210..aaa64e08 100644 --- a/src/pages/search.astro +++ b/src/pages/search.astro @@ -9,7 +9,7 @@ import Breadcrumbs from "~/components/Breadcrumbs.astro"; ogImage="/og/search.png" > - +
@@ -30,7 +30,21 @@ import Breadcrumbs from "~/components/Breadcrumbs.astro";

- + { + /* Inline Pagefind Component UI search, on a dedicated `page` instance so + it stays independent of the global ⌘K modal (which uses `default`). */ + } +
diff --git a/src/pages/webmcp.js.ts b/src/pages/webmcp.js.ts index cea647b0..eecd88e3 100644 --- a/src/pages/webmcp.js.ts +++ b/src/pages/webmcp.js.ts @@ -114,11 +114,7 @@ export const GET: APIRoute = async () => { function openSearchOverlay() { var trigger = document.querySelector('[data-search-trigger]'); if (trigger) { trigger.click(); return true; } - var dialog = document.getElementById('search-overlay'); - if (dialog && typeof dialog.showModal === 'function') { - if (!dialog.open) dialog.showModal(); - return true; - } + if (typeof window.__swOpenSearch === 'function') { window.__swOpenSearch(); return true; } return false; } @@ -245,20 +241,16 @@ export const GET: APIRoute = async () => { }, }, execute: function (input) { - var ok = openSearchOverlay(); - if (!ok) return 'ERROR: search overlay is not available on this page.'; var q = input && input.query; - if (q) { - setTimeout(function () { - var dialog = document.getElementById('search-overlay'); - var inp = dialog && dialog.querySelector('input'); - if (inp) { - inp.value = q; - inp.dispatchEvent(new Event('input', { bubbles: true })); - } - }, 50); + // Prefer the modal driver directly when a query is supplied — it + // handles lazy-loading the bundle and prefilling the input itself. + if (q && typeof window.__swOpenSearch === 'function') { + window.__swOpenSearch(q); + return 'Search overlay opened with query "' + q + '".'; } - return q ? 'Search overlay opened with query "' + q + '".' : 'Search overlay opened.'; + var ok = openSearchOverlay(); + if (!ok) return 'ERROR: search overlay is not available on this page.'; + return 'Search overlay opened.'; }, }, { diff --git a/src/styles/global.css b/src/styles/global.css index 3e6c8eff..1a2963ec 100644 --- a/src/styles/global.css +++ b/src/styles/global.css @@ -333,78 +333,89 @@ body { overflow-y: auto; } -/* ⌘K search overlay */ -.search-overlay { - border: 0; - padding: 0; - background: transparent; - width: min(640px, calc(100vw - 2rem)); - /* Dynamic viewport units keep the overlay inside the visible area as the - mobile browser chrome shows and hides. See /spec/performance/dynamic-viewport-units/. */ - max-height: 80dvh; - margin: 6dvh auto auto; - /* Keep the fixed overlay clear of the home indicator when it runs tall. */ - padding-bottom: env(safe-area-inset-bottom); - color: var(--color-ink-800); -} -.search-overlay::backdrop { - background: rgba(0, 0, 0, 0.55); - backdrop-filter: blur(2px); -} -.search-overlay__inner { - background: var(--color-ink-50); - border: 1px solid var(--color-ink-200); - border-radius: 0.75rem; - box-shadow: 0 20px 50px -10px rgba(0, 0, 0, 0.45); - overflow: hidden; +/* Pagefind Component UI — global ⌘K modal (default instance) and the inline + /search/ page (page instance). The components render in the light DOM and + read these --pf-* custom properties, so mapping them to our design tokens + themes the whole thing. The component ships its own --pf-* defaults on :root + in the linked pagefind-component-ui.css, which loads after this file, so we + select on `html[lang]` (always present, specificity 0,1,1) to outrank its + plain `:root` (0,0,1) and `[data-pf-theme]` (0,1,0) rules regardless of load + order. One block covers both themes because the ink scale flips via + light-dark(). */ +html[lang] { + --pf-font: var(--font-sans); + --pf-background: var(--color-ink-50); + --pf-text: var(--color-ink-800); + --pf-text-secondary: var(--color-ink-600); + --pf-text-muted: var(--color-ink-500); + --pf-border: var(--color-ink-200); + --pf-border-focus: var(--color-accent-600); + --pf-outline-focus: var(--color-accent-600); + --pf-hover: var(--color-ink-100); + /* Matched terms in result excerpts render as bold text coloured with --pf-mark + (transparent background), so this must be a readable accent, not a wash. + Reuse the site's accent-text token, which stays legible in both schemes. */ + --pf-mark: var(--link-color); + --pf-border-radius: 0.5rem; + --pf-modal-backdrop: rgba(0, 0, 0, 0.55); + /* Dynamic viewport units keep the modal inside the visible area as the mobile + browser chrome shows and hides. See /spec/performance/dynamic-viewport-units/. */ + --pf-modal-max-height: 80dvh; + --pf-modal-max-width: min(640px, calc(100vw - 2rem)); + --pf-modal-top: 6dvh; } -.search-overlay__bar { - display: flex; - align-items: center; - justify-content: space-between; - padding: 0.5rem 0.75rem; - border-bottom: 1px solid var(--color-ink-100); - background: var(--color-ink-100); -} -.search-overlay__foot { - display: flex; - flex-wrap: wrap; - gap: 1.25rem; - padding: 0.6rem 0.9rem; - border-top: 1px solid var(--color-ink-100); - background: var(--color-ink-100); - font-size: 0.75rem; - color: var(--color-ink-500); + +/* The component's stylesheet resets `color-scheme: initial` on every pagefind + custom-element tag (its host reset). That pins our light-dark() tokens to the + light branch — dark text on a dark panel. The reset ties our specificity and + wins on load order (its follows ours), so !important is the clean way + to force the widget to inherit the page scheme and flip with the theme toggle. */ +pagefind-modal, +pagefind-modal *, +pagefind-input, +pagefind-input *, +pagefind-summary, +pagefind-summary *, +pagefind-results, +pagefind-results * { + color-scheme: inherit !important; } -.search-overlay__foot kbd { - display: inline-block; - padding: 0.05rem 0.35rem; - font-family: var(--font-mono); - font-size: 0.7rem; - border: 1px solid var(--color-ink-200); - background: var(--color-ink-50); + +/* Make matched terms stand out: the component renders them as bold accent text + on a transparent background. Add a soft accent pill behind them too (overriding + the component's `background: transparent`, which carries id-level specificity) + so hits are scannable in both titles and excerpts. */ +pagefind-results mark, +pagefind-summary mark { + background: color-mix( + in srgb, + var(--color-accent-600) 18%, + transparent + ) !important; + color: var(--pf-mark) !important; + font-weight: 600 !important; border-radius: 0.25rem; - color: var(--color-ink-600); - margin-right: 0.25rem; + padding: 0 0.18rem; } -#search-overlay-mount { - max-height: 60dvh; - overflow-y: auto; -} -/* Make Pagefind UI fit the overlay nicely */ -#search-overlay-mount .pagefind-ui { - --pagefind-ui-primary: var(--color-accent-700); - --pagefind-ui-text: var(--color-ink-800); - --pagefind-ui-background: var(--color-ink-50); - --pagefind-ui-border: var(--color-ink-200); - --pagefind-ui-tag: var(--color-ink-100); - --pagefind-ui-border-radius: 0.5rem; - --pagefind-ui-font: var(--font-sans); - padding: 0.5rem 0.75rem; + +/* The component clamps every excerpt to a single line (white-space: nowrap + + text-overflow: ellipsis), which throws away most of our longer excerpt-length. + Let excerpts wrap and clamp to a few lines instead so more context shows. */ +pagefind-results .pf-result-excerpt, +pagefind-results .pf-heading-excerpt, +pagefind-results .pf-searchbox-result-excerpt { + white-space: normal !important; + display: -webkit-box !important; + -webkit-box-orient: vertical; + -webkit-line-clamp: 3; + line-clamp: 3; + overflow: hidden; } -#search-overlay-mount .pagefind-ui__form::before { - /* Hide the default search icon since we already have one in the trigger */ - opacity: 0.5; + +/* Hide the modal (and its declared children) until the lazily loaded component + bundle upgrades the custom elements, so nothing flashes un-styled first. */ +pagefind-modal:not(:defined) { + display: none; } /* Scheme-dependent overrides — single block driven by light-dark(). Both