diff --git a/src/features/packets/PacketTableRow.tsx b/src/features/packets/PacketTableRow.tsx
new file mode 100644
index 0000000..68b3212
--- /dev/null
+++ b/src/features/packets/PacketTableRow.tsx
@@ -0,0 +1,72 @@
+import { formatHex } from "../../lib/formatters";
+import { Timestamp } from "../../components/Timestamp";
+import { Badge } from "../../components/Badge";
+import { ScopeTag } from "../../components/ScopeTag";
+import { payloadTypeVariant } from "../../components/badge-utils";
+import { PAYLOAD_TYPE_NAMES, type PayloadTypeValue } from "../../types/enums";
+import type { PacketSummary } from "../../types/api";
+import { GRID_TEMPLATE } from "./packet-grid";
+import { PacketPathLine } from "./PacketPathLine";
+
+interface PacketTableRowProps {
+ packet: PacketSummary;
+ expanded: boolean;
+ isFresh?: boolean;
+ onToggle: () => void;
+}
+
+// two-line table row: line 1 is the identity grid (shares GRID_TEMPLATE with the header), line 2
+// is the latest path. The whole row is the expansion click target.
+export function PacketTableRow({ packet, expanded, isFresh, onToggle }: PacketTableRowProps) {
+ const observer = packet.latestObserver;
+
+ return (
+
+
+
+
+
+ );
+}
diff --git a/tests/features/packets/PacketTableRow.test.tsx b/tests/features/packets/PacketTableRow.test.tsx
new file mode 100644
index 0000000..3e55fb2
--- /dev/null
+++ b/tests/features/packets/PacketTableRow.test.tsx
@@ -0,0 +1,68 @@
+import { describe, it, expect, vi } from "vitest";
+import { render, screen, fireEvent } from "@testing-library/react";
+import { PacketTableRow } from "../../../src/features/packets/PacketTableRow";
+import type { PacketSummary } from "../../../src/types/api";
+
+const pkt = (over: Partial = {}): PacketSummary => ({
+ packetHash: "AA11BB22", payloadType: 1, payloadTypeName: "ADVERT",
+ routeType: 1, routeTypeName: "FLOOD",
+ firstHeardAt: 1700000000, lastHeardAt: 1700000000, observationCount: 3, ...over,
+});
+
+describe("PacketTableRow", () => {
+ it("exposes one button carrying the expansion state", () => {
+ render( {}} />);
+ const btn = screen.getByRole("button");
+ expect(btn).toHaveAttribute("aria-expanded", "false");
+ });
+
+ it("toggles on click", () => {
+ const onToggle = vi.fn();
+ render();
+ fireEvent.click(screen.getByRole("button"));
+ expect(onToggle).toHaveBeenCalledOnce();
+ });
+
+ it("renders line 2 even when there is no path data, so row height is constant", () => {
+ render( {}} />);
+ expect(screen.getByText("n/a")).toBeInTheDocument();
+ });
+
+ it("falls back to the observer id when there is no display name", () => {
+ render( {}} />);
+ expect(screen.getByText("abcdef12")).toBeInTheDocument();
+ expect(screen.getByText("YVR")).toBeInTheDocument();
+ });
+
+ it("prefers the observer display name over the id", () => {
+ render( {}} />);
+ expect(screen.getByText("Cypress Peak")).toBeInTheDocument();
+ expect(screen.queryByText("abcdef12")).not.toBeInTheDocument();
+ });
+
+ it("reflects the expanded state on the button and chevron", () => {
+ render( {}} />);
+ expect(screen.getByRole("button")).toHaveAttribute("aria-expanded", "true");
+ expect(screen.getByText("›")).toHaveClass("rotate-90");
+ });
+
+ it("marks a fresh row with the pulse class", () => {
+ const { container } = render( {}} />);
+ expect(container.querySelector(".packet-fresh")).toBeInTheDocument();
+ });
+
+ it("renders a scope tag when the packet has a scope", () => {
+ render( {}} />);
+ expect(screen.getByText("#bc")).toBeInTheDocument();
+ });
+
+ it("falls back to the raw payload type name for an unrecognized payload type", () => {
+ render( {}} />);
+ expect(screen.getByText("CUSTOM_99")).toBeInTheDocument();
+ });
+
+ it("falls back to Unknown when routeTypeName is empty", () => {
+ render( {}} />);
+ expect(screen.getByText("Unknown")).toBeInTheDocument();
+ });
+});