Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 7 additions & 2 deletions docs/doctoring/DESIGN_TOKEN_REFERENCES.md
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
# Design-token and Storybook traceability

**Status:** Active PR evidence; not protected-main truth until merge.
**Scope:** `frontend/src/styles/tokens.css`, repeated chip/close modules, and
the Storybook inventory.
**Scope:** `frontend/src/styles/tokens.css`, repeated chip/close modules, the
Ask evidence dialog, and the Storybook inventory.

## Standards mapped to implementation

Expand All @@ -11,6 +11,7 @@ the Storybook inventory.
| W3C Design Tokens Format Module 2025.10 | Name color, space, type, and radius once; consume those names from repeated objects. | `frontend/src/styles/tokens.css` defines `--color-*`, `--space-*`, `--size-control-min`, `--radius-chip`, `--radius-control`, `--radius-panel`, and `--font-*`. `CitationChip`, `PopupCloseButton`, `CutoffKnownBody`, and `LineageEntityPicker` read those names through `App.css`. |
| Storybook for React & Vite | Catalog repeated controls so a buyer can try the next click without reading `App.tsx`. | `frontend/src/components/*.stories.tsx` and `docs/storybook-inventory.md`. |
| WCAG 2.2 | Give interactive controls programmatic names and announce an asynchronous evidence failure instead of leaving a perpetual loading state. | Component interaction tests exercise the named controls; `EvidencePanel` exposes its terminal failure with `role="alert"`. This is targeted evidence, not a claim of complete WCAG conformance. |
| WAI-ARIA APG Dialog (Modal) Pattern | A surface marked `aria-modal="true"` must behave modally: focus moves inside, `Tab` and `Shift+Tab` remain inside, and `Escape` closes the layer. | `AskEvidenceLayerPopup` moves initial focus inside the dialog and explicitly cycles forward/backward keyboard focus between its actionable controls; component tests cover both focus-loop directions and Escape. Its evidence lists use dialog-specific accessible labels so assistive technology can distinguish the modal list from the still-rendered inline answer. |

## APA 7th references

Expand All @@ -23,3 +24,7 @@ https://storybook.js.org/docs/get-started/frameworks/react-vite

World Wide Web Consortium. (2024). *Web Content Accessibility Guidelines
(WCAG) 2.2* (W3C Recommendation). https://www.w3.org/TR/WCAG22/

World Wide Web Consortium. (n.d.). *Dialog (modal) pattern*. WAI-ARIA
Authoring Practices Guide. Retrieved August 22, 2026, from
https://www.w3.org/WAI/ARIA/apg/patterns/dialog-modal/
1 change: 1 addition & 0 deletions docs/storybook-inventory.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ buyer-facing control you can click before changing product CSS.
| Story | Buyer next action | Token / module |
|---|---|---|
| `Evidence/CitationChip` | Click a cited title to open that source post. | `--color-chip-border`, `--radius-chip`, `CitationChip` |
| `Evidence/AskEvidenceLayerPopup` | Inspect one citation without leaving the answer; close to continue the answer or open the complete source post. Stories cover text/image evidence, no-evidence, missing OCR, null caption, and blank-caption fallback states. | shared popup tokens through `App.css`, `PopupCloseButton`, `AskEvidenceLayerPopup` |
| `AnalysisRun/CutoffKnownBody` | Read the cutoff-known sentence, then compare it with the live body below. | `--color-accent-border`, `--space-panel-block`, `--radius-panel`, `CutoffKnownBody` |
| `Analysis/LineageEntityPicker` | Choose which corp to reconstruct, then click Request a lineage reconstruction. | `--space-control-gap`, `--size-control-min`, `--radius-control`, `LineageEntityPicker` |
| `Chrome/PopupCloseButton` | Close the evidence panel or post popup. | `--space-close-inset`, `--font-size-close`, `PopupCloseButton` |
Expand Down
21 changes: 21 additions & 0 deletions frontend/src/App.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -1712,6 +1712,27 @@ describe("App, authenticated", () => {
expect(screen.queryByText(/Image evidence:/)).not.toBeInTheDocument();
});

it("opens a cited post's evidence in a Layer Popup without leaving the answer", async () => {
stubBackend({ askImageCitation: true });
render(<App />);
expect(await screen.findByRole("button", { name: "View post: Public post" })).toBeInTheDocument();
await userEvent.click(screen.getByRole("button", { name: "Ask Agent" }));
await userEvent.type(screen.getByRole("textbox", { name: "Ask a question" }), "Which project?");
await userEvent.click(screen.getByRole("button", { name: "Ask" }));

await userEvent.click(await screen.findByRole("button", { name: "View evidence" }));

const dialog = await screen.findByRole("dialog", { name: "Linked post" });
expect(dialog).toBeInTheDocument();
expect(within(dialog).getByText(/project: Semantic project/)).toBeInTheDocument();
expect(within(dialog).getByText("Screenshot of the checkout error")).toBeInTheDocument();

await userEvent.click(screen.getByRole("button", { name: "Close evidence panel" }));
expect(screen.queryByRole("dialog")).not.toBeInTheDocument();
// The answer itself is still on screen -- the layer never navigated away.
expect(screen.getByRole("button", { name: "View evidence" })).toBeInTheDocument();
});

it("labels the Customer Master entity level and Keymen side, never the raw lookup code", async () => {
// Live UI finding (2026-08-19): read_customer_master() skipped the
// common_lookup_value join both endpoints elsewhere already use,
Expand Down
41 changes: 29 additions & 12 deletions frontend/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -85,7 +85,9 @@ import {
import { CitationChip } from "./components/CitationChip";
import { CutoffKnownBody } from "./components/CutoffKnownBody";
import { LineageEntityPicker } from "./components/LineageEntityPicker";
import { AskEvidenceLayerPopup } from "./components/AskEvidenceLayerPopup";
import { PopupCloseButton } from "./components/PopupCloseButton";
import { chatEvidenceKindLabel } from "./evidenceKindLabels";
import { BuyerNav, type BuyerDestination } from "./components/BuyerNav";
import { LineageDag } from "./LineageDag";
import { PostBody } from "./PostBody";
Expand Down Expand Up @@ -777,16 +779,6 @@ function projectProvenanceLabel(provenance: string): string {
return t(PROJECT_PROVENANCE_LABELS[provenance] ?? "Recorded evidence");
}

const CHAT_EVIDENCE_KIND_LABELS: Record<string, string> = {
source_field: "Source field hint",
semantic_project: "Semantic project",
semantic_role: "Semantic role",
semantic_keyman: "Semantic Keyman",
};

function chatEvidenceKindLabel(kind: string): string {
return t(CHAT_EVIDENCE_KIND_LABELS[kind] ?? "Evidence");
}

const VERIFICATION_BADGE: Record<string, string> = {
verify_pending: "Not yet checked",
Expand Down Expand Up @@ -4477,6 +4469,7 @@ function AskAgentPanel({
const [answer, setAnswer] = useState<AskAgentResponse | null>(null);
const [error, setError] = useState<string | null>(null);
const [asking, setAsking] = useState(false);
const [evidenceLayerPostId, setEvidenceLayerPostId] = useState<string | null>(null);

async function handleAsk() {
const normalized = question.trim();
Comment thread
seonghobae marked this conversation as resolved.
Expand Down Expand Up @@ -4525,6 +4518,13 @@ function AskAgentPanel({
<button className="post-list-item" onClick={() => onOpenPost(post.post_id)}>
<strong>{post.post_title}</strong>
</button>
<button
type="button"
className="citation-chip"
onClick={() => setEvidenceLayerPostId(post.post_id)}
>
{t("View evidence")}
</button>
{answer.cited_post_evidence?.find((item) => item.post_id === post.post_id)?.facts.length ? (
<ul className="post-evidence-list" aria-label={t("Evidence facts")}>
{answer.cited_post_evidence
Expand Down Expand Up @@ -4555,6 +4555,23 @@ function AskAgentPanel({
)}
</section>
)}
{evidenceLayerPostId && answer ? (
<AskEvidenceLayerPopup
postId={evidenceLayerPostId}
postTitle={
answer.cited_posts?.find((post) => post.post_id === evidenceLayerPostId)?.post_title ??
evidenceLayerPostId
}
facts={
answer.cited_post_evidence?.find((item) => item.post_id === evidenceLayerPostId)?.facts ?? []
}
images={
answer.cited_post_images?.filter((image) => image.post_id === evidenceLayerPostId) ?? []
}
onClose={() => setEvidenceLayerPostId(null)}
onOpenPost={onOpenPost}
/>
) : null}
</section>
);
}
Expand Down Expand Up @@ -4621,7 +4638,8 @@ export default function App({ showLabPanels = false }: { showLabPanels?: boolean
</div>
<div className="login-controls">
<button className="btn-primary" onClick={() => {
const returnUrl = window.location.pathname + window.location.search;
const returnUrl = returnUrlFromLocation();
rememberOidcReturnUrl(returnUrl);
void auth.signinRedirect({ state: { returnUrl } });
}}>
{t("Log in")}
Expand All @@ -4631,7 +4649,6 @@ export default function App({ showLabPanels = false }: { showLabPanels?: boolean
<small>Enterprise SSO Authentication</small>
</div>
</div>
Comment thread
seonghobae marked this conversation as resolved.
{destination === "admin" ? <AdminPanel currentBrandName={brandName} onBrandNameChange={setBrandName} accessToken={accessToken} /> : null}
</main>
<footer className="app-footer" role="contentinfo">
<div className="app-footer-title">
Expand Down
94 changes: 94 additions & 0 deletions frontend/src/components/AskEvidenceLayerPopup.stories.tsx
Original file line number Diff line number Diff line change
@@ -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<typeof AskEvidenceLayerPopup>;

export default meta;

type Story = StoryObj<typeof meta>;

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",
},
],
},
};
136 changes: 136 additions & 0 deletions frontend/src/components/AskEvidenceLayerPopup.test.tsx
Original file line number Diff line number Diff line change
@@ -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(
<AskEvidenceLayerPopup
{...baseProps}
facts={[{ kind: "semantic_project", text: "project: Checkout revamp | evidence: Body evidence" }]}
images={[
{
unit_index: 1,
caption: "Screenshot of the checkout error",
extracted_text: "Error code 500 on checkout",
},
]}
onClose={vi.fn()}
onOpenPost={vi.fn()}
/>,
);

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(
<AskEvidenceLayerPopup {...baseProps} facts={[]} images={[]} onClose={vi.fn()} onOpenPost={vi.fn()} />,
);
expect(
screen.getByText("No persisted evidence is available for this citation."),
).toBeInTheDocument();
});

it("uses the untitled fallback when an image caption is blank", () => {
render(
<AskEvidenceLayerPopup
{...baseProps}
facts={[]}
images={[{ unit_index: 0, caption: "", extracted_text: null }]}
onClose={vi.fn()}
onOpenPost={vi.fn()}
/>,
);
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(
<AskEvidenceLayerPopup {...baseProps} facts={[]} images={[]} onClose={onClose} onOpenPost={vi.fn()} />,
);

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(
<AskEvidenceLayerPopup
{...baseProps}
facts={[]}
images={[]}
onClose={onClose}
onOpenPost={onOpenPost}
/>,
);
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(
<AskEvidenceLayerPopup {...baseProps} facts={[]} images={[]} onClose={vi.fn()} onOpenPost={vi.fn()} />,
);
expect(screen.getByRole("dialog")).toHaveFocus();
});

it("contains Tab and Shift+Tab focus within the modal layer", async () => {
render(
<AskEvidenceLayerPopup {...baseProps} facts={[]} images={[]} onClose={vi.fn()} onOpenPost={vi.fn()} />,
);
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(
<AskEvidenceLayerPopup {...baseProps} facts={[]} images={[]} onClose={vi.fn()} onOpenPost={vi.fn()} />,
);
expect(screen.getByRole("dialog")).toHaveFocus();

unmount();
expect(opener).toHaveFocus();
opener.remove();
});
});
Loading