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
2 changes: 1 addition & 1 deletion docs/storybook-inventory.md
Original file line number Diff line number Diff line change
Expand Up @@ -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` |

Expand Down
17 changes: 14 additions & 3 deletions frontend/src/App.css
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand All @@ -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);
}
Expand Down
27 changes: 27 additions & 0 deletions frontend/src/LineageDag.css
Original file line number Diff line number Diff line change
@@ -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;
}
}
Comment thread
seonghobae marked this conversation as resolved.
34 changes: 34 additions & 0 deletions frontend/src/LineageDag.responsive.test.tsx
Original file line number Diff line number Diff line change
@@ -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(<LineageDag graph={wideGraph} onSelectPost={vi.fn()} />);

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);
});
});
30 changes: 30 additions & 0 deletions frontend/src/LineageDag.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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) => (
<div style={{ width: 322 }}>
<Story />
</div>
),
],
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).
Expand Down
46 changes: 46 additions & 0 deletions frontend/src/LineageDag.test.tsx
Original file line number Diff line number Diff line change
@@ -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(<LineageDag graph={graph} onSelectPost={() => 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(<LineageDag graph={graph} onSelectPost={onSelectPost} />);
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(<LineageDag graph={{ nodes: [], edges: [] }} onSelectPost={vi.fn()} />);
expect(screen.getByText("No reconstructed lineage yet. Rebuild after seeding posts.")).toBeInTheDocument();
Expand Down
142 changes: 82 additions & 60 deletions frontend/src/LineageDag.tsx
Original file line number Diff line number Diff line change
@@ -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;
Comment thread
seonghobae marked this conversation as resolved.

/** Render the authorized lineage projection and let the buyer open a post. */
export function LineageDag({
graph,
Expand Down Expand Up @@ -34,67 +40,83 @@ export function LineageDag({
edges: group.edges.length,
})}
</figcaption>
<svg
viewBox={`0 0 ${group.width} ${group.height}`}
width="100%"
height={Math.max(120, group.height)}
role="img"
aria-label={tf("{group} lineage", { group: group.heading })}
<p className="lineage-dag-scroll-hint">
{t("Swipe or use arrow keys to inspect the full lineage.")}
</p>
<div
className="lineage-dag-viewport"
role="region"
tabIndex={0}
aria-label={tf("{group} lineage viewport", { group: group.heading })}
>
{group.edges.map((edge) => {
const from = byId[edge.source];
const to = byId[edge.target];
const midX = (from.x + to.x) / 2;
return (
<path
key={`${edge.source}-${edge.target}`}
className="lineage-dag-edge"
d={`M ${from.x} ${from.y} C ${midX} ${from.y}, ${midX} ${to.y}, ${to.x} ${to.y}`}
>
<title>
{tf("{from} follows {to} ({score})", {
from: from.label,
to: to.label,
score: edge.fused_score.toFixed(2),
})}
</title>
</path>
);
})}
{group.nodes.map((node) => {
const kind = node.is_branch_point ? "branch" : node.is_root ? "root" : "node";
const isCurrent = node.id === currentPostId;
return (
<g
key={node.id}
className={`lineage-dag-node lineage-dag-${kind}`}
transform={`translate(${node.x}, ${node.y})`}
role="button"
tabIndex={0}
aria-label={tf("Open post: {label}", { label: node.label })}
aria-current={isCurrent ? "true" : undefined}
onClick={() => onSelectPost(node.id)}
onKeyDown={(event) => {
if (event.key === "Enter" || event.key === " ") {
event.preventDefault();
onSelectPost(node.id);
}
}}
>
<circle r={7} />
<text x={12} y={4}>
{truncateLabel(node.label)}
</text>
<title>
{tf("{label} — {date}", {
label: node.label,
date: node.occurred_at.slice(0, 10),
})}
</title>
</g>
);
})}
</svg>
<svg
viewBox={`0 0 ${group.width} ${group.height}`}
width={group.width}
Comment thread
seonghobae marked this conversation as resolved.
height={Math.max(120, group.height)}
role="img"
aria-label={tf("{group} lineage", { group: group.heading })}
>
{group.edges.map((edge) => {
const from = byId[edge.source];
const to = byId[edge.target];
const midX = (from.x + to.x) / 2;
return (
<path
key={`${edge.source}-${edge.target}`}
className="lineage-dag-edge"
d={`M ${from.x} ${from.y} C ${midX} ${from.y}, ${midX} ${to.y}, ${to.x} ${to.y}`}
>
<title>
{tf("{from} follows {to} ({score})", {
from: from.label,
to: to.label,
score: edge.fused_score.toFixed(2),
})}
</title>
</path>
);
})}
{group.nodes.map((node) => {
const kind = node.is_branch_point ? "branch" : node.is_root ? "root" : "node";
const isCurrent = node.id === currentPostId;
return (
<g
key={node.id}
className={`lineage-dag-node lineage-dag-${kind}`}
transform={`translate(${node.x}, ${node.y})`}
role="button"
tabIndex={0}
aria-label={tf("Open post: {label}", { label: node.label })}
aria-current={isCurrent ? "true" : undefined}
onClick={() => onSelectPost(node.id)}
onKeyDown={(event) => {
if (event.key === "Enter" || event.key === " ") {
event.preventDefault();
onSelectPost(node.id);
}
}}
>
<circle
className="lineage-dag-hit"
r={NODE_HIT_RADIUS}
fill="transparent"
style={{ pointerEvents: "all" }}
/>
<circle r={7} />
<text x={12} y={4}>
{truncateLabel(node.label)}
</text>
<title>
{tf("{label} — {date}", {
label: node.label,
date: node.occurred_at.slice(0, 10),
})}
</title>
</g>
);
})}
</svg>
</div>
</figure>
);
})}
Expand Down
Loading