daemon-sec-lotl

DÆMONBins: GTFOBins × LOLBAS × WADComs × LOOBins in one filterable catalog
git clone https://git.daemon-sec.xyz/daemon-sec-lotl.git
Log | Files | Refs | Submodules | README | LICENSE

SearchModal.astro (29082B)


      1 ---
      2 import Icon from './Icon.astro';
      3 ---
      4 <div class="search-overlay" data-search-overlay hidden>
      5   <div class="search-box plate corners" role="dialog" aria-modal="true" aria-label="Search DÆMONBins">
      6     <div class="search-titlebar">
      7       <span class="search-titlebar__label">Search — DÆMONBins</span>
      8       <span class="search-titlebar__hints"><kbd>Esc</kbd> close · <kbd>⌘K</kbd> toggle</span>
      9     </div>
     10     <div class="search-inputrow">
     11       <Icon name="search" class="search-ico" />
     12       <input
     13         type="search" class="search-input" data-search-input
     14         placeholder="Search tools, techniques, MITRE ids…" autocomplete="off"
     15         spellcheck="false" aria-label="Search query"
     16         role="combobox" aria-expanded="false" aria-controls="search-results"
     17         aria-autocomplete="list" />
     18       <button class="icon-btn" data-search-close type="button" aria-label="Close search">
     19         <Icon name="close" />
     20       </button>
     21     </div>
     22     <p class="search-status mono" data-search-status aria-live="polite" hidden></p>
     23     <div
     24       class="search-results" data-search-results id="search-results"
     25       aria-label="Search results">
     26       <p class="search-hint mono">
     27         Type to search the catalog. <kbd>↑</kbd><kbd>↓</kbd> move, <kbd>↵</kbd> open, <kbd>Esc</kbd> close.
     28       </p>
     29     </div>
     30   </div>
     31 </div>
     32 
     33 <style>
     34   /* An opaque scrim rather than a frosted one. The page under it is as
     35      often a dark band as it is cream, and a translucent overlay reads as
     36      two different dialogs depending on where you happened to be. */
     37   .search-overlay {
     38     position: fixed; inset: 0; z-index: 100;
     39     display: grid; place-items: start center;
     40     padding: clamp(3rem, 12vh, 8rem) 1rem 2rem;
     41     background: color-mix(in oklab, var(--base) 88%, transparent);
     42   }
     43   .search-overlay[hidden] { display: none; }
     44   .search-box {
     45     /* `.corners` draws its brackets against the box's hairline from
     46        pseudo-elements, so the box has to be their containing block. */
     47     position: relative;
     48     width: min(660px, 100%);
     49     /* The modal is a terminal window: a dark plate in BOTH modes (the
     50        `.plate` class pins the night palette for the subtree), framed by a
     51        hairline rather than by full ink — the corner brackets are the
     52        accent, the frame stays furniture. */
     53     border: 1px solid var(--rule-hi);
     54     background: var(--base);
     55   }
     56   /* The window's titlebar: a JetBrains Mono micro label naming the pane,
     57      kbd hints at the right, hairline under. `--font-term`, not
     58      `--font-mono` — this is terminal chrome, and chrome speaks JetBrains
     59      Mono everywhere else on the site (code captions, segbar segments). */
     60   .search-titlebar {
     61     display: flex; align-items: center; justify-content: space-between;
     62     gap: 1rem;
     63     padding: 0.5rem 0.9rem;
     64     border-bottom: 1px solid var(--rule);
     65     font-family: var(--font-term);
     66     font-size: var(--step-micro);
     67     letter-spacing: var(--track-micro);
     68     text-transform: uppercase;
     69     color: var(--fg-faint);
     70   }
     71   .search-titlebar__label { color: var(--fg-dim); }
     72   .search-titlebar__hints { display: flex; align-items: center; gap: 0.35rem; white-space: nowrap; }
     73   .search-titlebar kbd {
     74     border: 1px solid var(--rule);
     75     padding: 0.06rem 0.3rem;
     76     font-family: var(--font-term);
     77     color: var(--fg-faint);
     78   }
     79   /* Cross-theme in the other direction: on the night page the titlebar is
     80      the modal's dawn plate — a cream strip floating on the dark window.
     81      `.plate-dawn` is a token pin that applies in both modes, and here the
     82      strip must appear only on the night page (on cream the modal is
     83      already the cross-theme element), so the row re-pins the handful of
     84      Dawn tokens it actually reads, scoped to the dark theme. */
     85   :root[data-theme='dark'] .search-titlebar {
     86     --fg: #575279; --fg-dim: #635f7c; --fg-faint: #6e6a86;
     87     --rule: rgba(87, 82, 121, 0.22); --rule-hi: rgba(87, 82, 121, 0.48);
     88     background: #faf4ed;
     89     color: var(--fg-faint);
     90   }
     91   .search-inputrow { display: flex; align-items: center; gap: 0.6rem; padding: 0.7rem 0.8rem; border-bottom: 1px solid var(--rule); }
     92   /* .search-ico is sized in the is:global block below — it is the root
     93      <svg> of the Icon child component, which never receives this file's
     94      scope attribute, so a scoped rule here silently never applies and the
     95      icon renders at the SVG default replaced size (a huge magnifying
     96      glass). Same reason the result rows are styled globally. */
     97   .search-input {
     98     flex: 1; background: none; border: none; outline: none;
     99     color: var(--fg); font-size: 1.02rem; font-family: var(--font-mono);
    100   }
    101   .search-input::placeholder { color: var(--fg-faint); }
    102   /* The count bar. The site labels everything in 10px mono; a result set
    103      is no different, and knowing 8 of 47 are shown is the difference
    104      between "that's all there is" and "keep typing". */
    105   .search-status {
    106     display: flex; justify-content: space-between; gap: 1rem;
    107     padding: 0.5rem 0.9rem;
    108     border-bottom: 1px solid var(--rule);
    109     color: var(--fg-faint); font-size: var(--step-micro);
    110     letter-spacing: var(--track-micro); text-transform: uppercase;
    111   }
    112   .search-status[hidden] { display: none; }
    113 
    114   .search-results { max-height: min(56vh, 520px); overflow-y: auto; overscroll-behavior: contain; }
    115 </style>
    116 
    117 <!-- Everything below draws nodes this component writes with `innerHTML` at
    118      runtime — the result rows and the hint that replaces them.
    119 
    120      Astro scopes a component's CSS by rewriting each selector to carry a
    121      `[data-astro-cid-…]` attribute and stamping that attribute onto the
    122      markup *at build time*. A node created later by script never receives
    123      it, so a scoped rule silently does not apply: no error, no warning, and
    124      a results list that renders as raw stacked text with no padding, no
    125      rules and no wipe. That is what was wrong with this panel — the styles
    126      had simply never reached it.
    127 
    128      `is:global` is the escape hatch, and every rule is confined to
    129      `.search-overlay` so nothing here can reach the page behind it. -->
    130 <style is:global>
    131   /* The leading magnifying glass. Sized here, not in the scoped block: it
    132      is the Icon child's root <svg>, which carries Icon's scope id and not
    133      this component's, so `.search-ico { … }` up in the scoped styles never
    134      matched it and the icon fell back to the SVG default size. */
    135   .search-overlay .search-ico { width: 18px; height: 18px; color: var(--fg-faint); flex: 0 0 auto; }
    136 
    137   .search-overlay .search-hint {
    138     color: var(--fg-faint); font-size: var(--step-micro);
    139     letter-spacing: var(--track-micro); text-transform: uppercase;
    140     padding: 1rem 0.9rem; line-height: 2;
    141   }
    142   .search-overlay .search-hint kbd {
    143     border: 1px solid var(--rule); padding: 0.06rem 0.3rem;
    144     font-family: var(--font-mono); margin-right: 0.15rem;
    145   }
    146   .search-overlay .search-hint code {
    147     font-family: var(--font-mono); text-transform: none;
    148   }
    149 
    150   /* A hit is a record, not a card.
    151 
    152      The index rows on every other page of this site are one line — rank,
    153      kind, title, chevron — because a list is read by its shape and a row
    154      that grows to fit its content destroys the shape. This panel used to
    155      stack three free-height lines per hit, so eight results ran from 100px
    156      to 204px tall and only three and a half of them fitted the box. Fixing
    157      the height is what makes the list scannable; everything else here is
    158      the same furniture the vault indexes are built from. */
    159   .search-overlay .s-result {
    160     display: grid;
    161     grid-template-columns: 26px minmax(0, 1fr) 14px;
    162     align-items: center;
    163     column-gap: 0.7rem;
    164     row-gap: 0.1rem;
    165     padding: 0.62rem 0.9rem;
    166     border-bottom: 1px solid var(--rule);
    167     color: var(--fg);
    168     background-image: linear-gradient(90deg, var(--fg) 0 50%, transparent 50% 100%);
    169     background-size: 200% 100%;
    170     background-position: 100% 0;
    171     background-repeat: no-repeat;
    172     transition: background-position 380ms var(--ease-in), color 1ms;
    173   }
    174   @media (min-width: 560px) {
    175     .search-overlay .s-result { grid-template-columns: 26px 54px minmax(0, 1fr) 14px; }
    176   }
    177   /* Hover and keyboard selection are the same state deliberately: the
    178      pointer and the arrow keys are moving one cursor, not two. */
    179   .search-overlay .s-result:hover,
    180   .search-overlay .s-result[data-sel] { background-position: 0 0; color: var(--base); }
    181   .search-overlay .s-result:focus-visible { outline: 2px solid var(--accent); outline-offset: -2px; }
    182 
    183   /* Rank, and the keyboard address — ↑/↓ move this number, ↵ opens it. */
    184   .search-overlay .s-n {
    185     grid-column: 1; grid-row: 1;
    186     font-family: var(--font-mono); font-size: var(--step-micro);
    187     letter-spacing: var(--track-micro); color: var(--fg-faint);
    188   }
    189   /* The domain's own accent, from taxonomy.ts — the same legend the
    190      masthead and the domain plates key back to. */
    191   .search-overlay .s-kind {
    192     grid-column: 2; grid-row: 2;
    193     font-family: var(--font-mono); font-size: var(--step-micro);
    194     letter-spacing: var(--track-micro); text-transform: uppercase;
    195     color: var(--acc, var(--accent));
    196     white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
    197   }
    198   @media (min-width: 560px) { .search-overlay .s-kind { grid-row: 1; } }
    199   .search-overlay .s-title {
    200     grid-column: 2; grid-row: 1;
    201     font-family: var(--font-display); font-weight: 600;
    202     font-size: 0.98rem; letter-spacing: -0.015em;
    203     min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
    204   }
    205   @media (min-width: 560px) { .search-overlay .s-title { grid-column: 3; } }
    206   .search-overlay .s-snip {
    207     grid-column: 2; grid-row: 3;
    208     color: var(--fg-dim); font-size: 0.8rem; line-height: 1.35;
    209     min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
    210   }
    211   @media (min-width: 560px) { .search-overlay .s-snip { grid-column: 3; grid-row: 2; } }
    212   .search-overlay .s-go {
    213     grid-column: 3; grid-row: 1;
    214     color: var(--fg-faint);
    215     transition: transform 250ms var(--ease);
    216   }
    217   @media (min-width: 560px) { .search-overlay .s-go { grid-column: 4; } }
    218   .search-overlay .s-go svg { width: 14px; height: 14px; display: block; }
    219 
    220   /* On the wipe's ink the accents sit near 1:1, so every coloured cell
    221      drops its hue and takes the page ground at the /78 the record rows
    222      use. Mixed from `--base` rather than written as the cream literal the
    223      rest of the site uses for this: the wipe fills with `--fg`, which is
    224      dark ink on the dawn theme but near-white on the night one, so a fixed
    225      cream is correct in one mode and invisible in the other. */
    226   .search-overlay .s-result:hover .s-n, .search-overlay .s-result[data-sel] .s-n,
    227   .search-overlay .s-result:hover .s-kind, .search-overlay .s-result[data-sel] .s-kind,
    228   .search-overlay .s-result:hover .s-snip, .search-overlay .s-result[data-sel] .s-snip,
    229   .search-overlay .s-result:hover .s-go, .search-overlay .s-result[data-sel] .s-go {
    230     color: color-mix(in oklab, var(--base) 78%, transparent);
    231   }
    232   .search-overlay .s-result:hover .s-go,
    233   .search-overlay .s-result[data-sel] .s-go { transform: translateX(3px); }
    234 
    235   /* The match itself has to survive the wipe, so it gets a second
    236      treatment on ink rather than keeping a gold wash that goes muddy. */
    237   .search-overlay .s-snip mark {
    238     background: color-mix(in oklab, var(--gold) 34%, transparent);
    239     color: inherit; padding: 0 2px;
    240   }
    241   .search-overlay .s-result:hover .s-snip mark,
    242   .search-overlay .s-result[data-sel] .s-snip mark {
    243     background: color-mix(in oklab, var(--base) 22%, transparent);
    244     color: var(--base);
    245   }
    246 
    247   @media (prefers-reduced-motion: reduce) {
    248     .search-overlay .s-result, .search-overlay .s-go { transition: none; }
    249   }
    250 </style>
    251 
    252 <script>
    253   import { SOURCE_META } from '../lib/taxonomy';
    254 
    255   const BASE = import.meta.env.BASE_URL;
    256 
    257   /* Looked up per call, never held.
    258 
    259      `ClientRouter` swaps the whole body on an internal navigation, so the
    260      modal on screen after the first click-through is a different element
    261      from the one that was in the document when this module ran. Module
    262      scripts are executed once per session — Astro replaces an already-run
    263      inline script with an empty `data:` URI on swap — so a reference
    264      captured up here is never refreshed, and every later `open()` was
    265      unhiding a detached node while locking scroll on the live body:
    266      nothing appeared and the page could no longer be scrolled.
    267 
    268      Re-querying at the point of use costs one `querySelector` per
    269      interaction and cannot go stale. */
    270   const overlayEl = () => document.querySelector<HTMLElement>('[data-search-overlay]');
    271   const inputEl = () => document.querySelector<HTMLInputElement>('[data-search-input]');
    272   const resultsEl = () => document.querySelector<HTMLElement>('[data-search-results]');
    273 
    274   const statusEl = () => document.querySelector<HTMLElement>('[data-search-status]');
    275 
    276   const HINT =
    277     '<p class="search-hint mono">Type to search the catalog. ' +
    278     '<kbd>↑</kbd><kbd>↓</kbd> move, <kbd>↵</kbd> open, <kbd>Esc</kbd> close.</p>';
    279 
    280   /* Pagefind reports the category as its display title, which is all a
    281      human needs but not what the row is drawn from: the vault's rows carry
    282      a short module tag in the domain's own accent. Both come from
    283      `taxonomy.ts` rather than a second copy of the table here, so adding a
    284      domain there gives search the right badge and the right colour with no
    285      further edit. Payloads is not a domain in that list — it is the mirror
    286      — and takes the iris the masthead already gives it. */
    287   const KIND = new Map<string, { tag: string; accent: string }>(
    288     Object.values(SOURCE_META).map((s) => [s.label, { tag: s.tag, accent: s.accent }] as const),
    289   );
    290   const kindOf = (source?: string) =>
    291     (source && KIND.get(source)) || { tag: source || 'TECH', accent: 'foam' };
    292 
    293   /* The index itself does survive navigation, and should — it is a few
    294      hundred KB of WASM and index shards, and re-fetching it per page would
    295      undo the point of a client-side router. */
    296   let pagefind: any = null;
    297   let loading: Promise<any> | null = null;
    298 
    299   /* Native dynamic import, hidden from Vite.
    300 
    301      Pagefind ships as a prebuilt ES module with its own WASM and code-split
    302      chunks. A literal `import(path)` is rewritten by Vite's dev server: it
    303      appends `?import` and tries to push pagefind's bundle through its own
    304      transform pipeline, which 500s — so search worked under `astro preview`
    305      (no Vite) but never under `astro dev`. `@vite-ignore` doesn't help; Vite
    306      still rewrites the specifier.
    307 
    308      Building the import through `new Function` means the token `import(` is
    309      never in source Vite can scan, so it emits a genuine browser-native
    310      import that fetches pagefind untouched. Pagefind's own internal chunk
    311      imports then resolve against its real URL and are served straight from
    312      `public/pagefind/` (or `dist/pagefind/` in prod).
    313 
    314      Dev only. `new Function` is an eval, and the site's Content Security
    315      Policy (public/_headers) allows no eval, so in the build the import is
    316      written plainly: Rollup leaves a runtime-string `import()` native, and
    317      `import.meta.env.DEV` is a literal there, so the eval branch is not in
    318      the bundle at all. */
    319   const nativeImport: (u: string) => Promise<any> = import.meta.env.DEV
    320     ? (new Function('u', 'return import(u)') as any)
    321     : (u: string) => import(/* @vite-ignore */ u);
    322 
    323   function loadPagefind() {
    324     if (loading) return loading;
    325     /* Join on a normalised base rather than concatenating onto it.
    326        `BASE_URL` is `/daemon-sec-cheatsheet` — no trailing slash, because
    327        the config sets `trailingSlash: 'ignore'` — so the old
    328        `${BASE}pagefind/…` produced `/daemon-sec-cheatsheetpagefind/…` and
    329        404'd on every environment including production. Stripping any
    330        trailing slashes first makes the join correct whether or not the
    331        base carries one. */
    332     const path = `${BASE.replace(/\/+$/, '')}/pagefind/pagefind.js`;
    333     loading = nativeImport(path)
    334       .then(async (mod: any) => { await mod.init?.(); pagefind = mod; return mod; })
    335       .catch(() => { pagefind = false; return null; });
    336     return loading;
    337   }
    338 
    339   /* The element that opened the palette, so Esc / close hands focus back
    340      to it instead of dropping it on <body>. */
    341   let opener: HTMLElement | null = null;
    342 
    343   function open() {
    344     const overlay = overlayEl();
    345     if (!overlay) return;
    346     opener = document.activeElement instanceof HTMLElement ? document.activeElement : null;
    347     overlay.hidden = false;
    348     document.body.style.overflow = 'hidden';
    349     loadPagefind();
    350     setTimeout(() => inputEl()?.focus(), 20);
    351   }
    352   function close() {
    353     const overlay = overlayEl();
    354     document.body.style.overflow = '';
    355     if (!overlay) return;
    356     overlay.hidden = true;
    357     const input = inputEl();
    358     if (input) input.value = '';
    359     write(HINT);
    360     if (opener && opener.isConnected) opener.focus();
    361     opener = null;
    362   }
    363 
    364   /* Keep Tab inside the dialog while it is open: the page behind the
    365      scrim is inert to the eye but not to the keyboard. */
    366   function trapTab(e: KeyboardEvent) {
    367     const overlay = overlayEl();
    368     if (!overlay || overlay.hidden || e.key !== 'Tab') return;
    369     const focusable = Array.from(overlay.querySelectorAll<HTMLElement>(
    370       'input, button, a[href], [tabindex]:not([tabindex="-1"])',
    371     )).filter((el) => !el.hidden && el.offsetParent !== null);
    372     if (!focusable.length) return;
    373     const first = focusable[0];
    374     const last = focusable[focusable.length - 1];
    375     const active = document.activeElement;
    376     if (e.shiftKey && (active === first || !overlay.contains(active))) { e.preventDefault(); last.focus(); }
    377     else if (!e.shiftKey && active === last) { e.preventDefault(); first.focus(); }
    378   }
    379 
    380   function esc(s: string) {
    381     return String(s).replace(/[&<>"']/g, (c) =>
    382       ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c] as string));
    383   }
    384 
    385   const CHEVRON =
    386     '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" ' +
    387     'stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="m9 6 6 6-6 6"/></svg>';
    388 
    389   /* ---- Excerpt repair -----------------------------------------------------
    390      Pagefind excerpts are the page's own prose with `<mark>` around the hit,
    391      and the vault's prose is not prose: it is command listings, tables and
    392      emoji-headed sections. Untouched, a snippet arrives looking like
    393 
    394        "# ── Set SPN on target ──────────────────── Set-DomainObject -Identity"
    395 
    396      which is not a sentence, is not a preview, and is what pushed one row to
    397      204px while its neighbour sat at 100px. No amount of styling fixes text
    398      that is mostly rule characters, so the string is repaired before it is
    399      ever measured.
    400 
    401      Everything below is tag-aware: `<mark>` is the whole point of an excerpt
    402      and must survive, so nothing here counts or cuts raw HTML positions. */
    403 
    404   /** Drop the first `n` *visible* characters, stepping over tags. */
    405   function dropLeading(html: string, n: number) {
    406     let out = '';
    407     let i = 0;
    408     let dropped = 0;
    409     while (i < html.length) {
    410       if (html[i] === '<') {
    411         const end = html.indexOf('>', i);
    412         if (end === -1) break;
    413         out += html.slice(i, end + 1);
    414         i = end + 1;
    415         continue;
    416       }
    417       if (dropped < n) dropped++;
    418       else out += html[i];
    419       i++;
    420     }
    421     return out;
    422   }
    423 
    424   const plain = (html: string) => html.replace(/<[^>]*>/g, '');
    425 
    426   function tidyExcerpt(raw: string, title: string) {
    427     let html = String(raw || '');
    428 
    429     // Pagefind only ever emits <mark>; anything else reaching the panel is
    430     // content that escaped indexing, and it is not this component's job to
    431     // render it.
    432     html = html.replace(/<(?!\/?mark\s*\/?>)[^>]*>/gi, '');
    433 
    434     // Pictographs, box drawing, block elements, the long ASCII rules that
    435     // head almost every command section in this vault, and the `:warning:`
    436     // style shortcodes the PayloadsAllTheThings mirror carries in from
    437     // GitHub-flavoured markdown. The shortcode pattern is deliberately
    438     // anchored to whitespace on both sides so it cannot bite a `key:value:`
    439     // pair out of a command line.
    440     html = html
    441       .replace(/[\u{1F000}-\u{1FAFF}\u{2600}-\u{27BF}\u{2B00}-\u{2BFF}\u{FE0F}\u{20E3}]/gu, ' ')
    442       .replace(/[─-▟]+/g, ' ')
    443       .replace(/(^|\s):[a-z][a-z0-9_+-]{2,}:(?=\s|$)/gi, '$1')
    444       .replace(/([=_*~#·•+\-])\1{2,}/g, ' ')
    445       .replace(/\s+/g, ' ')
    446       .trim();
    447 
    448     // The heading is nearly always the first thing in the body text, so the
    449     // snippet opens by repeating the title directly above it. Say it once.
    450     const t = title.trim().toLowerCase();
    451     if (t && plain(html).toLowerCase().startsWith(t)) html = dropLeading(html, t.length);
    452 
    453     /* Tidying the head of the string can leave the punctuation that used to
    454        separate it from the title, and can empty a <mark> whose text was
    455        part of the title — a marked-up heading is the common case, not the
    456        exception. Both have to go, and each can uncover the other. */
    457     for (let pass = 0; pass < 3; pass++) {
    458       const trimmed = html
    459         .replace(/<mark[^>]*>\s*<\/mark>/gi, '')
    460         .replace(/^[\s.,:;|—–-]+/, '');
    461       if (trimmed === html) break;
    462       html = trimmed;
    463     }
    464 
    465     /* A one-line snippet is only worth showing if the match is on that line.
    466        Pagefind usually centres the excerpt on the hit, but not always — when
    467        the match starts late, wind the snippet forward so it lands early
    468        enough to survive the ellipsis. */
    469     const markTag = html.search(/<mark/i);
    470     if (markTag > 0) {
    471       const visibleBefore = plain(html.slice(0, markTag)).length;
    472       if (visibleBefore > 46) html = '… ' + dropLeading(html, visibleBefore - 24);
    473     }
    474 
    475     return html;
    476   }
    477 
    478   /* Writes go through here for the same reason reads do: a search started
    479      on one page can resolve after a navigation has replaced the results
    480      panel, and the answer belongs in whichever panel is on screen now. */
    481   function write(html: string, status?: string) {
    482     const results = resultsEl();
    483     if (results) results.innerHTML = html;
    484     const bar = statusEl();
    485     if (bar) {
    486       bar.hidden = !status;
    487       bar.innerHTML = status || '';
    488     }
    489     /* A listbox may only contain options, so the container is only a
    490        listbox while it holds results — the hint and the empty state are
    491        prose and are announced as prose. */
    492     if (results) {
    493       if (status) results.setAttribute('role', 'listbox');
    494       else results.removeAttribute('role');
    495     }
    496     const input = inputEl();
    497     input?.setAttribute('aria-expanded', String(!!status));
    498     select(status ? 0 : -1);
    499   }
    500 
    501   /* ---- The cursor ---------------------------------------------------------
    502      One cursor, moved by either device: `[data-sel]` takes the same ink wipe
    503      hovering does, so the pointer and the arrow keys are visibly driving the
    504      same thing rather than lighting two different rows. The top hit is
    505      selected on arrival, which is what makes ↵ worth pressing. */
    506   let sel = -1;
    507   function rows() {
    508     return Array.from(document.querySelectorAll<HTMLAnchorElement>('.s-result'));
    509   }
    510   function select(i: number) {
    511     const list = rows();
    512     list.forEach((r) => r.removeAttribute('data-sel'));
    513     sel = list.length ? Math.max(0, Math.min(i, list.length - 1)) : -1;
    514     const input = inputEl();
    515     if (sel < 0) { input?.removeAttribute('aria-activedescendant'); return; }
    516     const row = list[sel];
    517     row.setAttribute('data-sel', '');
    518     row.setAttribute('aria-selected', 'true');
    519     list.forEach((r, n) => { if (n !== sel) r.setAttribute('aria-selected', 'false'); });
    520     input?.setAttribute('aria-activedescendant', row.id);
    521     row.scrollIntoView({ block: 'nearest' });
    522   }
    523 
    524   const RESULT_LIMIT = 8;
    525 
    526   let seq = 0;
    527   async function run(q: string) {
    528     const mine = ++seq;
    529     if (!q.trim()) { write(HINT); return; }
    530     await loadPagefind();
    531     if (mine !== seq) return;
    532     /* The index is generated by Pagefind after `astro build`, so it exists
    533        in `dist/` and never under `astro dev`. This is the expected state on
    534        the dev server — `npm run preview` serves the built site with it. */
    535     if (pagefind === false) { write('<p class="search-hint mono">Search index not loaded. It is built by <code>npm run build</code>; use <code>npm run preview</code> to search locally.</p>'); return; }
    536     const search = await pagefind.search(q);
    537     if (mine !== seq) return;
    538     const total = search.results.length;
    539     const data = await Promise.all(
    540       search.results.slice(0, RESULT_LIMIT).map((r: any) => r.data()),
    541     );
    542     if (mine !== seq) return;
    543     if (!data.length) {
    544       write(`<p class="search-hint mono">No matches for “${esc(q)}”. Try a tool name, a technique, or a CVE.</p>`);
    545       return;
    546     }
    547 
    548     const html = data.map((d: any, i: number) => {
    549       const title = String(d.meta?.title || d.url);
    550       const kind = kindOf(d.meta?.source);
    551       const snip = tidyExcerpt(d.excerpt, title);
    552       return (
    553         `<a class="s-result" id="s-opt-${i}" role="option" aria-selected="false"` +
    554         ` href="${esc(String(d.url).replace(/(.)\/$/, '$1'))}" style="--acc: var(--${kind.accent});">` +
    555         `<span class="s-n">${String(i + 1).padStart(2, '0')}</span>` +
    556         `<span class="s-kind">${esc(kind.tag)}</span>` +
    557         `<span class="s-title">${esc(title)}</span>` +
    558         `<span class="s-snip">${snip}</span>` +
    559         `<span class="s-go">${CHEVRON}</span>` +
    560         `</a>`
    561       );
    562     }).join('');
    563 
    564     /* Say how much of the result set is on screen. "8 results" reads as the
    565        whole answer when it is the first page of forty. */
    566     const shown = data.length;
    567     const status =
    568       `<span>${total === shown ? `${total} match${total === 1 ? '' : 'es'}` : `${shown} of ${total} matches`}</span>` +
    569       `<span>↑↓ ↵</span>`;
    570     write(html, status);
    571   }
    572 
    573   /* Delegated rather than bound to the field, so that it keeps working
    574      against whichever field is in the document — `input` bubbles. */
    575   let t: any;
    576   document.addEventListener('input', (e) => {
    577     const el = e.target as HTMLElement;
    578     if (!el?.matches?.('[data-search-input]')) return;
    579     const value = (el as HTMLInputElement).value;
    580     clearTimeout(t);
    581     t = setTimeout(() => run(value), 120);
    582   });
    583 
    584   document.addEventListener('click', (e) => {
    585     const el = e.target as HTMLElement;
    586     if (el.closest('[data-search-open]')) { e.preventDefault(); open(); }
    587     else if (el.closest('[data-search-close]')) close();
    588     else if (el === overlayEl()) close();
    589   });
    590   document.addEventListener('keydown', (e) => {
    591     const overlay = overlayEl();
    592     const isOpen = !!overlay && !overlay.hidden;
    593 
    594     /* Arrow keys drive the list while the field keeps the caret, which is
    595        the whole reason a palette is quicker than a results page. Enter
    596        follows the selection rather than submitting the form. */
    597     if (isOpen && (e.key === 'ArrowDown' || e.key === 'ArrowUp' || e.key === 'Home' || e.key === 'End')) {
    598       const list = rows();
    599       if (!list.length) return;
    600       e.preventDefault();
    601       if (e.key === 'ArrowDown') select(sel < 0 ? 0 : (sel + 1) % list.length);
    602       else if (e.key === 'ArrowUp') select(sel <= 0 ? list.length - 1 : sel - 1);
    603       else if (e.key === 'Home') select(0);
    604       else select(list.length - 1);
    605       return;
    606     }
    607     if (isOpen && e.key === 'Enter') {
    608       const row = rows()[sel];
    609       if (row) { e.preventDefault(); row.click(); }
    610       return;
    611     }
    612 
    613     if (isOpen && e.key === 'Tab') { trapTab(e); return; }
    614     if (e.key === 'Escape' && isOpen) close();
    615     else if (e.key === '/' && overlay?.hidden && !/^(INPUT|TEXTAREA|SELECT)$/.test((e.target as HTMLElement)?.tagName)) { e.preventDefault(); open(); }
    616     else if ((e.key === 'k' || e.key === 'K') && (e.metaKey || e.ctrlKey)) { e.preventDefault(); overlay?.hidden ? open() : close(); }
    617   });
    618 
    619   /* Pointer and keyboard share one cursor, so moving the mouse over a row
    620      moves the selection to it rather than lighting a second one. */
    621   document.addEventListener('pointermove', (e) => {
    622     const row = (e.target as HTMLElement)?.closest?.('.s-result');
    623     if (!row) return;
    624     const i = rows().indexOf(row as HTMLAnchorElement);
    625     if (i >= 0 && i !== sel) select(i);
    626   });
    627 
    628   /* Following a result navigates client-side, which swaps the modal away
    629      without anything calling `close()`. The scroll lock lives on <body>,
    630      so release it on the way out rather than leaving the next page
    631      unscrollable. */
    632   document.addEventListener('astro:before-swap', () => { document.body.style.overflow = ''; });
    633   document.addEventListener('astro:after-swap', () => { document.body.style.overflow = ''; });
    634 </script>