diff --git a/docs/storybook-inventory.md b/docs/storybook-inventory.md
index 0d729eec7..dc695b5c9 100644
--- a/docs/storybook-inventory.md
+++ b/docs/storybook-inventory.md
@@ -10,7 +10,7 @@ buyer-facing control you can click before changing product CSS.
| `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` |
| `Admin/AdminPanel` | Change the tenant brand name, then verify the saved or failed state before leaving settings. | `--surface`, `--border`, `--space-panel-block`, `AdminPanel` |
-| `Lineage/LineageDag` | Open the current branch node; compare empty, grouped/forked, ungrouped, and long-title states before changing graph CSS. | `--surface`, `--border`, `LineageDag` |
+| `Lineage/LineageDag` | Open the current branch node; compare empty, grouped/forked, mobile-scroll, ungrouped, and long-title states before changing graph CSS. On narrow viewports, swipe the named viewport or focus it and use arrow keys to inspect the full lineage. | `--surface`, `--border`, `--color-focus-border`, `--size-control-min`, `LineageDag` |
| `Chrome/PopupCloseButton` | Close the evidence panel or post popup. | `--space-close-inset`, `--font-size-close`, `PopupCloseButton` |
| `Reports/LeftoverPairList` | Read residual R, observed Y, expected E, map rank, and distance after IRT main effects, then open the named post. | `--color-chip-border`, `LeftoverPairList` |
diff --git a/frontend/src/App.css b/frontend/src/App.css
index b88ee953a..3888c83b2 100644
--- a/frontend/src/App.css
+++ b/frontend/src/App.css
@@ -534,6 +534,15 @@
stroke-width: 1.5;
}
+/* The enlarged hit target sits under the visible mark. Author CSS beats the
+ fill="transparent" presentation attribute, so keep it invisible here or it
+ paints as a second opaque disc (WCAG 2.5.8 touch-target sizing must not
+ change what the reader sees). */
+.lineage-dag-node circle.lineage-dag-hit {
+ fill: transparent;
+ stroke: none;
+}
+
.lineage-dag-branch circle {
fill: var(--badge-actor-organization-bg);
stroke: var(--color-accent-orange);
@@ -552,12 +561,14 @@
outline: none;
}
-.lineage-dag-node:focus circle,
-.lineage-dag-node:hover circle {
+.lineage-dag-node:focus circle:not(.lineage-dag-hit),
+.lineage-dag-node:hover circle:not(.lineage-dag-hit) {
stroke-width: 2.5;
}
-.lineage-dag-node[aria-current="true"] circle {
+/* :not(.lineage-dag-hit) never matches the hit target, so the current
+ ring cannot paint a hollow disc around it regardless of source order. */
+.lineage-dag-node[aria-current="true"] circle:not(.lineage-dag-hit) {
stroke-width: 3;
stroke: var(--text-h);
}
diff --git a/frontend/src/LineageDag.css b/frontend/src/LineageDag.css
new file mode 100644
index 000000000..4a2b3f596
--- /dev/null
+++ b/frontend/src/LineageDag.css
@@ -0,0 +1,27 @@
+.lineage-dag-scroll-hint {
+ display: none;
+ margin: 0 0 var(--space-control-gap);
+ color: var(--text);
+ font-size: 0.8rem;
+}
+
+.lineage-dag-viewport {
+ max-width: 100%;
+ overflow-x: auto;
+ overflow-y: hidden;
+ overscroll-behavior-inline: contain;
+ scrollbar-gutter: stable;
+ -webkit-overflow-scrolling: touch;
+}
+
+.lineage-dag-viewport:focus-visible {
+ outline: 2px solid var(--color-focus-border);
+ outline-offset: 2px;
+ border-radius: var(--radius-control);
+}
+
+@media (max-width: 768px) {
+ .lineage-dag-scroll-hint {
+ display: block;
+ }
+}
diff --git a/frontend/src/LineageDag.responsive.test.tsx b/frontend/src/LineageDag.responsive.test.tsx
new file mode 100644
index 000000000..4ac272cb0
--- /dev/null
+++ b/frontend/src/LineageDag.responsive.test.tsx
@@ -0,0 +1,34 @@
+import { render, screen } from "@testing-library/react";
+import { describe, expect, it, vi } from "vitest";
+import { LineageDag } from "./LineageDag";
+import type { LineageGraph } from "./api";
+
+const wideGraph: LineageGraph = {
+ nodes: [
+ { id: "a1", group: "A-100", label: "Initial scope", occurred_at: "2026-01-01T00:00:00Z", is_root: true, is_branch_point: false },
+ { id: "a2", group: "A-100", label: "Terms follow-up", occurred_at: "2026-01-05T00:00:00Z", is_root: false, is_branch_point: false },
+ { id: "a3", group: "A-100", label: "Delivery question", occurred_at: "2026-01-06T00:00:00Z", is_root: false, is_branch_point: false },
+ { id: "a4", group: "A-100", label: "Delivery confirmed", occurred_at: "2026-01-11T00:00:00Z", is_root: false, is_branch_point: false },
+ ],
+ edges: [
+ { source: "a1", target: "a2", fused_score: 0.83 },
+ { source: "a2", target: "a3", fused_score: 0.87 },
+ { source: "a3", target: "a4", fused_score: 0.9 },
+ ],
+};
+
+describe("LineageDag responsive viewport", () => {
+ it("keeps an intrinsically wide DAG inside a named keyboard-focusable viewport", () => {
+ render();
+
+ expect(screen.getByText("Swipe or use arrow keys to inspect the full lineage.")).toBeInTheDocument();
+
+ const viewport = screen.getByRole("region", { name: "A-100 lineage viewport" });
+ expect(viewport).toHaveAttribute("tabindex", "0");
+ expect(viewport).toHaveClass("lineage-dag-viewport");
+
+ const svg = screen.getByRole("img", { name: "A-100 lineage" });
+ expect(viewport).toContainElement(svg);
+ expect(Number(svg.getAttribute("width"))).toBeGreaterThan(320);
+ });
+});
diff --git a/frontend/src/LineageDag.stories.tsx b/frontend/src/LineageDag.stories.tsx
index a0f5101ac..f41fcbd1d 100644
--- a/frontend/src/LineageDag.stories.tsx
+++ b/frontend/src/LineageDag.stories.tsx
@@ -37,6 +37,36 @@ export const SingleBranch: Story = {
},
};
+// Mirrors the live Figma mobile authority: a 322px viewport contains an
+// intrinsically wider lineage canvas and gives the buyer explicit scroll help.
+export const MobileScrollable: Story = {
+ decorators: [
+ (Story) => (
+
+
+
+ ),
+ ],
+ args: {
+ graph: {
+ nodes: [
+ { id: "m1", group: "DEMO-PROJECT", label: "Initial scope", occurred_at: "2026-01-01T00:00:00Z", is_root: true, is_branch_point: false },
+ { id: "m2", group: "DEMO-PROJECT", label: "Terms follow-up", occurred_at: "2026-01-05T00:00:00Z", is_root: false, is_branch_point: true },
+ { id: "m3", group: "DEMO-PROJECT", label: "Revised quotation", occurred_at: "2026-01-09T00:00:00Z", is_root: false, is_branch_point: false },
+ { id: "m4", group: "DEMO-PROJECT", label: "Delivery question", occurred_at: "2026-01-06T00:00:00Z", is_root: false, is_branch_point: false },
+ { id: "m5", group: "DEMO-PROJECT", label: "Delivery confirmed", occurred_at: "2026-01-11T00:00:00Z", is_root: false, is_branch_point: false },
+ ],
+ edges: [
+ { source: "m1", target: "m2", fused_score: 0.83 },
+ { source: "m2", target: "m3", fused_score: 0.94 },
+ { source: "m2", target: "m4", fused_score: 0.87 },
+ { source: "m4", target: "m5", fused_score: 0.9 },
+ ],
+ } satisfies LineageGraph,
+ currentPostId: "m5",
+ },
+};
+
// The multi-branch, git-branch-style case the Ask Agent answer view relies on:
// several independent lineage threads rendered as separate figures, plus one
// thread with an actual fork (a branch point with two children).
diff --git a/frontend/src/LineageDag.test.tsx b/frontend/src/LineageDag.test.tsx
index 8f1001a9a..aa3723cf7 100644
--- a/frontend/src/LineageDag.test.tsx
+++ b/frontend/src/LineageDag.test.tsx
@@ -1,9 +1,55 @@
import { fireEvent, render, screen } from "@testing-library/react";
+import userEvent from "@testing-library/user-event";
import { describe, expect, it, vi } from "vitest";
import { LineageDag } from "./LineageDag";
import type { LineageGraph } from "./api";
+const graph = {
+ nodes: [
+ {
+ id: "rec-001",
+ group: "A-100",
+ label: "Initial site visit and project scope discussion",
+ occurred_at: "2026-01-01T00:00:00",
+ is_root: true,
+ is_branch_point: false,
+ },
+ {
+ id: "rec-002",
+ group: "A-100",
+ label: "Pricing renegotiation follow-up",
+ occurred_at: "2026-01-06T00:00:00",
+ is_root: false,
+ is_branch_point: false,
+ },
+ ],
+ edges: [{ source: "rec-001", target: "rec-002", fused_score: 0.8 }],
+};
+
describe("LineageDag", () => {
+ it("gives every node mark a 24x24px-minimum transparent hit target ahead of the visible mark", () => {
+ render( undefined} />);
+ const button = screen.getByRole("button", { name: "Open post: Initial site visit and project scope discussion" });
+ const circles = button.querySelectorAll("circle");
+ expect(circles).toHaveLength(2);
+
+ // The hit circle must come first so the visible mark still paints on top of it.
+ const [hit, visible] = circles;
+ expect(hit.getAttribute("fill")).toBe("transparent");
+ expect(hit.style.pointerEvents).toBe("all");
+ // r=12 -> 24px diameter, matching --size-control-min (tokens.css) at the
+ // DAG's ~1 SVG-user-unit-per-px scale -- the WCAG 2.5.8 AA minimum.
+ expect(Number(hit.getAttribute("r"))).toBeGreaterThanOrEqual(12);
+ expect(Number(visible.getAttribute("r"))).toBeLessThan(Number(hit.getAttribute("r")));
+ });
+
+ it("still opens the post when the enlarged hit target is clicked", async () => {
+ const onSelectPost = vi.fn();
+ render();
+ await userEvent.click(screen.getByRole("button", { name: "Open post: Pricing renegotiation follow-up" }));
+ expect(onSelectPost).toHaveBeenCalledWith("rec-002");
+ });
+
it("shows an empty-state message instead of an empty graph", () => {
render();
expect(screen.getByText("No reconstructed lineage yet. Rebuild after seeding posts.")).toBeInTheDocument();
diff --git a/frontend/src/LineageDag.tsx b/frontend/src/LineageDag.tsx
index f26574d8e..b707f5249 100644
--- a/frontend/src/LineageDag.tsx
+++ b/frontend/src/LineageDag.tsx
@@ -1,11 +1,17 @@
import type { LineageGraph } from "./api";
import { t, tf } from "./i18n";
import { layoutLineageDag } from "./lineageLayout";
+import "./LineageDag.css";
function truncateLabel(label: string): string {
return label.length > 34 ? `${label.slice(0, 33)}…` : label;
}
+// Mirrors --size-control-min (24px, styles/tokens.css). One SVG user unit is
+// ~1px here (see lineageLayout ROW_H/COL_W/PAD), so this radius gives the
+// visible 7px node mark a 24x24px minimum hit area without CSS scale-up.
+const NODE_HIT_RADIUS = 12;
+
/** Render the authorized lineage projection and let the buyer open a post. */
export function LineageDag({
graph,
@@ -34,67 +40,83 @@ export function LineageDag({
edges: group.edges.length,
})}
-