diff --git a/src/components/ArgumentGraph.tsx b/src/components/ArgumentGraph.tsx
new file mode 100644
index 0000000..5a91719
--- /dev/null
+++ b/src/components/ArgumentGraph.tsx
@@ -0,0 +1,344 @@
+import { useCallback, useEffect, useMemo, useState } from "react";
+import ReactFlow, {
+ Background,
+ Controls,
+ Handle,
+ MiniMap,
+ Position,
+ applyEdgeChanges,
+ applyNodeChanges,
+ type Connection,
+ type Edge,
+ type EdgeChange,
+ type Node,
+ type NodeChange,
+ type NodeProps,
+ type XYPosition,
+} from "reactflow";
+import "reactflow/dist/style.css";
+import { useEditorContext } from "../contexts/EditorContext";
+import { useSession } from "../contexts/SessionContext";
+import { LABEL_CONFIGS } from "../types/labels";
+import type { Highlight, Relationship } from "../types";
+
+interface HighlightNodeData {
+ highlight: Highlight;
+ color: string;
+ onEditText: (id: string, newText: string) => void;
+ onDelete: (id: string) => void;
+}
+
+function HighlightNode({ data }: NodeProps) {
+ const { highlight, color, onEditText, onDelete } = data;
+ const [isEditing, setIsEditing] = useState(false);
+ const [draft, setDraft] = useState(highlight.text);
+
+ const commit = () => {
+ setIsEditing(false);
+ const next = draft.trim();
+ if (next && next !== highlight.text) onEditText(highlight.id, next);
+ else setDraft(highlight.text);
+ };
+
+ return (
+
+
+
+
+ {highlight.labelType}
+
+
+
+
+
+
+ {isEditing ? (
+
+ );
+}
+
+const nodeTypes = { highlight: HighlightNode };
+
+function buildAutoLayout(
+ highlights: Highlight[],
+ saved: Record,
+): Record {
+ // Column-based fallback: group by labelType, one column per label.
+ const columns: Record = {};
+ const unseen: Highlight[] = [];
+ for (const h of highlights) {
+ if (saved[h.id]) continue;
+ unseen.push(h);
+ }
+ for (const h of unseen) {
+ (columns[h.labelType] ??= []).push(h);
+ }
+ const laid: Record = {};
+ const labelOrder = LABEL_CONFIGS.map((l) => l.id);
+ const orderedLabels = [
+ ...labelOrder.filter((l) => columns[l]),
+ ...Object.keys(columns).filter((l) => !labelOrder.includes(l)),
+ ];
+ const colW = 280;
+ const rowH = 120;
+ orderedLabels.forEach((label, colIdx) => {
+ columns[label].forEach((h, rowIdx) => {
+ laid[h.id] = { x: colIdx * colW, y: rowIdx * rowH };
+ });
+ });
+ return laid;
+}
+
+export function ArgumentGraph() {
+ const {
+ highlights,
+ updateHighlightText,
+ removeHighlight,
+ } = useEditorContext();
+ const { session, saveRelationships, saveGraphPositions, saveContent } =
+ useSession();
+ const { editor } = useEditorContext();
+
+ const relationships = useMemo(
+ () => session?.relationships ?? [],
+ [session?.relationships],
+ );
+ const graphPositions = useMemo(
+ () => session?.graphPositions ?? {},
+ [session?.graphPositions],
+ );
+
+ const [labelFilter, setLabelFilter] = useState>(
+ () => Object.fromEntries(LABEL_CONFIGS.map((l) => [l.id, true])),
+ );
+
+ const labelColor = useCallback((labelType: string) => {
+ return LABEL_CONFIGS.find((l) => l.id === labelType)?.color ?? "#999";
+ }, []);
+
+ // Compute initial nodes whenever highlights change; keep ReactFlow's
+ // local drag state in between.
+ const visibleHighlights = useMemo(
+ () => highlights.filter((h) => labelFilter[h.labelType] !== false),
+ [highlights, labelFilter],
+ );
+
+ const computedPositions = useMemo(() => {
+ const autoLaid = buildAutoLayout(visibleHighlights, graphPositions);
+ const merged: Record = { ...autoLaid };
+ for (const h of visibleHighlights) {
+ if (graphPositions[h.id]) merged[h.id] = graphPositions[h.id];
+ }
+ return merged;
+ }, [visibleHighlights, graphPositions]);
+
+ const initialNodes = useMemo[]>(() => {
+ return visibleHighlights.map((h) => ({
+ id: h.id,
+ type: "highlight",
+ position: computedPositions[h.id] ?? { x: 0, y: 0 },
+ data: {
+ highlight: h,
+ color: labelColor(h.labelType),
+ onEditText: (id, next) => {
+ updateHighlightText(id, next);
+ // Persist doc after the mark update.
+ void saveContent(editor.getState().doc.toJSON());
+ },
+ onDelete: (id) => {
+ removeHighlight(id);
+ // Prune relationships referencing this node.
+ const pruned = relationships.filter(
+ (r) => r.sourceHighlightId !== id && r.targetHighlightId !== id,
+ );
+ if (pruned.length !== relationships.length) {
+ void saveRelationships(pruned);
+ }
+ void saveContent(editor.getState().doc.toJSON());
+ },
+ },
+ }));
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [visibleHighlights, computedPositions, relationships]);
+
+ const initialEdges = useMemo(() => {
+ const ids = new Set(visibleHighlights.map((h) => h.id));
+ return relationships
+ .filter(
+ (r) => ids.has(r.sourceHighlightId) && ids.has(r.targetHighlightId),
+ )
+ .map((r) => ({
+ id: `${r.sourceHighlightId}->${r.targetHighlightId}`,
+ source: r.sourceHighlightId,
+ target: r.targetHighlightId,
+ }));
+ }, [relationships, visibleHighlights]);
+
+ const [nodes, setNodes] = useState[]>(initialNodes);
+ const [edges, setEdges] = useState(initialEdges);
+
+ // When upstream data changes (highlights added/removed, edges changed by
+ // another view), reset local graph state. Drag-only position changes stay
+ // local until commit.
+ useEffect(() => {
+ setNodes(initialNodes);
+ }, [initialNodes]);
+ useEffect(() => {
+ setEdges(initialEdges);
+ }, [initialEdges]);
+
+ const onNodesChange = useCallback(
+ (changes: NodeChange[]) =>
+ setNodes((prev) => applyNodeChanges(changes, prev)),
+ [],
+ );
+ const onEdgesChange = useCallback(
+ (changes: EdgeChange[]) => {
+ setEdges((prev) => applyEdgeChanges(changes, prev));
+ const removedIds = changes
+ .filter((c): c is EdgeChange & { type: "remove"; id: string } =>
+ c.type === "remove",
+ )
+ .map((c) => c.id);
+ if (removedIds.length === 0) return;
+ const next: Relationship[] = relationships.filter((r) => {
+ const id = `${r.sourceHighlightId}->${r.targetHighlightId}`;
+ return !removedIds.includes(id);
+ });
+ if (next.length !== relationships.length) {
+ void saveRelationships(next);
+ }
+ },
+ [relationships, saveRelationships],
+ );
+
+ const onNodeDragStop = useCallback(
+ (_: unknown, node: Node) => {
+ const next: Record = { ...graphPositions };
+ next[node.id] = { x: node.position.x, y: node.position.y };
+ void saveGraphPositions(next);
+ },
+ [graphPositions, saveGraphPositions],
+ );
+
+ const onConnect = useCallback(
+ (connection: Connection) => {
+ if (!connection.source || !connection.target) return;
+ if (connection.source === connection.target) return;
+ const exists = relationships.some(
+ (r) =>
+ r.sourceHighlightId === connection.source &&
+ r.targetHighlightId === connection.target,
+ );
+ if (exists) return;
+ const next: Relationship[] = [
+ ...relationships,
+ {
+ sourceHighlightId: connection.source,
+ targetHighlightId: connection.target,
+ },
+ ];
+ void saveRelationships(next);
+ },
+ [relationships, saveRelationships],
+ );
+
+ const toggleLabel = (id: string) =>
+ setLabelFilter((prev) => ({ ...prev, [id]: !prev[id] }));
+
+ return (
+
+ );
+}
diff --git a/src/components/ClaimCard.tsx b/src/components/ClaimCard.tsx
new file mode 100644
index 0000000..d1f32da
--- /dev/null
+++ b/src/components/ClaimCard.tsx
@@ -0,0 +1,155 @@
+import { useState } from "react";
+import { LABEL_CONFIGS } from "../types/labels";
+import type { Highlight } from "../types";
+
+interface ClaimCardProps {
+ claim: Highlight;
+ evidence: Highlight[];
+ onEditText: (id: string, newText: string) => void;
+ onDeleteClaim: (id: string) => void;
+ onRemoveEvidence: (claimId: string, evidenceId: string) => void;
+}
+
+function labelColor(labelType: string): string {
+ return LABEL_CONFIGS.find((l) => l.id === labelType)?.color ?? "#999";
+}
+
+interface EditableTextProps {
+ value: string;
+ onCommit: (next: string) => void;
+ className?: string;
+ placeholder?: string;
+}
+
+function EditableText({ value, onCommit, className, placeholder }: EditableTextProps) {
+ const [editing, setEditing] = useState(false);
+ const [draft, setDraft] = useState(value);
+
+ if (!editing) {
+ return (
+
+ );
+ }
+
+ const commit = () => {
+ setEditing(false);
+ const next = draft.trim();
+ if (next && next !== value) onCommit(next);
+ };
+
+ return (
+