diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml
index 668ea07..940c85c 100644
--- a/.github/workflows/docs.yml
+++ b/.github/workflows/docs.yml
@@ -56,4 +56,4 @@ jobs:
steps:
- name: Deploy to GitHub Pages
id: deployment
- uses: actions/deploy-pages@d6db90af4856ee69a657050a5670eb98513188ba # v4.0.5
+ uses: actions/deploy-pages@d6db90164ac5ed86f2b6aed7e0febac5b3c0c03e # v4.0.5
diff --git a/Makefile b/Makefile
index bfe0fc3..92dccd4 100644
--- a/Makefile
+++ b/Makefile
@@ -354,13 +354,20 @@ docs-serve:
## Playwright through every portal page. Requires the binary to
## be running on $(SHOTS_BASE_URL) (default http://localhost:8080).
## Re-run after any portal UI change.
+##
+## Sources .env.dev for MCPTEST_DEV_KEY so the captured-portal API
+## key matches the running binary's accepted file key. Override
+## SHOTS_API_KEY explicitly to point at a different deployment
+## (e.g. staging); that wins over .env.dev.
SHOTS_BASE_URL ?= http://localhost:8080
-SHOTS_API_KEY ?= devkey-please-change
-screenshots:
+screenshots: dev-secrets
@command -v node >/dev/null || { echo "node is required"; exit 1; }
- @cd scripts/screenshots && \
+ @. ./.env.dev && \
+ KEY="$${SHOTS_API_KEY:-$$MCPTEST_DEV_KEY}" && \
+ if [ -z "$$KEY" ]; then echo "no API key: set SHOTS_API_KEY or run make dev-secrets"; exit 1; fi && \
+ cd scripts/screenshots && \
(test -d node_modules || npm install) && \
- MCPTEST_BASE_URL=$(SHOTS_BASE_URL) MCPTEST_DEV_KEY=$(SHOTS_API_KEY) node screenshots.mjs
+ MCPTEST_BASE_URL=$(SHOTS_BASE_URL) MCPTEST_DEV_KEY="$$KEY" node screenshots.mjs
## run: Build and run
run: build
diff --git a/docs/images/portal/audit-compare-dark.png b/docs/images/portal/audit-compare-dark.png
new file mode 100644
index 0000000..53f5e95
Binary files /dev/null and b/docs/images/portal/audit-compare-dark.png differ
diff --git a/docs/images/portal/audit-compare-light.png b/docs/images/portal/audit-compare-light.png
new file mode 100644
index 0000000..e0897b5
Binary files /dev/null and b/docs/images/portal/audit-compare-light.png differ
diff --git a/docs/images/portal/audit-dark.png b/docs/images/portal/audit-dark.png
index 91bc93d..52da184 100644
Binary files a/docs/images/portal/audit-dark.png and b/docs/images/portal/audit-dark.png differ
diff --git a/docs/images/portal/audit-drawer-dark.png b/docs/images/portal/audit-drawer-dark.png
new file mode 100644
index 0000000..d30dc8d
Binary files /dev/null and b/docs/images/portal/audit-drawer-dark.png differ
diff --git a/docs/images/portal/audit-drawer-light.png b/docs/images/portal/audit-drawer-light.png
new file mode 100644
index 0000000..d818096
Binary files /dev/null and b/docs/images/portal/audit-drawer-light.png differ
diff --git a/docs/images/portal/audit-jsonb-dark.png b/docs/images/portal/audit-jsonb-dark.png
new file mode 100644
index 0000000..22e2fc4
Binary files /dev/null and b/docs/images/portal/audit-jsonb-dark.png differ
diff --git a/docs/images/portal/audit-jsonb-light.png b/docs/images/portal/audit-jsonb-light.png
new file mode 100644
index 0000000..cc53bdb
Binary files /dev/null and b/docs/images/portal/audit-jsonb-light.png differ
diff --git a/docs/images/portal/audit-light.png b/docs/images/portal/audit-light.png
index 55d281e..804f13e 100644
Binary files a/docs/images/portal/audit-light.png and b/docs/images/portal/audit-light.png differ
diff --git a/docs/images/portal/audit-livetail-dark.png b/docs/images/portal/audit-livetail-dark.png
new file mode 100644
index 0000000..a1f23a9
Binary files /dev/null and b/docs/images/portal/audit-livetail-dark.png differ
diff --git a/docs/images/portal/audit-livetail-light.png b/docs/images/portal/audit-livetail-light.png
new file mode 100644
index 0000000..c9940a7
Binary files /dev/null and b/docs/images/portal/audit-livetail-light.png differ
diff --git a/docs/images/portal/config-dark.png b/docs/images/portal/config-dark.png
index f47a268..5fd8617 100644
Binary files a/docs/images/portal/config-dark.png and b/docs/images/portal/config-dark.png differ
diff --git a/docs/images/portal/config-light.png b/docs/images/portal/config-light.png
index 99f5af5..baa508c 100644
Binary files a/docs/images/portal/config-light.png and b/docs/images/portal/config-light.png differ
diff --git a/docs/images/portal/dashboard-dark.png b/docs/images/portal/dashboard-dark.png
index 7767782..ea3da97 100644
Binary files a/docs/images/portal/dashboard-dark.png and b/docs/images/portal/dashboard-dark.png differ
diff --git a/docs/images/portal/dashboard-light.png b/docs/images/portal/dashboard-light.png
index 2c15a75..233be8d 100644
Binary files a/docs/images/portal/dashboard-light.png and b/docs/images/portal/dashboard-light.png differ
diff --git a/docs/images/portal/keys-dark.png b/docs/images/portal/keys-dark.png
index 18327a7..9e06004 100644
Binary files a/docs/images/portal/keys-dark.png and b/docs/images/portal/keys-dark.png differ
diff --git a/docs/images/portal/keys-light.png b/docs/images/portal/keys-light.png
index dfa4c4e..b2f6a59 100644
Binary files a/docs/images/portal/keys-light.png and b/docs/images/portal/keys-light.png differ
diff --git a/docs/images/portal/tools-dark.png b/docs/images/portal/tools-dark.png
index 1625cbb..0f661e2 100644
Binary files a/docs/images/portal/tools-dark.png and b/docs/images/portal/tools-dark.png differ
diff --git a/docs/images/portal/tools-light.png b/docs/images/portal/tools-light.png
index df716fb..209e1df 100644
Binary files a/docs/images/portal/tools-light.png and b/docs/images/portal/tools-light.png differ
diff --git a/docs/images/portal/tools-tryit-dark.png b/docs/images/portal/tools-tryit-dark.png
index 2369824..efd48d9 100644
Binary files a/docs/images/portal/tools-tryit-dark.png and b/docs/images/portal/tools-tryit-dark.png differ
diff --git a/docs/images/portal/tools-tryit-light.png b/docs/images/portal/tools-tryit-light.png
index 0f75fad..07cef35 100644
Binary files a/docs/images/portal/tools-tryit-light.png and b/docs/images/portal/tools-tryit-light.png differ
diff --git a/docs/images/portal/wellknown-dark.png b/docs/images/portal/wellknown-dark.png
index f34b93b..c09cc2a 100644
Binary files a/docs/images/portal/wellknown-dark.png and b/docs/images/portal/wellknown-dark.png differ
diff --git a/docs/images/portal/wellknown-light.png b/docs/images/portal/wellknown-light.png
index 7affd7a..9db121e 100644
Binary files a/docs/images/portal/wellknown-light.png and b/docs/images/portal/wellknown-light.png differ
diff --git a/docs/javascripts/shots.js b/docs/javascripts/shots.js
index 04ddd00..f5275e5 100644
--- a/docs/javascripts/shots.js
+++ b/docs/javascripts/shots.js
@@ -1,10 +1,17 @@
/**
- * Portal-screenshots carousel. Slides the track via `transform: translateX`
- * with a CSS transition for the actual animation; JS just tracks the
- * current index and computes the offset.
+ * Portal-screenshots carousel + lightbox.
*
- * Wraparound: clicking next at the last slide returns to 0; prev at 0
- * jumps to the last slide. Buttons stay enabled the whole time.
+ * Carousel: slides the track via `transform: translateX` with a CSS
+ * transition for the actual animation; JS just tracks the current index
+ * and computes the offset. Wrap-around is implicit; buttons stay enabled.
+ *
+ * Lightbox: clicking any frame opens a near-full-screen modal showing
+ * the screenshot at full size. ESC closes; arrow keys step between
+ * shots. Backdrop click closes. Focus is captured on open and restored
+ * on close.
+ *
+ * Material's instant-nav can re-mount the page; we guard re-binding via
+ * data attributes on each subscribed root.
*/
(function () {
function init() {
@@ -20,6 +27,9 @@
var slides = Array.from(track.querySelectorAll(".plex-shots__slide"));
if (slides.length === 0) return;
+ var counter = root.querySelector("[data-shots-counter]");
+ var lightbox = root.querySelector("[data-shots-lightbox]");
+
var index = 0;
function step() {
@@ -34,15 +44,33 @@
slides.forEach(function (s, i) {
s.classList.toggle("is-active", i === index);
});
+ if (counter) {
+ counter.innerHTML =
+ "" + String(index + 1).padStart(2, "0") + "" +
+ " / " + String(slides.length).padStart(2, "0");
+ }
}
- next.addEventListener("click", function () {
- index = (index + 1) % slides.length;
- apply();
- });
- prev.addEventListener("click", function () {
- index = (index - 1 + slides.length) % slides.length;
+ function go(delta) {
+ index = (index + delta + slides.length) % slides.length;
apply();
+ }
+
+ next.addEventListener("click", function () { go(1); });
+ prev.addEventListener("click", function () { go(-1); });
+
+ // Keyboard nav on the carousel itself: when focus lives within the
+ // stage, arrow keys advance. The lightbox owns its own keyboard
+ // handler when open, so this only fires while it's closed.
+ root.addEventListener("keydown", function (e) {
+ if (lightbox && !lightbox.hidden && lightbox.classList.contains("is-open")) return;
+ if (e.target.closest(".plex-shots__frame")) {
+ // Don't hijack Enter/Space on the frame button: those
+ // legitimately open the lightbox.
+ if (e.key === "Enter" || e.key === " ") return;
+ }
+ if (e.key === "ArrowLeft") { go(-1); }
+ else if (e.key === "ArrowRight") { go(1); }
});
// Keep the offset correct across resizes (slide widths are vw-based).
@@ -53,6 +81,143 @@
});
apply();
+ bindLightbox(root, slides, lightbox, function (newIndex) {
+ // When the lightbox navigates, sync the carousel so closing
+ // returns the user to the slide they were last viewing.
+ index = newIndex;
+ apply();
+ }, function () { return index; });
+ });
+ }
+
+ function bindLightbox(root, slides, lightbox, onIndex, getIndex) {
+ if (!lightbox || lightbox.dataset.lightboxBound === "1") return;
+ lightbox.dataset.lightboxBound = "1";
+
+ var imgLight = lightbox.querySelector("[data-lightbox-img-light]");
+ var imgDark = lightbox.querySelector("[data-lightbox-img-dark]");
+ var titleEl = lightbox.querySelector("#plex-lightbox-title");
+ var bodyEl = lightbox.querySelector("[data-lightbox-body]");
+ var countEl = lightbox.querySelector("[data-lightbox-count]");
+ var prevBtn = lightbox.querySelector("[data-lightbox-prev]");
+ var nextBtn = lightbox.querySelector("[data-lightbox-next]");
+ // Note: data-shots-close lives on BOTH the backdrop div and the close
+ // button; the close button is what we focus on open (the backdrop
+ // isn't focusable). The dismiss handler iterates closeBtns to attach
+ // click handlers to both.
+ var closeBtns = lightbox.querySelectorAll("[data-shots-close]");
+ var closeBtn = lightbox.querySelector("button[data-shots-close]");
+
+ var lastFocus = null;
+ var current = 0;
+
+ function fillFromSlide(slideIndex) {
+ var slide = slides[slideIndex];
+ var frame = slide && slide.querySelector(".plex-shots__frame");
+ if (!frame) return;
+ current = slideIndex;
+
+ var title = frame.getAttribute("data-zoom-title") || "";
+ var body = frame.getAttribute("data-zoom-body") || "";
+ var light = frame.getAttribute("data-zoom-light") || "";
+ var dark = frame.getAttribute("data-zoom-dark") || "";
+
+ if (titleEl) titleEl.textContent = title;
+ if (bodyEl) bodyEl.textContent = body;
+ if (imgLight) {
+ imgLight.src = light;
+ imgLight.alt = "Portal " + title + " screen, light theme";
+ }
+ if (imgDark) {
+ imgDark.src = dark;
+ imgDark.alt = "Portal " + title + " screen, dark theme";
+ }
+ if (countEl) {
+ countEl.textContent =
+ String(slideIndex + 1).padStart(2, "0") +
+ " / " +
+ String(slides.length).padStart(2, "0");
+ }
+ }
+
+ function open(slideIndex) {
+ // If a previous close() is still in its 260ms post-fade hide
+ // window, cancel the pending hide so the freshly-opened modal
+ // doesn't get yanked back to hidden mid-display.
+ if (lightbox._hideTimer) {
+ clearTimeout(lightbox._hideTimer);
+ lightbox._hideTimer = null;
+ }
+ lastFocus = document.activeElement;
+ fillFromSlide(slideIndex);
+ lightbox.hidden = false;
+ // Force a reflow so the transition runs on the next paint.
+ void lightbox.offsetWidth;
+ lightbox.classList.add("is-open");
+ document.documentElement.classList.add("plex-lightbox-open");
+ // Focus the close button so ESC and Enter both work immediately
+ // and screen-reader users land inside the dialog.
+ if (closeBtn) closeBtn.focus();
+ window.addEventListener("keydown", onKey, true);
+ }
+
+ function close() {
+ lightbox.classList.remove("is-open");
+ document.documentElement.classList.remove("plex-lightbox-open");
+ window.removeEventListener("keydown", onKey, true);
+ // After the fade completes, hide outright so the modal can't
+ // catch tab focus or screen-reader attention.
+ lightbox._hideTimer = setTimeout(function () {
+ lightbox.hidden = true;
+ }, 260);
+ onIndex(current);
+ if (lastFocus && typeof lastFocus.focus === "function") {
+ lastFocus.focus();
+ }
+ lastFocus = null;
+ }
+
+ function onKey(e) {
+ if (e.key === "Escape") {
+ e.stopPropagation();
+ close();
+ } else if (e.key === "ArrowLeft") {
+ e.stopPropagation();
+ e.preventDefault();
+ fillFromSlide((current - 1 + slides.length) % slides.length);
+ } else if (e.key === "ArrowRight") {
+ e.stopPropagation();
+ e.preventDefault();
+ fillFromSlide((current + 1) % slides.length);
+ }
+ }
+
+ // Click handlers on the carousel frames.
+ slides.forEach(function (slide, i) {
+ var frame = slide.querySelector("[data-shots-zoom]");
+ if (!frame) return;
+ frame.addEventListener("click", function (e) {
+ e.preventDefault();
+ // Sync the carousel to the clicked slide first so the
+ // background reflects the lightbox's starting frame. open()
+ // handles its own pending-hide-timer cleanup.
+ if (i !== getIndex()) onIndex(i);
+ open(i);
+ });
+ });
+
+ // Lightbox buttons.
+ if (prevBtn) prevBtn.addEventListener("click", function () {
+ fillFromSlide((current - 1 + slides.length) % slides.length);
+ });
+ if (nextBtn) nextBtn.addEventListener("click", function () {
+ fillFromSlide((current + 1) % slides.length);
+ });
+ closeBtns.forEach(function (btn) {
+ btn.addEventListener("click", function (e) {
+ e.preventDefault();
+ close();
+ });
});
}
diff --git a/docs/operations/inspection.md b/docs/operations/inspection.md
index 2f7d0d4..77883f7 100644
--- a/docs/operations/inspection.md
+++ b/docs/operations/inspection.md
@@ -1,6 +1,6 @@
---
title: Inspection workflow
-description: End-to-end walkthrough of the audit inspection utility — capture a call, open the drawer, replay it, compare to a baseline, filter via JSONB paths, and export.
+description: End-to-end walkthrough of the audit inspection utility (capture a call, open the drawer, replay it, compare to a baseline, filter via JSONB paths, and export).
---
# Inspection workflow
@@ -17,8 +17,8 @@ The audit pipeline records every tool call. The inspection utility is the operat
The pipeline captures every `tools/call` automatically when `audit.enabled: true` (default). Two tables are written in one transaction:
-- `audit_events` — indexed summary (timestamp, tool, user, success, duration). Used for browsing and filtering.
-- `audit_payloads` — full request / response envelope (parameters, headers, response result, response error, notifications, replay linkage). Optional; `capture_payloads: false` keeps the summary only.
+- `audit_events`: indexed summary (timestamp, tool, user, success, duration). Used for browsing and filtering.
+- `audit_payloads`: full request / response envelope (parameters, headers, response result, response error, notifications, replay linkage). Optional; `capture_payloads: false` keeps the summary only.
To produce a fresh row to inspect, fire any tool. The portal's Try-It page (`/portal/tools/`) is the easiest way; any MCP client works too.
@@ -26,11 +26,15 @@ To produce a fresh row to inspect, fire any tool. The portal's Try-It page (`/po
In the portal, navigate to `Audit`. Each row in the events table is clickable; the click opens a side drawer with four tabs:
+
+
+
+
### Overview tab
Timing, identity, request id, session id, source (`mcp` for real client calls, `portal-tryit` for /admin/tryit invocations, `portal-replay` for replays), and the replay linkage (`Replayed from`) when present.
### Request tab
-The captured `request_params` (sanitized via `audit.redact_keys`, with redacted values shown as `"[redacted]"`). Captured request headers when `audit.capture_headers: true` — credential-bearing names (`Authorization`, `Cookie`, `Set-Cookie`, `Proxy-Authorization`, `X-API-Key`) are stored as `"[redacted]"` regardless of the redact-keys config; the names remain visible so an operator can confirm "this request carried an Authorization header" without seeing the token. A truncation warning when the request body exceeded `audit.max_payload_bytes`.
+The captured `request_params` (sanitized via `audit.redact_keys`, with redacted values shown as `"[redacted]"`). Captured request headers when `audit.capture_headers: true`; credential-bearing names (`Authorization`, `Cookie`, `Set-Cookie`, `Proxy-Authorization`, `X-API-Key`) are stored as `"[redacted]"` regardless of the redact-keys config; the names remain visible so an operator can confirm "this request carried an Authorization header" without seeing the token. A truncation warning when the request body exceeded `audit.max_payload_bytes`.
### Response tab
The full `CallToolResult` content blocks (text, image, audio, structured) plus `response_error` when the call errored. The shape matches what the SDK serializes to the wire so you can see what the client saw. A truncation warning fires when the response body was too large.
@@ -41,7 +45,7 @@ Chronological list of every `notifications/*` (progress, log message) the tool d
Drawer interactions:
- The browser URL gets `?id=` appended so the drawer is deep-linkable; share the URL and the recipient lands on the same row.
-- The **Compare** button stashes the open event id in `localStorage`. Open another row's drawer and you'll see "Compare with selected" — clicking opens the comparison page with both events.
+- The **Compare** button stashes the open event id in `localStorage`. Open another row's drawer and you'll see "Compare with selected"; clicking opens the comparison page with both events.
- The **Replay** button is the next step.
- `Esc` and the backdrop close the drawer.
@@ -59,11 +63,15 @@ Per-identity rate limit (scoped by API key id or OIDC subject): 5 burst, one tok
Two events you stashed via the drawer's Compare button can be opened side-by-side at `/portal/audit/compare?a=&b=`. The page renders:
+
+
+
+
- A summary block (tool, source, result, duration, user, auth type) with diffs highlighted.
- Per-payload diff trees for `request_params`, `response_result`, `response_error`, plus a count comparison for notifications.
- Each leaf in the tree is annotated: same (muted), differ (warning color, `before → after`), only-in-A (red `-`), only-in-B (green `+`).
-The diff is JSON-path-aware: it walks objects and arrays by key/index instead of doing a text diff, so reordered keys (a Postgres read returning fields in any order) don't show as changes, and a string-vs-object swap appears as one diff at the path it happened — not as a wall of red lines.
+The diff is JSON-path-aware: it walks objects and arrays by key/index instead of doing a text diff, so reordered keys (a Postgres read returning fields in any order) don't show as changes, and a string-vs-object swap appears as one diff at the path it happened, not as a wall of red lines.
Common compare workflows:
@@ -75,11 +83,15 @@ Common compare workflows:
The Audit page has a **JSONB filters** toggle that opens an editor for the path-aware filters the server compiles to JSONB containment queries. Operators routinely live with these set:
-- `param.user.id=alice` — every call where the request param at the dotted path `user.id` equals `alice`.
-- `response.isError=true` — every call whose response had `IsError=true` (matches the JSON literal `true`, not the string `"true"`; values are type-detected).
-- `header.User-Agent=curl/8.0` — every call from a specific User-Agent. Header names are canonicalized (`user-agent` matches `User-Agent`).
-- `has=response_error` — every call that recorded a transport-level error.
-- `has=notifications` — every call that fired any notification.
+
+
+
+
+- `param.user.id=alice`: every call where the request param at the dotted path `user.id` equals `alice`.
+- `response.isError=true`: every call whose response had `IsError=true` (matches the JSON literal `true`, not the string `"true"`; values are type-detected).
+- `header.User-Agent=curl/8.0`: every call from a specific User-Agent. Header names are canonicalized (`user-agent` matches `User-Agent`).
+- `has=response_error`: every call that recorded a transport-level error.
+- `has=notifications`: every call that fired any notification.
Filters are AND-combined with each other and with the indexed-column filters (tool, user, success, etc.). They run against `audit_payloads` via `EXISTS` subqueries that hit the existing GIN indexes on `request_params` and `response_result`; `request_headers` is unindexed today so pair `header.*` with a time-range filter on busy deployments.
@@ -89,6 +101,10 @@ Filters are AND-combined with each other and with the indexed-column filters (to
The **Live tail** toggle on the Audit page opens an SSE connection to `/api/v1/portal/audit/stream`. New audit events appear in a fixed-cap most-recent-first list (cap 20) above the table as they're written; clicking one opens the drawer. The table itself stays a historical-filter view so the live tail doesn't blow away your filtered context.
+
+
+
+
The stream sends an opening `: connected` comment on connect, an `event: audit\ndata: ` per write, and a `: keepalive` comment every 30 seconds. Slow consumers see per-subscriber drops; the producer never blocks.
## 7. Export
diff --git a/docs/operations/portal.md b/docs/operations/portal.md
index 0d675a2..add3510 100644
--- a/docs/operations/portal.md
+++ b/docs/operations/portal.md
@@ -16,7 +16,8 @@ binary via `go:embed all:dist`. There's no separate frontend server.
| `/portal/` | Dashboard: 1-hour stats and recent activity. |
| `/portal/tools` | Tool catalog grouped by category. |
| `/portal/tools/` | Per-tool detail with Overview / Try It tabs. |
-| `/portal/audit` | Filterable event browser with pagination. |
+| `/portal/audit` | Filterable event browser with pagination, click-to-expand drawer, JSONB filters, SSE live tail. |
+| `/portal/audit/compare` | Side-by-side structural diff of two events; staged from the drawer's Compare button (`?a=&b=`). |
| `/portal/keys` | DB-backed API key management. |
| `/portal/config` | Read-only JSON view of the running config (secrets redacted). |
| `/portal/wellknown` | Pretty-print of the protected-resource and authorization-server metadata that gateways read. |
@@ -50,6 +51,12 @@ A small inline script in `index.html` applies the `.dark` class to
`` before stylesheets load to avoid the classic light-flash on
dark systems.
+## Audit inspection
+
+The audit page is the operator-facing surface for the audit pipeline. Clicking any row opens a four-tab drawer (Overview / Request / Response / Notifications) deep-linked via `?id=`. Inline buttons replay the captured call against the live MCP server (with confirmation; rate-limited per identity) or stash the event for side-by-side comparison at `/portal/audit/compare`. A **Live tail** toggle subscribes to the SSE stream so new events surface above the historical filter view in real time, and a **JSONB filters** toggle opens the editor for the path-aware filters that compile to GIN-indexed containment queries against `audit_payloads`.
+
+The full operator workflow (capture a call, inspect it, replay it, compare to a baseline, filter, export) is in [Inspection workflow](inspection.md).
+
## Try It
The Tools page's **Try It** tab renders a per-tool form: sliders for
diff --git a/docs/overrides/home.html b/docs/overrides/home.html
index 779fbf9..349e362 100644
--- a/docs/overrides/home.html
+++ b/docs/overrides/home.html
@@ -63,45 +63,97 @@
-
- Portal preview
-
Inspect every call from the browser
+ Portal preview
+
Inspect every call from the browser
+
Click any frame to open it full-size. Use the side rails or the arrow keys to step through.
+
+
+
+
+
+
+
+ {% set shots = [
+ ["dashboard", "Dashboard", "Live counts, error rate, p50/p95 latency, and the most recent calls across every tool."],
+ ["audit", "Audit log", "Every call, filterable by tool, user, status, and source. Sanitized parameters and full response shape are one click away."],
+ ["audit-drawer", "Inspection drawer", "Click any row for the four-tab side panel: Overview / Request / Response / Notifications. Replay and Compare are inline; the URL deep-links to the open event so a drawer state is shareable."],
+ ["audit-compare", "Side-by-side compare", "Stash any two events and the comparison page renders a JSON-path-aware structural diff. Walks objects and arrays by key/index, so reordered keys don't masquerade as changes."],
+ ["audit-livetail","Live tail", "SSE stream of new audit events as they're written, surfaced in a fixed-cap buffer above the historical filter view. The table itself stays still so the live read doesn't blow away your filtered context."],
+ ["audit-jsonb", "JSONB filters", "Server-side path filters compile to JSONB containment against the existing GIN indexes: param.=v, response.=v, header.=v, has=. Type-detected values; quote to force string."],
+ ["tools", "Tools", "Twelve test fixtures grouped by category. Pick one to see its schema, run it, or browse the audit rows it produced."],
+ ["tools-tryit", "Try it", "Per-tool form generated from the JSON schema. Sliders, dropdowns, and inline help; progress notifications stream in over SSE."],
+ ["keys", "API keys", "Create or revoke Postgres-backed bcrypt keys. Plaintext is shown once, then never again."],
+ ["wellknown", "Discovery", "RFC 9728 protected-resource metadata and the upstream OIDC authorization-server document, rendered the way an MCP client will see them."],
+ ["config", "Config", "Read-only YAML view of the running server, with secrets masked. Useful for sanity-checking what's actually loaded."]
+ ] %}
+ {% for slug, title, body in shots %}
+
+
+
+ Portal
+
{{ title }}
+
{{ body }}
+
+
+ {% endfor %}
-
-
-
+
+
-
-
- {% set shots = [
- ["dashboard", "Dashboard", "Live counts, error rate, p50/p95 latency, and the most recent calls across every tool."],
- ["audit", "Audit log", "Every call, filterable by tool, user, status, and source. Sanitized parameters and full response shape are one click away."],
- ["tools", "Tools", "Twelve test fixtures grouped by category. Pick one to see its schema, run it, or browse the audit rows it produced."],
- ["tools-tryit", "Try it", "Per-tool form generated from the JSON schema. Sliders, dropdowns, and inline help; progress notifications stream in over SSE."],
- ["keys", "API keys", "Create or revoke Postgres-backed bcrypt keys. Plaintext is shown once, then never again."],
- ["wellknown", "Discovery", "RFC 9728 protected-resource metadata and the upstream OIDC authorization-server document, rendered the way an MCP client will see them."],
- ["config", "Config", "Read-only YAML view of the running server, with secrets masked. Useful for sanity-checking what's actually loaded."]
- ] %}
- {% for slug, title, body in shots %}
-
-
-
-
+
+
+
+ {# Lightbox modal. Hidden by default; shots.js shows it on slide click. #}
+
+
+
+
+
+ Portal
+
-
- Portal
-
{{ title }}
-
{{ body }}
-
+
+
+
+
+
+
+
+
+
+
+
- {% endfor %}
-
diff --git a/docs/stylesheets/extra.css b/docs/stylesheets/extra.css
index d28f6b2..e1b73ff 100644
--- a/docs/stylesheets/extra.css
+++ b/docs/stylesheets/extra.css
@@ -1363,11 +1363,11 @@ body,
@media (min-width: 1024px) { .plex-shots__inner { padding: 0 32px; } }
.plex-shots__head {
- display: flex;
- align-items: end;
- justify-content: space-between;
- gap: 1rem;
- margin-bottom: 1.75rem;
+ display: grid;
+ grid-template-columns: 1fr;
+ gap: 0.4rem;
+ margin-bottom: 1.5rem;
+ max-width: 720px;
}
.plex-shots__head h2 {
font-family: "Outfit", system-ui, sans-serif;
@@ -1375,18 +1375,39 @@ body,
font-size: clamp(1.4rem, 2vw + 0.75rem, 2rem);
line-height: 1.15;
letter-spacing: -0.018em;
- margin: 0.4rem 0 0;
+ margin: 0;
color: var(--md-default-fg-color);
}
[data-md-color-scheme="slate"] .plex-shots__head h2 { color: #fff; }
+.plex-shots__lede {
+ font-family: "DM Sans", system-ui, sans-serif;
+ font-size: 0.875rem;
+ line-height: 1.55;
+ color: var(--md-default-fg-color--light);
+ margin: 0.25rem 0 0;
+}
-.plex-shots__nav {
- display: flex;
- gap: 0.5rem;
+/* Stage = three-column grid: left rail | viewport | right rail. The rails
+ sit alongside the viewport so the active slide always has its
+ navigation immediately at the edge instead of hunting in the header. */
+.plex-shots__stage {
+ display: grid;
+ grid-template-columns: auto minmax(0, 1fr) auto;
+ align-items: center;
+ gap: 0.75rem;
+ position: relative;
+}
+@media (min-width: 1024px) {
+ .plex-shots__stage { gap: 1rem; }
}
-.plex-shots__btn {
- width: 2.4rem;
- height: 2.4rem;
+
+/* Side-rail nav. Buttons sit at the carousel edges, vertically centered.
+ They're chunkier than the old header buttons so they read as primary
+ navigation, not afterthoughts. */
+.plex-shots__rail {
+ flex: 0 0 auto;
+ width: 2.6rem;
+ height: 2.6rem;
display: inline-flex;
align-items: center;
justify-content: center;
@@ -1395,73 +1416,115 @@ body,
border: 1px solid var(--md-default-fg-color--lightest);
color: var(--md-default-fg-color);
cursor: pointer;
- transition: border-color 160ms ease, color 160ms ease, transform 160ms ease;
+ transition: border-color 180ms ease, color 180ms ease,
+ transform 220ms cubic-bezier(0.22, 1, 0.36, 1),
+ box-shadow 180ms ease;
+ box-shadow: 0 1px 2px rgba(15, 23, 42, 0.05),
+ 0 8px 24px -12px rgba(15, 23, 42, 0.18);
}
-.plex-shots__btn:hover {
+@media (min-width: 1024px) {
+ .plex-shots__rail { width: 3rem; height: 3rem; }
+}
+.plex-shots__rail svg { width: 1.25rem; height: 1.25rem; }
+.plex-shots__rail:hover {
border-color: var(--copper-500);
color: var(--copper-700);
+ box-shadow: 0 1px 2px rgba(15, 23, 42, 0.05),
+ 0 12px 28px -10px rgba(20, 184, 171, 0.28);
}
-.plex-shots__btn:active { transform: scale(0.96); }
-.plex-shots__btn[disabled] { opacity: 0.35; cursor: default; }
-.plex-shots__btn svg { width: 1.1rem; height: 1.1rem; }
-
-[data-md-color-scheme="slate"] .plex-shots__btn {
+.plex-shots__rail--prev:hover { transform: translateX(-2px); }
+.plex-shots__rail--next:hover { transform: translateX(2px); }
+.plex-shots__rail:active { transform: scale(0.96); }
+.plex-shots__rail:focus-visible {
+ outline: 2px solid var(--copper-500);
+ outline-offset: 3px;
+}
+[data-md-color-scheme="slate"] .plex-shots__rail {
background: var(--midnight-900);
border-color: var(--midnight-800);
color: #fff;
+ box-shadow: 0 1px 2px rgba(0, 0, 0, 0.45),
+ 0 12px 28px -16px rgba(0, 0, 0, 0.6);
}
-[data-md-color-scheme="slate"] .plex-shots__btn:hover {
+[data-md-color-scheme="slate"] .plex-shots__rail:hover {
border-color: var(--copper-400);
color: var(--copper-300);
+ box-shadow: 0 1px 2px rgba(0, 0, 0, 0.45),
+ 0 12px 28px -10px rgba(45, 211, 203, 0.28);
+}
+
+/* Counter under the stage: "3 / 11". Decimal-tabular figures so the digits
+ don't jiggle as the index changes. */
+.plex-shots__counter {
+ margin: 1rem auto 0;
+ text-align: center;
+ font-family: "DM Sans", system-ui, sans-serif;
+ font-feature-settings: "tnum" 1;
+ font-size: 0.7rem;
+ letter-spacing: 0.16em;
+ text-transform: uppercase;
+ color: var(--md-default-fg-color--light);
+}
+[data-md-color-scheme="slate"] .plex-shots__counter { color: var(--midnight-300); }
+.plex-shots__counter strong {
+ color: var(--copper-700);
+ font-weight: 600;
+ margin-right: 0.15em;
}
+[data-md-color-scheme="slate"] .plex-shots__counter strong { color: var(--copper-300); }
-/* Outer viewport: clips the off-screen slides. The track itself slides via
- `transform: translateX(...)` controlled by JS. */
+/* Outer viewport: clips off-screen slides. The track inside translates via
+ JS-computed offsets. */
.plex-shots__viewport {
overflow: hidden;
- padding: 0.5rem 0 1.5rem;
+ padding: 0.5rem 0 0.75rem;
}
.plex-shots__track {
display: flex;
- gap: 1.25rem;
+ gap: 1rem;
transform: translate3d(0, 0, 0);
transition: transform 520ms cubic-bezier(0.22, 1, 0.36, 1);
will-change: transform;
}
@media (min-width: 1024px) {
- .plex-shots__track { gap: 1.5rem; }
+ .plex-shots__track { gap: 1.25rem; }
}
@media (prefers-reduced-motion: reduce) {
- .plex-shots__track { transition: none; }
-}
-.plex-shots__track::-webkit-scrollbar { height: 6px; }
-.plex-shots__track::-webkit-scrollbar-thumb {
- background: var(--copper-500);
- border-radius: 3px;
+ .plex-shots__track,
+ .plex-shots__slide,
+ .plex-shots__rail,
+ .plex-shots__zoomhint,
+ .plex-shots__frame img { transition: none; }
}
-/* Material's `.md-typeset figure { width: fit-content }` outranks a plain
- `.plex-shots__slide` selector, so we double up the class for specificity
- instead of reaching for !important. */
+/* Slides ~30% smaller than the previous layout so neighbors are always
+ peeking in. Material's `.md-typeset figure { width: fit-content }`
+ outranks a plain `.plex-shots__slide` selector, so we double up the
+ class for specificity instead of reaching for !important. */
.plex-shots__slide.plex-shots__slide {
flex: 0 0 auto;
- width: min(66vw, 704px);
+ width: min(72vw, 480px);
border-radius: var(--plex-radius-lg);
- overflow: hidden;
background: var(--md-default-bg-color);
border: 1px solid var(--md-default-fg-color--lightest);
display: flex;
flex-direction: column;
margin: 0;
+ overflow: hidden;
box-shadow: 0 1px 2px rgba(15, 23, 42, 0.04),
0 12px 32px -16px rgba(15, 23, 42, 0.18);
- transition: border-color 200ms ease, box-shadow 200ms ease, opacity 520ms ease;
- opacity: 0.55;
+ transition: border-color 200ms ease, box-shadow 200ms ease,
+ opacity 520ms ease, transform 520ms cubic-bezier(0.22, 1, 0.36, 1);
+ opacity: 0.5;
+ transform: scale(0.96);
}
-.plex-shots__slide.is-active { opacity: 1; }
@media (min-width: 1024px) {
- .plex-shots__slide.plex-shots__slide { width: min(56vw, 832px); }
+ .plex-shots__slide.plex-shots__slide { width: min(40vw, 580px); }
+}
+.plex-shots__slide.is-active {
+ opacity: 1;
+ transform: scale(1);
}
.plex-shots__slide:hover {
border-color: var(--copper-500);
@@ -1475,12 +1538,25 @@ body,
0 24px 48px -20px rgba(0, 0, 0, 0.6);
}
+/* Frame is now an interactive button (the click target for the lightbox).
+ Strip default button chrome and rebuild as a flush surface. The
+ zoomhint badge slides in on hover/focus to telegraph the action. */
.plex-shots__frame {
position: relative;
aspect-ratio: 1440 / 900;
background: var(--md-default-bg-color);
overflow: hidden;
+ border: 0;
border-bottom: 1px solid var(--md-default-fg-color--lightest);
+ padding: 0;
+ margin: 0;
+ font: inherit;
+ color: inherit;
+ cursor: zoom-in;
+ display: block;
+ width: 100%;
+ text-align: left;
+ transition: filter 220ms ease;
}
[data-md-color-scheme="slate"] .plex-shots__frame {
background: var(--midnight-950);
@@ -1494,15 +1570,53 @@ body,
object-fit: cover;
object-position: top center;
display: block;
+ transition: transform 1200ms cubic-bezier(0.22, 1, 0.36, 1);
+}
+.plex-shots__slide.is-active .plex-shots__frame:hover img,
+.plex-shots__slide.is-active .plex-shots__frame:focus-visible img {
+ transform: scale(1.025);
+}
+.plex-shots__frame:focus-visible {
+ outline: 2px solid var(--copper-500);
+ outline-offset: -2px;
}
-/* Theme-gated visibility: show the matching screenshot, hide the other.
- Defaults assume light scheme; slate scheme flips it. */
+/* Theme-gated visibility: show the matching screenshot, hide the other. */
.plex-shots__frame img[data-theme="dark"] { display: none; }
.plex-shots__frame img[data-theme="light"] { display: block; }
[data-md-color-scheme="slate"] .plex-shots__frame img[data-theme="light"] { display: none; }
[data-md-color-scheme="slate"] .plex-shots__frame img[data-theme="dark"] { display: block; }
+/* Zoom hint: small copper-tinted glyph in the top-right corner of the
+ active slide's frame. Stays subtle until hover, then lifts. */
+.plex-shots__zoomhint {
+ position: absolute;
+ top: 0.65rem;
+ right: 0.65rem;
+ width: 1.85rem;
+ height: 1.85rem;
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ border-radius: 0.5rem;
+ background: rgba(15, 23, 42, 0.55);
+ color: #fff;
+ backdrop-filter: blur(6px);
+ -webkit-backdrop-filter: blur(6px);
+ opacity: 0;
+ transform: translateY(-4px);
+ transition: opacity 180ms ease, transform 220ms cubic-bezier(0.22, 1, 0.36, 1),
+ background-color 180ms ease;
+ pointer-events: none;
+}
+.plex-shots__zoomhint svg { width: 1rem; height: 1rem; }
+.plex-shots__slide.is-active .plex-shots__zoomhint { opacity: 0.85; transform: translateY(0); }
+.plex-shots__frame:hover .plex-shots__zoomhint,
+.plex-shots__frame:focus-visible .plex-shots__zoomhint {
+ opacity: 1;
+ background: var(--copper-700);
+}
+
.plex-shots__caption {
padding: 1rem 1.25rem 1.1rem;
display: flex;
@@ -1541,3 +1655,308 @@ body,
@media (prefers-reduced-motion: reduce) {
.plex-shots__track { scroll-behavior: auto; }
}
+
+/* ─────────────────────────────────────────────────────────────────────────
+ Lightbox: focused-artifact view of a single screenshot.
+
+ Aesthetic: the rest of the page recedes behind a deep midnight backdrop
+ with a faint copper haze; the screenshot floats as a single object
+ inside a panel that uses the same border / radius / shadow vocabulary
+ as the carousel slides, just sized up. Caption sits below the image
+ in the same Outfit + DM Sans typography so the modal feels like part
+ of the document, not a foreign overlay. Motion vocabulary mirrors the
+ carousel (same cubic-bezier(0.22, 1, 0.36, 1)) so opening one feels
+ like the carousel zooming into focus.
+ ───────────────────────────────────────────────────────────────────── */
+
+.plex-lightbox {
+ position: fixed;
+ inset: 0;
+ z-index: 9999;
+ display: grid;
+ place-items: center;
+ padding: clamp(0.75rem, 2vw, 2rem);
+ opacity: 0;
+ pointer-events: none;
+ transition: opacity 220ms cubic-bezier(0.22, 1, 0.36, 1);
+}
+.plex-lightbox[hidden] { display: none; }
+.plex-lightbox.is-open {
+ opacity: 1;
+ pointer-events: auto;
+}
+
+.plex-lightbox__backdrop {
+ position: absolute;
+ inset: 0;
+ background:
+ radial-gradient(
+ ellipse at 50% 20%,
+ rgba(20, 184, 171, 0.10) 0%,
+ transparent 60%
+ ),
+ rgba(7, 18, 36, 0.78);
+ backdrop-filter: blur(8px) saturate(120%);
+ -webkit-backdrop-filter: blur(8px) saturate(120%);
+ cursor: zoom-out;
+}
+[data-md-color-scheme="slate"] .plex-lightbox__backdrop {
+ background:
+ radial-gradient(
+ ellipse at 50% 20%,
+ rgba(45, 211, 203, 0.13) 0%,
+ transparent 60%
+ ),
+ rgba(2, 6, 12, 0.82);
+}
+
+.plex-lightbox__shell {
+ position: relative;
+ width: min(96vw, 1480px);
+ max-height: 92vh;
+ display: flex;
+ flex-direction: column;
+ background: var(--md-default-bg-color);
+ border: 1px solid var(--md-default-fg-color--lightest);
+ border-radius: var(--plex-radius-lg);
+ box-shadow:
+ 0 1px 0 rgba(255, 255, 255, 0.04) inset,
+ 0 24px 60px -20px rgba(0, 0, 0, 0.55),
+ 0 64px 120px -40px rgba(20, 184, 171, 0.18);
+ overflow: hidden;
+ transform: translateY(8px) scale(0.98);
+ transition: transform 260ms cubic-bezier(0.22, 1, 0.36, 1);
+}
+.plex-lightbox.is-open .plex-lightbox__shell { transform: translateY(0) scale(1); }
+[data-md-color-scheme="slate"] .plex-lightbox__shell {
+ background: var(--midnight-950);
+ border-color: var(--midnight-800);
+ box-shadow:
+ 0 1px 0 rgba(255, 255, 255, 0.03) inset,
+ 0 24px 60px -20px rgba(0, 0, 0, 0.7),
+ 0 64px 120px -40px rgba(45, 211, 203, 0.18);
+}
+
+/* Top bar: title on the left, count + nav + close on the right. Compact
+ toolbar height (~3.25rem) so the screenshot, not the chrome, is
+ what dominates the viewport. Material's `.md-typeset h3` and span
+ defaults are aggressive on margins; the rules below use element-
+ qualified selectors so they tie on specificity (0,1,1) and source
+ order picks the lightbox style as the winner. */
+.plex-lightbox__bar {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 0.75rem;
+ padding: 0.55rem 0.65rem 0.55rem 1rem;
+ min-height: 0;
+ border-bottom: 1px solid var(--md-default-fg-color--lightest);
+ background: linear-gradient(
+ 180deg,
+ color-mix(in srgb, var(--md-default-bg-color) 96%, var(--copper-500)) 0%,
+ var(--md-default-bg-color) 100%
+ );
+}
+[data-md-color-scheme="slate"] .plex-lightbox__bar {
+ border-color: var(--midnight-800);
+ background: linear-gradient(
+ 180deg,
+ color-mix(in srgb, var(--midnight-900) 88%, var(--copper-700)) 0%,
+ var(--midnight-900) 100%
+ );
+}
+
+.plex-lightbox__meta {
+ display: flex;
+ align-items: baseline;
+ gap: 0.6rem;
+ min-width: 0;
+ flex: 1 1 auto;
+}
+/* Element-qualified selectors below: Material's `.md-typeset h3`
+ (0,1,1) and span defaults beat single-class rules on specificity, so
+ we tag the element explicitly to win. */
+span.plex-lightbox__eyebrow {
+ font-family: "DM Sans", system-ui, sans-serif;
+ font-size: 0.65rem;
+ font-weight: 500;
+ letter-spacing: 0.16em;
+ text-transform: uppercase;
+ color: var(--copper-700);
+ margin: 0;
+ line-height: 1;
+ flex: 0 0 auto;
+}
+[data-md-color-scheme="slate"] span.plex-lightbox__eyebrow { color: var(--copper-300); }
+h3.plex-lightbox__title {
+ font-family: "Outfit", system-ui, sans-serif;
+ font-weight: 600;
+ font-size: 0.95rem;
+ letter-spacing: -0.012em;
+ margin: 0;
+ padding: 0;
+ color: var(--md-default-fg-color);
+ line-height: 1.2;
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ min-width: 0;
+ flex: 1 1 auto;
+}
+@media (min-width: 720px) {
+ h3.plex-lightbox__title { font-size: 1.05rem; }
+}
+[data-md-color-scheme="slate"] h3.plex-lightbox__title { color: #fff; }
+
+.plex-lightbox__controls {
+ display: flex;
+ align-items: center;
+ gap: 0.4rem;
+}
+.plex-lightbox__count {
+ font-family: "DM Sans", system-ui, sans-serif;
+ font-feature-settings: "tnum" 1;
+ font-size: 0.7rem;
+ letter-spacing: 0.14em;
+ text-transform: uppercase;
+ color: var(--md-default-fg-color--light);
+ margin-right: 0.35rem;
+}
+[data-md-color-scheme="slate"] .plex-lightbox__count { color: var(--midnight-300); }
+
+.plex-lightbox__navbtn,
+.plex-lightbox__close {
+ width: 2.1rem;
+ height: 2.1rem;
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ border-radius: 999px;
+ background: transparent;
+ border: 1px solid var(--md-default-fg-color--lightest);
+ color: var(--md-default-fg-color);
+ cursor: pointer;
+ transition: border-color 160ms ease, color 160ms ease,
+ background-color 160ms ease, transform 200ms cubic-bezier(0.22, 1, 0.36, 1);
+}
+.plex-lightbox__navbtn svg,
+.plex-lightbox__close svg { width: 1rem; height: 1rem; }
+.plex-lightbox__navbtn:hover,
+.plex-lightbox__close:hover {
+ border-color: var(--copper-500);
+ color: var(--copper-700);
+ background: color-mix(in srgb, var(--copper-500) 8%, transparent);
+}
+.plex-lightbox__navbtn:active,
+.plex-lightbox__close:active { transform: scale(0.94); }
+.plex-lightbox__navbtn:focus-visible,
+.plex-lightbox__close:focus-visible {
+ outline: 2px solid var(--copper-500);
+ outline-offset: 2px;
+}
+[data-md-color-scheme="slate"] .plex-lightbox__navbtn,
+[data-md-color-scheme="slate"] .plex-lightbox__close {
+ border-color: var(--midnight-700);
+ color: #fff;
+}
+[data-md-color-scheme="slate"] .plex-lightbox__navbtn:hover,
+[data-md-color-scheme="slate"] .plex-lightbox__close:hover {
+ border-color: var(--copper-400);
+ color: var(--copper-300);
+ background: color-mix(in srgb, var(--copper-500) 12%, transparent);
+}
+
+/* Stage: the screenshot itself. We use object-fit:contain so the entire
+ image fits without cropping; aspect-ratio keeps the panel proportional
+ so a missing image doesn't collapse the layout. */
+.plex-lightbox__stage {
+ position: relative;
+ margin: 0;
+ padding: clamp(0.75rem, 1.5vw, 1.5rem);
+ display: flex;
+ flex-direction: column;
+ gap: 0.85rem;
+ min-height: 0;
+ flex: 1 1 auto;
+ overflow: auto;
+ background: linear-gradient(
+ 180deg,
+ var(--md-default-bg-color) 0%,
+ color-mix(in srgb, var(--md-default-bg-color) 92%, var(--copper-500)) 100%
+ );
+}
+[data-md-color-scheme="slate"] .plex-lightbox__stage {
+ background: linear-gradient(
+ 180deg,
+ var(--midnight-950) 0%,
+ color-mix(in srgb, var(--midnight-950) 86%, var(--copper-700)) 100%
+ );
+}
+
+.plex-lightbox__img {
+ display: block;
+ width: 100%;
+ height: auto;
+ /* Budget ~5.5rem for the toolbar + caption + stage padding so the
+ screenshot owns the rest. Was 9rem when the bar was ~3x taller. */
+ max-height: calc(92vh - 5.5rem);
+ object-fit: contain;
+ border-radius: calc(var(--plex-radius-lg) - 6px);
+ border: 1px solid var(--md-default-fg-color--lightest);
+ background: var(--md-default-bg-color);
+ box-shadow: 0 12px 32px -16px rgba(15, 23, 42, 0.35);
+}
+[data-md-color-scheme="slate"] .plex-lightbox__img {
+ border-color: var(--midnight-800);
+ background: var(--midnight-900);
+ box-shadow: 0 12px 32px -16px rgba(0, 0, 0, 0.6);
+}
+
+/* Theme-gated visibility for the two stacked img tags inside the
+ lightbox stage. Same pattern as the carousel frame. */
+.plex-lightbox__img[data-theme="dark"] { display: none; }
+.plex-lightbox__img[data-theme="light"] { display: block; }
+[data-md-color-scheme="slate"] .plex-lightbox__img[data-theme="light"] { display: none; }
+[data-md-color-scheme="slate"] .plex-lightbox__img[data-theme="dark"] { display: block; }
+
+.plex-lightbox__caption {
+ font-family: "DM Sans", system-ui, sans-serif;
+ font-size: 0.9rem;
+ line-height: 1.6;
+ color: var(--md-default-fg-color--light);
+ text-align: left;
+ max-width: 70ch;
+ margin: 0 auto;
+}
+[data-md-color-scheme="slate"] .plex-lightbox__caption { color: var(--midnight-200); }
+
+@media (max-width: 720px) {
+ .plex-lightbox { padding: 0; }
+ .plex-lightbox__shell {
+ width: 100vw;
+ max-height: 100vh;
+ height: 100vh;
+ border-radius: 0;
+ border: 0;
+ }
+ .plex-lightbox__bar { padding: 0.7rem 0.85rem; }
+ .plex-lightbox__count { display: none; }
+ .plex-lightbox__navbtn,
+ .plex-lightbox__close { width: 2rem; height: 2rem; }
+ .plex-lightbox__img { max-height: calc(100vh - 5.5rem); }
+}
+
+@media (prefers-reduced-motion: reduce) {
+ .plex-lightbox,
+ .plex-lightbox__shell {
+ transition: none;
+ }
+}
+
+/* When the lightbox is open, lock the body scroll. JS toggles this class
+ on . We avoid `overflow:hidden` on body alone because Material's
+ layout fights it. */
+html.plex-lightbox-open,
+html.plex-lightbox-open body {
+ overflow: hidden;
+}
diff --git a/mkdocs.yml b/mkdocs.yml
index b4ea5dd..4758c02 100644
--- a/mkdocs.yml
+++ b/mkdocs.yml
@@ -77,6 +77,7 @@ nav:
- Streaming: tools/streaming.md
- Operations:
- Audit Log: operations/audit.md
+ - Inspection workflow: operations/inspection.md
- Portal: operations/portal.md
- Deployment: operations/deployment.md
- Testing a Gateway: operations/gateway-testing.md
diff --git a/scripts/screenshots/README.md b/scripts/screenshots/README.md
new file mode 100644
index 0000000..984f5ce
--- /dev/null
+++ b/scripts/screenshots/README.md
@@ -0,0 +1,73 @@
+# Portal screenshots
+
+Generates the `docs/images/portal/*-{light,dark}.png` set the documentation site embeds (homepage carousel + inline captures in `docs/operations/inspection.md`). Idempotent: the script truncates `audit_events` / `audit_payloads` / `api_keys` and re-seeds deterministic mock data each run.
+
+## Prerequisites
+
+The dev stack must be running. Open a separate terminal:
+
+```sh
+make dev
+```
+
+This brings up Postgres, Keycloak, and the mcp-test binary on `http://localhost:8080`. The script connects to the same Postgres to seed mock data and points a headless Chromium at the same binary to capture frames.
+
+## Capture
+
+From the repo root:
+
+```sh
+make screenshots
+```
+
+This runs `node scripts/screenshots/screenshots.mjs`. On first run it `npm install`s the script's `package.json` (Playwright + `pg`).
+
+Override host / API key for non-default deployments:
+
+```sh
+SHOTS_BASE_URL=https://staging.example.com \
+SHOTS_API_KEY=$REAL_KEY \
+make screenshots
+```
+
+## What gets captured
+
+Twelve screens × two themes = 24 PNGs at 1440×900 @ 2x DPR. The homepage carousel embeds 11 of these (login is captured but kept out of the rotation).
+
+| slug | shows |
+| --- | --- |
+| `login` | Sign-in screen (no auth required for capture). |
+| `dashboard` | 1-hour stats + recent activity table. |
+| `tools` | Tool catalog grouped by category. |
+| `tools-tryit` | The Try-It form for the `progress` tool. |
+| `audit` | Filterable event browser, populated with seeded data. |
+| `audit-drawer` | Drawer open over the events table; deep-linked to a seeded event id with payload + notifications. |
+| `audit-compare` | `/portal/audit/compare` side-by-side diff of two seeded events. |
+| `audit-livetail` | Live-tail toggle on, SSE stream connected, buffer rendering above the table. |
+| `audit-jsonb` | JSONB filter editor expanded with `param.user.id=alice` applied. |
+| `keys` | API key listing. |
+| `config` | Read-only config viewer. |
+| `wellknown` | Discovery metadata. |
+
+## Preview
+
+After capturing, two preview paths:
+
+```sh
+open docs/images/portal/ # raw PNG view in Finder / Preview
+make docs-serve # http://127.0.0.1:8001 (full site context)
+```
+
+`make docs-serve` is the closer-to-production view: the homepage carousel cycles through the screenshots (theme-paired with `data-theme` attributes; the page footer toggle swaps which one is visible), and `inspection.md` embeds use the mkdocs-material `#only-light` / `#only-dark` URL fragments to switch per the reader's selected theme.
+
+## When to re-run
+
+Any portal UI change that would shift pixels: layout tweaks, copy edits, new components, theme adjustments. The seed step is deterministic (a fixed PRNG seed produces the same audit events across runs), so re-running on an unchanged binary gives byte-stable PNGs, meaning git diffs only show real visual changes.
+
+## Troubleshooting
+
+**`Postgres connection failed`**: `make dev` isn't running or the stack hasn't finished starting. `make dev-wait` blocks until both Postgres and Keycloak are reachable.
+
+**`Drawer empty / "No response captured"`**: `audit_payloads` seed didn't run; check the Postgres logs for `relation "audit_payloads" does not exist`. Run migrations: `make migrate`.
+
+**`Timeout waiting for selector role="dialog"`**: the deep-link target id wasn't seeded. The script picks the two most-recent successful payload events for the drawer / compare captures; if the random seed produces fewer than two successful payloads (very unlikely with 100 events), the script aborts before capture with a clear message.
diff --git a/scripts/screenshots/screenshots.mjs b/scripts/screenshots/screenshots.mjs
index 98bca8b..a65e1f6 100644
--- a/scripts/screenshots/screenshots.mjs
+++ b/scripts/screenshots/screenshots.mjs
@@ -34,6 +34,11 @@ const OUT_DIR = resolve(REPO_ROOT, "docs/images/portal");
const VIEWPORT = { width: 1440, height: 900 };
const DEVICE_SCALE = 2; // retina-sharp screenshots
+// PAGES: each entry produces one screenshot per theme. `path` may be a
+// string or a 0-arg function (so deep-link targets can reference state
+// stashed by seed(), e.g. DRAWER_TARGET_ID). `prep` runs after navigation
+// to drive the UI into the right state for the capture (open a drawer,
+// toggle live tail, expand a panel).
const PAGES = [
{ slug: "login", path: "/portal/login", requiresAuth: false, prep: null },
{ slug: "dashboard", path: "/portal/", requiresAuth: true, prep: null },
@@ -46,6 +51,72 @@ const PAGES = [
await page.waitForTimeout(200);
} },
{ slug: "audit", path: "/portal/audit", requiresAuth: true, prep: null },
+
+ // v1.2 inspection-utility captures.
+ //
+ // audit-drawer: deep-link to a seeded event with payload so the four
+ // tabs (Overview / Request / Response / Notifications) all have
+ // meaningful content. Targets a successful "progress" or similar tool
+ // call so notifications render.
+ { slug: "audit-drawer",
+ path: () => `/portal/audit?id=${DRAWER_TARGET_ID}`,
+ requiresAuth: true,
+ prep: async (page) => {
+ // Wait for the drawer panel (role=dialog) to mount and for the
+ // detail query to resolve so the header shows the tool name
+ // rather than the loading spinner.
+ await page.waitForSelector('[role="dialog"][aria-label="Audit event detail"]', { timeout: 5000 });
+ await page.waitForTimeout(500);
+ } },
+
+ // audit-compare: side-by-side diff. Two seeded event ids piped via
+ // the URL params; the page renders Summary + per-payload diff trees.
+ { slug: "audit-compare",
+ path: () => `/portal/audit/compare?a=${COMPARE_A_ID}&b=${COMPARE_B_ID}`,
+ requiresAuth: true,
+ prep: async (page) => {
+ // Wait for both event queries to resolve before snapping; the
+ // headers go from "Loading..." to the tool name on success.
+ await page.waitForSelector('text="Compare events"', { timeout: 5000 });
+ await page.waitForLoadState("networkidle");
+ await page.waitForTimeout(400);
+ } },
+
+ // audit-livetail: live tail toggled on with the buffer visible.
+ // Doesn't actually need new SSE traffic to capture; the toggle is
+ // visually distinctive (animate-pulse Radio icon, success-colored
+ // border, "Waiting for events..." or buffer rendered above table).
+ { slug: "audit-livetail",
+ path: "/portal/audit",
+ requiresAuth: true,
+ prep: async (page) => {
+ const tail = page.locator('button:has-text("Live tail")').first();
+ await tail.click();
+ // Give the SSE handshake a beat to land before snapping. The
+ // server emits a `: connected` comment immediately, but the
+ // empty-buffer state ("Waiting for events...") is also a valid
+ // shot.
+ await page.waitForTimeout(800);
+ } },
+
+ // audit-jsonb: filter editor expanded with one sample filter applied.
+ { slug: "audit-jsonb",
+ path: "/portal/audit",
+ requiresAuth: true,
+ prep: async (page) => {
+ const toggle = page.locator('button:has-text("JSONB filters")').first();
+ await toggle.click();
+ await page.waitForTimeout(200);
+ // Add a representative `param.user.id=alice` filter so the
+ // editor and the active-filter chip both render.
+ const pathInput = page.locator('input[placeholder="dotted.path"]').first();
+ const valueInput = page.locator('input[placeholder="value"]').first();
+ await pathInput.fill("user.id");
+ await valueInput.fill("alice");
+ await page.locator('button:has-text("add")').first().click();
+ await page.waitForTimeout(400);
+ } },
+
{ slug: "keys", path: "/portal/keys", requiresAuth: true, prep: null },
{ slug: "config", path: "/portal/config", requiresAuth: true, prep: null },
{ slug: "wellknown", path: "/portal/wellknown", requiresAuth: true, prep: null },
@@ -157,8 +228,11 @@ async function seed() {
await client.connect();
try {
- console.log("→ truncating audit_events + api_keys");
- await client.query("TRUNCATE audit_events");
+ console.log("→ truncating audit_events + audit_payloads + api_keys");
+ // audit_payloads cascades on audit_events delete, but TRUNCATE is
+ // explicit per table so the order doesn't matter here.
+ await client.query("TRUNCATE audit_payloads");
+ await client.query("TRUNCATE audit_events CASCADE");
await client.query("TRUNCATE api_keys");
console.log("→ inserting api_keys");
@@ -214,12 +288,142 @@ async function seed() {
values,
);
- console.log(`✓ seeded ${events.length} audit events + ${apiKeys.length} api keys`);
+ // Seed audit_payloads for the 20 most-recent events so the v1.2
+ // drawer / compare / inspection screenshots have meaningful Request
+ // / Response / Notifications tabs to render. The other 80 events
+ // stay summary-only; that's a realistic mix for a deployment with
+ // capture_payloads on but post-retention payload pruning.
+ console.log("→ inserting audit_payloads (20 most-recent events)");
+ const recent = [...events]
+ .sort((a, b) => b.ts.getTime() - a.ts.getTime())
+ .slice(0, 20);
+ for (const e of recent) {
+ // Build payload request_params from the seeded summary params (when
+ // present), and inject a realistic gateway-context block: `user.id`
+ // mirrors the audit row's identity so demo JSONB filters like
+ // `param.user.id=alice` actually return matches; tenant/region are
+ // there to make the JSON view look like a real production payload.
+ const baseParams = e.parameters ? JSON.parse(e.parameters) : {};
+ const userId = e.user_email
+ ? e.user_email.split("@")[0]
+ : (e.api_key_name || "anonymous");
+ const params = {
+ ...baseParams,
+ user: {
+ id: userId,
+ tenant: pick(["acme", "globex", "initech"]),
+ },
+ request_id: e.request_id,
+ };
+ let result = null;
+ let errBlob = null;
+ let notifications = null;
+ if (e.success) {
+ // Build a plausible-looking CallToolResult JSON for the Response tab.
+ result = {
+ content: [
+ { type: "text", text: makeResponseText(e.tool_name, params) },
+ ],
+ isError: false,
+ };
+ if (e.tool_name === "progress" || e.tool_name === "chatty") {
+ notifications = makeProgressNotifications(e.ts, params);
+ }
+ } else {
+ errBlob = { message: e.error_message, category: e.error_category };
+ }
+ await client.query(
+ `INSERT INTO audit_payloads (
+ event_id, jsonrpc_method,
+ request_params, request_size_bytes,
+ request_headers, request_remote_addr,
+ response_result, response_error, response_size_bytes,
+ notifications, notifications_truncated,
+ captured_at
+ ) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12)`,
+ [
+ e.id,
+ "tools/call",
+ JSON.stringify(params),
+ e.request_chars,
+ JSON.stringify({
+ "User-Agent": [e.user_agent],
+ "X-Forwarded-For": [e.remote_addr],
+ "Content-Type": ["application/json"],
+ // Sensitive headers are stored redacted; mirror the pkg/auth
+ // RedactHeaders contract so the drawer Request tab shows the
+ // [redacted] values an operator will see in production.
+ "Authorization": ["[redacted]"],
+ "Cookie": ["[redacted]"],
+ }),
+ e.remote_addr,
+ result ? JSON.stringify(result) : null,
+ errBlob ? JSON.stringify(errBlob) : null,
+ e.response_chars,
+ notifications ? JSON.stringify(notifications) : null,
+ false,
+ e.ts,
+ ],
+ );
+ }
+
+ console.log(`✓ seeded ${events.length} audit events (${recent.length} with payloads) + ${apiKeys.length} api keys`);
+
+ // Stash the two most-recent successful event ids so the capture
+ // step can navigate to /audit?id= and /audit/compare?a=<>&b=<>
+ // without guessing.
+ const successful = recent.filter((e) => e.success);
+ if (successful.length < 2) {
+ throw new Error("seed produced fewer than 2 successful payload events; reduce errorRate or increase event count");
+ }
+ DRAWER_TARGET_ID = successful[0].id;
+ COMPARE_A_ID = successful[0].id;
+ COMPARE_B_ID = successful[1].id;
} finally {
await client.end();
}
}
+// makeResponseText shapes a plausible response-block string per tool so
+// the Response tab renders something that looks like the real call.
+function makeResponseText(tool, params) {
+ switch (tool) {
+ case "echo": return JSON.stringify({ ...params, echoed_at: new Date().toISOString() });
+ case "fixed_response": return JSON.stringify({ key: params.key, value: "static-fixture" });
+ case "sized_response": return "x".repeat(Math.min(params.size, 240)) + "...";
+ case "lorem": return "Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed do eiusmod...";
+ case "whoami": return JSON.stringify({ subject: "alice@example.com", auth_type: "oidc" });
+ case "headers": return JSON.stringify({ "User-Agent": "claude-code/1.0", "X-Forwarded-For": "10.0.1.42" });
+ case "slow": return JSON.stringify({ slept_ms: params.milliseconds });
+ case "progress": return JSON.stringify({ steps_completed: params.steps });
+ default: return `result for ${tool}`;
+ }
+}
+
+function makeProgressNotifications(eventTs, params) {
+ const steps = params.steps ?? 5;
+ const stepMs = params.step_ms ?? 200;
+ const out = [];
+ for (let i = 1; i <= steps; i++) {
+ out.push({
+ ts: new Date(eventTs.getTime() - (steps - i) * stepMs).toISOString(),
+ method: "notifications/progress",
+ params: {
+ progressToken: "demo-token",
+ progress: i,
+ total: steps,
+ message: `step ${i}/${steps}`,
+ },
+ });
+ }
+ return out;
+}
+
+// Filled by seed() and consumed by the capture step's prep functions.
+let DRAWER_TARGET_ID = null;
+let COMPARE_A_ID = null;
+let COMPARE_B_ID = null;
+
// ---------------------------------------------------------------------------
// Capture
// ---------------------------------------------------------------------------
@@ -262,7 +466,8 @@ async function capture() {
{ themeSlug: theme.slug, apiKey: API_KEY, requiresAuth: target.requiresAuth },
);
- await page.goto(`${BASE_URL}${target.path}`, { waitUntil: "networkidle" });
+ const targetPath = typeof target.path === "function" ? target.path() : target.path;
+ await page.goto(`${BASE_URL}${targetPath}`, { waitUntil: "networkidle" });
// Some portal pages trigger queries; wait a short beat for them to settle.
await page.waitForTimeout(500);