diff --git a/AGENTS.md b/AGENTS.md
index c790995c1..cf931abc0 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -75,12 +75,13 @@ in the same spirit) -- never against real data, per the hard rule above.
against a live local stack (`make up`) and self-skip without one -- see
[README.md](README.md#local-product-stack-docker-compose).
-Period leftover pairs (ADR 0017 / 0018) are computed in
+Period leftover pairs (ADR 0017 / 0018 / 0031) are computed in
`lineageweave/leftover_pairs.py` from the residual after a real
GRM/GPCM score, never invented. Missing cells stay out of the
Gabriel factorization. Closest and farthest post–criterion pairs
persist to `report_leftover_pair` and sit above the member list so
-a click opens that post.
+a click opens that post. A home post row that is a leftover pair
+also names the leftover criterion next to visibility.
`frontend/` has its own toolchain (Node pinned via `frontend/mise.toml`,
pnpm via Corepack -- do not add a second Node package manager or a
diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md
index f8a83ceb1..605e76be1 100644
--- a/ARCHITECTURE.md
+++ b/ARCHITECTURE.md
@@ -122,7 +122,8 @@ flowchart LR
`rankweave_client.py`'s default transport raises
`RankWeaveNotAvailable`. `GET /api/rankings` then returns
`rankweave_not_available` and an empty ranking list. Hidden posts
- are omitted from every channel. See ADR 0024.
+ are omitted from every channel. See ADR 0024. A home post row
+ that is also a leftover pair names that criterion (ADR 0031).
## Standards and citations
diff --git a/CHANGELOG.d/0.90.0-leftover-home-post.md b/CHANGELOG.d/0.90.0-leftover-home-post.md
new file mode 100644
index 000000000..d06bacb00
--- /dev/null
+++ b/CHANGELOG.d/0.90.0-leftover-home-post.md
@@ -0,0 +1,8 @@
+# 0.90.0 — Leftover badge on the matching home post row
+
+## Added
+
+- Home post rows that are leftover pairs name the leftover
+ criterion (ADR 0031). After `make seed`, the leftover row reads
+ **Closest leftover · sales-lead** next to visibility; click
+ opens that post. A row that is not a leftover pair stays unmarked.
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 6bfcaa28f..3dbc297fd 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -4,6 +4,16 @@ All notable changes to this project are documented here. Format follows
[Keep a Changelog](https://keepachangelog.com/en/1.1.0/); versioning follows
[Semantic Versioning](https://semver.org/spec/v2.0.0.html).
+## [0.90.0] - 2026-08-18
+
+### Added
+
+- Home post rows that are leftover pairs name the leftover
+ criterion (ADR 0031). After `make seed`, the leftover row reads
+ **Closest leftover · sales-lead** next to visibility; click
+ opens that post. A row that is not a leftover pair stays unmarked.
+ Never invent a fused score or a theta.
+
## [0.75.0] - 2026-08-17
### Added
diff --git a/docs/adr/0031-leftover-pair-on-home-post.md b/docs/adr/0031-leftover-pair-on-home-post.md
new file mode 100644
index 000000000..68ed13e10
--- /dev/null
+++ b/docs/adr/0031-leftover-pair-on-home-post.md
@@ -0,0 +1,52 @@
+# ADR 0031 — Name leftover on the matching home post row
+
+**Decision status:** Accepted
+**Date:** 2026-08-18
+
+## Context
+
+ADR 0018 puts leftover pairs above the period-report member list.
+The home post list is the last surface a buyer scans after Rankings,
+Calendar, Period reports, and Event Lineage. The leftover post is
+already in that list; the row just does not name the leftover
+criterion.
+
+Do not invent a second leftover store. Do not invent a fused score
+or a theta. Do not change the existing `View post: {title}`
+accessible name — leftover is a visible badge on the already-named
+control.
+
+## Decision
+
+When an authorized leftover pair names a home post row, that button
+shows `Closest leftover · {criterion}` or
+`Farthest leftover · {criterion}` next to the visibility badge.
+
+A row that is not a leftover pair stays unmarked. A leftover pair
+for a hidden post never reaches the home list (ADR 0017 ABAC). A
+report fetch error clears leftover badges and leaves the post list
+intact — never an invented pair.
+
+After `make seed`, the leftover home row reads **Closest leftover ·
+sales-lead** next to visibility; click still opens that post.
+
+Leftover evidence is the same authorized `leftover_pairs` already
+on the period-report payload.
+
+## Consequences
+
+Leftover buttons above the member list stay (ADR 0018). Calendar
+leftover stays on #253 / ADR 0030. Rankings leftover stays on #252
+/ ADR 0029. This slice only labels the already-visible home row.
+
+## Related
+
+Depends on [ADR 0017](0017-persist-lsirm-leftover-pairs.md) and
+[ADR 0018](0018-leftover-pair-report-ui.md).
+
+## References
+
+Jeon, M., Jin, I. H., Schweinberger, M., & Baugh, S. (2021). Mapping
+unobserved item–respondent interactions: A latent space item response
+model with interaction map. *Psychometrika, 86*(2), 378–403.
+https://doi.org/10.1007/s11336-021-09762-5
diff --git a/frontend/package.json b/frontend/package.json
index 575b7c586..8f06e6d45 100644
--- a/frontend/package.json
+++ b/frontend/package.json
@@ -1,7 +1,7 @@
{
"name": "frontend",
"private": true,
- "version": "0.75.0",
+ "version": "0.90.0",
"type": "module",
"scripts": {
"dev": "vite",
diff --git a/frontend/src/App.test.tsx b/frontend/src/App.test.tsx
index a32a26403..eaad486ae 100644
--- a/frontend/src/App.test.tsx
+++ b/frontend/src/App.test.tsx
@@ -56,6 +56,15 @@ describe("App, authenticated", () => {
function stubBackend(options?: {
admin?: boolean;
calendarCommitments?: unknown[];
+ homePosts?: {
+ post_id: string;
+ post_title: string;
+ voc_type_code: string;
+ voc_type_label?: string;
+ visibility_code: string;
+ visibility_label?: string;
+ created_at: string;
+ }[];
rankings?: {
status?: "accepted" | "unavailable";
status_reason?: string | null;
@@ -427,17 +436,19 @@ describe("App, authenticated", () => {
}
if (url.endsWith("/api/posts")) {
return Promise.resolve(
- jsonResponse([
- {
- post_id: "post-1",
- post_title: "Public post",
- voc_type_code: "voc",
- voc_type_label: "Voice of Customer",
- visibility_code: "public",
- visibility_label: "Public",
- created_at: "2026-01-01T00:00:00Z",
- },
- ]),
+ jsonResponse(
+ options?.homePosts ?? [
+ {
+ post_id: "post-1",
+ post_title: "Public post",
+ voc_type_code: "voc",
+ voc_type_label: "Voice of Customer",
+ visibility_code: "public",
+ visibility_label: "Public",
+ created_at: "2026-01-01T00:00:00Z",
+ },
+ ],
+ ),
);
}
if (url.endsWith("/api/posts/post-1")) {
@@ -830,6 +841,43 @@ describe("App, authenticated", () => {
expect(screen.queryByText("Not yet evaluated.")).not.toBeInTheDocument();
});
+ it("names leftover criterion on the matching home post row", async () => {
+ stubBackend({
+ homePosts: [
+ {
+ post_id: "post-1",
+ post_title: "Public post",
+ voc_type_code: "voc",
+ voc_type_label: "Voice of Customer",
+ visibility_code: "public",
+ visibility_label: "Public",
+ created_at: "2026-01-01T00:00:00Z",
+ },
+ {
+ post_id: "post-9",
+ post_title: "Riverbend calendar commitment",
+ voc_type_code: "voc",
+ voc_type_label: "Voice of Customer",
+ visibility_code: "public",
+ visibility_label: "Public",
+ created_at: "2026-01-03T00:00:00Z",
+ },
+ ],
+ });
+ render();
+
+ const leftoverRow = await screen.findByRole("button", { name: "View post: Public post" });
+ await waitFor(() => expect(leftoverRow).toHaveTextContent("Closest leftover · sales-lead"));
+ expect(leftoverRow).toHaveAccessibleName("View post: Public post");
+ const unmarked = screen.getByRole("button", { name: "View post: Riverbend calendar commitment" });
+ expect(unmarked).toHaveTextContent("Voice of Customer");
+ expect(unmarked).not.toHaveTextContent("leftover");
+
+ await userEvent.click(leftoverRow);
+
+ await waitFor(() => expect(screen.getByText("The full body text.")).toBeInTheDocument());
+ });
+
it("rebuilds lineage when the account has post_admin", async () => {
const fetchMock = stubBackend({ admin: true });
render();
diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx
index 6056e5eb4..5b9dcd51c 100644
--- a/frontend/src/App.tsx
+++ b/frontend/src/App.tsx
@@ -52,6 +52,7 @@ import {
type PostSummary,
type RankingList,
type RelatedNode,
+ type LeftoverPair,
type VocEvidence,
} from "./api";
import { LineageDag } from "./LineageDag";
@@ -82,6 +83,10 @@ function criterionShortLabel(itemCode: string): string {
return CRITERION_SHORT_LABEL[itemCode] ?? itemCode;
}
+function leftoverRowLabel(pairKind: string): string {
+ return pairKind === "farthest" ? "Farthest leftover" : "Closest leftover";
+}
+
// This popup's layout follows the textual product brief (Korean summary,
// key events, R&R, Event Lineage, Keyman, in-popup chat with a sliding
// evidence panel) rather than the referenced Figma frame's actual pixel
@@ -1425,10 +1430,12 @@ function ReportsPanel({
accessToken,
canRebuild,
onSelectPost,
+ onLeftoverPairsChange,
}: {
accessToken: string;
canRebuild: boolean;
onSelectPost: (postId: string) => void;
+ onLeftoverPairsChange: (pairs: LeftoverPair[]) => void;
}) {
const [grouping, setGrouping] = useState("process_unit");
const [period, setPeriod] = useState("2026-W02");
@@ -1455,9 +1462,13 @@ function ReportsPanel({
setPayload(reports);
setIndex(periods);
setComparison(compared);
+ onLeftoverPairsChange(reports.reports.flatMap((row) => row.leftover_pairs ?? []));
})
- .catch((err) => setError(String(err)));
- }, [accessToken, grouping, period]);
+ .catch((err) => {
+ setError(String(err));
+ onLeftoverPairsChange([]);
+ });
+ }, [accessToken, grouping, period, onLeftoverPairsChange]);
async function handleRebuild() {
setRebuilding(true);
@@ -1472,8 +1483,10 @@ function ReportsPanel({
setPayload(reports);
setIndex(periods);
setComparison(compared);
+ onLeftoverPairsChange(reports.reports.flatMap((row) => row.leftover_pairs ?? []));
} catch (err) {
setError(String(err));
+ onLeftoverPairsChange([]);
} finally {
setRebuilding(false);
}
@@ -1658,6 +1671,7 @@ function PostList({ accessToken }: { accessToken: string }) {
const [graph, setGraph] = useState(null);
const [error, setError] = useState(null);
const [selectedPostId, setSelectedPostId] = useState(null);
+ const [leftoverPairs, setLeftoverPairs] = useState([]);
const [canRebuild, setCanRebuild] = useState(false);
const [rebuilding, setRebuilding] = useState(false);
const [rebuildError, setRebuildError] = useState(null);
@@ -1691,7 +1705,12 @@ function PostList({ accessToken }: { accessToken: string }) {
<>
-
+