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, })} - + {t("Swipe or use arrow keys to inspect the full lineage.")} +

+
- {group.edges.map((edge) => { - const from = byId[edge.source]; - const to = byId[edge.target]; - const midX = (from.x + to.x) / 2; - return ( - - - {tf("{from} follows {to} ({score})", { - from: from.label, - to: to.label, - score: edge.fused_score.toFixed(2), - })} - - - ); - })} - {group.nodes.map((node) => { - const kind = node.is_branch_point ? "branch" : node.is_root ? "root" : "node"; - const isCurrent = node.id === currentPostId; - return ( - onSelectPost(node.id)} - onKeyDown={(event) => { - if (event.key === "Enter" || event.key === " ") { - event.preventDefault(); - onSelectPost(node.id); - } - }} - > - - - {truncateLabel(node.label)} - - - {tf("{label} — {date}", { - label: node.label, - date: node.occurred_at.slice(0, 10), - })} - - - ); - })} - + + {group.edges.map((edge) => { + const from = byId[edge.source]; + const to = byId[edge.target]; + const midX = (from.x + to.x) / 2; + return ( + + + {tf("{from} follows {to} ({score})", { + from: from.label, + to: to.label, + score: edge.fused_score.toFixed(2), + })} + + + ); + })} + {group.nodes.map((node) => { + const kind = node.is_branch_point ? "branch" : node.is_root ? "root" : "node"; + const isCurrent = node.id === currentPostId; + return ( + onSelectPost(node.id)} + onKeyDown={(event) => { + if (event.key === "Enter" || event.key === " ") { + event.preventDefault(); + onSelectPost(node.id); + } + }} + > + + + + {truncateLabel(node.label)} + + + {tf("{label} — {date}", { + label: node.label, + date: node.occurred_at.slice(0, 10), + })} + + + ); + })} + +
); })}