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
4 changes: 3 additions & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -191,7 +191,9 @@ Period leftover pairs (ADR 0048 / 0049) are computed in
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. Pair-member leftover-map criterion nodes
(ADR 0126) also open that leftover-pair post; non-pair criteria stay
non-interactive.

`frontend/` has its own toolchain (Node pinned via `frontend/mise.toml`,
pnpm via Corepack -- do not add a second Node package manager or a
Expand Down
3 changes: 2 additions & 1 deletion ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -604,7 +604,8 @@ through opens those DAG posts. Report members include the earliest
open ticket title, status lookup label, and due date when one exists. The home page renders
the actual mean θ, the FIPC delta, the CAT-selected item, leftover
closest/farthest pairs and the leftover interaction map above the
member list, and the
member list (click a leftover-map person or pair-member criterion
node to open that post), and the
PU / corp / thread comparison -- never a placeholder. TEPP is unchanged.

## Phase 6b: Knowledge Graph as a real Ontology + Semantic Layer
Expand Down
8 changes: 8 additions & 0 deletions CHANGELOG.d/2.12.10-leftover-map-criterion-node.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
## 2.12.10 — Leftover-map criterion nodes

- Pair-member leftover-map criterion nodes open the leftover-pair post
(closest preferred, then farthest). Non-pair criteria stay
non-interactive diamonds so the map does not invent a next action
(ADR 0126).
- Keyboard Enter/Space matches leftover-map person nodes. Click does
not land leftover focus on Post quality.
9 changes: 9 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,15 @@ All notable changes to this project are documented here. Format follows
environment, so local OIDC and synthetic-data workflows resolve the same
pinned dependencies as CI.

## [2.12.10] - 2026-08-23

### Added

- Pair-member leftover-map criterion nodes now open the leftover-pair
post (closest preferred, then farthest). Non-pair criteria stay
non-interactive. Click does not set leftover focus on Post quality
(ADR 0126).

## [2.12.7] - 2026-08-23

### Added
Expand Down
3 changes: 2 additions & 1 deletion docs/adr/0121-persist-leftover-interaction-map.md
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,8 @@ fabricated interaction.
Closest and farthest pairs remain ADR 0048 / ADR 0049. The map sits
**above** that pair list on the period-report group. Clicking a
person node opens that post with the same handler as a leftover
pair. Hidden posts stay hidden: leftover-map persons join
pair. Pair-member criterion nodes open that leftover-pair post
([ADR 0126](0126-leftover-map-criterion-node.md)). Hidden posts stay hidden: leftover-map persons join
`source_post` and use the same ABAC gate as members and leftover
pairs. Missing map rows render nothing.

Expand Down
60 changes: 60 additions & 0 deletions docs/adr/0126-leftover-map-criterion-node.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
# ADR 0126 — Leftover-map criterion nodes open the leftover-pair post

**Decision status:** Accepted
**Date:** 2026-08-23

## Context

ADR 0121 persists leftover interaction-map coordinates and renders a
2D Gabriel biplot above leftover pairs. Person (post) nodes are
buttons that open that post. Criterion (item) nodes are diamonds
without a next action: a buyer who sees a highlighted closest or
farthest criterion cannot act on it.

ADR 0125 lands leftover-pair *list* clicks on Post quality with a
leftover-focus flag. This increment is independent of that landing.
The map criterion node opens the leftover-pair post only. It does
not set leftover focus or `aria-current` on Post quality.

A criterion that is not a leftover-pair member has no buyer next
action. Inventing a click that opens an arbitrary post would
fabricate a pair.

## Decision

Export `leftoverPairForCriterion(pairs, criterionCode)`. Prefer the
closest leftover pair for that criterion, then farthest. If none,
the criterion stays a non-interactive diamond.

When a pair exists, the criterion node is `role="button"`, keyboard
activable (Enter / Space), and named `Open leftover map criterion:
{label}`. Activation calls `onSelectPost(pair.post_id)` — the same
handler as a leftover-map person node and leftover-pair list button
on this stack.

Do not pass leftover-focus flags. Hidden posts stay hidden because
the pair's `post_id` is already ABAC-filtered with leftover pairs.

## Consequences

Buyers can click a highlighted leftover-map criterion and read the
post that sat closest (or farthest) from it after IRT main effects.
Non-pair criteria remain visual context on the Gabriel biplot.

## Related

Depends on [ADR 0121](0121-persist-leftover-interaction-map.md),
[ADR 0048](0048-persist-lsirm-leftover-pairs.md), and
[ADR 0049](0049-leftover-pair-report-ui.md). Independent of leftover
criterion evaluation landing.

## References

Gabriel, K. R. (1971). The biplot graphic display of matrices with
application to principal component analysis. *Biometrika, 58*(3),
453–467. https://doi.org/10.1093/biomet/58.3.453

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
1 change: 1 addition & 0 deletions docs/storybook-inventory.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,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` |
| `Chrome/PopupCloseButton` | Close the evidence panel or post popup. | `--space-close-inset`, `--font-size-close`, `PopupCloseButton` |
| `Reports/LeftoverInteractionMap` | Click a leftover-pair criterion diamond to open that pair's post. | leftover-map item/person tokens, `LeftoverInteractionMap` |

Repeated web objects must use `frontend/src/styles/tokens.css` and a module
under `frontend/src/components/`. Do not add a second Node package manager;
Expand Down
2 changes: 1 addition & 1 deletion frontend/package.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"name": "frontend",
"private": true,
"version": "2.12.7",
"version": "2.12.10",
"type": "module",
"scripts": {
"dev": "vite",
Expand Down
13 changes: 13 additions & 0 deletions frontend/src/App.css
Original file line number Diff line number Diff line change
Expand Up @@ -567,6 +567,10 @@
stroke-width: 1.5;
}

.leftover-map-item[role="button"] {
cursor: pointer;
}

.leftover-map-item rect {
fill: var(--badge-actor-organization-bg);
stroke: var(--badge-actor-organization-text);
Expand Down Expand Up @@ -600,6 +604,15 @@
stroke-width: 2.5;
}

.leftover-map-item[role="button"]:focus {
outline: none;
}

.leftover-map-item[role="button"]:focus rect,
.leftover-map-item[role="button"]:hover rect {
stroke-width: 2.5;
}

.lineage-dag-node:focus {
outline: none;
}
Expand Down
10 changes: 10 additions & 0 deletions frontend/src/App.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3451,6 +3451,16 @@ describe("App, authenticated", () => {
await waitFor(() => expect(screen.getByText("The full body text.")).toBeInTheDocument());
});

it("opens a leftover-map criterion post from the report panel", async () => {
stubBackend();
render(<App showLabPanels />);

await userEvent.click(
await screen.findByRole("button", { name: "Open leftover map criterion: sales-lead" }),
);
await waitFor(() => expect(screen.getByText("The full body text.")).toBeInTheDocument());
});

it("opens Event Lineage, Keyman, and evaluation from a report member click", async () => {
stubBackend();
render(<App showLabPanels />);
Expand Down
76 changes: 76 additions & 0 deletions frontend/src/LeftoverInteractionMap.stories.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
import { LeftoverInteractionMap } from "./LeftoverInteractionMap";

const itemLabel = (code: string) =>
code === "sales_lead_specificity" ? "sales-lead" : code.replaceAll("_", " ");

const meta = {
title: "Reports/LeftoverInteractionMap",
component: LeftoverInteractionMap,
args: {
persons: [
{ post_id: "post-1", post_title: "Public post", axis_one: -0.5, axis_two: 0.1 },
{
post_id: "post-2",
post_title: "Specification revision requested",
axis_one: 0.8,
axis_two: -0.4,
},
],
items: [
{ criterion_code: "sales_lead_specificity", axis_one: -0.4, axis_two: 0.05 },
{ criterion_code: "general_sentiment_negative", axis_one: 1.2, axis_two: -0.9 },
{ criterion_code: "general_sentiment_positive", axis_one: 0.1, axis_two: 0.7 },
],
pairs: [
{
pair_kind: "closest",
post_id: "post-1",
post_title: "Public post",
criterion_code: "sales_lead_specificity",
leftover_distance: 0.12,
leftover_residual: 0.4,
},
{
pair_kind: "farthest",
post_id: "post-2",
post_title: "Specification revision requested",
criterion_code: "general_sentiment_negative",
leftover_distance: 1.84,
leftover_residual: -1.1,
},
],
itemLabel,
onSelectPost: () => undefined,
},
} satisfies Meta<typeof LeftoverInteractionMap>;

export default meta;

type Story = StoryObj<typeof meta>;

export const ClosestFarthest: Story = {};

export const CriterionClick: Story = {
args: {
items: [{ criterion_code: "sales_lead_specificity", axis_one: -0.4, axis_two: 0.05 }],
pairs: [
{
pair_kind: "closest",
post_id: "post-1",
post_title: "Public post",
criterion_code: "sales_lead_specificity",
leftover_distance: 0.12,
leftover_residual: 0.4,
},
],
},
};

export const OriginPad: Story = {
args: {
persons: [{ post_id: "post-1", post_title: "Public post", axis_one: 0, axis_two: 0 }],
items: [{ criterion_code: "sales_lead_specificity", axis_one: 0, axis_two: 0 }],
pairs: [],
},
};
Loading
Loading