From 0907648a571ccfcd620055d4f9121aef18514035 Mon Sep 17 00:00:00 2001 From: Silas Mantisa Date: Thu, 22 Oct 2026 09:40:11 +0100 Subject: [PATCH 1/4] feat(governance): show timelock countdown and shared status chips (#681) --- .../governance/[id]/ProposalDetailClient.tsx | 16 +++----- src/app/governance/archive/page.tsx | 5 ++- src/components/ProposalStatusChip.tsx | 23 ++++++++++++ src/components/TimelockBanner.tsx | 37 +++++++++++++++++++ 4 files changed, 68 insertions(+), 13 deletions(-) create mode 100644 src/components/ProposalStatusChip.tsx create mode 100644 src/components/TimelockBanner.tsx diff --git a/src/app/governance/[id]/ProposalDetailClient.tsx b/src/app/governance/[id]/ProposalDetailClient.tsx index d29b207..7803d47 100644 --- a/src/app/governance/[id]/ProposalDetailClient.tsx +++ b/src/app/governance/[id]/ProposalDetailClient.tsx @@ -14,6 +14,8 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import Link from "next/link"; import { Nav } from "@/components/Nav"; import { ConnectWalletButton } from "@/components/ConnectWalletButton"; +import { ProposalStatusChip } from "@/components/ProposalStatusChip"; +import { TimelockBanner } from "@/components/TimelockBanner"; import { VotePanel } from "@/components/VotePanel"; import { VotingPowerPanel } from "@/components/VotingPowerPanel"; import { useWalletStore } from "@/store/wallet"; @@ -322,17 +324,7 @@ export default function ProposalDetailClient({ proposalId }: { proposalId: strin {proposal.category} - - {proposal.status} - +

{proposal.title}

@@ -342,6 +334,8 @@ export default function ProposalDetailClient({ proposalId }: { proposalId: strin

+ {proposal.status === "queued" && } +
{/* ── Main column ──────────────────────────────────────────────── */}
diff --git a/src/app/governance/archive/page.tsx b/src/app/governance/archive/page.tsx index d911996..bfa2a7f 100644 --- a/src/app/governance/archive/page.tsx +++ b/src/app/governance/archive/page.tsx @@ -3,9 +3,10 @@ import Link from 'next/link'; import { notFound } from 'next/navigation'; import { governanceStore } from '@/lib/governanceStore'; import { isValidStellarAddress } from '@/lib/stellarAddress'; +import { ProposalStatusChip } from '@/components/ProposalStatusChip'; import { toCsv } from '@/lib/csv'; -const STATUSES = ['all', 'passed', 'rejected', 'expired', 'executed'] as const; +const STATUSES = ['all', 'active', 'queued', 'passed', 'rejected', 'expired', 'executed'] as const; type Status = (typeof STATUSES)[number]; const PAGE_SIZE = 10; @@ -179,7 +180,7 @@ export default async function GovernanceArchivePage({ {p.title} - {p.status} +
diff --git a/src/components/ProposalStatusChip.tsx b/src/components/ProposalStatusChip.tsx new file mode 100644 index 0000000..ff78a15 --- /dev/null +++ b/src/components/ProposalStatusChip.tsx @@ -0,0 +1,23 @@ +import type { GovernanceProposalStatus } from "@/lib/governanceStore"; + +/** Single source of truth for the label/color of every proposal status. */ +export const PROPOSAL_STATUS_CHIP: Record = { + active: { label: "Active", className: "bg-vx-sage-bg text-vx-sage border border-vx-sage/30" }, + passed: { label: "Passed", className: "bg-blue-500/10 text-blue-400 border border-blue-500/30" }, + rejected: { label: "Rejected", className: "bg-red-500/10 text-red-400 border border-red-500/30" }, + queued: { label: "Queued", className: "bg-amber-500/10 text-amber-400 border border-amber-500/30" }, + executed: { label: "Executed", className: "bg-emerald-500/10 text-emerald-400 border border-emerald-500/30" }, + expired: { label: "Expired", className: "bg-vx-surface text-vx-muted border border-vx-border" }, +}; + +export function ProposalStatusChip({ status }: { status: GovernanceProposalStatus }) { + const chip = PROPOSAL_STATUS_CHIP[status]; + return ( + + {chip.label} + + ); +} diff --git a/src/components/TimelockBanner.tsx b/src/components/TimelockBanner.tsx new file mode 100644 index 0000000..ab62d1d --- /dev/null +++ b/src/components/TimelockBanner.tsx @@ -0,0 +1,37 @@ +"use client"; + +import { useLiveRelativeTime } from "@/hooks/useLiveRelativeTime"; + +function formatRemaining(ms: number): string { + const totalMinutes = Math.floor(ms / 60_000); + const days = Math.floor(totalMinutes / 1440); + const hours = Math.floor((totalMinutes % 1440) / 60); + const minutes = totalMinutes % 60; + if (days > 0) return `${days}d ${hours}h ${minutes}m`; + if (hours > 0) return `${hours}h ${minutes}m`; + return `${minutes}m`; +} + +/** Read-only timelock countdown for a queued proposal. */ +export function TimelockBanner({ timelockEndsAt }: { timelockEndsAt?: string }) { + const now = useLiveRelativeTime(15_000); + const endsAt = timelockEndsAt ? Date.parse(timelockEndsAt) : NaN; + + let message: string; + if (Number.isNaN(endsAt)) { + message = "Queued in timelock. The execution time is not available yet."; + } else if (endsAt <= now) { + message = "Timelock ended. This proposal is ready to execute."; + } else { + message = `In timelock. Executable in ${formatRemaining(endsAt - now)} (${new Date(endsAt).toUTCString()}).`; + } + + return ( +
+ {message} +
+ ); +} From f72f3af63f45f3cf53b37317aef39c264a0ea661 Mon Sep 17 00:00:00 2001 From: Silas Mantisa Date: Thu, 22 Oct 2026 09:40:23 +0100 Subject: [PATCH 2/4] feat(governance): cross-link delegate directory and voter history (#682) --- src/app/governance/delegates/page.tsx | 53 +++++++-------------- src/app/governance/voter/[address]/page.tsx | 9 ++++ src/lib/delegateDirectory.ts | 35 ++++++++++++++ 3 files changed, 61 insertions(+), 36 deletions(-) create mode 100644 src/lib/delegateDirectory.ts diff --git a/src/app/governance/delegates/page.tsx b/src/app/governance/delegates/page.tsx index 76a1db7..4316389 100644 --- a/src/app/governance/delegates/page.tsx +++ b/src/app/governance/delegates/page.tsx @@ -1,51 +1,17 @@ 'use client'; +import Link from 'next/link'; import { useCallback, useEffect, useMemo, useState } from 'react'; import { useVotingPower } from '@/hooks/useVotingPower'; import { useDelegation } from '@/hooks/useDelegation'; import { sanitizeText } from '@/lib/textSafety'; import { isSameStellarAddress, isValidStellarAddress } from '@/lib/stellarAddress'; +import { MOCK_DELEGATES, type DelegateProfile } from '@/lib/delegateDirectory'; import { secureLogger } from '@/lib/secureLogging'; -interface DelegateProfile { - address: string; - name: string; - bio: string; - votingPower: number; - participationRate: number; - proposalsVoted: number; -} - type SortKey = 'votingPower' | 'participationRate' | 'proposalsVoted'; type FlowStage = 'idle' | 'review' | 'simulate' | 'sign' | 'pending' | 'done'; -const MOCK_DELEGATES: DelegateProfile[] = [ - { - address: 'GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAWHF', - name: 'Aurora Governance', - bio: 'Active voter focused on protocol upgrades and treasury stewardship.', - votingPower: 1250000, - participationRate: 0.94, - proposalsVoted: 87, - }, - { - address: 'GBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBWHF', - name: 'Nova Collective', - bio: 'Community-run delegate prioritising developer tooling and grants.', - votingPower: 860000, - participationRate: 0.81, - proposalsVoted: 64, - }, - { - address: 'GCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCWHF', - name: 'Meridian Labs', - bio: 'Security-first delegate reviewing every proposal before voting.', - votingPower: 540000, - participationRate: 0.72, - proposalsVoted: 51, - }, -]; - function formatPower(value: number): string { return new Intl.NumberFormat('en-US', { maximumFractionDigits: 0 }).format(value); } @@ -96,6 +62,13 @@ export default function DelegatesPage() { setConfirmedAddress(''); }, []); + // Deep link from the voter-history page: /governance/delegates?delegate=G... + useEffect(() => { + const addr = new URLSearchParams(window.location.search).get('delegate'); + const match = MOCK_DELEGATES.find((d) => isSameStellarAddress(d.address, addr ?? '')); + if (match) setSelected(match); + }, []); + useEffect(() => { if (stage === 'done') { void refreshVotingPower(); @@ -234,6 +207,13 @@ export default function DelegatesPage() {

{sanitizeText(d.name)}

{d.address}

+
+ + View voting history + +

{sanitizeText(d.bio)}

diff --git a/src/app/governance/voter/[address]/page.tsx b/src/app/governance/voter/[address]/page.tsx index 8d39374..a89e45d 100644 --- a/src/app/governance/voter/[address]/page.tsx +++ b/src/app/governance/voter/[address]/page.tsx @@ -3,6 +3,7 @@ import Link from 'next/link'; import { notFound } from 'next/navigation'; import { StrKey } from '@stellar/stellar-sdk'; import { governanceStore } from '@/lib/governanceStore'; +import { MOCK_DELEGATES } from '@/lib/delegateDirectory'; import { sanitizeText } from '@/lib/textSafety'; const WINDOWS = [30, 90, 365] as const; @@ -84,6 +85,14 @@ export default async function VoterHistoryPage({ params, searchParams }: VoterPa

Voter history

{address}

+ {MOCK_DELEGATES.some((d) => d.address === address) && ( + + Delegate to this address + + )}
diff --git a/src/lib/delegateDirectory.ts b/src/lib/delegateDirectory.ts new file mode 100644 index 0000000..542a77f --- /dev/null +++ b/src/lib/delegateDirectory.ts @@ -0,0 +1,35 @@ +export interface DelegateProfile { + address: string; + name: string; + bio: string; + votingPower: number; + participationRate: number; + proposalsVoted: number; +} + +export const MOCK_DELEGATES: DelegateProfile[] = [ + { + address: 'GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAWHF', + name: 'Aurora Governance', + bio: 'Active voter focused on protocol upgrades and treasury stewardship.', + votingPower: 1250000, + participationRate: 0.94, + proposalsVoted: 87, + }, + { + address: 'GBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBWHF', + name: 'Nova Collective', + bio: 'Community-run delegate prioritising developer tooling and grants.', + votingPower: 860000, + participationRate: 0.81, + proposalsVoted: 64, + }, + { + address: 'GCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCWHF', + name: 'Meridian Labs', + bio: 'Security-first delegate reviewing every proposal before voting.', + votingPower: 540000, + participationRate: 0.72, + proposalsVoted: 51, + }, +]; From 553e65608d75b14a7d324d26e343506c2bf64398 Mon Sep 17 00:00:00 2001 From: Silas Mantisa Date: Thu, 22 Oct 2026 09:40:55 +0100 Subject: [PATCH 3/4] feat(governance): proposal deadline and status notification subscriptions (#683) --- .../governance/[id]/ProposalDetailClient.tsx | 20 ++++ src/hooks/useProposalSubscriptions.ts | 93 +++++++++++++++++++ src/lib/notifications/proposalReminders.ts | 59 ++++++++++++ src/lib/storage.ts | 7 ++ 4 files changed, 179 insertions(+) create mode 100644 src/hooks/useProposalSubscriptions.ts create mode 100644 src/lib/notifications/proposalReminders.ts diff --git a/src/app/governance/[id]/ProposalDetailClient.tsx b/src/app/governance/[id]/ProposalDetailClient.tsx index 7803d47..ee71016 100644 --- a/src/app/governance/[id]/ProposalDetailClient.tsx +++ b/src/app/governance/[id]/ProposalDetailClient.tsx @@ -16,6 +16,7 @@ import { Nav } from "@/components/Nav"; import { ConnectWalletButton } from "@/components/ConnectWalletButton"; import { ProposalStatusChip } from "@/components/ProposalStatusChip"; import { TimelockBanner } from "@/components/TimelockBanner"; +import { useProposalSubscriptions } from "@/hooks/useProposalSubscriptions"; import { VotePanel } from "@/components/VotePanel"; import { VotingPowerPanel } from "@/components/VotingPowerPanel"; import { useWalletStore } from "@/store/wallet"; @@ -238,6 +239,8 @@ export default function ProposalDetailClient({ proposalId }: { proposalId: strin } }; + const { isSubscribed, toggle: toggleSubscription, reminders } = useProposalSubscriptions(proposal, userAddress ?? null); + const { power } = useVotingPower(userAddress, proposalId); const votingPower = power?.total ?? 0; @@ -334,6 +337,23 @@ export default function ProposalDetailClient({ proposalId }: { proposalId: strin

+
+ + {reminders.map((r, i) => ( +

+ {r.message} +

+ ))} +
+ {proposal.status === "queued" && }
diff --git a/src/hooks/useProposalSubscriptions.ts b/src/hooks/useProposalSubscriptions.ts new file mode 100644 index 0000000..4200c67 --- /dev/null +++ b/src/hooks/useProposalSubscriptions.ts @@ -0,0 +1,93 @@ +"use client"; + +import { useCallback, useEffect, useState } from "react"; +import { STORAGE_KEYS, onStorageChange, storage } from "@/lib/storage"; +import type { GovernanceProposal } from "@/lib/governanceStore"; +import { + DEFAULT_REMINDER_WINDOW_HOURS, + collectProposalEvents, + type ProposalEvent, + type ProposalSubscription, +} from "@/lib/notifications/proposalReminders"; +import { decideChannels } from "@/lib/notifications/dispatcher"; +import { useNotificationPreferences } from "@/store/notificationPreferences"; + +const KEY = STORAGE_KEYS.proposalSubscriptions.key; +const CHECK_INTERVAL_MS = 60_000; + +export function readSubscriptions(): ProposalSubscription[] { + const parsed = storage.getJSON(KEY); + return Array.isArray(parsed) ? parsed : []; +} + +function writeSubscriptions(subs: ProposalSubscription[]): void { + if (subs.length === 0) storage.removeItem(KEY); + else storage.setJSON(KEY, subs); +} + +/** + * Subscribe/unsubscribe to a proposal and surface due reminders while the tab + * is open. Quiet hours are honoured via the dispatcher's `decideChannels`. + */ +export function useProposalSubscriptions(proposal: GovernanceProposal | null | undefined, address: string | null) { + const [subs, setSubs] = useState([]); + const [reminders, setReminders] = useState([]); + const prefs = useNotificationPreferences(); + + useEffect(() => { + setSubs(readSubscriptions()); + return onStorageChange(() => setSubs(readSubscriptions())); + }, []); + + const proposalId = proposal?.id; + const isSubscribed = !!proposalId && !!address && subs.some((s) => s.proposalId === proposalId && s.address === address); + + const toggle = useCallback(() => { + if (!proposal || !address) return; + const current = readSubscriptions(); + const exists = current.some((s) => s.proposalId === proposal.id && s.address === address); + const next = exists + ? current.filter((s) => !(s.proposalId === proposal.id && s.address === address)) + : [ + ...current, + { proposalId: proposal.id, address, lastStatus: proposal.status, windowHours: DEFAULT_REMINDER_WINDOW_HOURS }, + ]; + writeSubscriptions(next); + setSubs(next); + if (exists) setReminders([]); + }, [proposal, address]); + + useEffect(() => { + if (!proposal || !address || !isSubscribed) return; + const check = () => { + const now = Date.now(); + const current = readSubscriptions(); + const sub = current.find((s) => s.proposalId === proposal.id && s.address === address); + if (!sub) return; + const events = collectProposalEvents(sub, proposal, now); + if (events.length === 0) return; + const decision = decideChannels({ + prefs, + status: "accepted", + visible: true, + isLeader: true, + permission: "unsupported", + now: new Date(now), + }); + writeSubscriptions( + current.map((s) => + s === sub + ? { ...s, lastStatus: proposal.status, deadlineNotified: s.deadlineNotified || events.some((e) => e.kind === "deadline") } + : s, + ), + ); + // Toast channel disabled means the user opted out of in-app reminders. + if (decision.toast) setReminders((prev) => [...prev, ...events]); + }; + check(); + const timer = setInterval(check, CHECK_INTERVAL_MS); + return () => clearInterval(timer); + }, [proposal, address, isSubscribed, prefs]); + + return { isSubscribed, toggle, reminders }; +} diff --git a/src/lib/notifications/proposalReminders.ts b/src/lib/notifications/proposalReminders.ts new file mode 100644 index 0000000..6df2a71 --- /dev/null +++ b/src/lib/notifications/proposalReminders.ts @@ -0,0 +1,59 @@ +import type { FeedItem } from "@/lib/types"; +import type { GovernanceProposal } from "@/lib/governanceStore"; + +export type ProposalSubscription = { + proposalId: string; + address: string; + /** Status seen at subscribe/last check; a change triggers a transition event. */ + lastStatus: GovernanceProposal["status"]; + /** Reminder window before the deadline, in hours. */ + windowHours: number; + /** Set once the deadline reminder fired so it is not repeated. */ + deadlineNotified?: boolean; +}; + +export const DEFAULT_REMINDER_WINDOW_HOURS = 24; + +export type ProposalEvent = { kind: "deadline" | "status"; item: FeedItem; message: string }; + +/** True when the deadline is in the future and within `windowHours`. */ +export function isWithinDeadlineWindow(deadline: string, windowHours: number, now: number): boolean { + const ends = Date.parse(deadline); + if (Number.isNaN(ends)) return false; + const remaining = ends - now; + return remaining > 0 && remaining <= windowHours * 3_600_000; +} + +/** Builds FeedItem-shaped payloads that `decideChannels` can consume. */ +export function collectProposalEvents( + sub: ProposalSubscription, + proposal: GovernanceProposal, + now: number, +): ProposalEvent[] { + const events: ProposalEvent[] = []; + const base = { + id: `${proposal.id}`, + srcAmount: "", + srcToken: proposal.title, + dstToken: "", + }; + if (proposal.status !== sub.lastStatus) { + events.push({ + kind: "status", + item: { ...base, status: "accepted" } as unknown as FeedItem, + message: `${proposal.id} is now ${proposal.status}`, + }); + } + if ( + !sub.deadlineNotified && + proposal.status === "active" && + isWithinDeadlineWindow(proposal.deadline, sub.windowHours, now) + ) { + events.push({ + kind: "deadline", + item: { ...base, status: "accepted" } as unknown as FeedItem, + message: `Voting on ${proposal.id} closes ${new Date(proposal.deadline).toUTCString()}`, + }); + } + return events; +} diff --git a/src/lib/storage.ts b/src/lib/storage.ts index 2697bb3..eb28b29 100644 --- a/src/lib/storage.ts +++ b/src/lib/storage.ts @@ -60,6 +60,13 @@ export const STORAGE_KEYS = { retentionMs: 90 * DAY, owner: "src/lib/knownAddresses.ts", }, + proposalSubscriptions: { + key: "vortex:proposalSubscriptions", + purpose: "privacy.purpose.proposalSubscriptions", + sensitivity: "personal", + retentionMs: 90 * DAY, + owner: "src/hooks/useProposalSubscriptions.ts", + }, columnVisibility: { key: "vortex:columns:", prefix: true, From 4ebf66db14288cfda7db16c6d54444fe2e9f9f04 Mon Sep 17 00:00:00 2001 From: Silas Mantisa Date: Thu, 22 Oct 2026 09:41:15 +0100 Subject: [PATCH 4/4] feat(governance): side-by-side proposal comparison view (#684) --- .../governance/[id]/ProposalDetailClient.tsx | 6 + .../governance/[id]/ProposalImpactPreview.tsx | 2 +- src/app/governance/archive/page.tsx | 20 ++++ src/app/governance/compare/page.tsx | 113 ++++++++++++++++++ 4 files changed, 140 insertions(+), 1 deletion(-) create mode 100644 src/app/governance/compare/page.tsx diff --git a/src/app/governance/[id]/ProposalDetailClient.tsx b/src/app/governance/[id]/ProposalDetailClient.tsx index ee71016..8fd5376 100644 --- a/src/app/governance/[id]/ProposalDetailClient.tsx +++ b/src/app/governance/[id]/ProposalDetailClient.tsx @@ -347,6 +347,12 @@ export default function ProposalDetailClient({ proposalId }: { proposalId: strin > {isSubscribed ? "Notifications on" : "Notify me"} + + Compare + {reminders.map((r, i) => (

{r.message} diff --git a/src/app/governance/[id]/ProposalImpactPreview.tsx b/src/app/governance/[id]/ProposalImpactPreview.tsx index 1b4f882..cd54767 100644 --- a/src/app/governance/[id]/ProposalImpactPreview.tsx +++ b/src/app/governance/[id]/ProposalImpactPreview.tsx @@ -29,7 +29,7 @@ export function ProposalImpactPreview({ proposal }: { proposal: GovernancePropos ); } -function BondImpact({ currentValue, proposedValue }: { currentValue: number; proposedValue: number }) { +export function BondImpact({ currentValue, proposedValue }: { currentValue: number; proposedValue: number }) { const { t } = useTranslation(); const { solvers, isLoading, error } = useSolvers(); diff --git a/src/app/governance/archive/page.tsx b/src/app/governance/archive/page.tsx index bfa2a7f..2d7d0a5 100644 --- a/src/app/governance/archive/page.tsx +++ b/src/app/governance/archive/page.tsx @@ -168,6 +168,26 @@ export default async function GovernanceArchivePage({ + {rows.length >= 2 && ( +

+ {(['a', 'b'] as const).map((name, i) => ( + + ))} + +
+ )} + {rows.length === 0 ? (

No proposals match the selected filters. diff --git a/src/app/governance/compare/page.tsx b/src/app/governance/compare/page.tsx new file mode 100644 index 0000000..639973b --- /dev/null +++ b/src/app/governance/compare/page.tsx @@ -0,0 +1,113 @@ +import Link from "next/link"; +import { notFound } from "next/navigation"; +import { getGovernanceProposalById, getGovernanceProposals } from "@/lib/governanceStore"; +import type { GovernanceProposal } from "@/lib/governanceStore"; +import { ProposalStatusChip } from "@/components/ProposalStatusChip"; +import { BondImpact } from "../[id]/ProposalImpactPreview"; +import { sanitizeText } from "@/lib/textSafety"; + +interface ComparePageProps { + searchParams?: { a?: string; b?: string }; +} + +function ProposalColumn({ proposal, showImpact }: { proposal: GovernanceProposal; showImpact: boolean }) { + const change = proposal.parameterChange; + return ( +

+
+ + {proposal.id} + + +
+

{sanitizeText(proposal.title)}

+
+
Category
+
{proposal.category}
+
Votes for
+
{proposal.votesFor.toLocaleString()}
+
Votes against
+
{proposal.votesAgainst.toLocaleString()}
+
Votes abstain
+
{(proposal.votesAbstain ?? 0).toLocaleString()}
+
Parameter change
+
+ {change ? `${change.parameter}: ${change.currentValue} → ${change.proposedValue}` : "None"} +
+
+ {showImpact && change && ( +
+

Projected solver impact

+ +
+ )} +
+ ); +} + +export default function GovernanceComparePage({ searchParams }: ComparePageProps) { + const aId = searchParams?.a; + const bId = searchParams?.b; + + const a = aId ? getGovernanceProposalById(aId) : undefined; + if (aId && !a) notFound(); + const b = bId ? getGovernanceProposalById(bId) : undefined; + if (bId && !b) notFound(); + + if (!a || !b) { + const proposals = getGovernanceProposals(); + return ( +
+

Compare proposals

+
+ {(["a", "b"] as const).map((name) => ( + + ))} + +
+
+ ); + } + + const sameParam = + a.id !== b.id && + a.parameterChange?.parameter === "minSolverBondUsd" && + b.parameterChange?.parameter === "minSolverBondUsd"; + + return ( +
+ + ← Back to Proposals + +

+ Compare {a.id} and {b.id} +

+ {a.id === b.id && ( +

+ Both selections are the same proposal. +

+ )} +
+ + +
+ {!sameParam && ( +

+ An impact comparison is not available: the proposals do not share a comparable parameter change, so only + tallies and metadata are shown. +

+ )} +
+ ); +}