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

IMPROVEMENT-PLAN.md (48710B)


      1 # DÆMONBins — Improvement Plan
      2 
      3 > Living document, written incrementally during the planning session on 2026-09-04. Lives at
      4 > `docs/IMPROVEMENT-PLAN.md`; tick items off here as they land.
      5 
      6 ## Context
      7 
      8 DÆMONBins is a static catalog merging GTFOBins × LOLBAS × WADComs (+ DÆMON-authored additions)
      9 into one filterable Technique index. The site has just been migrated from GitHub Pages to Vercel
     10 (uncommitted working tree: `deploy.yml` deleted, `vercel.json` added, `robots.txt.ts`,
     11 `HeroDeck.astro`, `hero.ts`, `fuzz.ts` new). The user wants a **prioritised plan for the next
     12 round of improvements** — not a single feature — so this document surveys the site as it stands,
     13 records concrete findings, and turns them into an ordered backlog with enough detail to execute.
     14 
     15 ## Second pass — 2026-10-04
     16 
     17 The main catalog work from this plan is now landed. The committed dataset contains 3,085
     18 techniques across 907 tools: GTFOBins, LOLBAS, WADComs, the pinned LOOBins snapshot, and 196
     19 DÆMON-authored rows. The catalog is now server-rendered for the initial result set and has a
     20 compact workbench with multi-word search, contextual facet counts, grouped variants, sorting,
     21 shareable URL state, a details inspector, shell-aware template configuration, bookmarks, saved
     22 views, density/font/wrapping controls, and reduced-motion persistence. The data pipeline no longer
     23 depends on a sibling checkout for authored rows: `src/data/sources/` and
     24 `src/data/catalog-additions.json` are the committed inputs, with `npm run data:enrich` available
     25 for metadata-only refreshes. LOOBins provenance and GPL-3.0 attribution are documented in
     26 `THIRD_PARTY_NOTICES.md` and `/credits`.
     27 
     28 The original survey below remains useful as historical context; its counts and “deferred” labels
     29 describe the first-pass checkout, not the current catalog.
     30 
     31 ## Survey log (what was inspected, in order)
     32 
     33 - [x] `package.json`, `astro.config.mjs`, `vercel.json`, `README.md` — read.
     34   - Build: `data:public` → `astro build` → `pagefind --site dist` → copies pagefind into `public/`
     35     (odd: `public/pagefind` is regenerated from `dist/pagefind` after the build; only matters for
     36     `astro dev`).
     37   - `test` script is `node --test` but no test files are known yet — verify.
     38   - `site` is `https://lotl.daemon-sec.xyz`; sitemap + MDX integrations on; Shiki `rose-pine-moon`.
     39   - Headers in `vercel.json`: nosniff, referrer, frame-ancestors, HSTS. No full CSP yet.
     40 - [ ] `src/pages/*` — routes, SEO tags, OG image.
     41 - [ ] `src/scripts/{app,catalog,hero,fuzz}.ts` — the client islands.
     42 - [ ] `src/lib/*` — taxonomy, techniques, url helpers.
     43 - [ ] `src/data/*.json` + `scripts/build-dataset.mjs` — data quality.
     44 - [ ] `src/styles/*`, `src/components/*` — design system, a11y.
     45 - [x] Working-tree diff (`git diff`) — what the in-flight Vercel migration changed.
     46   - `astro.config.mjs`: dropped `base: '/daemon-sec-lotl'` + the `rehypeBaseLinks` plugin; `site`
     47     is now `https://lotl.daemon-sec.xyz`. `url()` helper kept as the link chokepoint.
     48   - `Base.astro`: added Vercel Web Analytics + Speed Insights `<script>`s (PROD only) and a
     49     `.skip-link` (styled in `global.css`). Uses `<ClientRouter />` (view transitions).
     50   - `Header.astro`: GitHub icon + nav link (`DAEMON-404/daemon-sec-lotl`).
     51   - `app.ts`: new `initFuzz()`, `initHero()`, `initCounters()` (data-count roll-up with
     52     reduced-motion + IntersectionObserver).
     53   - `build-dataset.mjs`: Impacket suite split + family case-fold via `wadcoms-normalize.mjs`.
     54   - `SectionBanner.astro`: −212 lines = its inline `<script>` (the canvas "fuzz" signal field)
     55     was lifted into `src/scripts/fuzz.ts` and is now driven by `app.ts` → `initFuzz()`. Still
     56     used by `404.astro` and `[source]/index.astro`.
     57   - `index.astro`: `SlantTitle` hero replaced by new `HeroDeck.astro`; added "By platform"
     58     counters (`data-count`) and `data-reveal` on deck cards.
     59   - **No test files anywhere** (`find . -name '*.test.*'` → 0). `npm test` is a no-op.
     60   - **`vendor/` is absent on this machine** — `npm run data` cannot run here until
     61     `scripts/setup-vendor.sh` / submodules are checked out. Builds don't need it (committed JSON).
     62   - `.github/workflows/deploy.yml` deleted → **no CI at all now** (Vercel builds on push, but
     63     nothing runs data validation / typecheck / tests before merge).
     64 - [x] `dist/` output size + Lighthouse-style checks (sizes measured below; no Lighthouse run —
     65   the site is not deployed yet, so Speed Insights will provide field data after Track 1).
     66   - Sizes: `dist/` 34 MB total; `public/pagefind` 4.5 MB; `src/data/techniques.json` 2.0 MB and
     67     copied verbatim to `public/data/techniques.json` (client payload); `tools.json` 244 KB.
     68   - `dist/sitemap-0.xml` 57 KB (~850 URLs).
     69 
     70 ## Findings
     71 
     72 ### F1. No CI / quality gate after the Pages→Vercel move
     73 `deploy.yml` was deleted with the migration. `package.json` still has `"test": "node --test"`
     74 but no test files exist (to confirm). Nothing runs `astro check`, the Zod validation in
     75 `build-dataset.mjs`, or link checks before a deploy. Vercel will happily deploy a broken data
     76 file as long as `astro build` passes.
     77 
     78 ### F2. Client payload: the full 2 MB dataset ships to the browser
     79 `public/data/techniques.json` is a byte-for-byte copy of the canonical file. Measured:
     80 
     81 | artefact | raw | gzip -9 | brotli -11 |
     82 |---|--:|--:|--:|
     83 | `techniques.json` | 2,046,894 B | 220,979 B | 165,461 B |
     84 
     85 Vercel serves brotli for static files, so the wire cost is ~165 KB — acceptable but it is
     86 parsed into ~2 MB of JS objects on every catalog visit. Still worth a slim list projection
     87 (who fetches it and which fields dominate → data-agent findings below).
     88 
     89 Built page sizes (uncompressed HTML): `/catalog` 17 KB (so the catalog is client-rendered),
     90 `/gtfobins` 181 KB, `/lolbas` 104 KB, `/gtfobins/bash` 89 KB, home 30 KB. The per-deck index
     91 pages are heavy because every tool card is server-rendered — fine for SEO, but check that the
     92 `_tool_` CSS (78 KB!) isn't shipping unused rules. Largest JS chunk: `Base.astro` script 13.6 KB
     93 + `ClientRouter` 15 KB + `catalog` 9 KB + `SearchModal` 5 KB. Fonts: 15 woff2 files, the
     94 variable Noto Sans Display alone is 70 KB — check `font-display` and preload strategy.
     95 
     96 Pagefind: 4.5 MB index (21 `.pf_index` + 842 fragments) — loaded on demand from
     97 `SearchModal.astro:325`. Only `[source]/[tool].astro:48` carries `data-pagefind-body` (+ a
     98 `source:` meta), so search hits are **tool pages only**; the catalog, deck indexes and home are
     99 not indexed (correct — avoids duplicate hits) but the modal can't deep-link to a technique row.
    100 The SearchModal still references a `BASE` constant from the Pages era (`SearchModal.astro:321`)
    101 — confirm it resolves to `''` now.
    102 
    103 CSS: the shared bundle `_tool_.BP7bOQ_a.css` (78 KB) is linked from **every** page (home,
    104 catalog, credits, deck index, tool). Source is 126 KB across 7 files; `daemon.css` (29 KB) and
    105 `global.css` (43 KB) are the cheatsheet design system cloned wholesale — likely a large share of
    106 unused selectors (`.download-library__head`, TOC, callouts, CPTS scroll-progress remain in
    107 `app.ts` too). Candidate for a coverage pass, not a rewrite.
    108 
    109 ### F3. Deployment is half-migrated
    110 - `https://lotl.daemon-sec.xyz` **does not resolve** (NXDOMAIN at 2026-09-04) — DNS for the
    111   custom domain is not set up, yet `site`, canonical URLs, OG tags, sitemap and README badges all
    112   point there. Until DNS lands, every canonical/sitemap URL is dead for crawlers.
    113 - The old GitHub Pages URL `https://daemon-404.github.io/daemon-sec-lotl/` still returns 200
    114   for `/` and `/catalog/`, with `<link rel="canonical">` pointing at **itself** → once Vercel is
    115   live this is true duplicate content competing for the same queries. Pages can't redirect
    116   server-side; options are (a) disable Pages for the repo, or (b) keep a one-off `gh-pages`
    117   branch that only holds stub pages with `<meta http-equiv="refresh">` + canonical → new domain
    118   for every old URL. (a) is simpler; (b) preserves inbound links. Recommend (b) for ~30 days
    119   then (a). Confirmed via `gh api repos/DAEMON-404/daemon-sec-lotl/pages`: Pages is still
    120   **enabled** with `build_type: workflow` — since the workflow is now deleted, the last Pages
    121   deployment will stay live indefinitely until Pages is switched off in repo settings.
    122 - `.github/` directory no longer exists at all (no issue templates, no dependabot, no CI).
    123 - Local toolchain: node v26.7.0 / npm 11.19.0 — `engines: >=20` is satisfied; pin Vercel's
    124   Node version explicitly (project setting or `"node": "22.x"`) so a Vercel default bump can't
    125   silently change the build.
    126 - `https://daemon-sec-lotl.vercel.app` → 404. Checked the Vercel account via the Vercel MCP:
    127   team `00xnetrunners-projects` (Pro plan) has only two projects, `daemon-sec` (the main site)
    128   and `eve-chat-template`. **There is no Vercel project for this repo yet.** The migration in
    129   the working tree (vercel.json, analytics scripts, `site` URL) is ahead of the actual
    130   infrastructure: create the project (GitHub import, preset Astro), add the `lotl.daemon-sec.xyz`
    131   domain, then a CNAME/ALIAS in the `daemon-sec.xyz` DNS.
    132 - `vercel.json` has no `redirects`, and no `Cache-Control` override for `/data/*.json` or
    133   `/pagefind/*` (Vercel defaults are fine for hashed `_astro/*`, but `/data/techniques.json` is
    134   unhashed → stale-after-deploy risk unless cache headers are short or the URL is versioned).
    135 
    136 ### F4. Catalog island (`src/scripts/catalog.ts`, 211 lines) — functional gaps
    137 Verified by reading the code (line refs are to the working tree):
    138 - **Back button is broken for filters.** URL sync uses `history.replaceState` only
    139   (`catalog.ts:122`) and there is no `popstate` listener. Changing a filter never creates a
    140   history entry, so Back leaves the page instead of undoing the filter. Deep links do work on
    141   first load (`:91`).
    142 - **No sort.** Results render in dataset order only (GTFOBins first, alphabetical by tool).
    143   No "by tool / by source / by capability / NEW first".
    144 - **Facet chips lose keyboard focus.** Clicking a chip calls `renderFacets()` (`:95`) which
    145   `innerHTML`-rebuilds the chip bar (`:133-141`), destroying the focused button → focus drops to
    146   `<body>`. Counts on chips are global totals computed once (`:80-89`), not contextual, so the
    147   rebuild is pure waste.
    148 - **"Show more" collapses expanded rows.** Open state lives only in the DOM (`:168-171`) and
    149   `render()` (`:106`) replaces the whole list; every keystroke re-serialises all visible rows
    150   (400 after ten "Show more" clicks). `PAGE = 40` (`:16`).
    151 - **Text query is a per-call substring scan** (`lib/techniques.ts:85-91`) that concatenates and
    152   lowercases every record on each keystroke (130 ms debounce at `catalog.ts:143-147`). Fine at
    153   2885 rows but a precomputed lowercase haystack per record would cut allocation to zero.
    154 - Copy does a linear `all.find` per click (`:159`); clipboard logic is duplicated three times
    155   (`copy.ts:5-28`, `catalog.ts:178-189`, `app.ts:317-330`); `url()` is reimplemented at
    156   `catalog.ts:15` instead of importing `lib/url.ts`; `escapeHtml` duplicated in
    157   `SearchModal.astro:350`.
    158 - `Technique.context` and `toolType` are never rendered by the island; `isEmpty` and
    159   `accentOf` in lib are dead exports.
    160 - No fuzzy matching anywhere (`fuzz.ts` is the canvas background, not a fuzzy matcher).
    161 
    162 ### F5. Search (`SearchModal.astro`) — solid, but tool-page-only
    163 Opens on `/`, Cmd/Ctrl+K, and `[data-search-open]`; Pagefind loads lazily on first open
    164 (`:316-330`), 8 results, 120 ms debounce, stale-response guard, full listbox ARIA + keyboard
    165 nav. Limitation: results are tool pages, never a specific technique row, and no facet
    166 (platform/source) filtering inside the modal even though `source:` is emitted as Pagefind meta
    167 (`[tool].astro:49`). `escapeHtml` there also escapes `'`, unlike `lib/highlight.ts:6`.
    168 
    169 ### F6. XSS surface — clean, with one unvalidated sink
    170 Every `innerHTML`/`set:html` site interpolates through `escapeHtml`/`highlightCommand`. The one
    171 gap: `href` built from `detection.value` and `references[]` (`catalog.ts:48-49`,
    172 `[tool].astro:113,115`) escapes quotes but does not validate the URL scheme; the Zod schema
    173 (`build-dataset.mjs:201`) types references as bare `z.string()`. 0/2885 records are non-http
    174 today, but a bad upstream merge would become a `javascript:` link. Fix at the schema
    175 (`z.string().url()` + `^https?:`), not at render time.
    176 
    177 ### F7. Background animation cost (`fuzz.ts`)
    178 The canvas signal field runs a permanent 60 fps rAF loop with a DPR-scaled backing store
    179 (`fuzz.ts:175-179`) and has **no visibility/IntersectionObserver pause** — it keeps painting
    180 while scrolled off-screen and in background tabs (rAF throttles in hidden tabs, but not when
    181 merely off-screen). `hero.ts` already pauses on `visibilitychange` (`:55`); `fuzz.ts` should get
    182 both. Reduced motion is handled (single still frame).
    183 
    184 ### F8. Leaks / lifecycle
    185 `initTOC()` in `app.ts:414-432` is the only init without a `data-*-bound` guard: it creates a
    186 new `IntersectionObserver` on every `astro:page-load` and never disconnects. Harmless on this
    187 site (no TOCs) but it's live code that runs on every navigation. `copy.ts:26` fallback never
    188 calls `done()` when `execCommand` throws.
    189 
    190 ### F9. Zero tests, zero CI
    191 `"test": "node --test"` runs against nothing. Pure, DOM-free surface that is trivially
    192 testable with `node:test` + no extra deps: `lib/techniques.ts` (`toolSlug`, `filtersToSearch`
    193 ↔ `filtersFromSearch` round-trip, `matches`), `lib/highlight.ts` (`escapeHtml`,
    194 `highlightCommand` output never contains raw `<` from input), `scripts/wadcoms-normalize.mjs`
    195 (`impacketScript`, `canonicalizeFamilyCase`). No TODO/FIXME anywhere in `src/`.
    196 
    197 ### F10. The catalog is invisible to crawlers and JS-off readers
    198 `dist/catalog/index.html` is 17 KB: `[data-cat-results]` ships empty with "Loading the index…"
    199 (`catalog.astro:40`). Zero of 2885 techniques are in the catalog HTML. They *are* SSR'd on the
    200 842 tool pages, so the content is indexable, but `/catalog?p=Windows&c=...` deep links share one
    201 generic title/description and render nothing without JS. Options, cheapest first: (a) SSR the
    202 first `PAGE` rows into the shell so there is a first paint + crawlable sample; (b) generate
    203 static per-facet landing pages (`/catalog/windows`, `/catalog/privilege-escalation`) from
    204 `facets.json` with real titles; (c) both.
    205 
    206 ### F11. SEO / social bugs (all in `Base.astro` / `public/og.svg`)
    207 - `og:image` is **relative** (`Base.astro:41` → `content="/og.svg"`); OG requires absolute.
    208 - The OG image is an **SVG** — unsupported by Facebook, X, LinkedIn, Slack, Discord.
    209 - `public/og.svg` is the **wrong site's card** ("DÆMON//SEC — The cheatsheet vault…").
    210 - Missing: `og:url`, `og:site_name`, `og:image:width/height/alt`, `twitter:title`,
    211   `twitter:description`, `twitter:image`. `twitter:card=summary_large_image` with no image is a
    212   no-op.
    213 - **No JSON-LD** anywhere. Tool pages → `TechArticle` + `BreadcrumbList`; home → `WebSite` with
    214   `SearchAction`; the dataset itself → `Dataset` (with the GPL licence + upstream `isBasedOn`).
    215 - **Canonicals and sitemap emit trailing slashes** (`…/catalog/`) but `vercel.json` sets
    216   `cleanUrls: true, trailingSlash: false` → Vercel 308s `/catalog/` → `/catalog`. All 849
    217   canonical URLs point at a redirect. Fix: `trailingSlash: 'never'` in `astro.config.mjs` +
    218   `build.format: 'file'` **or** flip vercel.json to `trailingSlash: true`. Pick one and make
    219   canonical, sitemap and `url()` agree.
    220 - `[tool].astro:46` pluralises on `> 1` so a 0-technique tool would read "0 technique".
    221 - Per-deck index pages do pass a description (`[source]/index.astro:36`) — fine.
    222 - D1 verified against `node_modules/astro/dist/core/build/generate.js:310`
    223   (`ending = trailingSlash === "never" ? "" : "/"`) and `@astrojs/sitemap/dist/index.js:76`,
    224   which special-cases `never`.
    225 
    226 ### F12. Accessibility gaps
    227 - **Heading order**: home is `h1` → `h3` (`HeroDeck.astro:47`, `index.astro:78`), no h2.
    228   **Tool pages have a single h1 and no other headings** — each technique is an `<article>` with
    229   a `<span class="tech__name">` (`[tool].astro:83-86`), so heading navigation is useless on the
    230   site's core content. Make technique names `<h2>` (styled identically).
    231 - **SearchModal**: `#search-results` lacks `role="listbox"` (`:23-25`) while children are
    232   `role="option"` → invalid ARIA; **no focus trap**; `close()` (`:340-348`) never restores focus
    233   to the opener; `[data-search-status]` (`:22`) has no `aria-live`.
    234 - **Catalog**: `[data-cat-count]` rewritten on every filter with no `aria-live`; row expander
    235   has `aria-expanded` but no `aria-controls`/`id` pairing (`catalog.ts:53-58`).
    236 - Skip link uses `:focus-visible` only (fine in evergreen browsers).
    237 - Contrast ratios pass per `tokens.css:58-60,129-137`; the risk is **size**: `--fg-faint` at
    238   8.5–10.5 px labels (`[source]/index.astro:73`, `index.astro:130-134`). Bump to ≥11 px.
    239 - Reduced motion is handled thoroughly everywhere (app/hero/fuzz/css).
    240 
    241 ### F13. Theming: no OS dark-mode fallback
    242 `<html data-theme="light">` is hard-coded (`Base.astro:26`); the pre-paint script (`:44-78`)
    243 reads `localStorage` only, never `matchMedia('(prefers-color-scheme: dark)')`. `theme-color`
    244 meta *does* respond to the OS (`:36-37`), so a dark-OS first visit gets a cream page with dark
    245 browser chrome. One-line fix in the inline script.
    246 
    247 ### F14. Dead code and dead bytes
    248 - Components with **zero imports**: `Operator.astro` (71 lines), `RecordRow.astro` (48, superseded
    249   by `catalog.ts:53-67`), `SectionHeader.astro` (26, only used by the dead `Operator`).
    250 - **Dead font**: `'Old Standard TT'` `@font-face` at `tokens.css:50` is referenced by no
    251   `--font-*` token, yet Vite base64-inlines its 3 KB woff2 into the 78 KB CSS on every page.
    252 - `sharp` is a dependency but `astro:assets` is unused (no images on the site) — keep it only if
    253   the OG-PNG task below uses it at build time; otherwise drop.
    254 - `src/fonts/` has 16 woff2 files; only 2 are preloaded (`Base.astro:34-35`); all
    255   `font-display: swap`.
    256 
    257 ### F15. 404 page doesn't help
    258 `404.astro` shows a fake `curl -sI` block and two static actions. With 842 slugs known at build
    259 time, a client-side nearest-slug suggestion ("did you mean /gtfobins/tar?") from
    260 `Astro.url.pathname` is cheap: ship the slug list (~10 KB) inline on the 404 page only.
    261 
    262 ### F16. Data pipeline — clean, but unsorted and unmonitored
    263 Stats (computed read-only from `src/data/techniques.json`):
    264 
    265 | metric | value |
    266 |---|--:|
    267 | techniques / tools | 2,885 / 842 |
    268 | by source | GTFOBins 2,125 · LOLBAS 481 · WADComs 100 · DÆMON 179 |
    269 | by platform (multi) | Linux 2,306 · macOS 890 · Windows 744 · AD 217 |
    270 | missing `description` | 1,264 (44%) |
    271 | empty `mitre` | 190 (7%) |
    272 | has `detection` | 506 (18%) |
    273 | truly redundant rows (same toolId+context+command) | 46 (40 groups, e.g. `gtfo:code:download:0:sudo` ×6) |
    274 | schema / taxonomy / MITRE-id / URL violations | 0 |
    275 | orphan tools, orphan techniques, dup ids | 0 |
    276 
    277 - **Output order is readdir order**, not sorted (`build-dataset.mjs:135-140` `listFiles()` has no
    278   `.sort()`). Same machine → stable; different FS → a 2 MB reorder diff with no content change.
    279   Add a stable sort by `id` before emit.
    280 - **46 genuinely duplicate rows** (identical toolId + context + command) inflate counts. Dedup at
    281   ingest with a stable "keep first" rule and log what was dropped.
    282 - **Freshness is invisible**: upstream commit hashes are recorded (`facets.commits`) but there
    283   is no last-synced date and nothing checks for new upstream commits. A weekly GitHub Actions
    284   cron that runs `git ls-remote` against the three upstreams and opens an issue/PR when the
    285   hash moves is cheap.
    286 - **Cross-repo build input**: `build-dataset.mjs:33` reads
    287   `../daemon-sec/client/src/data/tools/wadcoms.ts` from a sibling checkout. Present on this
    288   machine, but if absent the script **silently skips 134 DÆMON additions** (`:428-432`) and
    289   emits a smaller dataset. Should be a hard error unless `--allow-missing-additions` is passed,
    290   and ideally the additions should be vendored into this repo (or fetched by URL/commit).
    291 - `references` schema is `z.string()` — tighten to `z.string().url()` + `^https?://` (see F6).
    292 - Client projection: the fetched file's byte budget is references 15% · description 14% ·
    293   detection 14% · command 11%. A list projection dropping `detection` + `references` and
    294   truncating `description` to 160 chars measures **1,019,976 B = 0.498×**; a minimal
    295   id/tool/platform/capability/command list is 774 KB (0.38×). Detail fields already exist on
    296   the SSR'd tool pages, so the island can link out rather than lazy-load.
    297 - `daemon-backlog.json`: 45 entries, 100% populated, no placeholders. Good.
    298 
    299 ### F17. 44% of techniques have no description
    300 1,264 rows (overwhelmingly GTFOBins, whose upstream YAML has per-function descriptions only
    301 sometimes) render as bare command + badges. The GTFOBins `_data/functions.yml` has a canonical
    302 description per function — verified against upstream master, e.g. `shell: "This executable can
    303 spawn an interactive system shell."`, `reverse-shell: "…can send back a reverse system shell to
    304 a listening attacker."`; falling back to that at ingest time (and, for the committed JSON, via a
    305 one-shot migration keyed on `nativeCategory[0]`) gives every GTFOBins row a one-line description
    306 with zero authoring. Prefix with the context where set ("As sudo: …").
    307 
    308 ## Survey complete — summary of the picture
    309 
    310 The site is in good shape structurally (clean data, escaped rendering, thorough reduced-motion,
    311 lazy Pagefind). The problems cluster into five tracks:
    312 
    313 1. **Ship it properly** — the Vercel migration is half done (no project, no DNS, wrong OG card,
    314    canonicals → redirects, old Pages site live, no CI).
    315 2. **Catalog UX** — back button, sort, focus loss, expanded-row loss, no SSR content.
    316 3. **Findability/SEO** — JSON-LD, per-facet landing pages, absolute OG PNG, heading structure.
    317 4. **Weight** — halve the dataset payload, purge unused CSS, drop the dead font/components.
    318 5. **Data** — sort-stable output, dedup, description fallback, upstream drift check, hard-fail
    319    on missing additions.
    320 
    321 ## Design decisions (resolved, not surveyed)
    322 
    323 **D1. Trailing slash → `trailingSlash: 'never'` in `astro.config.mjs`, keep `build.format`
    324 at its default `directory`.** `never` + `directory` makes Astro's `getUrlForPath` emit
    325 slash-less URLs (sitemap and `Astro.url.pathname` agree with `vercel.json`'s
    326 `trailingSlash: false`) while the physical output stays `…/index.html`, which is what keeps
    327 Pagefind emitting `/gtfobins/bash`-style hrefs. Switching to `file` would make Pagefind index
    328 `bash.html` and emit `.html` URLs that 308. Also: harden the canonical in `Base.astro:23`
    329 (`pathname.replace(/(.)\/$/, '$1')`) and strip the trailing slash on Pagefind hrefs in
    330 `SearchModal.astro:524`. `url()` in `lib/url.ts` needs no change.
    331 
    332 **D2. Catalog SSR → server-render the first `PAGE` (40) rows into the shell; per-facet landing
    333 pages are a follow-on.** `catalog.ts` runs `boot()` at module scope and touches `document`, so
    334 it can't be imported from Astro frontmatter. Extract the pure renderers (`renderRow`, `chip`,
    335 `facetGroup`, `badge`) into a new `src/lib/render-row.ts` (zero DOM, zero side effects) and
    336 import it from both `catalog.ts` and `catalog.astro`. Hand-off: the page renders
    337 `techniques.slice(0, 40).map(renderRow)` into `[data-cat-results]` with a `data-ssr` marker and
    338 the real count text. On boot the island reads `filtersFromSearch(location.search)`; if
    339 `isEmpty(filters)` (currently a dead export in `lib/techniques.ts`) **and** `data-ssr` is set,
    340 it wires listeners but skips the initial `render()` until the first interaction. Copy reads the
    341 command from the row's own `<pre><code>` textContent, so it works before the fetch resolves.
    342 
    343 **D3. Slim payload → `public/data/index-<sha256[0:8]>.json` (~1.0 MB, 0.5×).** Keep per row:
    344 `id, toolId, toolName, name, source, platform[], capability[], command, added?, description`
    345 (truncated to 160 chars). Drop `usecase, mitre, privilege, context, requires, services,
    346 fullPath, toolType, detection, references, verifyNote`. The expanded row shows description +
    347 badges + a "full details →" link to `toolRoute(t.toolId)#<technique-id>` (add `id={t.id}` on
    348 `<article class="tech">` at `[tool].astro:83`). Anchor beats lazy per-tool fetch: the detail is
    349 already SSR'd there and it needs no new fetch infrastructure. A `scripts/build-index.mjs`
    350 writes the hashed file plus `src/data/index-hash.json` (`{"file": "index-….json"}`), which
    351 `catalog.astro` imports → `data-index-url`; the island fetches `root.dataset.indexUrl`.
    352 `vercel.json` gets `Cache-Control: public, max-age=31536000, immutable` for
    353 `/data/index-(.*).json`. `.gitignore` already covers `/public/data/`; add `build:index` to
    354 both `build` and `dev` scripts.
    355 
    356 **D4. History → push on discrete intent, replace on keystroke.** `sync(push: boolean)`:
    357 `pushState` for facet toggle / NEW / clear / sort, `replaceState` from the debounced query
    358 handler. `popstate` handler re-reads `filtersFromSearch(location.search)`, resets `limit`,
    359 re-renders, and never calls `sync` (no echo entry).
    360 
    361 **D5. CI → two workflows, no deploy job (Vercel deploys).** `ci.yml`: job `quality`
    362 (`npm ci` → `npm run check` → `npm test` → `npm run validate:data`) and job `build`
    363 (`npm ci` → `npm run build`). `upstream-drift.yml`: weekly cron, `git ls-remote <upstream> HEAD`
    364 ×3, compare the short hash against `facets.json .commits`, `gh issue create` on divergence.
    365 New scripts: `check: astro check`, `test: node --test test/`,
    366 `validate:data: node scripts/validate-data.mjs`, `build:index`. New devDep: `@astrojs/check`
    367 (not installed today; `typescript` is).
    368 
    369 **D6. OG image → one site-wide 1200×630 PNG rendered at build by `sharp`** from a corrected
    370 DÆMONBins SVG template (`scripts/og.mjs` → `public/og.png`), run before `astro build` so it
    371 lands in `dist/`. Per-source variants are a nice-to-have later.
    372 
    373 **D7. JSON-LD → `src/lib/jsonld.ts` builders, injected by a `jsonLd` prop on `Base.astro`.**
    374 Tool page: `TechArticle` (headline = tool name, `articleSection` = source label, `keywords` =
    375 capabilities, `about` = MITRE ids) + `BreadcrumbList` (Catalog → Source → Tool). Home:
    376 `WebSite` + `SearchAction` targeting `/catalog?q={search_term_string}`. Catalog:
    377 `CollectionPage`. Credits: `Dataset` (`license` = GPL-3.0 URL, `isBasedOn` = the three
    378 upstream repos from `SOURCE_META`).
    379 
    380 **D8. Tests → `node --test test/*.test.mjs`, TS loaded through esbuild.** A tiny
    381 `test/_loadts.mjs` bundles a TS entry with `esbuild.build({bundle: true, format: 'esm',
    382 write: false})` and imports it as a `data:` URL — the exact pattern `build-dataset.mjs:435`
    383 already uses, so zero new deps and extensionless intra-lib imports resolve. Plain
    384 `--experimental-strip-types` would not resolve those.
    385 
    386 **D9. GitHub Pages → disable it once Vercel + DNS are live.** No CI can update it any more, so
    387 it will only rot. If inbound links matter, first push a one-off `gh-pages` branch of stub pages
    388 with `<meta http-equiv="refresh">` + canonical to the new domain, keep it ~30 days, then
    389 disable.
    390 
    391 ## Prerequisites (user-side, outside the repo)
    392 
    393 These block Track 1 verification and cannot be done from code:
    394 
    395 1. Create the Vercel project: import `DAEMON-404/daemon-sec-lotl` into team
    396    `00xnetrunners-projects`, framework preset Astro (vercel.json pins the rest). Set Node to
    397    `22.x` in project settings. Enable Web Analytics + Speed Insights (the `<head>` scripts in
    398    `Base.astro` are already wired and no-op until then).
    399 2. Add the domain `lotl.daemon-sec.xyz` to the project; add the CNAME (or ALIAS) record in the
    400    `daemon-sec.xyz` DNS zone.
    401 3. After the first successful production deploy: disable GitHub Pages in repo settings (D9).
    402 4. Locally, when Track 5 needs to regenerate data: run `scripts/setup-vendor.sh` /
    403    `git submodule update --init` to populate `vendor/`.
    404 
    405 ## Backlog (ordered)
    406 
    407 Effort S ≈ <1 h, M ≈ half a day, L ≈ a day. Impact is user-visible impact.
    408 
    409 ### Track 0 — Tooling / CI (guardrail first)
    410 
    411 **T0.1 Extract the Zod schema + `validate:data`** · S · dev
    412 Files: new `scripts/technique-schema.mjs` (move `TechniqueSchema` + enums out of
    413 `build-dataset.mjs:181-204`; `build-dataset.mjs` imports it), new `scripts/validate-data.mjs`
    414 (reads `src/data/techniques.json`, `safeParse` every row, dup-id check mirroring
    415 `build-dataset.mjs:534-549`, exit 1 on any failure). Verify: `node scripts/validate-data.mjs`
    416 exits 0 today.
    417 
    418 **T0.2 Test suite** · M · dev
    419 Files: `test/_loadts.mjs`, `test/techniques.test.mjs`, `test/highlight.test.mjs`,
    420 `test/wadcoms.test.mjs`; `package.json` `test` script. Assertions:
    421 - `toolSlug('gtfo:vim') === 'vim'`; `toolSlug('wadcoms:Impacket-GetUserSPNs')` lower-cases;
    422   `filtersFromSearch(filtersToSearch(f))` deep-equals `f`; `filtersToSearch(EMPTY_FILTERS) === ''`;
    423   `matches` passes a Linux row for `{platform:['Linux']}`, fails for `['Windows']`; `addedOnly`
    424   excludes non-`added`.
    425 - `escapeHtml('<b>&"')` has no `<` or `"`; `highlightCommand('echo <x>')` has no raw `<x>`.
    426 - `impacketScript(['…/examples/secretsdump.py'], cmd) === 'secretsdump'`, falls back to the
    427   command basename, `null` otherwise; `canonicalizeFamilyCase` folds `Enum4Linux`/`enum4linux`.
    428 Verify: `npm test` green. Deps: none.
    429 
    430 **T0.3 CI + upstream-drift workflows** · S · dev
    431 Files: `.github/workflows/ci.yml`, `.github/workflows/upstream-drift.yml`, `package.json`
    432 (`check`), devDep `@astrojs/check`. Per D5. Verify: `npm run check` locally, then a push.
    433 Deps: T0.1, T0.2.
    434 
    435 ### Track 1 — Ship it properly
    436 
    437 **T1.1 Trailing-slash canonicalisation** · S · high
    438 Files: `astro.config.mjs` (`trailingSlash: 'never'`), `Base.astro:23`, `SearchModal.astro:524`.
    439 Per D1. Verify: rebuild; `grep -c '/</loc>' dist/sitemap-0.xml` → 1 (only `/`); canonical in
    440 `dist/gtfobins/bash/index.html` is `…/gtfobins/bash`. Deps: none.
    441 
    442 **T1.2 OG PNG + complete social meta** · M · high
    443 Files: new `scripts/og.mjs`, corrected `public/og.svg` (currently the cheatsheet's card),
    444 `Base.astro:38-42`, `package.json` build chain. Add absolute `og:image`
    445 (`new URL('/og.png', Astro.site)`), `og:url`, `og:site_name`, `og:image:width/height/alt`,
    446 `twitter:title/description/image`. Verify: `file dist/og.png` → PNG 1200×630; view-source shows
    447 absolute URLs; paste a tool URL into a card debugger once live. Deps: none.
    448 
    449 **T1.3 OS dark-mode fallback** · S · med
    450 File: `Base.astro:48-50` — when `localStorage.theme` is unset, use
    451 `matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light'`. Verify: dark-OS
    452 first visit paints Night, toggle still persists. Deps: none.
    453 
    454 **T1.4 Cache headers + Node pin** · S · med
    455 File: `vercel.json` — immutable header for `/data/index-(.*).json`; `max-age=600` for the
    456 unhashed `/data/techniques.json` while it still exists; leave `_astro/*` to Vercel defaults.
    457 Node `22.x` in the Vercel project. Deps: T4.1 for the hashed name (header can land first).
    458 
    459 **T1.5 Pages sunset** · S · med — per D9; user-side switch after T1.1/T1.2 are live.
    460 
    461 ### Track 2 — Catalog UX (one coherent island refactor)
    462 
    463 **T2.1 Back button** · S · high
    464 File: `catalog.ts:119-151`. Per D4. Verify: toggle two facets, Back undoes them one at a time;
    465 typing does not spam history; reload keeps state. Deps: none.
    466 
    467 **T2.2 Facet focus retention + live counts** · S · med
    468 Files: `catalog.ts:94-101,133-141`; `catalog.astro:40` (`aria-live="polite"` on
    469 `[data-cat-count]`). Toggle `aria-checked` on the clicked chip in place instead of rebuilding
    470 `facetsEl.innerHTML`; rebuild only on clear/popstate. Verify: keyboard-toggle a chip, focus
    471 stays on it; screen reader announces the new count. Deps: T2.1.
    472 
    473 **T2.3 Persist expanded rows across "Show more"; copy from DOM** · S · med
    474 Files: `catalog.ts:103-117,151,154-172`. "Show more" appends only the new slice via
    475 `insertAdjacentHTML('beforeend', …)`; copy reads `.trow__cmd code` textContent (drops the
    476 `all.find` and lets `copy.ts` be the single clipboard helper — delete the duplicate in
    477 `catalog.ts:178-189`). Add `aria-controls`/`id` pairing on the expander. Verify: expand, Show
    478 more, row stays open. Deps: T2.1.
    479 
    480 **T2.4 Sort control** · M · med
    481 Files: `catalog.astro` (`<select data-cat-sort>`: tool · source · capability · NEW first),
    482 `lib/techniques.ts` (`sortTechniques(list, key)` pure fn; `sort=` in `filtersTo/FromSearch`),
    483 `catalog.ts` (sort before slice). Verify: unit test on `sortTechniques`; URL carries `sort=`.
    484 Deps: T2.1.
    485 
    486 **T2.5 Small island hygiene** · S · low
    487 `catalog.ts:14-15` import `url()` from `lib/url.ts` instead of reimplementing; delete dead
    488 exports `accentOf` (unless T3.4 uses it); precompute a lowercase haystack per record once at
    489 boot instead of per keystroke in `lib/techniques.ts:85-91`. Deps: T2.1.
    490 
    491 ### Track 3 — Findability / SEO / a11y
    492 
    493 **T3.1 Heading structure** · S · med
    494 Files: `[tool].astro:85-86` (`<span class="tech__name">` → `<h2 class="tech__name">`, CSS keys
    495 off the class so visuals hold), `index.astro` (an `h2` before the deck grid so home is not
    496 h1→h3), `[tool].astro:46,58` (pluralise on `!== 1`). Verify: heading outline in devtools /
    497 axe. Deps: none.
    498 
    499 **T3.2 SearchModal ARIA** · S · med
    500 File: `SearchModal.astro:22-25,340-348`. Add `role="listbox"` on `#search-results`,
    501 `aria-live="polite"` on `[data-search-status]`, remember the opener and restore focus in
    502 `close()`, trap Tab inside the dialog while open. Verify: open with `/`, Tab cycles inside,
    503 Esc returns focus to the trigger. Deps: none.
    504 
    505 **T3.3 JSON-LD** · M · med
    506 Files: new `src/lib/jsonld.ts`, `Base.astro` (`jsonLd` prop → `<script type="application/ld+json" set:html={JSON.stringify(obj)}>`),
    507 `[tool].astro`, `index.astro`, `catalog.astro`, `credits.astro`. Per D7. Verify: paste dist HTML
    508 into Google's Rich Results test. Deps: T1.1 (URLs in breadcrumbs must be canonical).
    509 
    510 **T3.4 SSR the first 40 catalog rows** · L · high
    511 Files: new `src/lib/render-row.ts`, `catalog.ts`, `catalog.astro`. Per D2. Verify:
    512 `wc -c dist/catalog/index.html` grows from 17 KB to ~60 KB; with JS disabled the page shows 40
    513 rows; no double-render flash; deep link `/catalog?p=Windows` still applies filters on boot.
    514 Deps: T2.1–T2.3.
    515 
    516 **T3.5 Per-facet landing pages** · M · med
    517 File: new `src/pages/catalog/[facet].astro` — `getStaticPaths` from `facets.json`
    518 (platforms + sources + capabilities ≈ 26 pages), each with a real title/description/canonical,
    519 reusing `render-row.ts` + `filterTechniques`, linking into `/catalog?…` for the interactive
    520 view. Verify: `ls dist/catalog/`; sitemap gains 26 URLs. Deps: T3.4, T1.1.
    521 
    522 **T3.6 Smarter 404** · S · low
    523 File: `404.astro` — inline the 842 slugs from `tools.json` (~10 KB, this page only), nearest
    524 match on `location.pathname` → "Did you mean /gtfobins/tar?". Deps: none.
    525 
    526 **T3.7 Label legibility** · S · low
    527 Bump `--fg-faint` micro-labels from 8.5–10.5 px to ≥ 11 px at `[source]/index.astro:73` and
    528 `index.astro:130-134`. Deps: none.
    529 
    530 ### Track 4 — Weight
    531 
    532 **T4.1 Slim hashed list payload** · M · med
    533 Files: new `scripts/build-index.mjs`, generated `src/data/index-hash.json`, `catalog.ts:200`,
    534 `catalog.astro`, `[tool].astro:83` (`id={t.id}`), `package.json` (`build:index` in `build` and
    535 `dev`). Per D3. Verify: `wc -c public/data/index-*.json` ≈ 1.0 MB; catalog still filters;
    536 "full details →" lands on the right anchor. Deps: T3.4.
    537 
    538 **T4.2 Canvas pause + lifecycle leaks** · S · low
    539 Files: `fuzz.ts:175-179` (pause the rAF loop on `visibilitychange` and when the canvas leaves
    540 the viewport via IntersectionObserver, like `hero.ts:55`), `app.ts:414-432` (guard `initTOC`
    541 with `data-toc-bound` and disconnect on `astro:before-swap`, or delete it — no TOCs exist),
    542 `copy.ts:26` (call `done()` before the `execCommand` try can throw). Verify: scroll the hero
    543 off-screen, CPU drops in the performance panel. Deps: none.
    544 
    545 **T4.3 Dead font + dead components** · S · low
    546 Remove the `'Old Standard TT'` `@font-face` at `tokens.css:50` (base64-inlined into every
    547 page's CSS); delete `Operator.astro`, `RecordRow.astro`, `SectionHeader.astro` after a final
    548 `grep -rn` confirms zero imports. Verify: bundle CSS shrinks by ~4 KB; build passes. Deps: none.
    549 
    550 **T4.4 CSS coverage pass** · M · low
    551 Prune cheatsheet-only selectors from `global.css` / `daemon.css` (`.download-library__head`,
    552 TOC, callouts, scroll-progress) after a browser coverage run on `/`, `/catalog`, a tool page and
    553 a deck page. Non-blocking; do last. Deps: none.
    554 
    555 ### Track 5 — Data (verify via one-shot migration + unit tests; `vendor/` is absent here)
    556 
    557 **T5.1 Stable sort + dedup** · M · low (user) / high (diff hygiene)
    558 Files: `build-dataset.mjs:135-140` (`.sort()` in `listFiles`), a stable `sort by id` before
    559 emit, keep-first dedup on `toolId+context+command` with a log line; new one-shot
    560 `scripts/normalize-order.mjs` (mirror `split-impacket.mjs`) that applies the same sort+dedup to
    561 the committed JSON now. Verify: run the one-shot; `validate:data` still 0; technique count
    562 drops by 46; `facets.json` counts regenerate. Deps: T0.1.
    563 
    564 **T5.2 Tighten `references` / `detection.value`** · S · low (security)
    565 File: `scripts/technique-schema.mjs` — `z.string().url().regex(/^https?:\/\//)`. Verify:
    566 `validate:data` passes (0 non-http today). Deps: T0.1.
    567 
    568 **T5.3 Hard-fail on missing DÆMON additions** · S · low
    569 File: `build-dataset.mjs:428-432` — throw unless `--allow-missing-additions`; longer term,
    570 vendor the 134 additions into this repo so the build has no sibling-checkout dependency.
    571 Deps: none.
    572 
    573 **T5.4 GTFOBins description fallback** · M · med
    574 Files: `build-dataset.mjs` (a `GTFO_FN_DESC` table from `_data/functions.yml`, applied when
    575 `description` is empty, prefixed with the context), plus a one-shot migration keyed on
    576 `nativeCategory[0]` for the committed JSON. Verify: missing-description count drops from 1,264
    577 toward ~0 for GTFOBins; unit test on `descFor(fn, context)`. Deps: T5.1.
    578 
    579 **T5.5 Freshness signal** · S · low
    580 Show `facets.commits` short hashes (already recorded) on `/credits` with a link to each
    581 upstream commit, so readers can see how current each deck is; the drift cron in T0.3 keeps
    582 them honest. Deps: T0.3.
    583 
    584 ## Recommended first pass (~1–2 days)
    585 
    586 1. **T0.1 → T0.2 → T0.3** — guardrail first; nothing after this can regress silently.
    587 2. **T1.1 + T1.3** — one-line, high-leverage: 849 canonicals stop pointing at redirects; dark-OS
    588    first visit is right.
    589 3. **T1.2 + T3.3 + T3.1 + T3.2** — the SEO/a11y payload that makes the migration worth shipping;
    590    all independent, all checkable in `dist/`.
    591 4. **T2.1 → T2.2 → T2.3 → T2.5** — the island refactor as one coherent change.
    592 5. **T3.4 + T4.1 (+ T1.4)** if time remains — the biggest single win (crawlable catalog, first
    593    paint, half the payload) and the riskiest hydration change, so it goes last with tests in
    594    place.
    595 
    596 Second pass: T2.4 sort, T3.5 facet pages, T3.6 404, T3.7, T4.2–T4.4, all of Track 5, T5.5.
    597 Track 5 lands behind the one-shot migrations + unit tests until `vendor/` is populated.
    598 
    599 ## Verification
    600 
    601 Per-item checks are listed inline above. End-to-end, after each pass:
    602 
    603 ```bash
    604 npm ci
    605 npm run check          # astro check (after T0.3)
    606 npm test               # node --test (after T0.2)
    607 npm run validate:data  # Zod over the committed JSON (after T0.1)
    608 npm run build          # data:public → astro build → pagefind
    609 npm run preview        # http://localhost:4321
    610 ```
    611 
    612 Then, in the browser (Chrome tools available in this session):
    613 - `/` — dark-OS first visit paints Night; counters animate; hero deck cycles; `/` opens search.
    614 - `/catalog` — 40 rows visible before the fetch resolves (T3.4); toggle two facets then press
    615   Back twice (T2.1); Tab to a chip, Space, focus stays (T2.2); expand a row, Show more, still
    616   open (T2.3); `?p=Windows&c=Execution` deep link applies on load.
    617 - `/gtfobins/bash` — h1 + h2 outline; JSON-LD validates; `#<technique-id>` anchor scrolls.
    618 - View-source on any page: canonical without trailing slash, absolute `og:image` PNG.
    619 - `dist/sitemap-0.xml` — no trailing slashes; `dist/og.png` is 1200×630.
    620 - Network panel on `/catalog` — one `index-<hash>.json` ≈ 165 KB brotli, `immutable` cached.
    621 
    622 Once the Vercel project + DNS exist: `curl -sI https://lotl.daemon-sec.xyz/catalog/` → 308 to
    623 `/catalog`; `curl -sI …/data/index-<hash>.json` shows the immutable header; social card
    624 debugger renders the PNG; GitHub Pages returns 404 after T1.5.
    625 
    626 ## Progress log (first pass, 2026-09-04)
    627 
    628 Ticked as each item lands in the working tree. Nothing is committed — VCS is the user's.
    629 
    630 - [x] **T0.1** `scripts/technique-schema.mjs` (vocabulary + `TechniqueSchema` + `HttpUrl` +
    631   `validateTechniques()`), `scripts/validate-data.mjs` (schema, unique ids, tool↔technique
    632   integrity, facet-count consistency, taxonomy.ts drift). `build-dataset.mjs` now imports both;
    633   `zod` import dropped there. `npm run validate:data` → OK on the committed data. T5.2 landed
    634   with it (references must match `^https?://\S+$`, MITRE ids must match `^T\d{4}(\.\d{3})?$`).
    635 - [x] **T0.2** `test/_loadts.mjs` (esbuild → data: URL loader), `test/techniques.test.mjs`,
    636   `test/highlight.test.mjs`, `test/wadcoms.test.mjs` — 12 tests, all green via `npm test`.
    637 - [x] **T0.3** `.github/workflows/ci.yml` (quality + build jobs, canonical-slash sanity check)
    638   and `.github/workflows/upstream-drift.yml` (weekly `git ls-remote` vs `facets.commits`,
    639   opens/updates a labelled issue). `@astrojs/check` installed; `npm run check` added.
    640   `astro check` surfaced 11 pre-existing type errors (implicit `any`s in
    641   `astro.config.mjs` under `// @ts-check`, `event.key` on `Event` in `app.ts:73`, an
    642   untyped optional `exact` on the Header NAV items) — all fixed; now **0 errors**.
    643 - [x] **T1.1** `trailingSlash: 'never'` in `astro.config.mjs`; canonical in `Base.astro` strips
    644   the slash; Pagefind result hrefs stripped in `SearchModal.astro`. Rebuild + sitemap check
    645   pending.
    646 - [x] **T1.2** `Base.astro` now emits absolute `og:image` (PNG), `og:url`, `og:site_name`,
    647   `og:image:width/height/alt`, `twitter:title/description/image`. New `scripts/og.mjs`
    648   (`npm run og`) renders `public/og.png` 1200×630 (2× supersampled) from a DÆMONBins template
    649   with live counts from `facets.json`, using the site's own faces: the WOFF2s in `src/fonts`
    650   are decompressed to TTF into a temp dir (`woff2_decompress`, fonttools fallback) and served
    651   to sharp through a private fontconfig — sharp's bundled FreeType renders WOFF2 as tofu.
    652   Decision change vs. the plan: the PNG is **committed, not built** — Vercel's build image has
    653   different fonts and a card that differs per environment is worse than one that changes only
    654   on `npm run og`. `public/og.svg` is now the DÆMONBins template (was the cheatsheet's card).
    655 - [x] **T1.3** `Base.astro` pre-paint script falls back to `prefers-color-scheme` when no
    656   stored theme.
    657 - [x] **T3.2** SearchModal: opener remembered and refocused on close, Tab trapped inside the
    658   dialog, `aria-live="polite"` on the status line, cheatsheet-era labels ("Search
    659   cheatsheets", "DÆMON//SEC") corrected. (`role="listbox"` was already toggled dynamically in
    660   `write()` — the audit's "missing listbox" finding was wrong.)
    661 - [x] **T3.1** Tool pages: each technique title is an `<h2 class="tech__name">` (metrics
    662   preserved via `margin:0; line-height:1.3; text-transform:none`); "1 techniques" pluralisation
    663   fixed in both title meta and description. Home: the two section eyebrows are `<h2>`s with a
    664   scoped rule that keeps the `<p>` metrics, so the outline is h1 → h2 → h3.
    665 - [x] **T3.3** `src/lib/jsonld.ts` — `website()` (+ `SearchAction` → `/catalog?q=`),
    666   `dataset()` (GPL licence, `isBasedOn` the three upstreams with their pinned commit as
    667   `version`, `DataDownload` → `/data/techniques.json`), `collectionPage()`, `breadcrumbs()`,
    668   `techArticle()` (keywords = capabilities + platforms + aliases, `about` = MITRE ids with
    669   ATT&CK URLs, `isBasedOn` = upstream entry), `serialize()` escapes `<`. `Base.astro` takes
    670   a `jsonLd` prop and emits one inline `application/ld+json` script. Wired on home (WebSite +
    671   Dataset), /catalog (CollectionPage), /credits (Dataset), every tool page (TechArticle +
    672   BreadcrumbList).
    673 - [x] **T2.1 / T2.2 / T2.3 / T2.5** `src/scripts/catalog.ts` rewritten around one `apply()`
    674   path: facet toggles, NEW and Clear `pushState`; the debounced query `replaceState`s; a
    675   `popstate` handler re-reads the URL (removed on `astro:before-swap`). Chips are built once and
    676   their `aria-checked` is toggled in place, so keyboard focus stays on the chip you pressed.
    677   "Show more" appends the next 40 rows with `insertAdjacentHTML`, so open rows stay open. Each
    678   expander has `aria-controls` → the panel's `id`. The text query runs against a lowercase
    679   haystack precomputed once per record. `url()` is imported from `lib/url.ts`. The island no
    680   longer carries clipboard code: `src/scripts/copy.ts` is now a delegated document-level
    681   `[data-copy]` handler shared with the tool pages (rows carry `data-cmdbar`), and its fallback
    682   reports "failed" instead of claiming success when `execCommand` returns false.
    683   `catalog.astro`: `aria-live="polite"` on the result count. `astro check` 0 errors; build OK.
    684   **Verified end to end** in headless Chromium over the DevTools protocol against
    685   `npm run preview` (script: session scratchpad `e2e.mjs`, 32 checks): dark-OS first visit
    686   paints Night; home outline H1,H2,H2,H3×4; JSON-LD parses; `/` opens search, Esc restores
    687   focus to the opener; 40 rows render; Windows chip → `?p=Windows`, focus stays on the chip,
    688   count updates; second facet pushes a second entry; Back undoes one facet at a time and
    689   restores the count; Forward re-applies; typing → `q=` without pushing history; Clear;
    690   expander `aria-expanded`/`aria-controls` → panel; Show more → 80 rows with the first row
    691   still open; deep link `?p=Linux&c=File%20Read&new=1` applies on load; tool pages have h2s,
    692   slash-less canonical, absolute PNG `og:image`; Pagefind result hrefs carry no trailing
    693   slash; results container is a listbox. The only console noise is 404s for
    694   `/_vercel/insights/script.js` and `/_vercel/speed-insights/script.js`, which exist only on
    695   Vercel (PROD-gated in `Base.astro`) — expected under `astro preview`.
    696 - [x] **T1.4** `vercel.json`: `Cache-Control: immutable` for `/data/index-(.*).json` (the
    697   future hashed slim payload) and `max-age=600, must-revalidate` for the unhashed
    698   `/data/techniques.json`. Node pin is a Vercel project setting (user-side prerequisite #1).
    699 - [x] **T3.7** Micro-labels raised to 11px: `.src__tplat` (was 8.5px) on deck pages, the
    700   platform-counter caption and `.home-source__tag` (were 10–10.5px) on the home page.
    701 
    702 ### First pass — final gate (all green)
    703 
    704 ```
    705 npm run validate:data   → OK: 2885 techniques, 842 tools, 179 NEW
    706 npm test                → 12 tests, 12 pass, 0 fail
    707 npm run check           → 0 errors, 0 warnings
    708 npm run build           → 850 pages, Pagefind indexed 842
    709 ```
    710 
    711 Headless-Chromium e2e (32 checks): 31 pass; the one "fail" is two `/_vercel/*` script 404s that
    712 exist only on Vercel and are expected under local preview.
    713 
    714 ### Deferred to a second pass (unchanged from the backlog above)
    715 
    716 - **T3.4 + T4.1** — SSR the first 40 catalog rows + ship the slim hashed payload. The single
    717   biggest win and the riskiest hydration change; the shared `render-row.ts` extraction is now
    718   low-risk because the island is already refactored and unit-tested. Do these together.
    719 - **T1.5** Pages sunset · **T2.4** sort · **T3.5** per-facet landing pages · **T3.6** smarter
    720   404 · **T4.2** canvas pause + `initTOC` leak + `copy.ts` fallback (partly done: copy.ts now
    721   reports failure) · **T4.3** dead font/components · **T4.4** CSS coverage.
    722 - **Track 5 data** — T5.1 stable sort + dedup, T5.3 hard-fail on missing additions, T5.4
    723   GTFOBins description fallback, T5.5 freshness on /credits. (T5.2 URL/MITRE schema tightening
    724   landed with T0.1.) These need `vendor/` for a real `npm run data`; land them behind the
    725   one-shot migration pattern + unit tests.
    726 
    727 ### User-side prerequisites still open (cannot be done from code)
    728 
    729 1. Create the Vercel project (import the repo, preset Astro, Node 22.x), enable Analytics +
    730    Speed Insights.
    731 2. Add `lotl.daemon-sec.xyz` to the project and the DNS record — the domain does not resolve yet.
    732 3. After first deploy, sunset GitHub Pages (still live at the old URL with a self-canonical).