From 86b799e7cfcc3fa4fa3718dae267ac45cde905ff Mon Sep 17 00:00:00 2001 From: jacobvjk Date: Mon, 26 Oct 2026 12:09:56 +0100 Subject: [PATCH 1/2] feat(comparison): name each column by publisher, pathway and year Once the pathway cards scroll away, the comparison header's condensed bar named each column by its short pathway name alone, so two editions of IEA NZE, or two publishers' "Reference", looked identical. * Each column reads "Publisher: Pathway" on the left and the publication year on the right. The year never truncates, so the name gives way first in narrow columns. * The per-column geography selects take the same name plus the year as their accessible name, which had the same ambiguity. Closes #1012 Co-Authored-By: Claude Opus 5.5 --- docs/design/tpr-ui-kit.html | 29 ++++++- src/components/ComparisonScopeHeader.test.tsx | 79 +++++++++++++++---- src/components/ComparisonScopeHeader.tsx | 30 +++++-- src/pages/ComparisonPage.test.tsx | 18 ++--- 4 files changed, 122 insertions(+), 34 deletions(-) diff --git a/docs/design/tpr-ui-kit.html b/docs/design/tpr-ui-kit.html index cad5bc77..959e3e1f 100644 --- a/docs/design/tpr-ui-kit.html +++ b/docs/design/tpr-ui-kit.html @@ -1177,7 +1177,24 @@ grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 0 24px; font-size: 12px; + } + .ribbon .condensed.cols > span { + display: flex; + align-items: baseline; + justify-content: space-between; + gap: 8px; + min-width: 0; + } + .ribbon .condensed.cols .n { font-weight: 600; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + .ribbon .condensed.cols .y { + flex-shrink: 0; + font-variant-numeric: tabular-nums; + color: rgb(255 255 255 / 0.8); } .ribbon .axes { background: rgb(255 255 255 / 0.95); @@ -4235,8 +4252,16 @@

Detail page ribbon — scrolled state

>
- IEA NZEACE ATSNGFS Net Zero + IEA: NZE2024IEA: NZE2025NGFS: Net Zero 20502024
{ it("gives every column its own control, named for its pathway", () => { renderHeader(); - expect(columnTrigger("APS")).toBeInTheDocument(); - expect(columnTrigger("ATS")).toBeInTheDocument(); + expect(columnTrigger("IEA: APS (2024)")).toBeInTheDocument(); + expect(columnTrigger("ACE: ATS (2024)")).toBeInTheDocument(); }); it("shows each column's own current geography", () => { renderHeader(); - expect(columnTrigger("APS")).toHaveTextContent("Southeast Asia"); - expect(columnTrigger("ATS")).toHaveTextContent("South East Asia"); + expect(columnTrigger("IEA: APS (2024)")).toHaveTextContent( + "Southeast Asia", + ); + expect(columnTrigger("ACE: ATS (2024)")).toHaveTextContent( + "South East Asia", + ); }); it("offers only the geographies that column's publisher declares", async () => { const { user } = renderHeader(); - await user.click(columnTrigger("ATS")); + await user.click(columnTrigger("ACE: ATS (2024)")); const names = screen .getAllByRole("option") @@ -183,7 +187,7 @@ describe("ComparisonScopeHeader", () => { // The column carries the provenance, so "(IEA)" would be pure noise — // and across the loadable pathways it disambiguates nothing anyway. const { user } = renderHeader(); - await user.click(columnTrigger("APS")); + await user.click(columnTrigger("IEA: APS (2024)")); const names = screen .getAllByRole("option") @@ -194,7 +198,7 @@ describe("ComparisonScopeHeader", () => { it("changes only the column that was used", async () => { const { onGeographyChange, user } = renderHeader(); - await user.click(columnTrigger("APS")); + await user.click(columnTrigger("IEA: APS (2024)")); await user.click(screen.getByRole("option", { name: "Global" })); expect(onGeographyChange).toHaveBeenCalledTimes(1); @@ -223,7 +227,7 @@ describe("ComparisonScopeHeader", () => { selectedGeographies: { [iea.id]: "Global", bare: null }, }); - expect(columnTrigger("BARE")).toBeDisabled(); + expect(columnTrigger("ACE: BARE (2024)")).toBeDisabled(); }); }); @@ -231,7 +235,7 @@ describe("ComparisonScopeHeader", () => { it("shades the geographies this tool cannot plot", async () => { // 13 of IEA's 15 declared geographies carry no timeseries rows. const { user } = renderHeader({ available: ["Southeast Asia"] }); - await user.click(columnTrigger("APS")); + await user.click(columnTrigger("IEA: APS (2024)")); const region = screen.getByRole("option", { name: "Southeast Asia" }); expect(region.querySelector(".bg-transparent")).toBeNull(); @@ -242,7 +246,7 @@ describe("ComparisonScopeHeader", () => { it("leads with what can be plotted", async () => { const { user } = renderHeader({ available: ["Southeast Asia"] }); - await user.click(columnTrigger("APS")); + await user.click(columnTrigger("IEA: APS (2024)")); expect(screen.getAllByRole("option")[0]).toHaveAccessibleName( "Southeast Asia", @@ -302,7 +306,9 @@ describe("ComparisonScopeHeader", () => { it("stays collapsed until the cards scroll away", () => { renderHeader(); - const bar = screen.getByText("APS").closest("div[aria-hidden='true']"); + const bar = screen + .getByText("IEA: APS") + .closest("div[aria-hidden='true']"); expect(bar).toHaveClass("h-0"); }); @@ -310,16 +316,57 @@ describe("ComparisonScopeHeader", () => { renderHeader(); scrollPast(); - const bar = screen.getByText("APS").closest("div[aria-hidden='true']"); + const bar = screen + .getByText("IEA: APS") + .closest("div[aria-hidden='true']"); expect(bar).toHaveClass("h-8"); - expect(screen.getByText("ATS")).toBeInTheDocument(); + expect(screen.getByText("ACE: ATS")).toBeInTheDocument(); }); it("holds no focusables, so nothing hides in a collapsed bar", () => { renderHeader(); - const bar = screen.getByText("APS").closest("div[aria-hidden='true']"); + const bar = screen + .getByText("IEA: APS") + .closest("div[aria-hidden='true']"); expect(bar?.querySelectorAll("button, a, input")).toHaveLength(0); }); + + it("tells apart columns that share a short pathway name", () => { + // Two editions of one pathway, and another publisher's same short name. + const nze2024 = { + ...iea, + id: "IEA-NZE-2024", + name: { full: "Net Zero Emissions by 2050", short: "NZE" }, + } as unknown as PathwayMetadataType; + const nze2025 = { + ...nze2024, + id: "IEA-NZE-2025", + publication: { ...nze2024.publication, year: 2025 }, + } as unknown as PathwayMetadataType; + const otherNze = { + ...ace, + id: "ACE-NZE-2024", + name: { full: "Net Zero", short: "NZE" }, + } as unknown as PathwayMetadataType; + + renderHeader({ + pathways: [nze2024, nze2025, otherNze], + selectedGeographies: {}, + }); + scrollPast(); + + const bar = screen + .getByText("ACE: NZE") + .closest("div[aria-hidden='true']") as HTMLElement; + expect(within(bar).getAllByText("IEA: NZE")).toHaveLength(2); + expect(within(bar).getByText("2025")).toBeInTheDocument(); + expect(within(bar).getAllByText("2024")).toHaveLength(2); + + // The geography selects are named apart too, year included. + expect(columnTrigger("IEA: NZE (2024)")).toBeInTheDocument(); + expect(columnTrigger("IEA: NZE (2025)")).toBeInTheDocument(); + expect(columnTrigger("ACE: NZE (2024)")).toBeInTheDocument(); + }); }); }); diff --git a/src/components/ComparisonScopeHeader.tsx b/src/components/ComparisonScopeHeader.tsx index ffa0b19c..3e34223d 100644 --- a/src/components/ComparisonScopeHeader.tsx +++ b/src/components/ComparisonScopeHeader.tsx @@ -27,8 +27,17 @@ interface ComparisonScopeHeaderProps { divergence: ColumnGeographyDivergence; } +/** + * "Publisher: Pathway", so two columns with the same short pathway name (two + * editions of IEA NZE, or two publishers' "Reference") stay distinguishable. + * Both the condensed bar and the geography selects' accessible names add the + * publication year, which separates two editions of the same pathway. + * `||` rather than `??`: some publishers have an empty short name. + */ const columnLabel = (pathway: PathwayMetadataType): string => - pathway.name.short || pathway.name.full; + `${pathway.publication.publisher.short || pathway.publication.publisher.full}: ${ + pathway.name.short || pathway.name.full + }`; /** * The comparison page's sticky scope bar: one shared sector axis, one geography @@ -83,9 +92,11 @@ export const ComparisonScopeHeader: React.FC = ({
{/* - Condensed column names. aria-hidden because it restates the pathway - cards above, which stay in the accessibility tree whether or not they - are on screen — and it holds no focusables, so nothing is lost. + Condensed column names, with the publication year on the right; the + year never truncates, so the name gives way first. aria-hidden + because it restates the pathway cards above, which stay in the + accessibility tree whether or not they are on screen — and it holds + no focusables, so nothing is lost. */} @@ -158,7 +174,7 @@ export const ComparisonScopeHeader: React.FC = ({ {pathways.map((pathway) => ( onGeographyChange(pathway.id, token)} diff --git a/src/pages/ComparisonPage.test.tsx b/src/pages/ComparisonPage.test.tsx index 1d459726..798a0657 100644 --- a/src/pages/ComparisonPage.test.tsx +++ b/src/pages/ComparisonPage.test.tsx @@ -284,8 +284,8 @@ describe("ComparisonPage — shared scope", () => { await screen.findByText("PubA: Comparison Pathway A", undefined, WAIT); - expect(columnTrigger("A")).toHaveTextContent("Europe"); - expect(columnTrigger("B")).toHaveTextContent("Germany"); + expect(columnTrigger("PubA: A (2024)")).toHaveTextContent("Europe"); + expect(columnTrigger("PubB: B (2023)")).toHaveTextContent("Germany"); expect( screen .getAllByRole("button") @@ -301,8 +301,8 @@ describe("ComparisonPage — shared scope", () => { await screen.findByText("PubA: Comparison Pathway A", undefined, WAIT); - expect(columnTrigger("A")).toHaveTextContent("Global"); - expect(columnTrigger("B")).toHaveTextContent("Germany"); + expect(columnTrigger("PubA: A (2024)")).toHaveTextContent("Global"); + expect(columnTrigger("PubB: B (2023)")).toHaveTextContent("Germany"); }); it("ignores a token the column does not declare", async () => { @@ -314,8 +314,8 @@ describe("ComparisonPage — shared scope", () => { await screen.findByText("PubA: Comparison Pathway A", undefined, WAIT); - expect(columnTrigger("A")).toHaveTextContent("Global"); - expect(columnTrigger("B")).toHaveTextContent("Germany"); + expect(columnTrigger("PubA: A (2024)")).toHaveTextContent("Global"); + expect(columnTrigger("PubB: B (2023)")).toHaveTextContent("Germany"); }); it("warns that the columns are not showing the same geography", async () => { @@ -353,11 +353,11 @@ describe("ComparisonPage — shared scope", () => { await screen.findByText("PubA: Comparison Pathway A", undefined, WAIT); const user = userEvent.setup(); - await user.click(columnTrigger("A")); + await user.click(columnTrigger("PubA: A (2024)")); await user.click(screen.getByRole("option", { name: "Europe" })); - expect(columnTrigger("A")).toHaveTextContent("Europe"); - expect(columnTrigger("B")).toHaveTextContent("Germany"); + expect(columnTrigger("PubA: A (2024)")).toHaveTextContent("Europe"); + expect(columnTrigger("PubB: B (2023)")).toHaveTextContent("Germany"); }); }); From ea92fe57fe674ab04e8ab3058cceb2907a3e7960 Mon Sep 17 00:00:00 2001 From: jacobvjk Date: Mon, 26 Oct 2026 12:32:55 +0100 Subject: [PATCH 2/2] test(comparison): drop an unnecessary type assertion nze2025 spreads an already-typed pathway, so `as unknown as` changed nothing and ESLint's no-unnecessary-type-assertion failed the PR. A type annotation keeps the intent and checks the shape. Co-Authored-By: Claude Opus 5.5 --- src/components/ComparisonScopeHeader.test.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/components/ComparisonScopeHeader.test.tsx b/src/components/ComparisonScopeHeader.test.tsx index b73165f7..fef030c5 100644 --- a/src/components/ComparisonScopeHeader.test.tsx +++ b/src/components/ComparisonScopeHeader.test.tsx @@ -339,11 +339,11 @@ describe("ComparisonScopeHeader", () => { id: "IEA-NZE-2024", name: { full: "Net Zero Emissions by 2050", short: "NZE" }, } as unknown as PathwayMetadataType; - const nze2025 = { + const nze2025: PathwayMetadataType = { ...nze2024, id: "IEA-NZE-2025", publication: { ...nze2024.publication, year: 2025 }, - } as unknown as PathwayMetadataType; + }; const otherNze = { ...ace, id: "ACE-NZE-2024",