diff --git a/frontend/src/components/AskEvidenceLayerPopup.stories.tsx b/frontend/src/components/AskEvidenceLayerPopup.stories.tsx
new file mode 100644
index 000000000..070ba40f5
--- /dev/null
+++ b/frontend/src/components/AskEvidenceLayerPopup.stories.tsx
@@ -0,0 +1,94 @@
+import type { Meta, StoryObj } from "@storybook/react-vite";
+import { AskEvidenceLayerPopup } from "./AskEvidenceLayerPopup";
+
+const meta = {
+ title: "Evidence/AskEvidenceLayerPopup",
+ component: AskEvidenceLayerPopup,
+ args: {
+ postId: "post-demo-public",
+ postTitle: "Checkout error follow-up",
+ facts: [
+ { kind: "semantic_project", text: "project: Checkout revamp | evidence: Body evidence" },
+ { kind: "semantic_keyman", text: "Keyman mention: Ada West | context: account lead" },
+ ],
+ images: [
+ {
+ unit_index: 1,
+ caption: "Screenshot of the checkout error",
+ extracted_text: "Error code 500 on checkout",
+ },
+ ],
+ onClose: () => undefined,
+ onOpenPost: () => undefined,
+ },
+} satisfies Meta
;
+
+export default meta;
+
+type Story = StoryObj;
+
+export const Default: Story = {};
+
+export const TextEvidenceOnly: Story = {
+ args: {
+ images: [],
+ },
+};
+
+export const ImageEvidenceOnly: Story = {
+ args: {
+ facts: [],
+ },
+};
+
+// Edge case: a citation with no persisted evidence facts or images at all --
+// must show an explicit placeholder, never a blank panel.
+export const NoEvidence: Story = {
+ args: {
+ facts: [],
+ images: [],
+ },
+};
+
+// Edge case: an image evidence entry whose OCR text was never extracted.
+export const ImageWithoutExtractedText: Story = {
+ args: {
+ facts: [],
+ images: [
+ {
+ unit_index: 0,
+ caption: "Architecture diagram",
+ extracted_text: null,
+ },
+ ],
+ },
+};
+
+// Edge case: an untitled/uncaptioned image.
+export const UntitledImage: Story = {
+ args: {
+ facts: [],
+ images: [
+ {
+ unit_index: 0,
+ caption: null,
+ extracted_text: null,
+ },
+ ],
+ },
+};
+
+// Edge case: some sources persist an empty caption rather than null. The buyer
+// still needs a visible label that explains what to do with the evidence row.
+export const BlankImageCaption: Story = {
+ args: {
+ facts: [],
+ images: [
+ {
+ unit_index: 0,
+ caption: "",
+ extracted_text: "Diagram OCR text remains available",
+ },
+ ],
+ },
+};
diff --git a/frontend/src/components/AskEvidenceLayerPopup.test.tsx b/frontend/src/components/AskEvidenceLayerPopup.test.tsx
new file mode 100644
index 000000000..7bed0b01b
--- /dev/null
+++ b/frontend/src/components/AskEvidenceLayerPopup.test.tsx
@@ -0,0 +1,136 @@
+import { render, screen } from "@testing-library/react";
+import userEvent from "@testing-library/user-event";
+import { describe, expect, it, vi } from "vitest";
+import { AskEvidenceLayerPopup } from "./AskEvidenceLayerPopup";
+
+const baseProps = {
+ postId: "post-demo-public",
+ postTitle: "Checkout error follow-up",
+};
+
+describe("AskEvidenceLayerPopup", () => {
+ it("renders text and image evidence for the cited post", () => {
+ render(
+ ,
+ );
+
+ expect(screen.getByRole("dialog", { name: "Checkout error follow-up" })).toBeInTheDocument();
+ expect(screen.getByText(/project: Checkout revamp/)).toBeInTheDocument();
+ expect(screen.getByText("Screenshot of the checkout error")).toBeInTheDocument();
+ expect(screen.getByText("Error code 500 on checkout")).toBeInTheDocument();
+ expect(
+ screen.getByRole("list", { name: "Checkout error follow-up Evidence facts" }),
+ ).toBeInTheDocument();
+ });
+
+ it("shows an explicit placeholder when the citation has no persisted evidence", () => {
+ render(
+ ,
+ );
+ expect(
+ screen.getByText("No persisted evidence is available for this citation."),
+ ).toBeInTheDocument();
+ });
+
+ it("uses the untitled fallback when an image caption is blank", () => {
+ render(
+ ,
+ );
+ expect(screen.getByText("Untitled image")).toBeInTheDocument();
+ });
+
+ it("closes on backdrop click, close button click, and Escape, but not on panel click", async () => {
+ const onClose = vi.fn();
+ const { container } = render(
+ ,
+ );
+
+ await userEvent.click(screen.getByRole("dialog"));
+ expect(onClose).not.toHaveBeenCalled();
+
+ const backdrop = container.querySelector(".popup-backdrop") as HTMLElement;
+ await userEvent.click(backdrop);
+ expect(onClose).toHaveBeenCalledTimes(1);
+
+ await userEvent.click(screen.getByRole("button", { name: "Close evidence panel" }));
+ expect(onClose).toHaveBeenCalledTimes(2);
+
+ await userEvent.keyboard("{Escape}");
+ expect(onClose).toHaveBeenCalledTimes(3);
+ });
+
+ it("closes the evidence layer before opening the cited post", async () => {
+ const onClose = vi.fn();
+ const onOpenPost = vi.fn();
+ render(
+ ,
+ );
+ await userEvent.click(screen.getByRole("button", { name: "Open post: Checkout error follow-up" }));
+ expect(onClose).toHaveBeenCalledTimes(1);
+ expect(onOpenPost).toHaveBeenCalledWith("post-demo-public");
+ expect(onClose.mock.invocationCallOrder[0]).toBeLessThan(onOpenPost.mock.invocationCallOrder[0]);
+ });
+
+ it("moves initial focus onto the dialog panel", () => {
+ render(
+ ,
+ );
+ expect(screen.getByRole("dialog")).toHaveFocus();
+ });
+
+ it("contains Tab and Shift+Tab focus within the modal layer", async () => {
+ render(
+ ,
+ );
+ const closeButton = screen.getByRole("button", { name: "Close evidence panel" });
+ const openPostButton = screen.getByRole("button", { name: "Open post: Checkout error follow-up" });
+
+ openPostButton.focus();
+ await userEvent.tab();
+ expect(closeButton).toHaveFocus();
+
+ closeButton.focus();
+ await userEvent.tab({ shift: true });
+ expect(openPostButton).toHaveFocus();
+ });
+
+ it("returns focus to the element that invoked the modal when the layer unmounts", () => {
+ const opener = document.createElement("button");
+ opener.textContent = "View evidence";
+ document.body.append(opener);
+ opener.focus();
+
+ const { unmount } = render(
+ ,
+ );
+ expect(screen.getByRole("dialog")).toHaveFocus();
+
+ unmount();
+ expect(opener).toHaveFocus();
+ opener.remove();
+ });
+});
diff --git a/frontend/src/components/AskEvidenceLayerPopup.tsx b/frontend/src/components/AskEvidenceLayerPopup.tsx
new file mode 100644
index 000000000..4aff6e88e
--- /dev/null
+++ b/frontend/src/components/AskEvidenceLayerPopup.tsx
@@ -0,0 +1,163 @@
+import { useEffect, useId, useRef } from "react";
+import { chatEvidenceKindLabel } from "../evidenceKindLabels";
+import { t, tf } from "../i18n";
+import { PopupCloseButton } from "./PopupCloseButton";
+
+const FOCUSABLE_SELECTOR = [
+ "a[href]",
+ "button:not([disabled])",
+ "input:not([disabled])",
+ "select:not([disabled])",
+ "textarea:not([disabled])",
+ '[tabindex]:not([tabindex="-1"])',
+].join(",");
+
+export type AskEvidenceLayerFact = {
+ kind: string;
+ text: string;
+};
+
+export type AskEvidenceLayerImage = {
+ unit_index: number;
+ caption: string | null;
+ extracted_text: string | null;
+};
+
+export type AskEvidenceLayerPopupProps = {
+ postId: string;
+ postTitle: string;
+ facts: AskEvidenceLayerFact[];
+ images: AskEvidenceLayerImage[];
+ onClose: () => void;
+ onOpenPost: (postId: string) => void;
+};
+
+/**
+ * A focused evidence layer for one Ask Agent citation -- opened from the
+ * answer without leaving it, unlike the full post detail popup.
+ *
+ * Next action: open the source post for the complete record, or close to
+ * return to the answer.
+ */
+export function AskEvidenceLayerPopup({
+ postId,
+ postTitle,
+ facts,
+ images,
+ onClose,
+ onOpenPost,
+}: AskEvidenceLayerPopupProps) {
+ const headingId = useId();
+ const factsHeadingId = useId();
+ const imagesHeadingId = useId();
+ const panelRef = useRef(null);
+ const restoreFocusOnUnmountRef = useRef(true);
+
+ useEffect(() => {
+ const previouslyFocused = document.activeElement instanceof HTMLElement ? document.activeElement : null;
+ panelRef.current?.focus();
+ return () => {
+ if (restoreFocusOnUnmountRef.current && previouslyFocused?.isConnected) {
+ previouslyFocused.focus();
+ }
+ };
+ }, []);
+
+ useEffect(() => {
+ function handleKeyDown(event: KeyboardEvent) {
+ if (event.key === "Escape") {
+ event.preventDefault();
+ onClose();
+ return;
+ }
+ if (event.key !== "Tab") return;
+
+ const panel = panelRef.current;
+ if (!panel) return;
+ const focusable = Array.from(panel.querySelectorAll(FOCUSABLE_SELECTOR)).filter(
+ (element) => !element.hasAttribute("hidden") && element.getAttribute("aria-hidden") !== "true",
+ );
+ if (focusable.length === 0) {
+ event.preventDefault();
+ panel.focus();
+ return;
+ }
+
+ const first = focusable[0];
+ const last = focusable[focusable.length - 1];
+ const active = document.activeElement;
+ if (active === panel || !panel.contains(active)) {
+ event.preventDefault();
+ (event.shiftKey ? last : first).focus();
+ return;
+ }
+ if (event.shiftKey && active === first) {
+ event.preventDefault();
+ last.focus();
+ } else if (!event.shiftKey && active === last) {
+ event.preventDefault();
+ first.focus();
+ }
+ }
+ document.addEventListener("keydown", handleKeyDown);
+ return () => document.removeEventListener("keydown", handleKeyDown);
+ }, [onClose]);
+
+ function handleOpenPost() {
+ // Opening the full post is a workflow transition rather than returning to
+ // the invoking citation, so do not restore focus to the old trigger while
+ // the destination surface is being mounted.
+ restoreFocusOnUnmountRef.current = false;
+ onClose();
+ onOpenPost(postId);
+ }
+
+ return (
+
+
event.stopPropagation()}
+ >
+
+
{postTitle}
+ {facts.length === 0 && images.length === 0 ? (
+
{t("No persisted evidence is available for this citation.")}
+ ) : null}
+ {facts.length > 0 ? (
+
+ {t("Evidence facts")}
+
+ {facts.map((fact, index) => (
+ -
+ {chatEvidenceKindLabel(fact.kind)}
+ {fact.text}
+
+ ))}
+
+
+ ) : null}
+ {images.length > 0 ? (
+
+ {t("Image evidence")}
+
+ {images.map((image) => (
+ -
+ {image.caption?.trim() ? image.caption : t("Untitled image")}
+ {image.extracted_text ? {image.extracted_text} : null}
+
+ ))}
+
+
+ ) : null}
+
+
+
+ );
+}
diff --git a/frontend/src/evidenceKindLabels.ts b/frontend/src/evidenceKindLabels.ts
new file mode 100644
index 000000000..ab92dabd4
--- /dev/null
+++ b/frontend/src/evidenceKindLabels.ts
@@ -0,0 +1,12 @@
+import { t } from "./i18n";
+
+const CHAT_EVIDENCE_KIND_LABELS: Record = {
+ source_field: "Source field hint",
+ semantic_project: "Semantic project",
+ semantic_role: "Semantic role",
+ semantic_keyman: "Semantic Keyman",
+};
+
+export function chatEvidenceKindLabel(kind: string): string {
+ return t(CHAT_EVIDENCE_KIND_LABELS[kind] ?? "Evidence");
+}
diff --git a/frontend/src/i18n.ts b/frontend/src/i18n.ts
index 45a4e560b..482bbcb3f 100644
--- a/frontend/src/i18n.ts
+++ b/frontend/src/i18n.ts
@@ -167,8 +167,10 @@ const TRANSLATIONS: Partial>> = {
"Search related posts": "관련 글 검색",
"Search related posts for: {name}": "{name} 관련 글 검색",
"Evidence facts": "근거 사실",
+ "View evidence": "근거 보기",
"Image evidence": "이미지 근거",
"Untitled image": "제목 없는 이미지",
+ "No persisted evidence is available for this citation.": "이 인용에 사용할 수 있는 저장된 근거가 없습니다.",
"Source field hint": "원천 필드 힌트",
"Semantic project": "의미 기반 프로젝트",
"Semantic role": "의미 기반 역할",
@@ -508,8 +510,10 @@ const TRANSLATIONS: Partial>> = {
"Search related posts": "搜索相关文章",
"Search related posts for: {name}": "搜索与{name}相关的文章",
"Evidence facts": "证据事实",
+ "View evidence": "查看证据",
"Image evidence": "图像证据",
"Untitled image": "无标题图像",
+ "No persisted evidence is available for this citation.": "此引用没有可用的已保存证据。",
"Source field hint": "来源字段提示",
"Semantic project": "语义项目",
"Semantic role": "语义角色",
@@ -872,8 +876,10 @@ const TRANSLATIONS: Partial>> = {
"Search related posts": "関連投稿を検索",
"Search related posts for: {name}": "{name}の関連投稿を検索",
"Evidence facts": "証拠の事実",
+ "View evidence": "証拠を見る",
"Image evidence": "画像証拠",
"Untitled image": "無題の画像",
+ "No persisted evidence is available for this citation.": "この引用に使用できる保存済みの証拠はありません。",
"Source field hint": "原典フィールドのヒント",
"Semantic project": "意味的なプロジェクト",
"Semantic role": "意味的な役割",
@@ -1212,8 +1218,10 @@ const TRANSLATIONS: Partial>> = {
"Search related posts": "Tìm bài viết liên quan",
"Search related posts for: {name}": "Tìm bài viết liên quan đến {name}",
"Evidence facts": "Sự kiện bằng chứng",
+ "View evidence": "Xem bằng chứng",
"Image evidence": "Bằng chứng hình ảnh",
"Untitled image": "Hình ảnh chưa đặt tên",
+ "No persisted evidence is available for this citation.": "Không có bằng chứng đã lưu cho trích dẫn này.",
"Source field hint": "Gợi ý trường nguồn",
"Semantic project": "Dự án ngữ nghĩa",
"Semantic role": "Vai trò ngữ nghĩa",