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).