From 5e0bdf10e55721d9620665eb1e26bb675e4fce0f Mon Sep 17 00:00:00 2001 From: Cyber-preacher Date: Tue, 14 Apr 2026 14:05:52 +0400 Subject: [PATCH] phase-83: refine veto proposal surfaces --- src/components/ProposalPageHeader.tsx | 3 + src/components/ProposalStageBar.tsx | 30 +++-- src/pages/MyGovernance.tsx | 100 +++----------- src/pages/proposals/CitizenVetoActions.tsx | 68 ++++++++++ src/pages/proposals/ProposalChamber.tsx | 103 ++++++++++++--- src/pages/proposals/ProposalChamberVeto.tsx | 125 ++++++++++++++---- src/pages/proposals/ProposalCitizenVeto.tsx | 138 +++++++++++++++----- src/pages/proposals/ProposalFinished.tsx | 6 +- src/pages/proposals/ProposalReferendum.tsx | 96 +++++++++++++- src/pages/proposals/useProposalStageSync.ts | 15 +++ src/types/api.ts | 43 ++++++ 11 files changed, 549 insertions(+), 178 deletions(-) create mode 100644 src/pages/proposals/CitizenVetoActions.tsx diff --git a/src/components/ProposalPageHeader.tsx b/src/components/ProposalPageHeader.tsx index 9bd460a..e290819 100644 --- a/src/components/ProposalPageHeader.tsx +++ b/src/components/ProposalPageHeader.tsx @@ -13,6 +13,7 @@ type ProposalPageHeaderProps = { showFormationStage?: boolean; chamber: string; proposer: string; + stageLinks?: Partial>; children?: ReactNode; }; @@ -22,6 +23,7 @@ export function ProposalPageHeader({ showFormationStage = true, chamber, proposer, + stageLinks, children, }: ProposalPageHeaderProps) { return ( @@ -30,6 +32,7 @@ export function ProposalPageHeader({
>; }; export const ProposalStageBar: React.FC = ({ current, showFormationStage = true, className, + stageLinks, }) => { const allStages: { key: ProposalStage; @@ -59,6 +62,7 @@ export const ProposalStageBar: React.FC = ({ > {stages.map((stage) => { const active = stage.key === current; + const href = stageLinks?.[stage.key]; const activeClasses = stage.key === "draft" ? "bg-panel text-text border border-border shadow-[var(--shadow-control)] ring-1 ring-inset ring-[color:var(--glass-border)]" @@ -75,17 +79,21 @@ export const ProposalStageBar: React.FC = ({ : stage.key === "passed" ? "bg-[color:var(--ok)]/20 text-[color:var(--ok)]" : "bg-[color:var(--danger)]/12 text-[color:var(--danger)]"; - return ( -
- {stage.render ?? stage.label} + const className = [ + "min-w-0 basis-[calc(50%-0.25rem)] rounded-full px-3 py-2 text-center text-xs leading-tight font-semibold transition sm:flex-1 sm:basis-0", + active + ? activeClasses + : "border border-border bg-panel-alt [background-image:var(--card-grad)] bg-cover bg-no-repeat text-muted", + href ? "hover:border-border-strong hover:text-text" : "", + ].join(" "); + const content = stage.render ?? stage.label; + return href ? ( + + {content} + + ) : ( +
+ {content}
); })} diff --git a/src/pages/MyGovernance.tsx b/src/pages/MyGovernance.tsx index ac86e9c..1a311c5 100644 --- a/src/pages/MyGovernance.tsx +++ b/src/pages/MyGovernance.tsx @@ -15,7 +15,6 @@ import { Button } from "@/components/primitives/button"; import { Input } from "@/components/primitives/input"; import { AddressInline } from "@/components/AddressInline"; import { PipelineList } from "@/components/PipelineList"; -import { StageChip } from "@/components/StageChip"; import { StatGrid, makeChamberStats } from "@/components/StatGrid"; import { Surface } from "@/components/Surface"; import { PageHint } from "@/components/PageHint"; @@ -28,7 +27,6 @@ import { apiLegitimacyObjectSet, apiCmMe, apiMyGovernance, - apiProposals, } from "@/lib/apiClient"; import { formatLoadError } from "@/lib/errorFormatting"; import { toTimestampMs } from "@/lib/dateTime"; @@ -37,7 +35,6 @@ import type { CmSummaryDto, GetClockResponse, GetMyGovernanceResponse, - ProposalListItemDto, } from "@/types/api"; import { cn } from "@/lib/utils"; @@ -185,7 +182,6 @@ const MyGovernance: React.FC = () => { const [delegationErrorByChamber, setDelegationErrorByChamber] = useState< Record >({}); - const [vetoWindows, setVetoWindows] = useState([]); const [nowMs, setNowMs] = useState(() => Date.now()); useEffect(() => { @@ -232,39 +228,6 @@ const MyGovernance: React.FC = () => { }; }, []); - useEffect(() => { - if (!gov) { - setVetoWindows([]); - return; - } - let active = true; - const currentTierLabel = gov.tier?.tier ?? "Nominee"; - (async () => { - try { - const [citizenResult, chamberResult] = await Promise.all([ - currentTierLabel === "Citizen" - ? apiProposals({ stage: "citizen_veto" }) - : Promise.resolve({ items: [] as ProposalListItemDto[] }), - currentTierLabel !== "Nominee" - ? apiProposals({ stage: "chamber_veto" }) - : Promise.resolve({ items: [] as ProposalListItemDto[] }), - ]); - if (!active) return; - setVetoWindows( - [...citizenResult.items, ...chamberResult.items].sort( - (a, b) => toTimestampMs(b.date, -1) - toTimestampMs(a.date, -1), - ), - ); - } catch { - if (!active) return; - setVetoWindows([]); - } - })(); - return () => { - active = false; - }; - }, [gov]); - const eraActivity = gov?.eraActivity; const timeLeftValue = useMemo(() => { const targetMs = clock?.nextEraAt @@ -727,7 +690,7 @@ const MyGovernance: React.FC = () => { - Veto windows + Veto process { shadow="tile" className="px-4 py-3 text-sm text-muted" > - Snapshot eligibility is finalized on each veto proposal page. This - section highlights currently open Citizen and Chamber Veto windows - you may want to inspect. + Veto happens on the chamber vote page itself. Citizens and chambers + can cast veto votes during the chamber vote window, and if the + ordinary vote passes, a 24h veto countdown remains open on that same + page. + + + If a veto succeeds, the proposal returns to reconsideration draft + and the proposer can resubmit it directly into chamber vote without + going through proposal pool again. - {vetoWindows.length === 0 ? ( - - No live veto windows right now. - - ) : ( -
- {vetoWindows.map((proposal) => ( - -
-
-
- - - {proposal.chamber} - -
-

- {proposal.title} -

-

{proposal.summary}

-
- -
-
- ))} -
- )}
diff --git a/src/pages/proposals/CitizenVetoActions.tsx b/src/pages/proposals/CitizenVetoActions.tsx new file mode 100644 index 0000000..49b52a8 --- /dev/null +++ b/src/pages/proposals/CitizenVetoActions.tsx @@ -0,0 +1,68 @@ +import { VoteButton } from "@/components/VoteButton"; +import type { ChamberProposalPageDto } from "@/types/api"; + +type CitizenVetoActionsProps = { + citizenVeto: ChamberProposalPageDto["citizenVeto"]; + viewerIsProposer: boolean; + windowOpen: boolean; + submittingKey: string | null; + onVote: () => void; +}; + +export const CitizenVetoActions: React.FC = ({ + citizenVeto, + viewerIsProposer, + windowOpen, + submittingKey, + onVote, +}) => { + const currentVote = citizenVeto.viewer.currentVote; + const vetoRecorded = currentVote === "veto"; + const tone = currentVote === "keep" ? "neutral" : "destructive"; + const disabled = + Boolean(submittingKey) || + viewerIsProposer || + !windowOpen || + !citizenVeto.available || + !citizenVeto.viewer.eligible || + currentVote !== null; + + return ( +
+ +
+ ); +}; diff --git a/src/pages/proposals/ProposalChamber.tsx b/src/pages/proposals/ProposalChamber.tsx index 7f13df7..e4684c9 100644 --- a/src/pages/proposals/ProposalChamber.tsx +++ b/src/pages/proposals/ProposalChamber.tsx @@ -1,11 +1,12 @@ import { useCallback, useEffect, useState } from "react"; -import { useParams } from "react-router"; +import { Link, useParams } from "react-router"; import { StatTile } from "@/components/StatTile"; import { PageHint } from "@/components/PageHint"; import { ProposalPageHeader } from "@/components/ProposalPageHeader"; import { VoteButton } from "@/components/VoteButton"; import { AddressInline } from "@/components/AddressInline"; import { Input } from "@/components/primitives/input"; +import { Button } from "@/components/primitives/button"; import { ProposalSummaryCard, ProposalTeamMilestonesCard, @@ -15,6 +16,7 @@ import { Surface } from "@/components/Surface"; import { HintLabel } from "@/components/Hint"; import { apiChamberVote, + apiCitizenVetoVote, apiProposalChamberPage, apiProposalTimeline, } from "@/lib/apiClient"; @@ -29,6 +31,7 @@ import { useProposalTransitionNotice, } from "./useProposalStageSync"; import { useAuth } from "@/app/auth/AuthContext"; +import { CitizenVetoActions } from "./CitizenVetoActions"; const ProposalChamber: React.FC = () => { const { id } = useParams(); @@ -36,6 +39,9 @@ const ProposalChamber: React.FC = () => { const [loadError, setLoadError] = useState(null); const [submitError, setSubmitError] = useState(null); const [submitting, setSubmitting] = useState(false); + const [submittingVetoKey, setSubmittingVetoKey] = useState( + null, + ); const [timeline, setTimeline] = useState([]); const [timelineError, setTimelineError] = useState(null); const [yesScore, setYesScore] = useState(5); @@ -230,12 +236,21 @@ const ProposalChamber: React.FC = () => { ? "No" : "Abstain" : null; + const ordinaryVoteClosed = proposal.ordinaryVoteClosed; + const stageLinks = id + ? { + vote: `/app/proposals/${id}/chamber`, + citizen_veto: `/app/proposals/${id}/citizen-veto`, + chamber_veto: `/app/proposals/${id}/chamber-veto`, + } + : undefined; + const vetoWindowOpen = proposal.timeLeft !== "Ended"; const handleVote = async ( choice: "yes" | "no" | "abstain", score?: number, ) => { - if (!id || submitting || viewerIsProposer) return; + if (!id || submitting || ordinaryVoteClosed || viewerIsProposer) return; setSubmitting(true); setSubmitError(null); try { @@ -255,6 +270,30 @@ const ProposalChamber: React.FC = () => { } }; + const handleCitizenVetoVote = async (choice: "veto" | "keep") => { + if ( + !id || + submittingVetoKey || + viewerIsProposer || + !proposal.citizenVeto.viewer.eligible + ) { + return; + } + setSubmittingVetoKey(`citizen:${choice}`); + setSubmitError(null); + try { + await apiCitizenVetoVote({ proposalId: id, choice }); + const redirected = await syncProposalStage(); + if (redirected) return; + await loadPage(); + } catch (error) { + setSubmitError((error as Error).message); + } finally { + setSubmittingVetoKey(null); + void syncProposalStage(); + } + }; + return (
@@ -274,6 +313,7 @@ const ProposalChamber: React.FC = () => { showFormationStage={proposal.formationEligible} chamber={proposal.chamber} proposer={proposal.proposer} + stageLinks={stageLinks} > {milestoneVoteIndex !== null ? ( { handleVote("yes", yesScore)} /> @@ -321,36 +363,61 @@ const ProposalChamber: React.FC = () => { handleVote("no")} /> handleVote("abstain")} />
- {viewerIsProposer ? ( - - You cannot vote on your own proposal. - + handleCitizenVetoVote("veto")} + /> + {id ? ( +
+ + +
) : null} + + {ordinaryVoteClosed + ? "The ordinary vote passed. Use the veto pages above during the 24h veto window. If a veto succeeds, the proposal returns to the proposer for reconsideration." + : "Use the Citizen Veto page for Citizen votes and the Chamber Veto page for chamber-by-chamber veto activity before the chamber vote closes."} + {submitError ? ( { valueClassName="flex flex-col items-center gap-1 text-2xl font-semibold" /> { const viewerIsProposer = auth.address?.trim().toLowerCase() === proposal.proposerId.trim().toLowerCase(); + const stageLinks = id + ? { + vote: proposal.voteRoute, + citizen_veto: `/app/proposals/${id}/citizen-veto`, + chamber_veto: `/app/proposals/${id}/chamber-veto`, + } + : undefined; + const vetoWindowOpen = proposal.timeLeft !== "Ended"; + const chamberVetoVoteCount = proposal.chambers.reduce( + (sum, chamber) => + sum + chamber.votes.veto + chamber.votes.keep + chamber.votes.abstain, + 0, + ); const handleVote = async ( chamberId: string, choice: "veto" | "keep" | "abstain", ) => { - if (!id || submittingKey || viewerIsProposer) return; + if (!id || submittingKey || !vetoWindowOpen || viewerIsProposer) return; setSubmittingKey(`${chamberId}:${choice}`); setSubmitError(null); try { @@ -163,14 +176,24 @@ const ProposalChamberVeto: React.FC = () => { showFormationStage={proposal.formationEligible} chamber={proposal.chamber} proposer={proposal.proposer} + stageLinks={stageLinks} > - All snapped active chambers may separately decide whether to veto +

Chamber Veto

+

+ Chambers captured for this proposal's veto process can each + vote to veto, keep, or abstain. +

+

+ If enough chambers cross their own internal veto thresholds before + this window ends, the proposal returns to the proposer for + reconsideration. +

{viewerIsProposer ? ( {

Chamber veto window

+ {chamberVetoVoteCount === 0 ? ( + + No chamber veto initiated yet. + + ) : null}
{ const totalVotes = chamber.votes.veto + chamber.votes.keep + chamber.votes.abstain; const chamberKey = chamber.chamberId; + const currentVote = chamber.viewer.currentVote; + const vetoRecorded = currentVote === "veto"; + const keepRecorded = currentVote === "keep"; + const abstainRecorded = currentVote === "abstain"; return ( {
@@ -275,7 +312,12 @@ const ProposalChamberVeto: React.FC = () => { className="px-3 py-3" /> + @@ -290,6 +332,12 @@ const ProposalChamberVeto: React.FC = () => { />
+

+ Eligible voters and quorum are headcount-based. Weighted + totals below include delegated voting weight captured for this + proposal. +

+

{viewerIsProposer ? "You cannot vote on your own proposal." @@ -303,59 +351,92 @@ const ProposalChamberVeto: React.FC = () => {

handleVote(chamber.chamberId, "veto")} /> handleVote(chamber.chamberId, "keep")} /> handleVote(chamber.chamberId, "abstain")} />

- {totalVotes} votes cast so far in this chamber. + {totalVotes} weighted votes cast so far in this chamber.

); @@ -365,7 +446,7 @@ const ProposalChamberVeto: React.FC = () => { { const viewerIsProposer = auth.address?.trim().toLowerCase() === proposal.proposerId.trim().toLowerCase(); + const stageLinks = id + ? { + vote: proposal.voteRoute, + citizen_veto: `/app/proposals/${id}/citizen-veto`, + chamber_veto: `/app/proposals/${id}/chamber-veto`, + } + : undefined; + const vetoWindowOpen = proposal.timeLeft !== "Ended"; + const currentVote = proposal.viewer.currentVote; + const vetoRecorded = currentVote === "veto"; + const keepRecorded = currentVote === "keep"; const handleVote = async (choice: "veto" | "keep") => { - if (!id || submitting || !proposal.viewer.eligible || viewerIsProposer) + if ( + !id || + submitting || + !vetoWindowOpen || + !proposal.viewer.eligible || + viewerIsProposer + ) return; setSubmitting(true); setSubmitError(null); @@ -163,61 +180,102 @@ const ProposalCitizenVeto: React.FC = () => { showFormationStage={proposal.formationEligible} chamber={proposal.chamber} proposer={proposal.proposer} + stageLinks={stageLinks} > - Only snapped Citizen-tier voters can participate in this window +

Citizen Veto

+

+ {viewerIsProposer + ? "You proposed this decision, so you cannot vote in its Citizen Veto." + : proposal.viewer.eligible + ? proposal.viewer.currentVote === "veto" + ? "Only Citizens captured when this veto window opened can vote here. Your current vote is Veto." + : proposal.viewer.currentVote === "keep" + ? "Only Citizens captured when this veto window opened can vote here. Your current vote is Keep." + : "Only Citizens captured when this veto window opened can vote here. Cast Veto to send the decision back for reconsideration, or Keep to leave it in place." + : "Only Citizens captured when this veto window opened can vote here. You were not in that group for this proposal."} +

+

+ {vetoWindowOpen + ? "If Veto reaches the threshold before this window ends, the proposal returns to the proposer for reconsideration." + : "This veto window has ended."} +

handleVote("veto")} /> handleVote("keep")} />
- - {viewerIsProposer - ? "You cannot vote on your own proposal." - : proposal.viewer.eligible - ? proposal.viewer.currentVote - ? `Your current vote: ${proposal.viewer.currentVote === "veto" ? "Veto" : "Keep"}` - : "You are eligible to vote in this Citizen Veto window." - : "You are not eligible in the snapped Citizen electorate for this window."} - {submitError ? ( {

Citizen veto window

+ {castVotes === 0 ? ( + + No Citizen Veto votes yet. + + ) : null}
{ {castVotes} / {proposal.quorumNeeded} - {quorumPercent}% of snapped Citizens + {quorumPercent}% of eligible Citizens } @@ -273,15 +341,13 @@ const ProposalCitizenVeto: React.FC = () => { valueClassName="flex flex-col items-center gap-1 text-2xl font-semibold" /> - - {proposal.attemptsUsed} /{" "} - {proposal.attemptsUsed + proposal.attemptsRemaining} - + {proposal.attemptsRemaining} - {proposal.attemptsRemaining} Citizen veto tries left + {proposal.attemptsUsed} of{" "} + {proposal.attemptsUsed + proposal.attemptsRemaining} used } @@ -294,7 +360,7 @@ const ProposalCitizenVeto: React.FC = () => { {
+ +
+ ) : null} + + Use the Citizen Veto tab to inspect or cast Citizen Veto votes, and + the Chamber Veto tab to inspect chamber-veto activity. + {submitError ? ( , +): boolean { + if (status.canonicalStage !== "vote") return false; + return ( + currentPath.endsWith("/citizen-veto") || + currentPath.endsWith("/chamber-veto") + ); +} + function hasSnapshotRouteOverride(search?: string): boolean { if (!search) return false; const params = new URLSearchParams(search); @@ -70,6 +81,9 @@ export function formatProposalStageTransitionMessage( if (status.redirectReason === "formation_canceled") { return "Project was canceled and moved to Finished."; } + if (status.redirectReason === "veto_remanded") { + return "Proposal was remanded for reconsideration."; + } if (status.canonicalStage === "vote") return "Proposal moved to Chamber vote."; if (status.canonicalStage === "citizen_veto") @@ -131,6 +145,7 @@ export async function syncToCanonicalProposalStage( return false; } const status = await apiProposalStatus(input.proposalId); + if (isEmbeddedVetoRouteOverride(input.currentPath, status)) return false; if (!shouldNavigateToCanonicalRoute(input.currentPath, status)) return false; storeTransitionNotice({ route: status.canonicalRoute, diff --git a/src/types/api.ts b/src/types/api.ts index c607e0b..91985a3 100644 --- a/src/types/api.ts +++ b/src/types/api.ts @@ -16,6 +16,7 @@ export type ProposalResolutionKindDto = | "ordinary_failed_pool" | "ordinary_failed_vote" | "citizen_veto_remand" + | "chamber_veto_remand" | "chamber_veto_void" | "formation_completed" | "formation_canceled"; @@ -652,6 +653,10 @@ export type ChamberProposalPageDto = { teamSlots: string; milestones: string; timeLeft: string; + timeContextLabel: string; + ordinaryVoteClosed: boolean; + votePassedAt: string | null; + voteFinalizesAt: string | null; votes: { yes: number; no: number; abstain: number }; attentionQuorum: number; quorumNeeded: number; @@ -687,6 +692,40 @@ export type ChamberProposalPageDto = { directVoteOverrideApplies: boolean; }; }; + citizenVeto: { + available: boolean; + attemptsUsed: number; + attemptsRemaining: number; + eligibleCitizens: number; + quorumNeeded: number; + vetoNeeded: number; + votes: { veto: number; keep: number }; + viewer: { + eligible: boolean; + currentVote: "veto" | "keep" | null; + }; + }; + chamberVeto: { + activeChambers: number; + chamberThreshold: number; + vetoingChambers: number; + chambers: Array<{ + chamberId: string; + chamberTitle: string; + eligibleVoters: number; + quorumNeeded: number; + votes: { veto: number; keep: number; abstain: number }; + countsAsVetoing: boolean; + delegation: { + source: "snapshot" | "live"; + snapshotCapturedAt: string | null; + }; + viewer: { + eligible: boolean; + currentVote: "veto" | "keep" | "abstain" | null; + }; + }>; + }; thresholdContext?: { activityThreshold: { categories: string[]; @@ -710,6 +749,7 @@ export type CitizenVetoProposalPageDto = { proposer: string; proposerId: string; chamber: string; + voteRoute: string; budget: string; timeLeft: string; formationEligible: boolean; @@ -737,6 +777,7 @@ export type ChamberVetoProposalPageDto = { proposer: string; proposerId: string; chamber: string; + voteRoute: string; budget: string; timeLeft: string; formationEligible: boolean; @@ -755,6 +796,7 @@ export type ChamberVetoProposalPageDto = { chamberTitle: string; eligibleVoters: number; quorumNeeded: number; + vetoNeeded: number; votes: { veto: number; keep: number; abstain: number }; countsAsVetoing: boolean; delegation: { @@ -809,6 +851,7 @@ export type ProposalFinishedPageDto = { terminalSummary: string; decisionRootProposalId: string; canReconsider: boolean; + reconsiderationDraftId: string | null; formationEligible: boolean; budget: string; timeLeft: string;