daemon-sec-cheatsheet

The cheatsheet vault for operators: AD, enumeration, exploitation, priv-esc, web, DFIR
git clone https://git.daemon-sec.xyz/daemon-sec-cheatsheet.git
Log | Files | Refs | README | LICENSE

SearchModal.astro (27959B)


      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 cheatsheets">
      6     <div class="search-titlebar">
      7       <span class="search-titlebar__label">Search — DÆMON//SEC</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 sheets, tools, techniques…" 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 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 vault. <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 { CATEGORIES } 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 vault. ' +
    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. Mirrored collections are not domains in that list, so they
    286      receive the same accents used by the masthead. */
    287   const KIND = new Map<string, { tag: string; accent: string }>([
    288     ...CATEGORIES.map((c) => [c.title, { tag: c.tag, accent: c.accent }] as const),
    289     ['Payloads', { tag: 'PAY', accent: 'iris' }],
    290     ['Internal', { tag: 'INT', accent: 'pine' }],
    291     ['HackTricks', { tag: 'HT', accent: 'gold' }],
    292   ]);
    293   const kindOf = (category?: string) =>
    294     (category && KIND.get(category)) || { tag: category || 'SHEET', accent: 'foam' };
    295 
    296   /* The index itself does survive navigation, and should — it is a few
    297      hundred KB of WASM and index shards, and re-fetching it per page would
    298      undo the point of a client-side router. */
    299   let pagefind: any = null;
    300   let loading: Promise<any> | null = null;
    301 
    302   /* Native dynamic import, hidden from Vite.
    303 
    304      Pagefind ships as a prebuilt ES module with its own WASM and code-split
    305      chunks. A literal `import(path)` is rewritten by Vite's dev server: it
    306      appends `?import` and tries to push pagefind's bundle through its own
    307      transform pipeline, which 500s — so search worked under `astro preview`
    308      (no Vite) but never under `astro dev`. `@vite-ignore` doesn't help; Vite
    309      still rewrites the specifier.
    310 
    311      Building the import through `new Function` means the token `import(` is
    312      never in source Vite can scan, so it emits a genuine browser-native
    313      import that fetches pagefind untouched. Pagefind's own internal chunk
    314      imports then resolve against its real URL and are served straight from
    315      `public/pagefind/` (or `dist/pagefind/` in prod).
    316 
    317      Dev only. `new Function` is an eval, and the site's Content Security
    318      Policy (public/_headers) allows no eval, so in the build the import is
    319      written plainly: Rollup leaves a runtime-string `import()` native, and
    320      `import.meta.env.DEV` is a literal there, so the eval branch is not in
    321      the bundle at all. */
    322   const nativeImport: (u: string) => Promise<any> = import.meta.env.DEV
    323     ? (new Function('u', 'return import(u)') as any)
    324     : (u: string) => import(/* @vite-ignore */ u);
    325 
    326   function loadPagefind() {
    327     if (loading) return loading;
    328     /* Join on a normalised base rather than concatenating onto it.
    329        `BASE_URL` is `/daemon-sec-cheatsheet` — no trailing slash, because
    330        the config sets `trailingSlash: 'ignore'` — so the old
    331        `${BASE}pagefind/…` produced `/daemon-sec-cheatsheetpagefind/…` and
    332        404'd on every environment including production. Stripping any
    333        trailing slashes first makes the join correct whether or not the
    334        base carries one. */
    335     const path = `${BASE.replace(/\/+$/, '')}/pagefind/pagefind.js`;
    336     loading = nativeImport(path)
    337       .then(async (mod: any) => { await mod.init?.(); pagefind = mod; return mod; })
    338       .catch(() => { pagefind = false; return null; });
    339     return loading;
    340   }
    341 
    342   function open() {
    343     const overlay = overlayEl();
    344     if (!overlay) return;
    345     overlay.hidden = false;
    346     document.body.style.overflow = 'hidden';
    347     loadPagefind();
    348     setTimeout(() => inputEl()?.focus(), 20);
    349   }
    350   function close() {
    351     const overlay = overlayEl();
    352     document.body.style.overflow = '';
    353     if (!overlay) return;
    354     overlay.hidden = true;
    355     const input = inputEl();
    356     if (input) input.value = '';
    357     write(HINT);
    358   }
    359 
    360   function esc(s: string) {
    361     return String(s).replace(/[&<>"']/g, (c) =>
    362       ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c] as string));
    363   }
    364 
    365   const CHEVRON =
    366     '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" ' +
    367     'stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="m9 6 6 6-6 6"/></svg>';
    368 
    369   /* ---- Excerpt repair -----------------------------------------------------
    370      Pagefind excerpts are the page's own prose with `<mark>` around the hit,
    371      and the vault's prose is not prose: it is command listings, tables and
    372      emoji-headed sections. Untouched, a snippet arrives looking like
    373 
    374        "# ── Set SPN on target ──────────────────── Set-DomainObject -Identity"
    375 
    376      which is not a sentence, is not a preview, and is what pushed one row to
    377      204px while its neighbour sat at 100px. No amount of styling fixes text
    378      that is mostly rule characters, so the string is repaired before it is
    379      ever measured.
    380 
    381      Everything below is tag-aware: `<mark>` is the whole point of an excerpt
    382      and must survive, so nothing here counts or cuts raw HTML positions. */
    383 
    384   /** Drop the first `n` *visible* characters, stepping over tags. */
    385   function dropLeading(html: string, n: number) {
    386     let out = '';
    387     let i = 0;
    388     let dropped = 0;
    389     while (i < html.length) {
    390       if (html[i] === '<') {
    391         const end = html.indexOf('>', i);
    392         if (end === -1) break;
    393         out += html.slice(i, end + 1);
    394         i = end + 1;
    395         continue;
    396       }
    397       if (dropped < n) dropped++;
    398       else out += html[i];
    399       i++;
    400     }
    401     return out;
    402   }
    403 
    404   const plain = (html: string) => html.replace(/<[^>]*>/g, '');
    405 
    406   function tidyExcerpt(raw: string, title: string) {
    407     let html = String(raw || '');
    408 
    409     // Pagefind only ever emits <mark>; anything else reaching the panel is
    410     // content that escaped indexing, and it is not this component's job to
    411     // render it.
    412     html = html.replace(/<(?!\/?mark\s*\/?>)[^>]*>/gi, '');
    413 
    414     // Pictographs, box drawing, block elements, the long ASCII rules that
    415     // head almost every command section in this vault, and the `:warning:`
    416     // style shortcodes the PayloadsAllTheThings mirror carries in from
    417     // GitHub-flavoured markdown. The shortcode pattern is deliberately
    418     // anchored to whitespace on both sides so it cannot bite a `key:value:`
    419     // pair out of a command line.
    420     html = html
    421       .replace(/[\u{1F000}-\u{1FAFF}\u{2600}-\u{27BF}\u{2B00}-\u{2BFF}\u{FE0F}\u{20E3}]/gu, ' ')
    422       .replace(/[─-▟]+/g, ' ')
    423       .replace(/(^|\s):[a-z][a-z0-9_+-]{2,}:(?=\s|$)/gi, '$1')
    424       .replace(/([=_*~#·•+\-])\1{2,}/g, ' ')
    425       .replace(/\s+/g, ' ')
    426       .trim();
    427 
    428     // The heading is nearly always the first thing in the body text, so the
    429     // snippet opens by repeating the title directly above it. Say it once.
    430     const t = title.trim().toLowerCase();
    431     if (t && plain(html).toLowerCase().startsWith(t)) html = dropLeading(html, t.length);
    432 
    433     /* Tidying the head of the string can leave the punctuation that used to
    434        separate it from the title, and can empty a <mark> whose text was
    435        part of the title — a marked-up heading is the common case, not the
    436        exception. Both have to go, and each can uncover the other. */
    437     for (let pass = 0; pass < 3; pass++) {
    438       const trimmed = html
    439         .replace(/<mark[^>]*>\s*<\/mark>/gi, '')
    440         .replace(/^[\s.,:;|—–-]+/, '');
    441       if (trimmed === html) break;
    442       html = trimmed;
    443     }
    444 
    445     /* A one-line snippet is only worth showing if the match is on that line.
    446        Pagefind usually centres the excerpt on the hit, but not always — when
    447        the match starts late, wind the snippet forward so it lands early
    448        enough to survive the ellipsis. */
    449     const markTag = html.search(/<mark/i);
    450     if (markTag > 0) {
    451       const visibleBefore = plain(html.slice(0, markTag)).length;
    452       if (visibleBefore > 46) html = '… ' + dropLeading(html, visibleBefore - 24);
    453     }
    454 
    455     return html;
    456   }
    457 
    458   /* Writes go through here for the same reason reads do: a search started
    459      on one page can resolve after a navigation has replaced the results
    460      panel, and the answer belongs in whichever panel is on screen now. */
    461   function write(html: string, status?: string) {
    462     const results = resultsEl();
    463     if (results) results.innerHTML = html;
    464     const bar = statusEl();
    465     if (bar) {
    466       bar.hidden = !status;
    467       bar.innerHTML = status || '';
    468     }
    469     /* A listbox may only contain options, so the container is only a
    470        listbox while it holds results — the hint and the empty state are
    471        prose and are announced as prose. */
    472     if (results) {
    473       if (status) results.setAttribute('role', 'listbox');
    474       else results.removeAttribute('role');
    475     }
    476     const input = inputEl();
    477     input?.setAttribute('aria-expanded', String(!!status));
    478     select(status ? 0 : -1);
    479   }
    480 
    481   /* ---- The cursor ---------------------------------------------------------
    482      One cursor, moved by either device: `[data-sel]` takes the same ink wipe
    483      hovering does, so the pointer and the arrow keys are visibly driving the
    484      same thing rather than lighting two different rows. The top hit is
    485      selected on arrival, which is what makes ↵ worth pressing. */
    486   let sel = -1;
    487   function rows() {
    488     return Array.from(document.querySelectorAll<HTMLAnchorElement>('.s-result'));
    489   }
    490   function select(i: number) {
    491     const list = rows();
    492     list.forEach((r) => r.removeAttribute('data-sel'));
    493     sel = list.length ? Math.max(0, Math.min(i, list.length - 1)) : -1;
    494     const input = inputEl();
    495     if (sel < 0) { input?.removeAttribute('aria-activedescendant'); return; }
    496     const row = list[sel];
    497     row.setAttribute('data-sel', '');
    498     row.setAttribute('aria-selected', 'true');
    499     list.forEach((r, n) => { if (n !== sel) r.setAttribute('aria-selected', 'false'); });
    500     input?.setAttribute('aria-activedescendant', row.id);
    501     row.scrollIntoView({ block: 'nearest' });
    502   }
    503 
    504   const RESULT_LIMIT = 8;
    505 
    506   let seq = 0;
    507   async function run(q: string) {
    508     const mine = ++seq;
    509     if (!q.trim()) { write(HINT); return; }
    510     await loadPagefind();
    511     if (mine !== seq) return;
    512     /* The index is generated by Pagefind after `astro build`, so it exists
    513        in `dist/` and never under `astro dev`. This is the expected state on
    514        the dev server — `npm run preview` serves the built site with it. */
    515     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; }
    516     const search = await pagefind.search(q);
    517     if (mine !== seq) return;
    518     const total = search.results.length;
    519     const data = await Promise.all(
    520       search.results.slice(0, RESULT_LIMIT).map((r: any) => r.data()),
    521     );
    522     if (mine !== seq) return;
    523     if (!data.length) {
    524       write(`<p class="search-hint mono">No matches for “${esc(q)}”. Try a tool name, a technique, or a CVE.</p>`);
    525       return;
    526     }
    527 
    528     const html = data.map((d: any, i: number) => {
    529       const title = String(d.meta?.title || d.url);
    530       const kind = kindOf(d.meta?.category);
    531       const snip = tidyExcerpt(d.excerpt, title);
    532       return (
    533         `<a class="s-result" id="s-opt-${i}" role="option" aria-selected="false"` +
    534         ` href="${esc(d.url)}" style="--acc: var(--${kind.accent});">` +
    535         `<span class="s-n">${String(i + 1).padStart(2, '0')}</span>` +
    536         `<span class="s-kind">${esc(kind.tag)}</span>` +
    537         `<span class="s-title">${esc(title)}</span>` +
    538         `<span class="s-snip">${snip}</span>` +
    539         `<span class="s-go">${CHEVRON}</span>` +
    540         `</a>`
    541       );
    542     }).join('');
    543 
    544     /* Say how much of the result set is on screen. "8 results" reads as the
    545        whole answer when it is the first page of forty. */
    546     const shown = data.length;
    547     const status =
    548       `<span>${total === shown ? `${total} match${total === 1 ? '' : 'es'}` : `${shown} of ${total} matches`}</span>` +
    549       `<span>↑↓ ↵</span>`;
    550     write(html, status);
    551   }
    552 
    553   /* Delegated rather than bound to the field, so that it keeps working
    554      against whichever field is in the document — `input` bubbles. */
    555   let t: any;
    556   document.addEventListener('input', (e) => {
    557     const el = e.target as HTMLElement;
    558     if (!el?.matches?.('[data-search-input]')) return;
    559     const value = (el as HTMLInputElement).value;
    560     clearTimeout(t);
    561     t = setTimeout(() => run(value), 120);
    562   });
    563 
    564   document.addEventListener('click', (e) => {
    565     const el = e.target as HTMLElement;
    566     if (el.closest('[data-search-open]')) { e.preventDefault(); open(); }
    567     else if (el.closest('[data-search-close]')) close();
    568     else if (el === overlayEl()) close();
    569   });
    570   document.addEventListener('keydown', (e) => {
    571     const overlay = overlayEl();
    572     const isOpen = !!overlay && !overlay.hidden;
    573 
    574     /* Arrow keys drive the list while the field keeps the caret, which is
    575        the whole reason a palette is quicker than a results page. Enter
    576        follows the selection rather than submitting the form. */
    577     if (isOpen && (e.key === 'ArrowDown' || e.key === 'ArrowUp' || e.key === 'Home' || e.key === 'End')) {
    578       const list = rows();
    579       if (!list.length) return;
    580       e.preventDefault();
    581       if (e.key === 'ArrowDown') select(sel < 0 ? 0 : (sel + 1) % list.length);
    582       else if (e.key === 'ArrowUp') select(sel <= 0 ? list.length - 1 : sel - 1);
    583       else if (e.key === 'Home') select(0);
    584       else select(list.length - 1);
    585       return;
    586     }
    587     if (isOpen && e.key === 'Enter') {
    588       const row = rows()[sel];
    589       if (row) { e.preventDefault(); row.click(); }
    590       return;
    591     }
    592 
    593     if (e.key === 'Escape' && isOpen) close();
    594     else if (e.key === '/' && overlay?.hidden && !/^(INPUT|TEXTAREA|SELECT)$/.test((e.target as HTMLElement)?.tagName)) { e.preventDefault(); open(); }
    595     else if ((e.key === 'k' || e.key === 'K') && (e.metaKey || e.ctrlKey)) { e.preventDefault(); overlay?.hidden ? open() : close(); }
    596   });
    597 
    598   /* Pointer and keyboard share one cursor, so moving the mouse over a row
    599      moves the selection to it rather than lighting a second one. */
    600   document.addEventListener('pointermove', (e) => {
    601     const row = (e.target as HTMLElement)?.closest?.('.s-result');
    602     if (!row) return;
    603     const i = rows().indexOf(row as HTMLAnchorElement);
    604     if (i >= 0 && i !== sel) select(i);
    605   });
    606 
    607   /* Following a result navigates client-side, which swaps the modal away
    608      without anything calling `close()`. The scroll lock lives on <body>,
    609      so release it on the way out rather than leaving the next page
    610      unscrollable. */
    611   document.addEventListener('astro:before-swap', () => { document.body.style.overflow = ''; });
    612   document.addEventListener('astro:after-swap', () => { document.body.style.overflow = ''; });
    613 </script>