diff --git a/CHANGELOG.md b/CHANGELOG.md index f2be68fc88..b2a7d7fbfc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,6 +18,15 @@ aggregate instead: an italic *Catalog* line at the end of the version section an ### Added +- **Product/UX audit 2026-07-08** (`agentic/audits/2026-07-08-product-ux.md`) — 8-auditor + workflow run scoped to pipeline, rating criteria, tabs, and product qualities; Health Score 43, + headlined by a critical crawler outage (every bot UA gets 502 from the `@seo_proxy` hop) and + quality-score calibration drift (#9616). +- **Legal page "other projects" block** — below the disclaimer, cross-linking the maintainer's + other projects kurrentschrift.ink and cite-citadel, tracked as `external_link` with + destinations `kurrentschrift` / `cite_citadel`; deliberately without `noreferrer` so the + target sites' analytics can attribute the traffic (#9616). + - **Project skill layer under `.claude/skills/`** — six skills, ported from the kurrentschrift and cite-citadel setups and adapted to anyplot: `verify-frontend` (browser-drive changed flows, both viewports × both themes, cloud playwright-core fallback probe), `verify-api` (read sweep + @@ -55,6 +64,13 @@ aggregate instead: an italic *Catalog* line at the end of the version section an - **Catalog curation: 13 interactive-first specs removed** — plot types whose value is inherently interactive don't fit a static-first catalog (#8645). +### Fixed + +- **All react-refresh ESLint warnings resolved** — `PALETTE` / `snippet()` extracted from + `PalettePage.tsx` into `PalettePage.helpers.ts` (following the `MapPage.helpers.ts` pattern), + and the `react-refresh/only-export-components` rule scoped off for test modules + (`*.test.{ts,tsx}`, `test-utils.tsx`), which never participate in Fast Refresh (#9616). + ## [3.0.0] — 2026-06-10 — Julia & JavaScript, 15 libraries & the Imprint palette anyplot 3.0 doubles the language count — Julia and JavaScript join Python and R, growing the diff --git a/agentic/audits/2026-07-08-product-ux.md b/agentic/audits/2026-07-08-product-ux.md new file mode 100644 index 0000000000..4b38a0677e --- /dev/null +++ b/agentic/audits/2026-07-08-product-ux.md @@ -0,0 +1,135 @@ +# Audit Report: anyplot + +**Date:** 2026-07-08 | **Scope:** product/UX (pipeline, rating criteria, tabs, functions, "interesting · minimalistic · beautiful · self-explanatory · fast · sensible") | **Mode:** full | **Engine:** workflow +**Health Score:** 43 | **Baseline:** ruff: 0 issues, format: clean (146 files) +**Auditors:** 8 ran (llm-pipeline, frontend, design, pagespeed, backend, seo, plausible, catalog) | **Findings:** 67 raw → 64 after dedup (57 actionable + 7 positive) | **Auto-fixable:** 2/57 (codemod), rest manual +**External sources:** +- Plausible site: anyplot.ai — Stats v1+v2, 30d window ending 2026-07-08 (full) +- PageSpeed: **blocked** — anonymous PSI quota 429 on 5/5 runs; no lab timestamps (5th consecutive audit) +- Search Console mode: structural-only (gcloud token lacks `webmasters.readonly` scope) +- Catalog: 324 specs / 3,261 metadata files (filesystem pass) + 7-URL GCS HEAD sample +- Not in scope this run: gcloud, github, security, infra, quality, db, observability, agentic auditors + +## Dimension Scorecard (how close to exemplary?) + +| Dimension | Grade | Score | Findings (C/H/M) | Biggest lever | +|---|---|---|---|---| +| Security | A+ | 100 | 0/0/0 | (limited by scope — security-auditor not in this run; 1 low: raw DB error text reaches clients) | +| Speed | A | 91 | 0/1/6 | Prewarm `/plots/filter` + `/specs/map` at startup — the two heaviest user-facing payloads are cold | +| Looks | A | 92 | 0/2/2 | Dark-mode styleOverrides for stock MUI components (tabs/dividers/skeletons fail contrast on dark) | +| Modern | A | 94 | 0/1/3 | Enrich the bot-serving HTML template — Google currently gets title+description only, no code | +| Correctness | B | 75 | 1/3/6 | **Fix the @seo_proxy 502 — every search/social crawler gets HTTP 502 on every page** | +| Maintainability | A | 91 | 0/0/9 | Resume targeted regen: 136 specs are still Python-only (70% of catalog below full coverage) | + +## Summary + +The product core is in genuinely strong shape — token-disciplined design system, exemplary caching architecture, 0 ruff issues, 0 null quality scores across 3,261 metadata files, and analytics that can actually answer product questions. Two things drag the score: a **critical crawler outage** (nginx routes every bot UA to a broken `@seo_proxy` hop → sustained 502s = de-facto noindex of the whole site) and a cluster of **"the 15-library expansion never propagated"** drift (rubric prompts, index.html/OG copy, plausible.md, debug library names all still describe the 9-Python-library era). Third theme, straight at your question: the **quality score no longer discriminates** (median 89 vs the rubric's own 72–78 target) and the **spec-detail tabs aren't self-explanatory** (all-collapsed on first visit, quality tab is a bare number). Shortest path to exemplary: fix the 502 today, then the two Quick Wins, then the dark-mode MUI overrides and the seo-proxy enrichment. + +## Quick Wins (Importance ≥4 & Effort=S) + +| # | Finding | Dim | Auto-fix | Files | Hint | +|---|---------|-----|----------|-------|------| +| 1 | Global Space/Enter/Backspace shortcuts on /plots fire even when a card/chip/toggle has keyboard focus | correctness | manual | `app/src/pages/PlotsPage.tsx`, `ImageCard.tsx` | Bail out when `e.target.closest('button,[role="button"],[tabindex],a')` in the window keydown handler | +| 2 | Gallery cold path not prewarmed: `/plots/filter` + `/specs/map` missing from startup prewarm/SWR | speed | manual | `api/main.py`, `api/routers/plots.py`, `specs.py` | Add `filter:all` + `_refresh_specs_map` to the `_prewarm_cache()` refreshers tuple | + +## Critical (Importance 5) + +| # | Finding | Dim | Effort | Auto-fix | Files | Hint | +|---|---------|-----|--------|----------|-------|------| +| 1 | Every search/social crawler gets HTTP 502 on every page — nginx `$is_bot` map routes to broken `@seo_proxy` TLS hop (verified on /, /plots, /about, spec + impl pages for googlebot/bingbot/twitterbot/discord/slack/whatsapp UAs; sustained 5xx = de-facto site-wide noindex) [CV:KEEP] | correctness | M | manual | `app/nginx.conf`, `api/routers/seo.py` | Reproduce in the container (`curl -A Googlebot`), check upstream SSL verify failure; likely CA bundle / `proxy_ssl_server_name on` fix. Then add the synthetic bot-UA check (see High #6 / Medium) | + +## High (Importance 4) + +| # | Finding | Dim | Effort | Auto-fix | Files | Hint | +|---|---------|-----|--------|----------|-------|------| +| 1 | Global keyboard shortcuts hijack focused cards/chips on /plots (Quick Win 1) [CV:KEEP] | correctness | S | manual | `PlotsPage.tsx`, `ImageCard.tsx` | Interactive-element bail-out in window keydown | +| 2 | Gallery cold path not prewarmed (Quick Win 2) [CV:KEEP] | speed | S | manual | `api/main.py`, `plots.py`, `specs.py` | Extend `_prewarm_cache()` refreshers | +| 3 | Quality score no longer discriminates: catalog median 89 vs rubric's own 72–78 target (3,261 scores: 87% ≥85, 1% <72; anti-inflation anchors demonstrably not applied; daily-regen picks oldest, not worst) [CV:KEEP] | correctness | L | manual | `prompts/quality-criteria.md`, `prompts/workflow-prompts/ai-quality-review.md`, `.github/workflows/daily-regen.yml` | (1) store first-review score next to final score, (2) add 2–3 image anchor few-shots ("this is a 70 / a 90"), (3) point daily-regen at lowest-scoring specs so the score becomes load-bearing | +| 4 | MUI palette locked to `mode:'light'` — stock MUI components fail contrast in dark theme (unselected tab labels ~2.1:1 on dark bg, dividers/skeletons/alerts wrong) [CV:KEEP] | looks | M | manual | `app/src/theme/palette.ts`, `theme/components.ts`, `SpecTabs` | styleOverrides from the CSS-var system: MuiTab → `var(--ink-soft)`, MuiDivider → `var(--rule)`, MuiSkeleton → `var(--rule)` | +| 5 | Spec-detail tabs not self-explanatory: all-collapsed on first visit (main content hidden), click-to-collapse has no affordance, quality tab is an unexplained bare number [CV:KEEP] | looks | M | manual | `app/src/sections/spec-detail/SpecTabs/index.tsx` | Default-open the Code tab in detail mode; drop or signal the collapse toggle; label "quality 87" or tooltip "87/100 ai review score" | +| 6 | Pages served to Googlebot are thin shells: title+description only — no code sample, no JSON-LD, no internal links (the site's actual value never reaches the index) [CV:KEEP] | modern | M | manual | `api/routers/seo.py`, `app/nginx.conf` | Enrich `BOT_HTML_TEMPLATE`: `
` code block, preview ``, BreadcrumbList + SoftwareSourceCode JSON-LD, hub↔impl `` links |
+| 7 | ~40–45% of recorded visitors are unfiltered crawler traffic (China 349/~830 visitors at 0.44 pv/visitor; headless-Chrome waves firing CWV without pageviews) — inflates visitors, breaks trend lines [CV:KEEP] | correctness | M | manual | `plausible:event/pageview`, `app/index.html`, nginx `/api/event` proxy | Drop datacenter-ASN / HeadlessChrome UAs at the event proxy; country exclusions in Plausible; filter CN/RU when reading history |
+
+## Medium (Importance 3)
+
+| # | Finding | Dim | Effort | Auto-fix | Files | Hint |
+|---|---------|-----|--------|----------|-------|------|
+| 1 | Stale 5-category checklist example in reviewer prompt corrupts stored review JSON (~6% of shipped review checklists shape-inconsistent: `library_features` vs `library_mastery`, stray keys) | correctness | S | manual | `prompts/workflow-prompts/ai-quality-review.md` | Rewrite the step-10 example to the canonical 6-category shape + "exactly these six keys"; optional one-off normalization of existing YAMLs |
+| 2 | `/libraries/{id}/images` loads the entire ~13MB code corpus of ALL libraries to serve one | speed | S | manual | `api/routers/libraries.py`, `core/database/repositories.py` | `get_by_library(...)` + `undefer(code)`, or delete the endpoint if no consumer; wrap in `get_or_set_cache` |
+| 3 | MCP tools return broken `website_url` `/python/{spec_id}` instead of `/{spec_id}` (3 call sites) | correctness | S | manual | `api/mcp/server.py` | Replace with `/{spec.id}`; unit-test URL shape against the sitemap tiers |
+| 4 | `/proxy/html` refetches interactive plot HTML from GCS per iframe load — fresh client, no cache, no Cache-Control | speed | S | manual | `api/routers/proxy.py`, `api/main.py` | Shared client + `get_or_set_cache` keyed on (url, origin) + add `/proxy/html` to cache-header whitelist |
+| 5 | Library display names hand-duplicated in 3 places; `/debug` already shows raw ids for the 4 JS libs | maintainability | S | manual | `api/routers/insights.py`, `debug.py`, `core/constants.py` | Derive `LIBRARY_NAMES` once from `LIBRARIES_METADATA` |
+| 6 | Stale "9 python libraries" copy across index.html meta/OG/Twitter/JSON-LD ×5 + LandingPage; head tags also duplicate (Helmet appends, doesn't replace shell defaults) *(merged: design + seo)* | modern | S | manual | `app/index.html`, `app/src/pages/LandingPage.tsx` | Update to 15-libraries/4-languages (or count-free copy); move head defaults into a root-layout Helmet block so per-route tags replace |
+| 7 | Nav vocabulary assumes insider knowledge: specs vs plots, `mcp`, `pal`/`libs` shorthands — no tooltips | looks | S | manual | `app/src/layouts/NavBar.tsx` | Title/Tooltip per NAV_LINK in the masthead comment style; keep the minimal aesthetic |
+| 8 | Cloudflare-managed robots.txt blocks all AI assistant crawlers (ClaudeBot, GPTBot, CCBot, Google-Extended `Disallow: /`) — overrides the `use=reference` content signal | modern | S | manual | Cloudflare dashboard, `app/public/robots.txt` | Deliberate decision: allow retrieval/citation crawlers, keep training blocks if desired |
+| 9 | PSI lab visibility blocked for the 5th consecutive audit — `PAGESPEED_API_KEY` never provisioned | speed | S | manual | `psi:*`, `agentic/audits/latest.md` | Free GCP API key with PSI API enabled → secret `PAGESPEED_API_KEY` |
+| 10 | `/plots` pageviews recorded as `/` — the #2 page is invisible in analytics (47 visitors, 0 pageviews in 30d) | correctness | S | manual | `app/src/hooks/useAnalytics.ts` | Keep the `/plots` prefix in `buildPlausibleUrl` for reserved routes that are real pages; `/debug` fires no pageview at all |
+| 11 | AR-06/08/09 score-0 auto-rejects collide with impl-review's score==0 "invalid output" path — legit rejections never reach impl-repair, land as `ai-review-failed` | correctness | M | manual | `.github/workflows/impl-review.yml`, rubric prompts | Distinct sentinel (`auto_reject.txt`) checked before the invalid-score branch, or map AR-rejects to score=1 |
+| 12 | Rubric prompts don't know 4 of 15 libraries exist: JS libs missing from workflow-active reviewer; SC-04 penalizes every JS impl; `${LANGUAGE}`/`${EXT}` never supplied; interactive-fairness rules live only in a file that never runs | maintainability | M | manual | `prompts/workflow-prompts/ai-quality-review.md`, `quality-criteria.md`, `quality-evaluator.md`, `impl-review.yml` | Pass LANGUAGE/EXT from `core/constants.py` as run vars; regenerate library lists; move interactive-fairness + per-language CQ guidance into the files that run |
+| 13 | FilterMenu arrow-key highlight never scrolls into view; search input lacks combobox ARIA | correctness | M | manual | `FilterMenu.tsx`, `FilterSearch.tsx` | `aria-activedescendant` + `scrollIntoView({block:'nearest'})` on highlight change |
+| 14 | Duplicated 50-line tag-chip rendering block in SpecTabs (spec tags vs impl tags) | maintainability | M | manual | `SpecTabs/index.tsx` | Extract `TagChipGroup` and render twice |
+| 15 | react-helmet-async is archived/unmaintained; React 19 hoists `/<meta>` natively | modern | M | codemod | `routes/index.tsx`, 4 pages, `useUrlSync.ts` | Mechanical: bare `<title>/<meta>` per page, drop HelmetProvider + dependency |
+| 16 | Hover-only card actions unreachable on touch and invisible to keyboard focus (ImageCard copy button opacity:0 but in tab order; SpecOverview action row same) *(merged: frontend + design)* | looks | M | manual | `ImageCard.tsx`, `SpecOverview.tsx` | Reveal on `:focus-visible`/`:focus-within`; `@media (hover:none)` low-opacity always; theme-adapt the white pill |
+| 17 | No synthetic monitoring of the bot-serving path (exactly how the 502 outage shipped and persisted) + no Search Console API access | maintainability | M | manual | `app/nginx.conf`, `.github/workflows/` | Scheduled action curling 2–3 prod URLs with Googlebot/Twitterbot UA, fail on non-200 or missing `<title>`; post-deploy + daily |
+| 18 | Usage reality: code tab + spec-detail copy ARE the product (all 29 copy_code from spec_detail); quality tab, random filter, grid quick-copy, feedback widget, POTD, map search/pin, og-sharing effectively dead in 30d | maintainability | M | manual | `plausible:event/tab_toggle`, `ImageCard.tsx`, `FeedbackWidget.tsx` | Optimize the code-tab/copy flow; surface or prune the dead periphery; check grid copy button actually renders |
+| 19 | New-spec creation nearly stalled: May 0, Jun 10, Jul 0 (vs Dec–Mar 27–159/mo) — "spannend" needs fresh subject matter | maintainability | M | manual | `plots/*/specification.yaml` | Light cadence: one themed batch of 5–10 spec requests/month (June's sports batch was the model) |
+| 20 | Last 3 old-design metadata files: line-stress-strain (bokeh, letsplot), heatmap-cohort-retention (plotnine) still single-preview, no dark variant | correctness | M | manual | `plots/line-stress-strain/metadata/...`, `plots/heatmap-cohort-retention/metadata/...` | Three targeted impl-generate runs finish the light/dark migration at 100% |
+| 21 | Field Web Vitals (human-only): LCP p75 right at the 2.5s boundary, FCP/TTFB p75 needs-improvement; raw dashboard numbers are bot-skewed 3× worse | speed | L | manual | `plausible:event/LCP`, `TTFB`, `app/index.html` | TTFB is the lever (Cloud Run region/cold starts, edge caching); re-measure after bot filtering lands |
+| 22 | Gallery has no list virtualization — DOM grows unbounded browsing the full catalog (~4,900 impls) | speed | L | manual | `ImagesGrid.tsx`, `useInfiniteScroll.ts` | react-virtuoso VirtuosoGrid; interim: derive tooltip state, cap batches |
+| 23 | Three monolithic pages (PalettePage 2027*, DebugPage 1898, MapPage 1292 lines) break the otherwise clean sections/ decomposition (*PalettePage.helpers.ts extraction landed in PR #9616 during this audit) | maintainability | L | manual | `PalettePage.tsx`, `DebugPage.tsx`, `MapPage.tsx` | Extract sections/palette, sections/map, sections/debug with co-located tests |
+| 24 | Flagship 3D/geo specs are the sparsest (6–7 impls, Python-only) and missing makie — the library that would shine there | maintainability | L | manual | `plots/scatter-3d/`, `surface-basic/`, 10 more | Targeted regen (makie, echarts, ggplot2) on the 3D cluster first; record legitimate per-library exclusions explicitly |
+| 25 | 70% of the catalog lacks the newer libraries — 136 specs still Python-only (8 impls), only ~65 at 14–15 | maintainability | XL | manual | `plots/`, `core/constants.py` | Resume babysit-pipeline systematically; burn down the 8/9/10-impl buckets with targeted (not `all`) regen |
+
+## Low (Importance 2)
+
+| # | Finding | Dim | Effort | Auto-fix | Files | Hint |
+|---|---------|-----|--------|----------|-------|------|
+| 1 | Tabs/TabPanel missing id/aria-controls wiring; click-to-collapse is nonstandard tabs semantics | correctness | S | manual | `SpecTabs/index.tsx`, `md.tsx` | Standard a11yProps helper |
+| 2 | NavBar search button loses focus indicator via `all:unset`; active link lacks `aria-current` | correctness | S | manual | `NavBar.tsx` | `:focus-visible` outline + `aria-current="page"` |
+| 3 | Motion/focus polish: `rise` + loader animations ignore `prefers-reduced-motion`; MapPage/DebugPage remove outlines without replacement | looks | S | manual | `tokens.css`, `LoaderSpinner.tsx`, `MapPage.tsx` | Extend the reduced-motion block; copy SpecDetailView's focus-visible pattern |
+| 4 | Hero stacks three competing taglines; centered "from .md to art." breaks the left-aligned rhythm | looks | S | manual | `HeroSection.tsx` | Keep one emotional tagline (typewriter), left-align the rest |
+| 5 | DatabaseQueryError reflects raw SQLAlchemy error text to API clients (contradicts the no-leak policy in the same file) | security | S | manual | `api/routers/plots.py`, `api/exceptions.py` | Log detail server-side; generic client message |
+| 6 | Cloud SQL async fallback wraps a sync pg8000 engine in async_sessionmaker — would 500 on first query if ever configured | correctness | S | manual | `core/database/connection.py` | Fail fast with RuntimeError in that branch |
+| 7 | Leftover `/hello/{name}` demo endpoint on the production API; three conflicting version strings | maintainability | S | manual | `api/routers/health.py` | Delete; source version from one constant |
+| 8 | Previous-gen model default (`claude-sonnet-4-6`) + dead Python-highcharts pattern block in local evaluator | modern | S | manual | `core/config.py`, `scripts/evaluate-plot.py` | Bump to claude-sonnet-5 after side-by-side; derive patterns from constants |
+| 9 | pagespeed-auditor brief's auth contract is factually wrong ("works without a key … plenty") — failed 5/5 runs | maintainability | S | manual | `agentic/commands/audit/pagespeed-auditor.md` | Rewrite: key required in practice; probe once, fail fast to blocked |
+| 10 | python.anyplot.ai nginx server block + docs exist but DNS doesn't resolve — dead config invites drift | maintainability | S | manual | `app/nginx.conf`, `docs/reference/seo.md` | Finish the launch or delete the block; one-line decision comment |
+| 11 | Cross-site links (now on /legal per maintainer decision): consider ONE contextual sentence on /about with a descriptive anchor for kurrentschrift; the rel handling (`noopener`, no `noreferrer`, followed) is right *(merged: design + seo; original footer-placement advice overtaken by events)* | modern | S | manual | `app/src/pages/AboutPage.tsx` | Optional follow-up; sitewide keyword-anchor links would look spammy — brand anchor is correct |
+| 12 | Ghost event `library_filter` with unregistered `framework` prop, undocumented (9 events/30d, values invisible in dashboards) | maintainability | S | manual | `LibrariesPage.tsx`, `docs/reference/plausible.md` | Rename into taxonomy or register+document the prop |
+| 13 | Outbound clicks double-tracked: auto "Outbound Link: Click" (33) overlaps custom `external_link` (12) | maintainability | S | manual | `app/index.html`, `docs/reference/plausible.md` | Pick one; document the survivor |
+| 14 | `search_no_results` shows real content gaps (backtest equity curve, arrow/flow map) but 200ms debounce leaks keystrokes | maintainability | S | manual | `FilterBar.tsx` | File the two spec requests; fire on ~1s idle or blur/enter |
+| 15 | plausible.md (updated 2026-04-25) + index.html drifted from the shipped product (missing nav_map, library_filter, /map, /debug; 9-library lists) | maintainability | S | manual | `docs/reference/plausible.md`, `app/index.html` | One doc-sync pass regenerating event tables from code |
+| 16 | Git-tracked strays in plots/: two ~200KB render PNGs (gitignore matches but never untracked), leaked `_run_plotly.py`, 5 stale .gitkeep | maintainability | S | manual | `plots/bar-basic/...`, `plots/contour-3d/...` | `git rm --cached`; grep automation/ before deleting the runner |
+| 17 | plot_type synonym drift: 6 singletons ('graph', 'curve', 'evaluation', …) where established tags exist (advisory facets policy-exempt, untouched) | maintainability | S | manual | 6 `specification.yaml` files | Normalize the synonyms only |
+| 18 | 118 specs have `updated: null`; date quoting inconsistent across generations of tooling | maintainability | S | codemod | `plots/*/specification.yaml` | One-shot codemod: `updated := created` where null; normalize quoting |
+| 19 | Duplicate-adjacent spec clusters (regression-linear/-polynomial/-lowess; sma/ema) read as variations of one page | maintainability | S | manual | `plots/scatter-regression-*/`, `plots/indicator-*ma/` | Cross-check traffic; consider merging weak siblings into variants |
+| 20 | evaluate-plot.py: no prompt caching (image-first ordering defeats it), tight max_tokens, freeform JSON instead of structured output | modern | M | manual | `scripts/evaluate-plot.py`, `core/config.py` | Rubric-first + cache_control, ~4000 max_tokens, json_schema output format |
+| 21 | `search_by_tags` matches via substring LIKE over stringified JSON — cross-category false positives, unindexable | correctness | M | manual | `core/database/repositories.py` | JSONB containment per category (`@>`) + optional GIN index |
+| 22 | Ten sub-70 quality-score impls sit approved, 7/10 are pygal faking contours/maps/trees it has no primitives for | correctness | M | manual | `plots/*/metadata/python/pygal.yaml` ×7, altair ×2, seaborn ×1 | Regenerate with opus per the migration playbook, or allow explicit per-library opt-outs |
+| 23 | useUrlSync bypasses React Router with raw `history.replaceState` — router location permanently stale on /plots, Back leaves the page | maintainability | M | manual | `useUrlSync.ts`, `useFilterState.ts` | Document as intentional or migrate to `useSearchParams(..., {replace:true})` |
+
+## Positive Patterns (Importance 1)
+
+- **Two-stage rubric + review-pipeline resilience** — 9-check auto-reject with false-positive guards, "correct but boring" cap, both-themes checklist, cascading review/repair. Reference this structure; don't redesign it (llm-pipeline).
+- **Frontend engineering baseline** — green strict tsc on app+test configs, ~zero `any`, 80+ co-located test files, all-lazy routes with route-scoped error boundaries, typed API layer (frontend).
+- **Token discipline & coherent terminal-editorial design language** — single CSS-var source, AA-checked ink scales, theme-adaptive previews, method-call microcopy (design).
+- **Caching architecture** — per-key stampede locks with lifecycle-bound pruning, SWR with self-owned sessions, startup prewarm, deferred multi-MB columns, batched sync (backend).
+- **Sitemap pipeline** — 324/324 specs, zero drift both directions, documented duplicate-content decisions (seo).
+- **Analytics instrumentation** — 31-event taxonomy with journey props that answered "what do visitors actually do" directly from live data (plausible).
+- **Metadata review layer** — 0 null quality_scores across 3,261 files, zero manual-merge bypasses detected, 100% consistent spec structure, all sampled previews live (catalog).
+
+## Statistics
+
+- Total: 57 actionable (+7 positive) | Critical: 1, High: 7, Medium: 25, Low: 23 (+1 medium became part of a merge)
+- By Dimension: security 1, speed 7, looks 6, modern 7, correctness 15, maintainability 21
+- Effort: S 31, M 20, L 5, XL 1
+- Auto-fix: codemod 2, manual 55
+- By Auditor (raw): llm-pipeline 7, frontend 11, design 9, pagespeed 2, backend 11, seo 8, plausible 9, catalog 10 — 3 cross-auditor merges applied
+- Cross-validation: 8 reviewed (all importance ≥4), 0 dropped, 0 downgraded — every high-severity finding survived an independent domain-expert refutation attempt
+- Coverage: 3 full (frontend, plausible, catalog), 4 partial/structural (llm-pipeline, design, backend, seo), 1 blocked (pagespeed — anonymous PSI quota; recurring, see Medium #9)
+
+## Cross-auditor themes (synthesis)
+
+1. **"The 15-library expansion never propagated to secondary surfaces"** — found independently by four auditors: rubric prompts (llm-pipeline M12), index.html/OG/JSON-LD copy (design+seo M6), plausible.md + event docs (plausible L15), debug/insights library names (backend M5). One sweep with `core/constants.py` as the single source closes all of them.
+- **Lab vs field Web Vitals divergence**: not computable — pagespeed blocked (5th time). Field-only verdict: interaction is fast (INP/CLS good), first paint is the weak spot, TTFB the lever.
+- **Deprecation candidates**: catalog's duplicate clusters (regression trio, sma/ema) should be cross-checked against per-spec traffic before merging — per-spec Plausible breakdown wasn't pulled this run.
diff --git a/agentic/audits/latest.md b/agentic/audits/latest.md
index 13ae76ecf6..4b38a0677e 100644
--- a/agentic/audits/latest.md
+++ b/agentic/audits/latest.md
@@ -1,160 +1,135 @@
 # Audit Report: anyplot
 
-**Date:** 2026-05-29 | **Scope:** all | **Mode:** full | **Engine:** workflow
-**Health Score:** 30 / 100 (floored — see note) | **Baseline:** ruff: 0 issues, format: clean (144 files)
-**Auditors:** 16 ran (backend, frontend, design, infra, quality, llm-pipeline, db, security, observability, agentic, gcloud, github, plausible, pagespeed, seo, catalog) | **Findings:** 86 unique (90 raw − 4 merged) | **Auto-fixable:** 4/86 (1 eslint, 3 codemod)
+**Date:** 2026-07-08 | **Scope:** product/UX (pipeline, rating criteria, tabs, functions, "interesting · minimalistic · beautiful · self-explanatory · fast · sensible") | **Mode:** full | **Engine:** workflow
+**Health Score:** 43 | **Baseline:** ruff: 0 issues, format: clean (146 files)
+**Auditors:** 8 ran (llm-pipeline, frontend, design, pagespeed, backend, seo, plausible, catalog) | **Findings:** 67 raw → 64 after dedup (57 actionable + 7 positive) | **Auto-fixable:** 2/57 (codemod), rest manual
 **External sources:**
-- GCP project: `anyplot` (acct meakeiok@gmail.com, region europe-west4) — gcloud-auditor
-- Plausible site: `anyplot.ai` (key via Secret Manager; Stats v1 GET + v2 POST, read-only) — plausible-auditor
-- PageSpeed: **blocked** — anonymous PSI quota exhausted (HTTP 429, no key); 0 lab runs, no analysisUTCTimestamp — pagespeed-auditor
-- Search Console mode: **structural-only** (gcloud token `ACCESS_TOKEN_SCOPE_INSUFFICIENT`) | freshness: n/a — seo-auditor
-- GitHub: `MarkusNeusinger` / `MarkusNeusinger/anyplot` — github-auditor
-- Catalog: 327 specs / 2903 python impl files (+84 R, +33 Julia metadata); GCS HEAD spot-check n=4 all 200 image/png — catalog-auditor
-
-> **Health-Score note:** 30 is the formula's hard floor (`100 − min(70, 10·crit + 3·high + 1·med)` with 1 crit + 9 high + 35 med = 72 → capped 70). The score is dominated by the **breadth of medium-severity exemplary gaps** (35 of them — doc drift, CI hygiene, catalog debt) plus the breadth of a 16-auditor sweep, **not** by a broken codebase: the build baseline is green (ruff 0, format clean, type-check clean), zero open Dependabot alerts, zero app errors in 7d, and 10 explicit positive patterns. **Trend:** the three prior all-scope runs (2026-04-26, 04-27, 05-05) also scored **30** — the aggregate is saturated for every full 16-auditor sweep, so the headline number carries no signal between runs. Read the **Dimension Scorecard** (mostly A/A-) as the instrument that actually moves; the single Critical and the Correctness B+ are the real levers.
+- Plausible site: anyplot.ai — Stats v1+v2, 30d window ending 2026-07-08 (full)
+- PageSpeed: **blocked** — anonymous PSI quota 429 on 5/5 runs; no lab timestamps (5th consecutive audit)
+- Search Console mode: structural-only (gcloud token lacks `webmasters.readonly` scope)
+- Catalog: 324 specs / 3,261 metadata files (filesystem pass) + 7-URL GCS HEAD sample
+- Not in scope this run: gcloud, github, security, infra, quality, db, observability, agentic auditors
 
 ## Dimension Scorecard (how close to exemplary?)
+
 | Dimension | Grade | Score | Findings (C/H/M) | Biggest lever |
 |---|---|---|---|---|
-| Security | A- | 85 | 1/1/2 | Gate `spec-create`/`report-validate` on `author_association` + fence the untrusted issue body (the only Critical) |
-| Speed | A | 94 | 0/1/3 | Cut TTFB (warm Cloud Run min-instances / cache the SEO-proxy HTML) → pulls FCP out of the "poor" bucket |
-| Looks | A | 91 | 0/1/6 | Make the MUI theme mode-aware from one token source → fixes white-menu + invisible-divider class at the root |
-| Modern | A | 93 | 0/2/1 | Emit JSON-LD + per-library meta on the **bot-proxy** path (Google currently sees neither) |
-| Correctness | B+ | 80 | 0/4/8 | Add frontend `tsc`/ESLint **and** `alembic check` to CI — two latent prod-breakers caught pre-merge |
-| Maintainability | A- | 85 | 0/0/15 | Close doc/registration drift (README libs, project-guide count, MCP `.mcp.json`, plausible docs) |
-
-> **Coverage caveats on grades:** Speed and Looks are **lab-blind** (PageSpeed quota exhausted → no Lighthouse CWV/a11y/bundle data); the Speed grade rests only on Plausible field RUM + code review. Modern's **field-search** half is unmeasured (Search Console scope insufficient) — the Modern grade is structural-only. No dimension scored A+ purely on absence of findings, so no coverage-inflated grades.
+| Security | A+ | 100 | 0/0/0 | (limited by scope — security-auditor not in this run; 1 low: raw DB error text reaches clients) |
+| Speed | A | 91 | 0/1/6 | Prewarm `/plots/filter` + `/specs/map` at startup — the two heaviest user-facing payloads are cold |
+| Looks | A | 92 | 0/2/2 | Dark-mode styleOverrides for stock MUI components (tabs/dividers/skeletons fail contrast on dark) |
+| Modern | A | 94 | 0/1/3 | Enrich the bot-serving HTML template — Google currently gets title+description only, no code |
+| Correctness | B | 75 | 1/3/6 | **Fix the @seo_proxy 502 — every search/social crawler gets HTTP 502 on every page** |
+| Maintainability | A | 91 | 0/0/9 | Resume targeted regen: 136 specs are still Python-only (70% of catalog below full coverage) |
 
 ## Summary
 
-anyplot is a **well-run repository with a green baseline and genuinely strong fundamentals** — clean lint/format/type-check, SHA-pinned actions, least-privilege workflow permissions, ORM-only SQL, reversible migrations, zero open CVEs in the frontend and zero app errors in production over 7 days. The headline risk is a single **Critical**: the public, auto-labeled `spec-create.yml` / `report-validate.yml` workflows feed a raw, attacker-controlled `${{ github.event.issue.body/title }}` straight into a `contents:write` Claude agent with no `author_association` gate (the repo already has the correct gate in `util-claude.yml` — apply that pattern). The weakest pillar is **Correctness (B+)**, driven by two latent prod-breakers that CI does not catch — the frontend has no `tsc`/ESLint gate (the exact PR #6961 failure mode CLAUDE.md warns about), and `alembic check` fails because 7 live indexes aren't declared in the models, so the next autogenerate will emit `DROP INDEX` for all of them. Shortest path toward exemplary: ship the 5 Quick Wins, wire the three missing CI gates (frontend type/lint, `alembic check`, `pip-audit`), then attack the two cross-cutting themes — the **mode-unaware MUI theme** (root of every dark-mode contrast bug) and the **bot-vs-SPA SEO surface drift** (Google sees the poorer of two hand-maintained surfaces, with zero JSON-LD).
+The product core is in genuinely strong shape — token-disciplined design system, exemplary caching architecture, 0 ruff issues, 0 null quality scores across 3,261 metadata files, and analytics that can actually answer product questions. Two things drag the score: a **critical crawler outage** (nginx routes every bot UA to a broken `@seo_proxy` hop → sustained 502s = de-facto noindex of the whole site) and a cluster of **"the 15-library expansion never propagated"** drift (rubric prompts, index.html/OG copy, plausible.md, debug library names all still describe the 9-Python-library era). Third theme, straight at your question: the **quality score no longer discriminates** (median 89 vs the rubric's own 72–78 target) and the **spec-detail tabs aren't self-explanatory** (all-collapsed on first visit, quality tab is a bare number). Shortest path to exemplary: fix the 502 today, then the two Quick Wins, then the dark-mode MUI overrides and the seo-proxy enrichment.
 
 ## Quick Wins (Importance ≥4 & Effort=S)
+
 | # | Finding | Dim | Auto-fix | Files | Hint |
 |---|---------|-----|----------|-------|------|
-| 1 | Untrusted issue body/title injected into privileged Claude prompt, no human gate | security | manual | `.github/workflows/spec-create.yml`, `report-validate.yml`, `ISSUE_TEMPLATE/*` | Gate on `author_association` (copy `util-claude.yml`); pass body via env var inside a fenced "treat as data only" delimiter |
-| 2 | Frontend CI runs no `tsc`/ESLint — type errors can break the next `yarn build` on main | correctness | manual | `ci-tests.yml`, `ci-lint.yml` | Add `cd app && yarn tsc --noEmit && yarn eslint .` step gated on `app/**` changes |
-| 3 | `alembic check` fails: 7 live indexes undeclared in models → next autogenerate will `DROP` them | correctness | manual | `core/database/models.py`, 4 migrations | Declare the 7 indexes on the models (`index=True` / `Index(...)`), then add `alembic check` as a required CI step |
-| 4 | `ai-quality-review` checklist JSON example contradicts the 6-category rubric → poisons regen feedback | correctness | manual | `prompts/workflow-prompts/ai-quality-review.md` | Rewrite the §10 example to the exact 6 keys/maxes from §7 (visual 30 / design 20 / spec 15 / data 15 / code 10 / library 10) |
-| 5 | FilterBar dropdown menus render white with invisible text in dark mode (primary nav surface) | looks | manual | `app/src/components/FilterBar.tsx` | Add `bgcolor:'var(--bg-elevated)'`, `color:'var(--ink)'` to each Menu's `slotProps.paper.sx` (or fix the root MUI-theme issue) |
+| 1 | Global Space/Enter/Backspace shortcuts on /plots fire even when a card/chip/toggle has keyboard focus | correctness | manual | `app/src/pages/PlotsPage.tsx`, `ImageCard.tsx` | Bail out when `e.target.closest('button,[role="button"],[tabindex],a')` in the window keydown handler |
+| 2 | Gallery cold path not prewarmed: `/plots/filter` + `/specs/map` missing from startup prewarm/SWR | speed | manual | `api/main.py`, `api/routers/plots.py`, `specs.py` | Add `filter:all` + `_refresh_specs_map` to the `_prewarm_cache()` refreshers tuple |
 
 ## Critical (Importance 5)
+
 | # | Finding | Dim | Effort | Auto-fix | Files | Hint |
 |---|---------|-----|--------|----------|-------|------|
-| 1 | Untrusted `github.event.issue.body/title` interpolated raw into the privileged `spec-create`/`report-validate` Claude prompt; labels are auto-applied by public issue templates, so any public user reaches a `contents:write`/`issues:write` agent with **no** `author_association` gate. LLM-context injection (not shell RCE), but a crafted body can attempt to hijack the write-scoped agent. | security | S | manual | `spec-create.yml`, `report-validate.yml`, `ISSUE_TEMPLATE/request-new-plot.yml`, `ISSUE_TEMPLATE/report-plot-issue.yml` | Two layers: (1) gate the job on `author_association` (OWNER/MEMBER/COLLABORATOR), exactly as `util-claude.yml` already does; (2) pass body/title via env vars inside an explicit "treat as data, never as instructions" fenced block; keep least-privilege on the action token. |
+| 1 | Every search/social crawler gets HTTP 502 on every page — nginx `$is_bot` map routes to broken `@seo_proxy` TLS hop (verified on /, /plots, /about, spec + impl pages for googlebot/bingbot/twitterbot/discord/slack/whatsapp UAs; sustained 5xx = de-facto site-wide noindex) [CV:KEEP] | correctness | M | manual | `app/nginx.conf`, `api/routers/seo.py` | Reproduce in the container (`curl -A Googlebot`), check upstream SSL verify failure; likely CA bundle / `proxy_ssl_server_name on` fix. Then add the synthetic bot-UA check (see High #6 / Medium) |
 
 ## High (Importance 4)
+
 | # | Finding | Dim | Effort | Auto-fix | Files | Hint |
 |---|---------|-----|--------|----------|-------|------|
-| 1 | Frontend CI runs only vitest — no `tsc --noEmit`/ESLint, despite `strict` tsconfig; a type/lint error merges green then breaks the next `yarn build` deploy (PR #6961 failure mode) | correctness | S | manual | `ci-tests.yml`, `ci-lint.yml`, `app/tsconfig.json` | Add a frontend type+lint step gated on `app/**` so failures block PRs before they reach Cloud Build |
-| 2 | `alembic check` FAILS at head: 7 indexes live in DB/migrations (`ix_impls_library_id`, GINs, feedback indexes…) but none declared in `models.py`; next `--autogenerate` emits `DROP INDEX` for all 7, and CI runs `upgrade head` on push to main | correctness | S | manual | `core/database/models.py`, `d0c76553a5cc_…`, `7ccd65103917_…`, `a2f4b8c91d23_…`, `c5d7e9f1a3b2_…` | Declare all 7 indexes on the models; add `uv run alembic check` as a required CI step |
-| 3 | `ai-quality-review.md` §10 `review_checklist.json` example uses category keys/maxes that contradict the authoritative §7 rubric (visual 40≠30, missing `design_excellence`, `library_features`≠`library_mastery`); `update_metadata.py` persists it and `impl-generate` reads it back into the regen prompt → degraded repair loop | correctness | S | manual | `prompts/workflow-prompts/ai-quality-review.md`, `impl-review.yml`, `impl-generate.yml` | Rewrite the §10 example to the exact 6 keys/maxes from §7 |
-| 4 | Both Cloud Run services run as the **default Compute SA bound to `roles/editor`** project-wide — a compromised request handler inherits Editor over the whole project; purpose-built scoped SAs already exist but aren't applied to runtime | security | M | manual | `gcp:run/services/anyplot-api`, `anyplot-app`, `gcp:iam/.../239660669828-compute@…` | Create a dedicated runtime SA per service (cloudsql.client + bucket-scoped storage + specific secretAccessor); grant the deploy SA `run.admin` for the invoker-policy step |
-| 5 | **No alerting policies and no uptime checks** on a live public site backed by Cloud Run + Cloud SQL — an outage/5xx-spike/DB-down goes unnoticed until a human notices | correctness | M | manual | `gcp:monitoring/alertPolicies`, `gcp:monitoring/uptimeChecks` | Add an HTTPS uptime check + Cloud Run 5xx/latency alerts + Cloud SQL availability/disk alerts, wired to a notification channel; commit as Terraform |
-| 6 | Field CWV (Plausible RUM, 30d): **FCP p75 in "poor"**, LCP & TTFB p75 in "needs-improvement" (CLS/INP fine) — slow first byte + first paint, likely cold start / SEO-proxy on critical path | speed | L | manual | `plausible:event/FCP,LCP,TTFB`, `app/src/analytics/reportWebVitals.ts` | Attack TTFB first (warm Cloud Run min-instances / cache SEO-proxy HTML); FCP follows; re-check rating breakdown |
-| 7 | Google receives **ZERO structured data**: the 3 valid JSON-LD schemas live only in `app/index.html` (browser-UA path); the nginx bot proxy (`seo.py` `BOT_HTML_TEMPLATE`) emits no JSON-LD and no SPA-renderable shell, so Googlebot sees none — and ~3000 plot pages have no `CreativeWork`/`BreadcrumbList` even in the SPA | modern | M | manual | `api/routers/seo.py`, `app/index.html` | Embed WebSite/Organization/WebApplication JSON-LD into `BOT_HTML_TEMPLATE`; emit per-page `SoftwareSourceCode`+`BreadcrumbList` in the spec/impl handlers |
-| 8 | Identical meta description (and near-identical title) across all 10 library variants of every spec → ~11 near-duplicate URLs per spec across ~3000 pages, a thin-variant signal that suppresses indexation | modern | M | manual | `api/routers/seo.py` | Give impl pages a library-specific description (interpolate library + angle); keep the hub description as canonical |
-| 9 | FilterBar dropdown menus render white with invisible muted text (~2.3:1) in dark mode — the site's primary catalog-navigation control | looks | S | manual | `app/src/components/FilterBar.tsx`, `app/src/main.tsx` | Add `bgcolor:'var(--bg-elevated)'`/`color:'var(--ink)'` to each Menu's `slotProps.paper.sx`; better, fix the root MUI-theme issue |
+| 1 | Global keyboard shortcuts hijack focused cards/chips on /plots (Quick Win 1) [CV:KEEP] | correctness | S | manual | `PlotsPage.tsx`, `ImageCard.tsx` | Interactive-element bail-out in window keydown |
+| 2 | Gallery cold path not prewarmed (Quick Win 2) [CV:KEEP] | speed | S | manual | `api/main.py`, `plots.py`, `specs.py` | Extend `_prewarm_cache()` refreshers |
+| 3 | Quality score no longer discriminates: catalog median 89 vs rubric's own 72–78 target (3,261 scores: 87% ≥85, 1% <72; anti-inflation anchors demonstrably not applied; daily-regen picks oldest, not worst) [CV:KEEP] | correctness | L | manual | `prompts/quality-criteria.md`, `prompts/workflow-prompts/ai-quality-review.md`, `.github/workflows/daily-regen.yml` | (1) store first-review score next to final score, (2) add 2–3 image anchor few-shots ("this is a 70 / a 90"), (3) point daily-regen at lowest-scoring specs so the score becomes load-bearing |
+| 4 | MUI palette locked to `mode:'light'` — stock MUI components fail contrast in dark theme (unselected tab labels ~2.1:1 on dark bg, dividers/skeletons/alerts wrong) [CV:KEEP] | looks | M | manual | `app/src/theme/palette.ts`, `theme/components.ts`, `SpecTabs` | styleOverrides from the CSS-var system: MuiTab → `var(--ink-soft)`, MuiDivider → `var(--rule)`, MuiSkeleton → `var(--rule)` |
+| 5 | Spec-detail tabs not self-explanatory: all-collapsed on first visit (main content hidden), click-to-collapse has no affordance, quality tab is an unexplained bare number [CV:KEEP] | looks | M | manual | `app/src/sections/spec-detail/SpecTabs/index.tsx` | Default-open the Code tab in detail mode; drop or signal the collapse toggle; label "quality 87" or tooltip "87/100 ai review score" |
+| 6 | Pages served to Googlebot are thin shells: title+description only — no code sample, no JSON-LD, no internal links (the site's actual value never reaches the index) [CV:KEEP] | modern | M | manual | `api/routers/seo.py`, `app/nginx.conf` | Enrich `BOT_HTML_TEMPLATE`: `<pre>` code block, preview `<img>`, BreadcrumbList + SoftwareSourceCode JSON-LD, hub↔impl `<a>` links |
+| 7 | ~40–45% of recorded visitors are unfiltered crawler traffic (China 349/~830 visitors at 0.44 pv/visitor; headless-Chrome waves firing CWV without pageviews) — inflates visitors, breaks trend lines [CV:KEEP] | correctness | M | manual | `plausible:event/pageview`, `app/index.html`, nginx `/api/event` proxy | Drop datacenter-ASN / HeadlessChrome UAs at the event proxy; country exclusions in Plausible; filter CN/RU when reading history |
 
 ## Medium (Importance 3)
+
 | # | Finding | Dim | Effort | Auto-fix | Files | Hint |
 |---|---------|-----|--------|----------|-------|------|
-| 1 | Public compute-heavy endpoints (`/og/*` PIL render + 1-6 GCS fetches, `/proxy/html` GCS fetch) have **no rate limit** — iterating fresh spec_ids bypasses cache → cheap DoS / cost-amplification on Cloud Run | security | M | manual | `api/routers/og_images.py`, `proxy.py`, `main.py` | Add a per-IP limiter (slowapi or Cloud Armor) on `/og/*` and `/proxy/html`; reuse feedback.py's x-forwarded-for logic |
-| 2 | `starlette 0.49.3` carries PYSEC-2026-161 / CVE-2026-48710 (fix 1.0.1); shipped transitively on the public ASGI surface; no `pip-audit` CI gate (CVSS 6.5 MEDIUM, no path-based surface in app → downgraded from High) | security | M | manual | `pyproject.toml`, `uv.lock` | Bump starlette ≥1.0.1 (major bump — verify FastAPI compat, rerun api tests); add `pip-audit` to CI |
-| 3 | `init_db()` wraps a **sync** pg8000 engine in `async_sessionmaker(class_=AsyncSession)` on the `INSTANCE_CONNECTION_NAME`-only path → first `await execute` fails; dead fallback today (prod uses `DATABASE_URL`) but a latent trap that fails confusingly instead of loudly | correctness | M | manual | `core/database/connection.py` | Delete the branch and fail-fast on missing `DATABASE_URL`, or build a real async Cloud SQL connector |
-| 4 | `SpecPage` spec-fetch effect has no cancellation guard → rapid nav can let a stale response clobber newer state; the `cancelled`-flag idiom already exists in `usePlotOfTheDay`/`RelatedSpecs` | correctness | S | manual | `app/src/pages/SpecPage.tsx` | Add `let cancelled=false`, gate every setState, return a cleanup that flips it |
-| 5 | `--code-constant` syntax token fails WCAG AA on the code surface in **both** themes (dark 2.71:1, light 2.82:1); real but rarer than first rated — fires only on decorators/Julia constants, not True/False/None (those map to `--code-number`, which passes) → downgraded from High | looks | S | manual | `app/src/styles/tokens.css` | Pick AA-passing constant hues (e.g. dark→lime 7.3:1, light→darker rose 6.1:1) |
-| 6 | MUI theme is permanently `mode:'light'` (module-level const, never consumes `useThemeMode`); dark mode hand-rolled per component via CSS vars — any reliance on a MUI default (background.paper, divider) leaks light styling into dark. **Root cause** of the menu/divider/rgba findings | looks | M | manual | `app/src/main.tsx`, `SpecDetailView.tsx`, `useThemeMode.ts` | Memoize `createTheme({palette:{mode:isDark?…}})`; remove per-component `isDark` ternaries |
-| 7 | Four `<Divider>` in the filter menus have no color override → render as MUI default near-black hairline, invisible on the dark page | looks | S | manual | `app/src/components/FilterBar.tsx` | Set `borderColor:'var(--rule)'` (or override `palette.divider` per mode) |
-| 8 | Hardcoded `rgba(255,255,255,…)` floating surfaces (loading pill, image-overlay buttons) don't adapt to dark mode; `SpecDetailView` already shows the correct adaptive pattern | looks | S | manual | `app/src/components/ImagesGrid.tsx`, `ImageCard.tsx` | Replace white literals with `var(--bg-elevated)`/`var(--ink)` or mirror SpecDetailView's `isDark` overlay style |
-| 9 | Exemplary gap: no single source of truth unifying the CSS-var tokens, the TS `theme/index.ts` constants, and the MUI `createTheme` palette — three uncoordinated sources; the drift is what produces the dark-mode defects | looks | M | manual | `app/src/theme/index.ts`, `styles/tokens.css`, `main.tsx` | Adopt MUI v9 CSS theme variables with `colorSchemes:{light,dark}` keyed off `[data-theme]`; retire duplicated hexes |
-| 10 | Stale brand `pyplots.ai` baked into ~879 implementation `.py` titles/docstrings across ~195 specs (~60% of catalog) — the watermark is visible in the rendered GCS PNGs, so the public gallery shows the old brand | looks | L | codemod | `plots/*/implementations/python/*.py` | Codemod `pyplots.ai`→`anyplot.ai` in title/docstring lines, then regenerate affected previews via the impl pipeline |
-| 11 | `cors_origins` is defined in `core/config.py` (incl. `www.anyplot.ai`) but never read — `main.py` hardcodes `allow_origins=['https://anyplot.ai']`; config drift that falsely implies env-configurability and silently CORS-blocks www | maintainability | S | manual | `core/config.py`, `api/main.py` | Wire CORSMiddleware to `settings.cors_origins`, or delete the unused field |
-| 12 | `ignore_errors=true` over the **highest-value** backend modules — DB layer, MCP server, `api.main`, all routers — so mypy gives no safety on the code most likely to cause incidents | maintainability | L | manual | `pyproject.toml` | Burn down the override list module-by-module (start `core.database.repositories`, `api.routers.*`); track in an issue |
-| 13 | README is stale: omits Julia/Makie, claims "10 libraries", no Julia/Makie tech-stack row — contradicts `core/constants.py` (11 libs, 3 languages) and `docs/reference/repository.md` | maintainability | S | manual | `README.md`, `core/constants.py` | Update to "11 libraries across Python, R, and Julia"; add Makie |
-| 14 | Missing community-health files (rolled up): `CONTRIBUTING.md` (root), `CODE_OF_CONDUCT.md`, `.github/pull_request_template.md`, `.editorconfig`, `CHANGELOG.md` — present & good: LICENSE, SECURITY.md, ISSUE_TEMPLATEs, dependabot, badges | maintainability | M | manual | (repo root / `.github/`) | Add the 5 files; root CONTRIBUTING.md can be a thin pointer to `docs/contributing.md` |
-| 15 | `core/palette.py` (the canonical design-system color module, consumed by `core/images.py` for every preview) has **zero** unit tests — a token regression silently degrades every rendered image | correctness | M | manual | `core/palette.py`, `tests/unit/core/` | Add `test_palette.py` (neutral/muted/diverging, `__getattr__` dispatch, `register_with_matplotlib`) |
-| 16 | Agentic pipeline phase scripts (`plan.py` 349L, `review.py` 325L, `ship.py`, `prompt.py`, `document.py`) have zero direct unit tests though CLAUDE.md treats the pipeline as mandatory infra | correctness | L | manual | `agentic/workflows/*.py` | Test the pure logic (CLI parse, state transitions, phase gating); mock `prompt_claude_code_with_retry` |
-| 17 | Node version drift: frontend CI tests on Node 24 but Docker builds/deploys on Node 20 (`engines:>=20`) — tests pass on a different runtime than ships | correctness | S | manual | `ci-tests.yml`, `app/Dockerfile`, `app/package.json` | Pin one Node LTS across CI/Dockerfile/engines (e.g. via `.nvmrc`) |
-| 18 | `SpecRepository.search_by_tags` casts JSONB→text + `LIKE '%"tag"%'`, which a GIN index can never serve → the `ix_specs_tags` GIN index is dead (seq scan); f-string builds only the bind value so **not** SQLi | speed | M | manual | `core/database/repositories.py`, `7ccd65103917_…` | Use a JSONB containment/existence predicate (`@>`, `jsonb_path_exists`) so the GIN index is used; or pg_trgm for true substring |
-| 19 | No cache hit/miss observability: `api/cache.py` `get_cache_stats()` exposes only size/ttl, no hit-rate — can't tune TTLs or detect a cache that quietly stopped helping (rubric asks for observable hit-rate) | speed | M | manual | `api/cache.py` | Add `_hits/_misses` counters; surface `hit_rate` in `get_cache_stats()` and `/debug/cache` |
-| 20 | PageSpeed lab audit blocked by exhausted **anonymous** PSI quota (429) — 4th consecutive audit blocked by the identical root cause; the fix (provision a key) was prescribed in prior reports and never actioned → anyplot has zero lab CWV visibility | speed | S | manual | `psi:https://anyplot.ai/*` | Create a GCP API key with PSI enabled, store as repo secret `PAGESPEED_API_KEY`, export into the audit env |
-| 21 | LLM calls discard captured cost/latency/turn metrics — the CLI result JSONL carries `duration_ms`/`num_turns`/`total_cost_usd` but `agent.py` parses only session_id/result; no log line. Can't answer "what did this run cost / which calls are slow" | maintainability | S | manual | `agentic/workflows/modules/agent.py` | Log a structured line per call (agent, model, duration, turns, cost, retry); aggregate cost in WorkflowState |
-| 22 | MCP servers (Serena, Context7) are mandated by CLAUDE.md and allow-listed in committed `settings.json`, but enablement lives **only** in gitignored `settings.local.json` — a fresh clone gets the mandate + allow-list for tools that don't exist | maintainability | S | manual | `CLAUDE.md`, `.claude/settings.json` | Commit a `.mcp.json` defining the anyplot/serena/context7 servers; keep only secrets local |
-| 23 | Broken `@`-reference in `prime-deep.md`: `@agentic/commands/context.md` does not exist → silent missing include on every `/prime-deep` | maintainability | S | manual | `agentic/commands/prime-deep.md` | Create the file, repoint the ref, or delete the stale line |
-| 24 | Serena tool names disagree between two self-described-canonical docs: CLAUDE.md uses `jet_brains_*`, prime-deep.md does not — an agent following one calls a name the other says is wrong | maintainability | S | manual | `CLAUDE.md`, `agentic/commands/prime-deep.md` | Verify against the live MCP registration; make both docs state identical names |
-| 25 | `project-guide.md` library count internally inconsistent: header "(10 total)/Python (9)" but body + line 696 say 11; the canonical architecture doc CLAUDE.md points agents to | maintainability | S | manual | `agentic/docs/project-guide.md` | Fix to "(11 total)"; reconcile the Python/R/Julia grouping |
-| 26 | `anyplot-images` bucket has no lifecycle policy → `staging/` blobs accumulate forever (steady cost creep) | maintainability | S | manual | `gcp:storage/buckets/anyplot-images` | Add a lifecycle rule: delete `staging/` objects older than 14-30d; manage as code |
-| 27 | 4 spec issues carry full `impl:*:done` labels but stay open 137-142d (#3550/#3344/#3293/#3783); #3783 still has stale `spec-request` — CLAUDE.md lists "issues open when complete" as a pipeline-failure symptom | maintainability | S | manual | `gh:issues/3550,3344,3293,3783` | Verify impl-merge's close step; close them; strip stale `spec-request` |
-| 28 | Bot-vs-SPA SEO drift: two independently hand-maintained surfaces (SPA `index.html` rich vs `seo.py` proxy bare); nginx routes Google to whichever is **poorer**, no parity test. Root cause of the JSON-LD + meta-desc + homepage-title findings | maintainability | M | manual | `api/routers/seo.py`, `app/index.html` | Derive both from one shared meta/JSON-LD builder (or SSR the head for bots); add a parity test |
-| 29 | Plausible doc drift: `feedback_opened`/`feedback_submitted` fire in prod but are absent from the Goals/Reference/Checklist registries; FCP/TTFB missing from the `rating` used-by line and Goals table; "Total: 31 events" header is stale | maintainability | S | manual | `docs/reference/plausible.md` | Add the missing events to Goals/Reference/rating-line; refresh the total count |
-| 30 | Metadata schema fork: 697 legacy single-preview files (no `language`, no `/python/` path, no dark preview) vs 2323 current theme-split files — migration debt; DB sync + frontend must handle two shapes and the legacy cohort lacks dark previews | maintainability | L | codemod | `plots/*/metadata/python/*.yaml` | Backfill legacy files to the theme-split schema via codemod + one-off dark render; or normalize both shapes in sync/frontend |
-| 31 | `contour-3d/plotly.py` is an `exec()` shim into an orphan `_run_plotly.py` that strips `sys.path` — the only non-self-contained impl, no metadata counterpart; brittle one-off for the package-name-shadowing hazard | maintainability | M | manual | `plots/contour-3d/implementations/python/plotly.py`, `_run_plotly.py` | Regenerate as a single self-contained file; delete the orphan; consider a stable import shim |
-| 32 | 21 `SetIamPolicy` permission-denied audit errors in 7d (the only Cloud Run ERRORs) — the deploy's public-invoker step lacks the permission (Editor excludes `run.services.setIamPolicy`) and fails silently every deploy | correctness | S | manual | `gcp:run/services/anyplot-api` | Grant the deploy SA `run.admin`, or drop the redundant SetIamPolicy step (services already public) |
-| 33 | `og_image_view` platform detection misses ~85% of bots (`platform=unknown` dominates: 114/134) — `PLATFORM_PATTERNS` don't match the UAs reaching the endpoint (likely nginx UA normalization); defeats the point of server-side og tracking | correctness | M | manual | `api/analytics.py`, `plausible:event/og_image_view` | Log a sample of raw "unknown" UAs, confirm what nginx forwards, refresh the patterns |
-| 34 | `potd_dismiss` is wired (`PlotOfTheDay.tsx:57`) and registered as a goal but has fired **0 times all-time** — points to a wiring/render problem (banner not reached, sessionStorage gate, or handler short-circuit) | correctness | S | manual | `app/src/components/PlotOfTheDay.tsx` | Clear `potd_dismissed` sessionStorage, click dismiss, confirm the event reaches Plausible; trace the onClick order |
+| 1 | Stale 5-category checklist example in reviewer prompt corrupts stored review JSON (~6% of shipped review checklists shape-inconsistent: `library_features` vs `library_mastery`, stray keys) | correctness | S | manual | `prompts/workflow-prompts/ai-quality-review.md` | Rewrite the step-10 example to the canonical 6-category shape + "exactly these six keys"; optional one-off normalization of existing YAMLs |
+| 2 | `/libraries/{id}/images` loads the entire ~13MB code corpus of ALL libraries to serve one | speed | S | manual | `api/routers/libraries.py`, `core/database/repositories.py` | `get_by_library(...)` + `undefer(code)`, or delete the endpoint if no consumer; wrap in `get_or_set_cache` |
+| 3 | MCP tools return broken `website_url` `/python/{spec_id}` instead of `/{spec_id}` (3 call sites) | correctness | S | manual | `api/mcp/server.py` | Replace with `/{spec.id}`; unit-test URL shape against the sitemap tiers |
+| 4 | `/proxy/html` refetches interactive plot HTML from GCS per iframe load — fresh client, no cache, no Cache-Control | speed | S | manual | `api/routers/proxy.py`, `api/main.py` | Shared client + `get_or_set_cache` keyed on (url, origin) + add `/proxy/html` to cache-header whitelist |
+| 5 | Library display names hand-duplicated in 3 places; `/debug` already shows raw ids for the 4 JS libs | maintainability | S | manual | `api/routers/insights.py`, `debug.py`, `core/constants.py` | Derive `LIBRARY_NAMES` once from `LIBRARIES_METADATA` |
+| 6 | Stale "9 python libraries" copy across index.html meta/OG/Twitter/JSON-LD ×5 + LandingPage; head tags also duplicate (Helmet appends, doesn't replace shell defaults) *(merged: design + seo)* | modern | S | manual | `app/index.html`, `app/src/pages/LandingPage.tsx` | Update to 15-libraries/4-languages (or count-free copy); move head defaults into a root-layout Helmet block so per-route tags replace |
+| 7 | Nav vocabulary assumes insider knowledge: specs vs plots, `mcp`, `pal`/`libs` shorthands — no tooltips | looks | S | manual | `app/src/layouts/NavBar.tsx` | Title/Tooltip per NAV_LINK in the masthead comment style; keep the minimal aesthetic |
+| 8 | Cloudflare-managed robots.txt blocks all AI assistant crawlers (ClaudeBot, GPTBot, CCBot, Google-Extended `Disallow: /`) — overrides the `use=reference` content signal | modern | S | manual | Cloudflare dashboard, `app/public/robots.txt` | Deliberate decision: allow retrieval/citation crawlers, keep training blocks if desired |
+| 9 | PSI lab visibility blocked for the 5th consecutive audit — `PAGESPEED_API_KEY` never provisioned | speed | S | manual | `psi:*`, `agentic/audits/latest.md` | Free GCP API key with PSI API enabled → secret `PAGESPEED_API_KEY` |
+| 10 | `/plots` pageviews recorded as `/` — the #2 page is invisible in analytics (47 visitors, 0 pageviews in 30d) | correctness | S | manual | `app/src/hooks/useAnalytics.ts` | Keep the `/plots` prefix in `buildPlausibleUrl` for reserved routes that are real pages; `/debug` fires no pageview at all |
+| 11 | AR-06/08/09 score-0 auto-rejects collide with impl-review's score==0 "invalid output" path — legit rejections never reach impl-repair, land as `ai-review-failed` | correctness | M | manual | `.github/workflows/impl-review.yml`, rubric prompts | Distinct sentinel (`auto_reject.txt`) checked before the invalid-score branch, or map AR-rejects to score=1 |
+| 12 | Rubric prompts don't know 4 of 15 libraries exist: JS libs missing from workflow-active reviewer; SC-04 penalizes every JS impl; `${LANGUAGE}`/`${EXT}` never supplied; interactive-fairness rules live only in a file that never runs | maintainability | M | manual | `prompts/workflow-prompts/ai-quality-review.md`, `quality-criteria.md`, `quality-evaluator.md`, `impl-review.yml` | Pass LANGUAGE/EXT from `core/constants.py` as run vars; regenerate library lists; move interactive-fairness + per-language CQ guidance into the files that run |
+| 13 | FilterMenu arrow-key highlight never scrolls into view; search input lacks combobox ARIA | correctness | M | manual | `FilterMenu.tsx`, `FilterSearch.tsx` | `aria-activedescendant` + `scrollIntoView({block:'nearest'})` on highlight change |
+| 14 | Duplicated 50-line tag-chip rendering block in SpecTabs (spec tags vs impl tags) | maintainability | M | manual | `SpecTabs/index.tsx` | Extract `TagChipGroup` and render twice |
+| 15 | react-helmet-async is archived/unmaintained; React 19 hoists `<title>/<meta>` natively | modern | M | codemod | `routes/index.tsx`, 4 pages, `useUrlSync.ts` | Mechanical: bare `<title>/<meta>` per page, drop HelmetProvider + dependency |
+| 16 | Hover-only card actions unreachable on touch and invisible to keyboard focus (ImageCard copy button opacity:0 but in tab order; SpecOverview action row same) *(merged: frontend + design)* | looks | M | manual | `ImageCard.tsx`, `SpecOverview.tsx` | Reveal on `:focus-visible`/`:focus-within`; `@media (hover:none)` low-opacity always; theme-adapt the white pill |
+| 17 | No synthetic monitoring of the bot-serving path (exactly how the 502 outage shipped and persisted) + no Search Console API access | maintainability | M | manual | `app/nginx.conf`, `.github/workflows/` | Scheduled action curling 2–3 prod URLs with Googlebot/Twitterbot UA, fail on non-200 or missing `<title>`; post-deploy + daily |
+| 18 | Usage reality: code tab + spec-detail copy ARE the product (all 29 copy_code from spec_detail); quality tab, random filter, grid quick-copy, feedback widget, POTD, map search/pin, og-sharing effectively dead in 30d | maintainability | M | manual | `plausible:event/tab_toggle`, `ImageCard.tsx`, `FeedbackWidget.tsx` | Optimize the code-tab/copy flow; surface or prune the dead periphery; check grid copy button actually renders |
+| 19 | New-spec creation nearly stalled: May 0, Jun 10, Jul 0 (vs Dec–Mar 27–159/mo) — "spannend" needs fresh subject matter | maintainability | M | manual | `plots/*/specification.yaml` | Light cadence: one themed batch of 5–10 spec requests/month (June's sports batch was the model) |
+| 20 | Last 3 old-design metadata files: line-stress-strain (bokeh, letsplot), heatmap-cohort-retention (plotnine) still single-preview, no dark variant | correctness | M | manual | `plots/line-stress-strain/metadata/...`, `plots/heatmap-cohort-retention/metadata/...` | Three targeted impl-generate runs finish the light/dark migration at 100% |
+| 21 | Field Web Vitals (human-only): LCP p75 right at the 2.5s boundary, FCP/TTFB p75 needs-improvement; raw dashboard numbers are bot-skewed 3× worse | speed | L | manual | `plausible:event/LCP`, `TTFB`, `app/index.html` | TTFB is the lever (Cloud Run region/cold starts, edge caching); re-measure after bot filtering lands |
+| 22 | Gallery has no list virtualization — DOM grows unbounded browsing the full catalog (~4,900 impls) | speed | L | manual | `ImagesGrid.tsx`, `useInfiniteScroll.ts` | react-virtuoso VirtuosoGrid; interim: derive tooltip state, cap batches |
+| 23 | Three monolithic pages (PalettePage 2027*, DebugPage 1898, MapPage 1292 lines) break the otherwise clean sections/ decomposition (*PalettePage.helpers.ts extraction landed in PR #9616 during this audit) | maintainability | L | manual | `PalettePage.tsx`, `DebugPage.tsx`, `MapPage.tsx` | Extract sections/palette, sections/map, sections/debug with co-located tests |
+| 24 | Flagship 3D/geo specs are the sparsest (6–7 impls, Python-only) and missing makie — the library that would shine there | maintainability | L | manual | `plots/scatter-3d/`, `surface-basic/`, 10 more | Targeted regen (makie, echarts, ggplot2) on the 3D cluster first; record legitimate per-library exclusions explicitly |
+| 25 | 70% of the catalog lacks the newer libraries — 136 specs still Python-only (8 impls), only ~65 at 14–15 | maintainability | XL | manual | `plots/`, `core/constants.py` | Resume babysit-pipeline systematically; burn down the 8/9/10-impl buckets with targeted (not `all`) regen |
 
 ## Low (Importance 2)
+
 | # | Finding | Dim | Effort | Auto-fix | Files | Hint |
 |---|---------|-----|--------|----------|-------|------|
-| 1 | CORS allows any `http://localhost:<port>` origin with `allow_credentials=True` unconditionally in prod (low exploitability — read-only data, header-token auth) | security | S | manual | `api/main.py` | Gate the localhost regex behind `settings.is_development` (or drop `allow_credentials`) |
-| 2 | MCP server mounts 6 read-only tools at `/mcp` with no auth — almost certainly intentional (public read-only catalog) but the decision is undocumented | security | S | manual | `api/mcp/server.py`, `api/main.py` | Add a comment/SECURITY.md note: "intentionally public + read-only; no write tools here" |
-| 3 | `anyplot-local-dev` SA carries a long-lived user-managed key (~45d, under the 90d staleness bar) — exported keys are the highest-risk credential type | security | S | manual | `gcp:iam/.../anyplot-local-dev@…` | Replace with ADC `application-default login` + impersonation; if a key must exist, set a rotation reminder |
-| 4 | `ThemeToggle` is ~15px tall on mobile (sub-44px tap target) and has only `:hover`, no `:focus-visible` ring | looks | S | manual | `app/src/components/ThemeToggle.tsx` | Give it a 44×44 hit area on xs and a `:focus-visible` outline |
-| 5 | 11 metadata files score `quality_score < 70` (pygal/altair/seaborn on hard plot types) — lowest-confidence catalog cells, best regen candidates | looks | M | manual | `plots/contour-density/metadata/python/pygal.yaml` (+10) | Regen the sub-70 cells; if a library structurally can't render the type, drop that cell |
-| 6 | `tsconfig` `target`/`lib` pinned to ES2020 on a React 19 / Vite 8 app — down-levels syntax, hides `Array.at`/`Object.hasOwn`/error `cause` | modern | S | manual | `app/tsconfig.json` | Bump to ES2022/ESNext to match Vite's transpile target |
-| 7 | `api/Dockerfile` installs `uv` unpinned (reproducibility gap on an otherwise `--frozen` build), HEALTHCHECK has no interval/timeout/retries, no root `.dockerignore` | modern | S | manual | `api/Dockerfile` | Pin uv via distroless copy; add HEALTHCHECK flags; add a root `.dockerignore` |
-| 8 | Public CDN buckets use legacy fine-grained ACLs, not Uniform Bucket-Level Access (public-read posture itself is correct & intentional) | modern | S | manual | `gcp:storage/buckets/anyplot-images,anyplot-static` | Enable UBLA (keep the `allUsers:objectViewer` IAM grant; do NOT enable publicAccessPrevention) |
-| 9 | OG image host `api.anyplot.ai` is robots `Disallow: /` — fine for social scrapers today, but becomes a blocker once per-page `CreativeWork`/`ImageObject` references those images | modern | S | manual | `api/routers/seo.py` | Serve schema-referenced images from a crawlable host (anyplot.ai/...) before wiring them into JSON-LD |
-| 10 | Minor OG/meta completeness gaps: `BOT_HTML_TEMPLATE` omits `og:locale`, `og:image:width/height/alt`; WebApplication vs Organization descriptions differ for the same entity | modern | S | manual | `api/routers/seo.py`, `app/index.html` | Add `og:locale`/`og:image:alt`; align the two descriptions to one string |
-| 11 | Catalog rendered against below-floor library versions (letsplot 4.9.0<4.10.1, plotly 6.5.x<6.7.0, altair 6.0.0<6.1.0, matplotlib 3.10.8<3.10.9) — version drift between catalog and declared toolchain | modern | L | manual | `pyproject.toml`, `plots/*/metadata/python/*.yaml` | Regen below-floor impls to the pinned versions, or lower the floors to what shipped |
-| 12 | Plain-text `logging.basicConfig` on Cloud Run → Cloud Logging can't parse severity, warnings can't drive log-based metrics/alerts without brittle text matching; no request/spec-ID context across the async boundary | modern | M | manual | `api/main.py` | Emit JSON logs in prod (python-json-logger / google-cloud-logging); keep console format for dev |
-| 13 | `evaluate-plot.py` parses model JSON via a ` ```json ` regex fence rather than tool-use/structured output (error handling is good; local dev CLI, not the prod path) | correctness | M | manual | `scripts/evaluate-plot.py` | Define the result as a tool `input_schema`; read the structured `tool_use` block |
-| 14 | `impl-review.yml` final-status comment uses a single-quoted heredoc (`<<'EOF'`) so `$SCORE`/`$RUN_ID` post **literally** instead of expanding | correctness | S | manual | `.github/workflows/impl-review.yml` | Use an unquoted `<<EOF` (or interpolate the values directly) |
-| 15 | The two GIN-index migrations downgrade via raw `DROP INDEX` (no `IF EXISTS`) / upgrade via raw `CREATE INDEX` (no `IF NOT EXISTS`) → a partially-applied state aborts the downgrade | correctness | S | manual | `7ccd65103917_…`, `a2f4b8c91d23_…` | Use `IF EXISTS`/`IF NOT EXISTS`, or `op.create_index`/`drop_index` with `postgresql_using='gin'` |
-| 16 | Cloud SQL `anyplot-db` is ZONAL (no HA failover) on a public IP with two dev /32 allowlists — defensible cost trade-off for a showcase site; backups/PITR/IAM-auth/ENCRYPTED_ONLY all on (positives) | correctness | M | manual | `gcp:sql/instances/anyplot-db` | If uptime matters, switch to REGIONAL; consider private-IP-only to drop the dev allowlist |
-| 17 | `highcharts` `library_version` is `unknown` in 312/321 metadata files — the version probe can't resolve highcharts-core (all other libs populate correctly) | correctness | M | manual | `plots/*/metadata/python/highcharts.yaml` | Fix the probe (`importlib.metadata.version('highcharts-core')`); backfill optional |
-| 18 | 45 routes vs 16 `response_model`s — several JSON endpoints (`get_impl_code`, `get_spec_images`) return ad-hoc dicts, absent from the OpenAPI contract and skipping response validation | maintainability | M | manual | `api/routers/specs.py`, `insights.py` | Add small Pydantic response models and annotate the dict-returning JSON routes |
-| 19 | `MapPage.tsx` (1615L) and `DebugPage.tsx` (1329L) dwarf the rest of `app/src` — a consistency gap vs the otherwise well-decomposed tree (MapPage already partly factored into `.helpers.ts`) | maintainability | L | manual | `app/src/pages/MapPage.tsx`, `DebugPage.tsx` | Continue MapPage's pattern: extract force-graph config/effects + legend UI into co-located subcomponents |
-| 20 | Inconsistent type-only imports: `ErrorBoundary.tsx` imports `ReactNode` as a value; no `consistent-type-imports` ESLint rule catches the divergence (matters under future `verbatimModuleSyntax`) | modern | S | eslint | `app/src/components/ErrorBoundary.tsx`, `app/eslint.config.js` | Enable `@typescript-eslint/consistent-type-imports` (fixable); split the import to `type` |
-| 21 | Inconsistent placeholder syntax across workflow prompts (`${VAR}` vs `{VAR}`) — both work only because values arrive via an appended block, not templating; invites a future author to assume one is templated | maintainability | S | manual | `prompts/workflow-prompts/*.md` | Pick one convention; note in the README that vars arrive via the appended block |
-| 22 | `claude_model` config pins a dated string (`claude-sonnet-4-6`, local-evaluator only) while workflows use bare aliases — goes stale on each model release, easy to miss | maintainability | S | manual | `core/config.py`, `scripts/evaluate-plot.py` | Resolve from an alias like the CLI tiers, or comment that it intentionally pins a dated SDK model |
-| 23 | `.claude/settings.json` allow-lists blanket `Bash` **and** specific `Bash(git commit *)`/`Bash(git push *)` — the broad entry makes the narrow ones dead config, undercutting the least-privilege appearance | maintainability | S | manual | `.claude/settings.json` | Pick a policy: blanket Bash (drop the git lines) or enumerate (drop blanket Bash) |
-| 24 | Two malformed pipeline-created labels: empty `quality:` (score never substituted) and `impl:_run_plotly:done` (internal function name leaked; canonical is `impl:plotly:done`) — pollute label filtering + Postgres sync | maintainability | S | manual | `gh:labels/quality:`, `gh:labels/impl:_run_plotly:done` | Harden the label-emitting step (skip empty score; map library ids through a canonical table) |
-| 25 | Legacy library labels for removed libs: `impl:ggplot2:done/failed` (no `generate:ggplot2` target) and orphan `library:makie` (0 issues) — dead/inconsistent labels | maintainability | S | manual | `gh:labels/impl:ggplot2:done`, `library:makie` | Confirm ggplot2 retired; remove the dead labels; make the active library list the single source |
-| 26 | Two orphaned generation branches with no PR (`implementation/map-marker-clustered/pygal`, `auto-polish/range-interval/…`) — residue from runs that produced a branch but no PR | maintainability | S | manual | `gh:branches/…` | Add a cleanup step deleting the working branch when a run fails to open a PR; delete the two orphans |
-| 27 | Undocumented Plausible enhanced-measurement goals active in prod (Outbound Link, Form Submission, Scroll %, Visit /specs) — overlap the hand-rolled external/internal_link events (double-counting risk) | maintainability | S | manual | `docs/reference/plausible.md` | Document them; decide whether "Outbound Link: Click" should coexist with the custom event or be retired |
-| 28 | Canonical `plot_type` vocabulary fragmented by true synonyms: `spider`/`radar`, `beeswarm`/`swarm`, `column`/`bar` — genuine vocabulary debt (not the protected advisory-tag case) that splits filtering | maintainability | M | manual | `plots/radar-basic/specification.yaml` (+2) | Pick a canonical term per pair; drop the alias; add an allow-list to prevent re-introduction |
-| 29 | Two metadata created-date formats coexist (quoted ISO-Z vs unquoted space-separated, 25 files) — minor smell for timestamp string-matching tooling | maintainability | S | codemod | `plots/*/metadata/python/*.yaml` | Normalize to one quoted ISO-8601-Z format via a one-pass codemod |
-| 30 | `uv` dependency cache not enabled in Python CI (`setup-uv` without `enable-cache`) — every lint/test run re-downloads the full scientific stack; frontend job correctly caches yarn | speed | S | manual | `ci-lint.yml`, `ci-tests.yml` | Add `enable-cache: true` (+ `cache-dependency-glob: uv.lock`) to the setup-uv steps |
-| 31 | Local evaluator concatenates large static prompts (evaluator 350L + criteria 549L + library guide) with no `cache_control` ephemeral — re-billed each call on `--all` runs (local CLI only) | speed | S | manual | `scripts/evaluate-plot.py` | Split static evaluator+criteria into cached content blocks; or accept as local-only |
+| 1 | Tabs/TabPanel missing id/aria-controls wiring; click-to-collapse is nonstandard tabs semantics | correctness | S | manual | `SpecTabs/index.tsx`, `md.tsx` | Standard a11yProps helper |
+| 2 | NavBar search button loses focus indicator via `all:unset`; active link lacks `aria-current` | correctness | S | manual | `NavBar.tsx` | `:focus-visible` outline + `aria-current="page"` |
+| 3 | Motion/focus polish: `rise` + loader animations ignore `prefers-reduced-motion`; MapPage/DebugPage remove outlines without replacement | looks | S | manual | `tokens.css`, `LoaderSpinner.tsx`, `MapPage.tsx` | Extend the reduced-motion block; copy SpecDetailView's focus-visible pattern |
+| 4 | Hero stacks three competing taglines; centered "from .md to art." breaks the left-aligned rhythm | looks | S | manual | `HeroSection.tsx` | Keep one emotional tagline (typewriter), left-align the rest |
+| 5 | DatabaseQueryError reflects raw SQLAlchemy error text to API clients (contradicts the no-leak policy in the same file) | security | S | manual | `api/routers/plots.py`, `api/exceptions.py` | Log detail server-side; generic client message |
+| 6 | Cloud SQL async fallback wraps a sync pg8000 engine in async_sessionmaker — would 500 on first query if ever configured | correctness | S | manual | `core/database/connection.py` | Fail fast with RuntimeError in that branch |
+| 7 | Leftover `/hello/{name}` demo endpoint on the production API; three conflicting version strings | maintainability | S | manual | `api/routers/health.py` | Delete; source version from one constant |
+| 8 | Previous-gen model default (`claude-sonnet-4-6`) + dead Python-highcharts pattern block in local evaluator | modern | S | manual | `core/config.py`, `scripts/evaluate-plot.py` | Bump to claude-sonnet-5 after side-by-side; derive patterns from constants |
+| 9 | pagespeed-auditor brief's auth contract is factually wrong ("works without a key … plenty") — failed 5/5 runs | maintainability | S | manual | `agentic/commands/audit/pagespeed-auditor.md` | Rewrite: key required in practice; probe once, fail fast to blocked |
+| 10 | python.anyplot.ai nginx server block + docs exist but DNS doesn't resolve — dead config invites drift | maintainability | S | manual | `app/nginx.conf`, `docs/reference/seo.md` | Finish the launch or delete the block; one-line decision comment |
+| 11 | Cross-site links (now on /legal per maintainer decision): consider ONE contextual sentence on /about with a descriptive anchor for kurrentschrift; the rel handling (`noopener`, no `noreferrer`, followed) is right *(merged: design + seo; original footer-placement advice overtaken by events)* | modern | S | manual | `app/src/pages/AboutPage.tsx` | Optional follow-up; sitewide keyword-anchor links would look spammy — brand anchor is correct |
+| 12 | Ghost event `library_filter` with unregistered `framework` prop, undocumented (9 events/30d, values invisible in dashboards) | maintainability | S | manual | `LibrariesPage.tsx`, `docs/reference/plausible.md` | Rename into taxonomy or register+document the prop |
+| 13 | Outbound clicks double-tracked: auto "Outbound Link: Click" (33) overlaps custom `external_link` (12) | maintainability | S | manual | `app/index.html`, `docs/reference/plausible.md` | Pick one; document the survivor |
+| 14 | `search_no_results` shows real content gaps (backtest equity curve, arrow/flow map) but 200ms debounce leaks keystrokes | maintainability | S | manual | `FilterBar.tsx` | File the two spec requests; fire on ~1s idle or blur/enter |
+| 15 | plausible.md (updated 2026-04-25) + index.html drifted from the shipped product (missing nav_map, library_filter, /map, /debug; 9-library lists) | maintainability | S | manual | `docs/reference/plausible.md`, `app/index.html` | One doc-sync pass regenerating event tables from code |
+| 16 | Git-tracked strays in plots/: two ~200KB render PNGs (gitignore matches but never untracked), leaked `_run_plotly.py`, 5 stale .gitkeep | maintainability | S | manual | `plots/bar-basic/...`, `plots/contour-3d/...` | `git rm --cached`; grep automation/ before deleting the runner |
+| 17 | plot_type synonym drift: 6 singletons ('graph', 'curve', 'evaluation', …) where established tags exist (advisory facets policy-exempt, untouched) | maintainability | S | manual | 6 `specification.yaml` files | Normalize the synonyms only |
+| 18 | 118 specs have `updated: null`; date quoting inconsistent across generations of tooling | maintainability | S | codemod | `plots/*/specification.yaml` | One-shot codemod: `updated := created` where null; normalize quoting |
+| 19 | Duplicate-adjacent spec clusters (regression-linear/-polynomial/-lowess; sma/ema) read as variations of one page | maintainability | S | manual | `plots/scatter-regression-*/`, `plots/indicator-*ma/` | Cross-check traffic; consider merging weak siblings into variants |
+| 20 | evaluate-plot.py: no prompt caching (image-first ordering defeats it), tight max_tokens, freeform JSON instead of structured output | modern | M | manual | `scripts/evaluate-plot.py`, `core/config.py` | Rubric-first + cache_control, ~4000 max_tokens, json_schema output format |
+| 21 | `search_by_tags` matches via substring LIKE over stringified JSON — cross-category false positives, unindexable | correctness | M | manual | `core/database/repositories.py` | JSONB containment per category (`@>`) + optional GIN index |
+| 22 | Ten sub-70 quality-score impls sit approved, 7/10 are pygal faking contours/maps/trees it has no primitives for | correctness | M | manual | `plots/*/metadata/python/pygal.yaml` ×7, altair ×2, seaborn ×1 | Regenerate with opus per the migration playbook, or allow explicit per-library opt-outs |
+| 23 | useUrlSync bypasses React Router with raw `history.replaceState` — router location permanently stale on /plots, Back leaves the page | maintainability | M | manual | `useUrlSync.ts`, `useFilterState.ts` | Document as intentional or migrate to `useSearchParams(..., {replace:true})` |
 
 ## Positive Patterns (Importance 1)
-- **Hardened security posture (security-auditor):** SSRF-safe proxy (hardcoded host+bucket, json.dumps-encoded postMessage origin), **zero raw-SQL f-strings** anywhere in core/api/automation (all `select()`/`.where()`/`.in_()`), every third-party action SHA-pinned, leak-safe exception handler (never reflects `str(exc)`), `/debug/*` gated by Cloudflare-Access JWT or `compare_digest` token, list-form subprocess + allowlisted env, no hardcoded/logged secrets.
-- **`util-claude.yml` author_association gate (infra):** the highest-risk trigger (`issue_comment`) correctly restricts the privileged agent to OWNER/MEMBER/COLLABORATOR — the exact template the Critical finding should follow. All 17 workflows declare least-privilege `permissions:` blocks.
-- **Strict main-branch ruleset + zero open Dependabot alerts (github):** 3 required checks, no force-push/deletion, PRs enforced; 84 lifetime alerts (2 crit/38 high) **all** fixed/auto-dismissed — meets the "zero High/Critical CVE" bar.
-- **Healthy production runtime (gcloud):** zero app ERROR logs in 7d, `minScale=1` warm instances + startup-cpu-boost, Cloud SQL with backups + PITR + IAM-auth + ENCRYPTED_ONLY, scoped CI SA with no exported keys.
-- **Reversible migrations (db):** every non-merge migration has a real mirrored `downgrade()`; destructive renames carry data steps; dialect-guarded `ALTER … USING id::uuid`; diverged heads cleanly reconciled with a no-op merge.
-- **Strong frontend foundation (frontend):** clean type-check + ESLint, zero `any` outside two rationalized force-graph sites, route-level `lazy()`+`HydrateFallback`, a complete `ErrorBoundary`, request de-dup in `useCodeFetch`, correct React 19 idioms, no legacy MUI styling.
-- **Server-side fire-and-forget analytics (observability):** strong-ref task set prevents GC reaping, 5s-timeout httpx POST never blocks the response, WARNING-level (not DEBUG) failure logging survives Cloud Run filtering; Web Vitals pipeline prod-gated, dynamically imported, unit-tested.
-- **Clean analytics canonicalization (plausible):** `theme` ambient prop on ~100% of pageviews (light 1364 / dark 1055 / 2 none), zero legacy `/python/` paths or query-string leaks across the top 200 URLs.
-- **README badge row (quality):** Python/License/Tests/Ruff/codecov badges — exactly the exemplary set.
-- **Catalog migration discipline (catalog):** quality scores broadly healthy (no nulls, mode 90); the theme-split metadata schema is the right direction.
-
-## Cross-Auditor Synthesis (Phase 3 step 9)
-- **Deprecation candidates (Catalog × Plausible × SEO): SKIPPED.** The intersection needs zero-impression data, but seo-auditor ran `structural-only` (Search Console token scope insufficient). Partial signal exists: catalog Low #5 already lists the 11 sub-70 quality cells (pygal/altair on hard plot types) as the best regen/deprecation candidates.
-- **Web Vitals lab-vs-field divergence (Pagespeed × Plausible): SKIPPED.** pagespeed-auditor was blocked (PSI quota), so there is no lab CWV to compare against. Field-only signal: Plausible High #6 shows FCP p75 "poor" and LCP/TTFB "needs-improvement" — actionable on its own (TTFB → warm Cloud Run / cache SEO-proxy).
-- **Exemplary-gap rollups (real themes spanning auditors):**
-  - **Mode-unaware MUI theme** is the single root cause behind 4 separate dark-mode defects (High #9 white menus, Medium #6/#7/#8 theme/dividers/rgba) — fixing the theme architecture (Medium #9) collapses the class.
-  - **Bot-vs-SPA SEO surface drift** is the root cause behind 3 modern findings (High #7 zero JSON-LD, High #8 duplicate descriptions, Medium homepage-title) — one shared meta/JSON-LD builder + a parity test closes all of them.
-  - **CI safety-net gaps:** three latent-prod-breaker classes are uncaught because CI lacks the gate — frontend `tsc`/ESLint (High #1), `alembic check` (High #2), and `pip-audit` (Medium #2). Wiring all three is the highest-leverage Correctness/Security investment.
+
+- **Two-stage rubric + review-pipeline resilience** — 9-check auto-reject with false-positive guards, "correct but boring" cap, both-themes checklist, cascading review/repair. Reference this structure; don't redesign it (llm-pipeline).
+- **Frontend engineering baseline** — green strict tsc on app+test configs, ~zero `any`, 80+ co-located test files, all-lazy routes with route-scoped error boundaries, typed API layer (frontend).
+- **Token discipline & coherent terminal-editorial design language** — single CSS-var source, AA-checked ink scales, theme-adaptive previews, method-call microcopy (design).
+- **Caching architecture** — per-key stampede locks with lifecycle-bound pruning, SWR with self-owned sessions, startup prewarm, deferred multi-MB columns, batched sync (backend).
+- **Sitemap pipeline** — 324/324 specs, zero drift both directions, documented duplicate-content decisions (seo).
+- **Analytics instrumentation** — 31-event taxonomy with journey props that answered "what do visitors actually do" directly from live data (plausible).
+- **Metadata review layer** — 0 null quality_scores across 3,261 files, zero manual-merge bypasses detected, 100% consistent spec structure, all sampled previews live (catalog).
 
 ## Statistics
-- Total: 86 unique (90 raw − 4 deduped) | Critical: 1, High: 9, Medium: 35, Low: 31, Positive: 10
-- By Dimension: security 11, speed 6, looks 9, modern 11, correctness 20, maintainability 29
-- Effort: S 56, M 23, L 7, XL 0
-- Auto-fix: ruff 0, eslint 1, format 0, codemod 3, manual 82
-- By Auditor (raw): backend 3, frontend 4, design 7, infra 8, quality 5, llm 7, db 5, security 6, obs 5, agentic 5, gcloud 8, github 6, plausible 6, pagespeed 1, seo 6, catalog 8
-- Cross-validation: 13 reviewed (importance≥4), 0 dropped, 2 downgraded (`--code-constant` contrast 4→3; starlette CVE 4→3)
-- Contradiction flagged: the Critical injection finding was rated **5 by infra, 4 by llm-pipeline, 3 by security** — kept at 5 per the dedup rule (highest-importance copy governs). Mitigating factors the lower raters cited: it is LLM-context injection (not shell RCE), the trigger requires a privileged label, and an `approved` human gate bounds the *merge* step (but not the *create* step). Reasonable to treat as High if those mitigations are deemed sufficient.
-- Coverage: 13 full, 2 partial (design — code-only, no live render; pagespeed — PSI quota exhausted, no lab metrics), 1 structural-only (seo — Search Console scope insufficient), 0 blocked. **Note:** security & observability failed to emit structured output on the first pass (ran long on `pip-audit`/`yarn audit`) and were recovered in a hardened follow-up run — both `full` in the final tally.
+
+- Total: 57 actionable (+7 positive) | Critical: 1, High: 7, Medium: 25, Low: 23 (+1 medium became part of a merge)
+- By Dimension: security 1, speed 7, looks 6, modern 7, correctness 15, maintainability 21
+- Effort: S 31, M 20, L 5, XL 1
+- Auto-fix: codemod 2, manual 55
+- By Auditor (raw): llm-pipeline 7, frontend 11, design 9, pagespeed 2, backend 11, seo 8, plausible 9, catalog 10 — 3 cross-auditor merges applied
+- Cross-validation: 8 reviewed (all importance ≥4), 0 dropped, 0 downgraded — every high-severity finding survived an independent domain-expert refutation attempt
+- Coverage: 3 full (frontend, plausible, catalog), 4 partial/structural (llm-pipeline, design, backend, seo), 1 blocked (pagespeed — anonymous PSI quota; recurring, see Medium #9)
+
+## Cross-auditor themes (synthesis)
+
+1. **"The 15-library expansion never propagated to secondary surfaces"** — found independently by four auditors: rubric prompts (llm-pipeline M12), index.html/OG/JSON-LD copy (design+seo M6), plausible.md + event docs (plausible L15), debug/insights library names (backend M5). One sweep with `core/constants.py` as the single source closes all of them.
+- **Lab vs field Web Vitals divergence**: not computable — pagespeed blocked (5th time). Field-only verdict: interaction is fast (INP/CLS good), first paint is the weak spot, TTFB the lever.
+- **Deprecation candidates**: catalog's duplicate clusters (regression trio, sma/ema) should be cross-checked against per-spec traffic before merging — per-spec Plausible breakdown wasn't pulled this run.
diff --git a/app/eslint.config.js b/app/eslint.config.js
index 876fcf162b..afa40c56f6 100644
--- a/app/eslint.config.js
+++ b/app/eslint.config.js
@@ -67,6 +67,14 @@ export default [
       'perfectionist/sort-named-imports': ['error', { type: 'natural' }],
     },
   },
+  {
+    // Test modules never participate in Fast Refresh — the rule only guards
+    // HMR boundaries of the running app, so it has no meaning here.
+    files: ['src/**/*.test.{ts,tsx}', 'src/test-utils.tsx'],
+    rules: {
+      'react-refresh/only-export-components': 'off',
+    },
+  },
   {
     ignores: ['dist/**', 'node_modules/**', 'coverage/**', '*.config.js', '*.config.mjs'],
   },
diff --git a/app/src/pages/LegalPage.test.tsx b/app/src/pages/LegalPage.test.tsx
index 9a64ca96ef..eaf2576352 100644
--- a/app/src/pages/LegalPage.test.tsx
+++ b/app/src/pages/LegalPage.test.tsx
@@ -64,6 +64,31 @@ describe('LegalPage', () => {
     expect(screen.getByText('~$34/month')).toBeInTheDocument();
   });
 
+  it('renders other projects links without noreferrer', () => {
+    render(<LegalPage />);
+
+    const kurrentLink = screen.getByRole('link', { name: 'kurrentschrift.ink' });
+    expect(kurrentLink).toHaveAttribute('href', 'https://kurrentschrift.ink');
+    expect(kurrentLink).toHaveAttribute('target', '_blank');
+    expect(kurrentLink).toHaveAttribute('rel', 'noopener');
+
+    const citadelLink = screen.getByRole('link', { name: 'cite-citadel' });
+    expect(citadelLink).toHaveAttribute('href', 'https://github.com/MarkusNeusinger/cite-citadel');
+    expect(citadelLink).toHaveAttribute('target', '_blank');
+    expect(citadelLink).toHaveAttribute('rel', 'noopener');
+  });
+
+  it('tracks external link clicks for other projects', () => {
+    trackEvent.mockClear();
+    render(<LegalPage />);
+
+    fireEvent.click(screen.getByRole('link', { name: 'kurrentschrift.ink' }));
+    fireEvent.click(screen.getByRole('link', { name: 'cite-citadel' }));
+
+    expect(trackEvent).toHaveBeenCalledWith('external_link', { destination: 'kurrentschrift' });
+    expect(trackEvent).toHaveBeenCalledWith('external_link', { destination: 'cite_citadel' });
+  });
+
   it('tracks external link clicks for linkedin, x and github', () => {
     trackEvent.mockClear();
     render(<LegalPage />);
diff --git a/app/src/pages/LegalPage.tsx b/app/src/pages/LegalPage.tsx
index 1b72898495..6208185a18 100644
--- a/app/src/pages/LegalPage.tsx
+++ b/app/src/pages/LegalPage.tsx
@@ -91,6 +91,36 @@ export function LegalPage() {
     </>
   );
 
+  const otherProjectsBlock = (
+    <>
+      <Typography sx={subheadingStyle}>other projects</Typography>
+      <Typography sx={textStyle}>
+        {/* no noreferrer — own sites, keep the referrer for their analytics */}
+        <Link
+          href="https://kurrentschrift.ink"
+          target="_blank"
+          rel="noopener"
+          onClick={() => trackEvent('external_link', { destination: 'kurrentschrift' })}
+          sx={proseLinkStyle}
+        >
+          kurrentschrift.ink
+        </Link>{' '}
+        — read and write old german cursive
+        <br />
+        <Link
+          href="https://github.com/MarkusNeusinger/cite-citadel"
+          target="_blank"
+          rel="noopener"
+          onClick={() => trackEvent('external_link', { destination: 'cite_citadel' })}
+          sx={proseLinkStyle}
+        >
+          cite-citadel
+        </Link>{' '}
+        — llm-maintained, fully-cited personal wiki
+      </Typography>
+    </>
+  );
+
   const disclaimerBlock = (
     <>
       <Typography sx={subheadingStyle}>disclaimer</Typography>
@@ -148,6 +178,7 @@ export function LegalPage() {
               <Box sx={{ flex: 1, minWidth: 0 }}>{operatorContactBlock}</Box>
             </Box>
             {disclaimerBlock}
+            {otherProjectsBlock}
           </Box>
         </Box>
 
@@ -523,7 +554,7 @@ export function LegalPage() {
             mt: 2,
           }}
         >
-          last updated: April 2026
+          last updated: July 2026
         </Typography>
       </Box>
     </>
diff --git a/app/src/pages/PalettePage.helpers.ts b/app/src/pages/PalettePage.helpers.ts
new file mode 100644
index 0000000000..1deda4e4a9
--- /dev/null
+++ b/app/src/pages/PalettePage.helpers.ts
@@ -0,0 +1,251 @@
+/**
+ * Helpers for the /palette page: the imprint palette data (8 categorical
+ * hues with OKLCH coords + ΔE / WCAG stats) and the per-language copy-paste
+ * snippet generator. Pure data + string building, extracted from
+ * PalettePage.tsx so the page module only exports the component (Fast
+ * Refresh) and snippet() stays unit-testable in PalettePage.snippet.test.ts.
+ */
+
+export type Swatch = {
+  hex: string;
+  name: string;
+  family: string;
+  role: string;
+  L: number;
+  C: number;
+  H: number;
+  oklch: string;
+  /** Per-hex min ΔE to any other imprint member under normal vision. */
+  minNorm: number;
+  /** Per-hex min ΔE to any other imprint member under worst CVD sim (deuter / protan / tritan). */
+  minCvd: number;
+  /** WCAG contrast on the cream page bg #F5F3EC (--bg-page). */
+  wcagL: number;
+  /** WCAG contrast on warm near-black bg #121210. */
+  wcagD: number;
+};
+
+export const PALETTE: Swatch[] = [
+  {
+    hex: '#009E73',
+    name: 'brand green',
+    family: 'green',
+    role: 'first series',
+    L: 0.62,
+    C: 0.13,
+    H: 165.5,
+    oklch: 'oklch(0.620 0.130 165.5)',
+    minNorm: 22.51,
+    minCvd: 13.7,
+    wcagL: 3.08,
+    wcagD: 5.48,
+  },
+  {
+    hex: '#C475FD',
+    name: 'lavender',
+    family: 'purple',
+    role: 'creative',
+    L: 0.704,
+    C: 0.202,
+    H: 308.9,
+    oklch: 'oklch(0.704 0.202 308.9)',
+    minNorm: 30.03,
+    minCvd: 13.81,
+    wcagL: 2.59,
+    wcagD: 6.53,
+  },
+  {
+    hex: '#4467A3',
+    name: 'blue',
+    family: 'blue',
+    role: 'cool / info',
+    L: 0.516,
+    C: 0.104,
+    H: 260.6,
+    oklch: 'oklch(0.516 0.104 260.6)',
+    minNorm: 32.35,
+    minCvd: 10.7,
+    wcagL: 5.09,
+    wcagD: 3.32,
+  },
+  {
+    hex: '#BD8233',
+    name: 'ochre',
+    family: 'yellow',
+    role: 'earth / commodity',
+    L: 0.652,
+    C: 0.118,
+    H: 70.5,
+    oklch: 'oklch(0.652 0.118 70.5)',
+    minNorm: 24.0,
+    minCvd: 8.81,
+    wcagL: 2.95,
+    wcagD: 5.72,
+  },
+  {
+    hex: '#AE3030',
+    name: 'matte red',
+    family: 'red',
+    role: 'bad / loss / error',
+    L: 0.503,
+    C: 0.163,
+    H: 25.2,
+    oklch: 'oklch(0.503 0.163 25.2)',
+    minNorm: 20.73,
+    minCvd: 15.2,
+    wcagL: 5.79,
+    wcagD: 2.92,
+  },
+  {
+    hex: '#2ABCCD',
+    name: 'cyan',
+    family: 'cyan',
+    role: 'sky / tech-cool',
+    L: 0.73,
+    C: 0.117,
+    H: 207.1,
+    oklch: 'oklch(0.730 0.117 207.1)',
+    minNorm: 22.51,
+    minCvd: 13.7,
+    wcagL: 2.06,
+    wcagD: 8.19,
+  },
+  {
+    hex: '#954477',
+    name: 'rose',
+    family: 'purple',
+    role: 'wellness / health',
+    L: 0.508,
+    C: 0.125,
+    H: 343.9,
+    oklch: 'oklch(0.508 0.125 343.9)',
+    minNorm: 20.73,
+    minCvd: 10.7,
+    wcagL: 5.61,
+    wcagD: 3.01,
+  },
+  {
+    hex: '#99B314',
+    name: 'lime',
+    family: 'green',
+    role: 'growth / nature',
+    L: 0.722,
+    C: 0.167,
+    H: 119.8,
+    oklch: 'oklch(0.722 0.167 119.8)',
+    minNorm: 24.0,
+    minCvd: 8.81,
+    wcagL: 2.15,
+    wcagD: 7.87,
+  },
+];
+
+export type Lang = 'python' | 'r' | 'julia' | 'js';
+
+export function snippet(lang: Lang, oklch: boolean, sortedPalette: Swatch[]): string {
+  // CSS `oklch()` literals are only consumable where the runtime parses CSS
+  // colours (JS / CSS). matplotlib, ggplot2 and Makie can't read them, so those
+  // snippets stay hex (runnable) and surface the OKLCH coordinate as a trailing
+  // comment instead — toggling OKLCH then annotates rather than breaks the code.
+  const useLiteral = oklch && lang === 'js';
+  const colorVal = (s: Swatch) => (useLiteral ? s.oklch : s.hex);
+  const hueComment = (s: Swatch) => (oklch && lang !== 'js' ? `  # ${s.oklch}` : '');
+  const amberVal = useLiteral ? 'oklch(0.841 0.108 98.3)' : '#DDCC77';
+  const seqStart = useLiteral ? 'oklch(0.620 0.130 165.5)' : '#009E73';
+  const seqEnd = useLiteral ? 'oklch(0.516 0.104 260.6)' : '#4467A3';
+  const divStart = useLiteral ? 'oklch(0.503 0.163 25.2)' : '#AE3030';
+  const divEnd = useLiteral ? 'oklch(0.516 0.104 260.6)' : '#4467A3';
+  // Theme-adaptive neutrals + diverging midpoints are kept as hex in both
+  // modes — they are structural near-greys, and converting them to OKLCH adds
+  // noise without helping anyone paste them.
+  switch (lang) {
+    case 'python':
+      return `from types import SimpleNamespace
+
+# imprint — anyplot's palette (https://anyplot.ai/palette)
+IMPRINT = SimpleNamespace(
+    hues=[
+${sortedPalette.map(s => `        "${s.hex}",${hueComment(s)}`).join('\n')}
+    ],
+    amber="${amberVal}",  # warning / caution
+    neutral=SimpleNamespace(light="#1A1A17", dark="#F0EFE8"),
+    muted=SimpleNamespace(light="#6B6A63", dark="#A8A79F"),
+    seq=["${seqStart}", "${seqEnd}"],  # sequential: green -> blue
+    div=SimpleNamespace(               # diverging: red <-> midpoint <-> blue
+        light=["${divStart}", "#FAF8F1", "${divEnd}"],
+        dark=["${divStart}", "#1A1A17", "${divEnd}"],
+    ),
+)
+
+color = IMPRINT.hues[0]  # first series is ALWAYS slot 0 (brand green)
+
+# continuous data — build matplotlib cmaps from the stops
+from matplotlib.colors import LinearSegmentedColormap
+imprint_seq = LinearSegmentedColormap.from_list("imprint_seq", IMPRINT.seq)
+imprint_div = LinearSegmentedColormap.from_list("imprint_div", IMPRINT.div.light)  # .dark on dark bg`;
+    case 'r':
+      return `# imprint — anyplot's palette (https://anyplot.ai/palette)
+IMPRINT <- list(
+  hues = c(
+${sortedPalette.map((s, i, arr) => `    "${s.hex}"${i < arr.length - 1 ? ',' : ''}${hueComment(s)}`).join('\n')}
+  ),
+  amber = "${amberVal}",  # warning / caution
+  neutral = list(light = "#1A1A17", dark = "#F0EFE8"),
+  muted   = list(light = "#6B6A63", dark = "#A8A79F"),
+  seq = c("${seqStart}", "${seqEnd}"),  # sequential: green -> blue
+  div = list(                           # diverging: red <-> midpoint <-> blue
+    light = c("${divStart}", "#FAF8F1", "${divEnd}"),
+    dark  = c("${divStart}", "#1A1A17", "${divEnd}")
+  )
+)
+
+color <- IMPRINT$hues[1]  # first series = slot 0, brand green (R is 1-based: index 1)
+
+# continuous data — ggplot2 gradient scales
+scale_color_gradientn(colours = IMPRINT$seq)        # sequential
+scale_color_gradient2(low = IMPRINT$div$light[1], mid = IMPRINT$div$light[2], high = IMPRINT$div$light[3], midpoint = 0)  # diverging — 0 maps to the neutral midpoint (light theme)`;
+    case 'julia':
+      return `using Colors  # colorant"#..."
+
+# imprint — anyplot's palette (https://anyplot.ai/palette)
+const IMPRINT = (
+    hues = [
+${sortedPalette.map(s => `        colorant"${s.hex}",${hueComment(s)}`).join('\n')}
+    ],
+    amber = colorant"${amberVal}",  # warning / caution
+    neutral = (light = colorant"#1A1A17", dark = colorant"#F0EFE8"),
+    muted   = (light = colorant"#6B6A63", dark = colorant"#A8A79F"),
+    seq = [colorant"${seqStart}", colorant"${seqEnd}"],
+    div = (light = [colorant"${divStart}", colorant"#FAF8F1", colorant"${divEnd}"],
+           dark  = [colorant"${divStart}", colorant"#1A1A17", colorant"${divEnd}"]),
+)
+
+color = IMPRINT.hues[1]  # first series = slot 0, brand green (Julia is 1-based: index 1)
+
+# continuous data — Makie / ColorSchemes
+using ColorSchemes
+imprint_seq = cgrad(IMPRINT.seq)
+imprint_div = cgrad(IMPRINT.div.light)  # .dark on dark bg`;
+    case 'js':
+      return `// imprint — anyplot's palette (https://anyplot.ai/palette)
+const IMPRINT = {
+  hues: [
+${sortedPalette.map(s => `    "${colorVal(s)}",`).join('\n')}
+  ],
+  amber: "${amberVal}", // warning / caution
+  neutral: { light: "#1A1A17", dark: "#F0EFE8" },
+  muted:   { light: "#6B6A63", dark: "#A8A79F" },
+  seq: ["${seqStart}", "${seqEnd}"], // sequential: green -> blue
+  div: {                             // diverging: red <-> midpoint <-> blue
+    light: ["${divStart}", "#FAF8F1", "${divEnd}"],
+    dark:  ["${divStart}", "#1A1A17", "${divEnd}"],
+  },
+};
+
+const color = IMPRINT.hues[0]; // first series is ALWAYS slot 0 (brand green)
+
+// continuous data — [stop, color] pairs for your charting lib
+const imprintSeq = IMPRINT.seq.map((c, i, a) => [i / (a.length - 1), c]);
+const imprintDiv = IMPRINT.div.light.map((c, i, a) => [i / (a.length - 1), c]);`;
+  }
+}
diff --git a/app/src/pages/PalettePage.snippet.test.ts b/app/src/pages/PalettePage.snippet.test.ts
index 3b0e55153d..93ba72bfc1 100644
--- a/app/src/pages/PalettePage.snippet.test.ts
+++ b/app/src/pages/PalettePage.snippet.test.ts
@@ -1,6 +1,6 @@
 import { describe, expect, it } from 'vitest';
 
-import { type Lang, PALETTE, snippet } from 'src/pages/PalettePage';
+import { type Lang, PALETTE, snippet } from 'src/pages/PalettePage.helpers';
 
 const LANGS: Lang[] = ['python', 'r', 'julia', 'js'];
 
diff --git a/app/src/pages/PalettePage.tsx b/app/src/pages/PalettePage.tsx
index 048c899c61..d976095b4b 100644
--- a/app/src/pages/PalettePage.tsx
+++ b/app/src/pages/PalettePage.tsx
@@ -15,6 +15,7 @@ import Tooltip from '@mui/material/Tooltip';
 import { SectionHeader } from 'src/components/SectionHeader';
 import matrixData from 'src/data/paletteMatrices.json';
 import { useAnalytics, useTheme } from 'src/hooks';
+import { type Lang, PALETTE, snippet, type Swatch } from 'src/pages/PalettePage.helpers';
 import { paths } from 'src/routes/paths';
 import { colors, fontSize, textStyle, typography } from 'src/theme';
 
@@ -22,140 +23,6 @@ import { colors, fontSize, textStyle, typography } from 'src/theme';
 // Imprint palette — 8 categorical hues + OKLCH coords for the wheel
 // ─────────────────────────────────────────────────────────────────────────────
 
-type Swatch = {
-  hex: string;
-  name: string;
-  family: string;
-  role: string;
-  L: number;
-  C: number;
-  H: number;
-  oklch: string;
-  /** Per-hex min ΔE to any other imprint member under normal vision. */
-  minNorm: number;
-  /** Per-hex min ΔE to any other imprint member under worst CVD sim (deuter / protan / tritan). */
-  minCvd: number;
-  /** WCAG contrast on the cream page bg #F5F3EC (--bg-page). */
-  wcagL: number;
-  /** WCAG contrast on warm near-black bg #121210. */
-  wcagD: number;
-};
-
-export const PALETTE: Swatch[] = [
-  {
-    hex: '#009E73',
-    name: 'brand green',
-    family: 'green',
-    role: 'first series',
-    L: 0.62,
-    C: 0.13,
-    H: 165.5,
-    oklch: 'oklch(0.620 0.130 165.5)',
-    minNorm: 22.51,
-    minCvd: 13.7,
-    wcagL: 3.08,
-    wcagD: 5.48,
-  },
-  {
-    hex: '#C475FD',
-    name: 'lavender',
-    family: 'purple',
-    role: 'creative',
-    L: 0.704,
-    C: 0.202,
-    H: 308.9,
-    oklch: 'oklch(0.704 0.202 308.9)',
-    minNorm: 30.03,
-    minCvd: 13.81,
-    wcagL: 2.59,
-    wcagD: 6.53,
-  },
-  {
-    hex: '#4467A3',
-    name: 'blue',
-    family: 'blue',
-    role: 'cool / info',
-    L: 0.516,
-    C: 0.104,
-    H: 260.6,
-    oklch: 'oklch(0.516 0.104 260.6)',
-    minNorm: 32.35,
-    minCvd: 10.7,
-    wcagL: 5.09,
-    wcagD: 3.32,
-  },
-  {
-    hex: '#BD8233',
-    name: 'ochre',
-    family: 'yellow',
-    role: 'earth / commodity',
-    L: 0.652,
-    C: 0.118,
-    H: 70.5,
-    oklch: 'oklch(0.652 0.118 70.5)',
-    minNorm: 24.0,
-    minCvd: 8.81,
-    wcagL: 2.95,
-    wcagD: 5.72,
-  },
-  {
-    hex: '#AE3030',
-    name: 'matte red',
-    family: 'red',
-    role: 'bad / loss / error',
-    L: 0.503,
-    C: 0.163,
-    H: 25.2,
-    oklch: 'oklch(0.503 0.163 25.2)',
-    minNorm: 20.73,
-    minCvd: 15.2,
-    wcagL: 5.79,
-    wcagD: 2.92,
-  },
-  {
-    hex: '#2ABCCD',
-    name: 'cyan',
-    family: 'cyan',
-    role: 'sky / tech-cool',
-    L: 0.73,
-    C: 0.117,
-    H: 207.1,
-    oklch: 'oklch(0.730 0.117 207.1)',
-    minNorm: 22.51,
-    minCvd: 13.7,
-    wcagL: 2.06,
-    wcagD: 8.19,
-  },
-  {
-    hex: '#954477',
-    name: 'rose',
-    family: 'purple',
-    role: 'wellness / health',
-    L: 0.508,
-    C: 0.125,
-    H: 343.9,
-    oklch: 'oklch(0.508 0.125 343.9)',
-    minNorm: 20.73,
-    minCvd: 10.7,
-    wcagL: 5.61,
-    wcagD: 3.01,
-  },
-  {
-    hex: '#99B314',
-    name: 'lime',
-    family: 'green',
-    role: 'growth / nature',
-    L: 0.722,
-    C: 0.167,
-    H: 119.8,
-    oklch: 'oklch(0.722 0.167 119.8)',
-    minNorm: 24.0,
-    minCvd: 8.81,
-    wcagL: 2.15,
-    wcagD: 7.87,
-  },
-];
-
 /** Pure-CVD-greedy max-min sort — picks each next slot to maximise ΔE under CVD
  *  against the already-placed set. Best discrimination at n=3..6 but groups
  *  two greens (slot 7 lime + slot 0 brand) and two purples (slot 1 lavender +
@@ -320,7 +187,6 @@ const HISTORY: HistoryEntry[] = [
 // Code snippets per language
 // ─────────────────────────────────────────────────────────────────────────────
 
-export type Lang = 'python' | 'r' | 'julia' | 'js';
 const LANG_LABELS: Record<Lang, string> = {
   python: 'Python',
   r: 'R',
@@ -328,114 +194,6 @@ const LANG_LABELS: Record<Lang, string> = {
   js: 'JavaScript',
 };
 
-export function snippet(lang: Lang, oklch: boolean, sortedPalette: Swatch[]): string {
-  // CSS `oklch()` literals are only consumable where the runtime parses CSS
-  // colours (JS / CSS). matplotlib, ggplot2 and Makie can't read them, so those
-  // snippets stay hex (runnable) and surface the OKLCH coordinate as a trailing
-  // comment instead — toggling OKLCH then annotates rather than breaks the code.
-  const useLiteral = oklch && lang === 'js';
-  const colorVal = (s: Swatch) => (useLiteral ? s.oklch : s.hex);
-  const hueComment = (s: Swatch) => (oklch && lang !== 'js' ? `  # ${s.oklch}` : '');
-  const amberVal = useLiteral ? 'oklch(0.841 0.108 98.3)' : '#DDCC77';
-  const seqStart = useLiteral ? 'oklch(0.620 0.130 165.5)' : '#009E73';
-  const seqEnd = useLiteral ? 'oklch(0.516 0.104 260.6)' : '#4467A3';
-  const divStart = useLiteral ? 'oklch(0.503 0.163 25.2)' : '#AE3030';
-  const divEnd = useLiteral ? 'oklch(0.516 0.104 260.6)' : '#4467A3';
-  // Theme-adaptive neutrals + diverging midpoints are kept as hex in both
-  // modes — they are structural near-greys, and converting them to OKLCH adds
-  // noise without helping anyone paste them.
-  switch (lang) {
-    case 'python':
-      return `from types import SimpleNamespace
-
-# imprint — anyplot's palette (https://anyplot.ai/palette)
-IMPRINT = SimpleNamespace(
-    hues=[
-${sortedPalette.map(s => `        "${s.hex}",${hueComment(s)}`).join('\n')}
-    ],
-    amber="${amberVal}",  # warning / caution
-    neutral=SimpleNamespace(light="#1A1A17", dark="#F0EFE8"),
-    muted=SimpleNamespace(light="#6B6A63", dark="#A8A79F"),
-    seq=["${seqStart}", "${seqEnd}"],  # sequential: green -> blue
-    div=SimpleNamespace(               # diverging: red <-> midpoint <-> blue
-        light=["${divStart}", "#FAF8F1", "${divEnd}"],
-        dark=["${divStart}", "#1A1A17", "${divEnd}"],
-    ),
-)
-
-color = IMPRINT.hues[0]  # first series is ALWAYS slot 0 (brand green)
-
-# continuous data — build matplotlib cmaps from the stops
-from matplotlib.colors import LinearSegmentedColormap
-imprint_seq = LinearSegmentedColormap.from_list("imprint_seq", IMPRINT.seq)
-imprint_div = LinearSegmentedColormap.from_list("imprint_div", IMPRINT.div.light)  # .dark on dark bg`;
-    case 'r':
-      return `# imprint — anyplot's palette (https://anyplot.ai/palette)
-IMPRINT <- list(
-  hues = c(
-${sortedPalette.map((s, i, arr) => `    "${s.hex}"${i < arr.length - 1 ? ',' : ''}${hueComment(s)}`).join('\n')}
-  ),
-  amber = "${amberVal}",  # warning / caution
-  neutral = list(light = "#1A1A17", dark = "#F0EFE8"),
-  muted   = list(light = "#6B6A63", dark = "#A8A79F"),
-  seq = c("${seqStart}", "${seqEnd}"),  # sequential: green -> blue
-  div = list(                           # diverging: red <-> midpoint <-> blue
-    light = c("${divStart}", "#FAF8F1", "${divEnd}"),
-    dark  = c("${divStart}", "#1A1A17", "${divEnd}")
-  )
-)
-
-color <- IMPRINT$hues[1]  # first series = slot 0, brand green (R is 1-based: index 1)
-
-# continuous data — ggplot2 gradient scales
-scale_color_gradientn(colours = IMPRINT$seq)        # sequential
-scale_color_gradient2(low = IMPRINT$div$light[1], mid = IMPRINT$div$light[2], high = IMPRINT$div$light[3], midpoint = 0)  # diverging — 0 maps to the neutral midpoint (light theme)`;
-    case 'julia':
-      return `using Colors  # colorant"#..."
-
-# imprint — anyplot's palette (https://anyplot.ai/palette)
-const IMPRINT = (
-    hues = [
-${sortedPalette.map(s => `        colorant"${s.hex}",${hueComment(s)}`).join('\n')}
-    ],
-    amber = colorant"${amberVal}",  # warning / caution
-    neutral = (light = colorant"#1A1A17", dark = colorant"#F0EFE8"),
-    muted   = (light = colorant"#6B6A63", dark = colorant"#A8A79F"),
-    seq = [colorant"${seqStart}", colorant"${seqEnd}"],
-    div = (light = [colorant"${divStart}", colorant"#FAF8F1", colorant"${divEnd}"],
-           dark  = [colorant"${divStart}", colorant"#1A1A17", colorant"${divEnd}"]),
-)
-
-color = IMPRINT.hues[1]  # first series = slot 0, brand green (Julia is 1-based: index 1)
-
-# continuous data — Makie / ColorSchemes
-using ColorSchemes
-imprint_seq = cgrad(IMPRINT.seq)
-imprint_div = cgrad(IMPRINT.div.light)  # .dark on dark bg`;
-    case 'js':
-      return `// imprint — anyplot's palette (https://anyplot.ai/palette)
-const IMPRINT = {
-  hues: [
-${sortedPalette.map(s => `    "${colorVal(s)}",`).join('\n')}
-  ],
-  amber: "${amberVal}", // warning / caution
-  neutral: { light: "#1A1A17", dark: "#F0EFE8" },
-  muted:   { light: "#6B6A63", dark: "#A8A79F" },
-  seq: ["${seqStart}", "${seqEnd}"], // sequential: green -> blue
-  div: {                             // diverging: red <-> midpoint <-> blue
-    light: ["${divStart}", "#FAF8F1", "${divEnd}"],
-    dark:  ["${divStart}", "#1A1A17", "${divEnd}"],
-  },
-};
-
-const color = IMPRINT.hues[0]; // first series is ALWAYS slot 0 (brand green)
-
-// continuous data — [stop, color] pairs for your charting lib
-const imprintSeq = IMPRINT.seq.map((c, i, a) => [i / (a.length - 1), c]);
-const imprintDiv = IMPRINT.div.light.map((c, i, a) => [i / (a.length - 1), c]);`;
-  }
-}
-
 // ─────────────────────────────────────────────────────────────────────────────
 // Inline sub-components
 // ─────────────────────────────────────────────────────────────────────────────
diff --git a/docs/reference/plausible.md b/docs/reference/plausible.md
index d5f1846068..881bd1c6c3 100644
--- a/docs/reference/plausible.md
+++ b/docs/reference/plausible.md
@@ -184,12 +184,12 @@ carry `spec`, `library`, or `value` for richer breakdowns.
 
 | Event Name | Properties | Where | Description |
 |------------|-----------|-------|-------------|
-| `external_link` | `destination`, `spec`?, `library`? | Footer.tsx, LegalPage.tsx | User clicks external link |
-| `internal_link` | `destination`, `spec`?, `library`? | Footer.tsx | User clicks internal link in footer |
+| `external_link` | `destination`, `spec`?, `library`? | Footer.tsx, LegalPage.tsx, AboutPage.tsx, LibrariesPage.tsx, SectionHeader.tsx | User clicks external link |
+| `internal_link` | `destination`, `spec`?, `library`? | Footer.tsx, AboutPage.tsx | User clicks internal link |
 
-**External destinations (Footer)**: `github`, `stats`, `linkedin`
-**External destinations (LegalPage)**: `linkedin`, `x`, `github_personal`
-**Internal destinations**: `mcp`, `legal`
+**External destinations (Footer)**: `github`, `github_issue_chooser`, `linkedin`
+**External destinations (LegalPage)**: `linkedin`, `x`, `github_personal`, `kurrentschrift`, `cite_citadel`
+**Internal destinations (Footer)**: `about`, `legal`
 
 ### Performance (Core Web Vitals)
 
@@ -309,7 +309,7 @@ To see event properties in Plausible dashboard, you **MUST** register them as cu
 | `category` | Filter category (lib, spec, plot, data, dom, feat, dep, tech, pat, prep, style) | `search`, `random_filter`, `filter_remove` |
 | `value` | Filter value | `random_filter`, `filter_remove`, `tag_click` |
 | `query` | Search query text | `search`, `search_no_results` |
-| `destination` | Link target (github, plausible, stats, compare, linkedin, mcp, legal) | `external_link`, `internal_link` |
+| `destination` | Link-target slug — external: `github`, `github_issue_chooser`, `github_personal`, `github_bug`, `github_discussions`, `github_plot_request`, `github_releases`, `linkedin`, `x`, `kurrentschrift`, `cite_citadel`, `library_docs`; internal: `about`, `legal`, `legal_transparency`, `palette`. `SectionHeader` external links emit the target **hostname** instead (e.g. `plausible.io`). Grep `destination:` in `app/src` for the authoritative list. | `external_link`, `internal_link` |
 | `tab` | Tab name (code, specification, implementation, quality) | `tab_toggle` |
 | `action` | Toggle action (open, close) | `tab_toggle` |
 | `size` | Grid size (normal, compact) | `grid_resize` |
@@ -449,8 +449,8 @@ User lands on anyplot.ai
 | `open_interactive` | `spec`, `library` | SpecOverview.tsx, SpecDetailView.tsx |
 | `suggest_spec` | - | SpecsListPage.tsx (LandingPage mirror attributed via `nav_click` with `source: suggest_spec_link`) |
 | `report_issue` | `spec`, `library`? | SpecPage.tsx |
-| `external_link` | `destination`, `spec`?, `library`? | Footer.tsx, LegalPage.tsx |
-| `internal_link` | `destination`, `spec`, `library` | Footer.tsx |
+| `external_link` | `destination`, `spec`?, `library`? | Footer.tsx, LegalPage.tsx, AboutPage.tsx, LibrariesPage.tsx, SectionHeader.tsx |
+| `internal_link` | `destination`, `spec`?, `library`? | Footer.tsx, AboutPage.tsx |
 | `nav_click` | `source`, `target`, `spec`?, `library`?, `value`? | NavBar, MastheadRule, HeroSection, SectionHeader, PlotOfTheDay, PlotOfTheDayTerminal, LandingPage |
 | `theme_toggle` | `to` | MastheadRule.tsx |
 | `potd_dismiss` | `spec`, `library` | PlotOfTheDay.tsx |