diff --git a/src/app/AppRoutes.tsx b/src/app/AppRoutes.tsx index 924f319..e46a4d2 100644 --- a/src/app/AppRoutes.tsx +++ b/src/app/AppRoutes.tsx @@ -15,6 +15,7 @@ import FactionInitiativeCreate from "../pages/factions/FactionInitiativeCreate"; import FactionCreate from "../pages/factions/FactionCreate"; import ProposalPP from "../pages/proposals/ProposalPP"; import ProposalChamber from "../pages/proposals/ProposalChamber"; +import ProposalReferendum from "../pages/proposals/ProposalReferendum"; import ProposalFormation from "../pages/proposals/ProposalFormation"; import ProposalFinished from "../pages/proposals/ProposalFinished"; import Profile from "../pages/profile/Profile"; @@ -90,6 +91,10 @@ const AppRoutes: React.FC = () => { } /> } /> } /> + } + /> } /> } /> } /> diff --git a/src/lib/apiClient.ts b/src/lib/apiClient.ts index 06b8df9..0146ccd 100644 --- a/src/lib/apiClient.ts +++ b/src/lib/apiClient.ts @@ -613,6 +613,39 @@ export async function apiProposalChamberPage( return await apiGet(`/api/proposals/${id}/chamber`); } +export async function apiProposalReferendumPage( + id: string, +): Promise { + return await apiGet( + `/api/proposals/${id}/referendum`, + ); +} + +export async function apiReferendumVote(input: { + proposalId: string; + choice: ChamberVoteChoice; + idempotencyKey?: string; +}): Promise<{ + ok: true; + type: "referendum.vote"; + proposalId: string; + choice: ChamberVoteChoice; + counts: { yes: number; no: number; abstain: number }; + systemReset?: boolean; +}> { + return await apiPost( + "/api/command", + { + type: "referendum.vote", + payload: { proposalId: input.proposalId, choice: input.choice }, + idempotencyKey: input.idempotencyKey, + }, + input.idempotencyKey + ? { headers: { "idempotency-key": input.idempotencyKey } } + : undefined, + ); +} + export async function apiProposalFormationPage( id: string, ): Promise { @@ -1218,6 +1251,29 @@ export async function apiMyGovernance(): Promise { return await apiGet("/api/my-governance"); } +export async function apiLegitimacyObjectSet(input: { + active: boolean; + idempotencyKey?: string; +}): Promise<{ + ok: true; + type: "legitimacy.object.set"; + legitimacy: GetMyGovernanceResponse["legitimacy"]; +}> { + return await apiPost( + "/api/command", + { + type: "legitimacy.object.set", + payload: { + active: input.active, + }, + idempotencyKey: input.idempotencyKey, + }, + input.idempotencyKey + ? { headers: { "idempotency-key": input.idempotencyKey } } + : undefined, + ); +} + export async function apiCmMe(): Promise { return await apiGet("/api/cm/me"); } diff --git a/src/pages/MyGovernance.tsx b/src/pages/MyGovernance.tsx index 84e08f9..90327ba 100644 --- a/src/pages/MyGovernance.tsx +++ b/src/pages/MyGovernance.tsx @@ -20,6 +20,7 @@ import { Kicker } from "@/components/Kicker"; import { apiChambers, apiClock, + apiLegitimacyObjectSet, apiCmMe, apiMyGovernance, } from "@/lib/apiClient"; @@ -157,6 +158,8 @@ const MyGovernance: React.FC = () => { const [clock, setClock] = useState(null); const [cmSummary, setCmSummary] = useState(null); const [loadError, setLoadError] = useState(null); + const [legitimacyPending, setLegitimacyPending] = useState(false); + const [legitimacyError, setLegitimacyError] = useState(null); const [nowMs, setNowMs] = useState(() => Date.now()); useEffect(() => { @@ -244,6 +247,32 @@ const MyGovernance: React.FC = () => { ) : 100; + const legitimacy = gov?.legitimacy ?? { + percent: 100, + objecting: false, + objectingHumanNodes: 0, + eligibleHumanNodes: 0, + referendumTriggered: false, + triggerThresholdPercent: 33.3, + }; + + const handleLegitimacyToggle = async () => { + try { + setLegitimacyPending(true); + setLegitimacyError(null); + await apiLegitimacyObjectSet({ + active: !legitimacy.objecting, + idempotencyKey: crypto.randomUUID(), + }); + const fresh = await apiMyGovernance(); + setGov(fresh); + } catch (error) { + setLegitimacyError(formatLoadError((error as Error).message)); + } finally { + setLegitimacyPending(false); + } + }; + return (
@@ -647,6 +676,83 @@ const MyGovernance: React.FC = () => { )} + + + + System legitimacy + + +
+ {[ + { + label: "Legitimacy", + value: `${legitimacy.percent}%`, + }, + { + label: "Objectors", + value: `${legitimacy.objectingHumanNodes} / ${legitimacy.eligibleHumanNodes}`, + }, + { + label: "Referendum trigger", + value: `< ${legitimacy.triggerThresholdPercent}%`, + }, + ].map((tile) => ( + +

{tile.label}

+

{tile.value}

+
+ ))} +
+ + +

+ Any active human node can object to Vortex legitimacy. Each + objector reduces legitimacy by their equal share of the active + human-node base. +

+
+ + + {legitimacy.objecting + ? "You are objecting" + : "You are not objecting"} + + {legitimacy.referendumTriggered ? ( + + Referendum threshold reached + + ) : null} +
+ {legitimacyError ? ( +

{legitimacyError}

+ ) : null} +
+
+
); }; diff --git a/src/pages/feed/Feed.tsx b/src/pages/feed/Feed.tsx index 59e618d..a490756 100644 --- a/src/pages/feed/Feed.tsx +++ b/src/pages/feed/Feed.tsx @@ -80,7 +80,7 @@ const proposalIdFromHref = (href?: string) => { ? noQuery.slice("/app".length) : noQuery; const match = clean.match( - /^\/proposals\/([^/]+)\/(pp|chamber|formation|finished)$/, + /^\/proposals\/([^/]+)\/(pp|chamber|referendum|formation|finished)$/, ); return match?.[1] ?? null; }; @@ -115,6 +115,7 @@ const isUrgentItemInteractable = ( return Boolean(viewer && proposer && viewer === proposer); } if ((item.stage === "pool" || item.stage === "vote") && !isGovernorActive) { + if (item.href?.includes("/referendum")) return true; return false; } return true; diff --git a/src/pages/invision/Invision.tsx b/src/pages/invision/Invision.tsx index 3dd4e15..8c921bf 100644 --- a/src/pages/invision/Invision.tsx +++ b/src/pages/invision/Invision.tsx @@ -69,9 +69,21 @@ const Invision: React.FC = () => { }); }, [factions, search, factionSort]); + const hasInvisionContent = + Boolean(invision?.governanceState.metrics.length) || + Boolean(invision?.economicIndicators.length) || + Boolean(invision?.riskSignals.length) || + Boolean(invision?.chamberProposals.length); + const primaryGovernanceMetrics = ( + invision?.governanceState.metrics ?? [] + ).slice(0, 3); + const secondaryGovernanceMetrics = ( + invision?.governanceState.metrics ?? [] + ).slice(3); + return ( -
-
+
+
{invision === null ? ( @@ -86,6 +98,7 @@ const Invision: React.FC = () => { {invision !== null && factions !== null && factions.length === 0 && + !hasInvisionContent && !loadError ? ( ) : null} @@ -94,23 +107,44 @@ const Invision: React.FC = () => { variant="panelAlt" className="px-6 py-5 text-center sm:col-span-2 lg:col-span-3" > - Governance model + System state

{invision?.governanceState.label ?? "—"}

- {(invision?.governanceState.metrics ?? []).map((metric) => ( + {primaryGovernanceMetrics.map((metric) => ( {metric.label} -

{metric.value}

+

{metric.value}

))}
+ {secondaryGovernanceMetrics.length > 0 ? ( + + + Governance signals + + + {secondaryGovernanceMetrics.map((metric) => ( +
+ {metric.label} +

+ {metric.value} +

+
+ ))} +
+
+ ) : null} + setSearch(e.target.value)} @@ -254,11 +288,6 @@ const Invision: React.FC = () => {
-
-

- coming sooner than you think... -

-
); }; diff --git a/src/pages/proposals/ProposalChamber.tsx b/src/pages/proposals/ProposalChamber.tsx index a96ddef..b14be28 100644 --- a/src/pages/proposals/ProposalChamber.tsx +++ b/src/pages/proposals/ProposalChamber.tsx @@ -115,15 +115,19 @@ const ProposalChamber: React.FC = () => { const noTotal = proposal.votes.no; const abstainTotal = proposal.votes.abstain; const totalVotes = yesTotal + noTotal + abstainTotal; - const engaged = proposal.engagedGovernors; - const activeGovernors = Math.max(1, proposal.activeGovernors); + const engaged = proposal.engagedVoters ?? proposal.engagedGovernors; + const eligibleVoters = Math.max( + 1, + proposal.eligibleVoters ?? proposal.activeGovernors, + ); const quorumFraction = proposal.thresholdContext?.quorumThreshold?.quorumFraction ?? proposal.attentionQuorum ?? 0.33; const quorumNeeded = proposal.quorumNeeded; - const quorumPercent = Math.round((engaged / activeGovernors) * 100); + const quorumPercent = Math.round((engaged / eligibleVoters) * 100); const quorumNeededPercent = Math.round(quorumFraction * 100); + const referendumQuorumRuleLabel = "33.3% + 1"; const yesPercentOfQuorum = engaged > 0 ? Math.round((yesTotal / engaged) * 100) : 0; const yesPercentOfTotal = @@ -137,10 +141,16 @@ const ProposalChamber: React.FC = () => { typeof proposal.milestoneIndex === "number" && proposal.milestoneIndex > 0 ? proposal.milestoneIndex : null; + const referendumVote = proposal.voteKind === "referendum"; const scoreLabel = - proposal.scoreLabel === "MM" || milestoneVoteIndex !== null ? "MM" : "CM"; - const chamberTitle = - milestoneVoteIndex !== null + proposal.scoreLabel === "MM" || milestoneVoteIndex !== null + ? "MM" + : proposal.scoreLabel === "CM" + ? "CM" + : null; + const chamberTitle = referendumVote + ? `${proposal.title} — Referendum` + : milestoneVoteIndex !== null ? `${proposal.title} — Milestone vote (M${milestoneVoteIndex})` : proposal.title; @@ -160,7 +170,7 @@ const ProposalChamber: React.FC = () => { await apiChamberVote({ proposalId: id, choice, - score: choice === "yes" ? score : undefined, + score: choice === "yes" && proposal?.scoreEnabled ? score : undefined, }); const redirected = await syncProposalStage(); if (redirected) return; @@ -210,25 +220,27 @@ const ProposalChamber: React.FC = () => { disabled={submitting} onClick={() => handleVote("yes", yesScore)} /> -
- - {scoreLabel} score - - { - const next = Number(event.target.value); - if (Number.isFinite(next)) { - setYesScore(Math.min(Math.max(Math.round(next), 0), 10)); - } - }} - className="h-8 w-16" - /> -
+ {proposal.scoreEnabled && scoreLabel ? ( +
+ + {scoreLabel} score + + { + const next = Number(event.target.value); + if (Number.isFinite(next)) { + setYesScore(Math.min(Math.max(Math.round(next), 0), 10)); + } + }} + className="h-8 w-16" + /> +
+ ) : null} {
+ referendumVote ? ( + "Referendum quorum (%)" + ) : ( + + ) } value={ <> - {quorumPercent}% / {quorumNeededPercent}% + {quorumPercent}% /{" "} + {referendumVote + ? referendumQuorumRuleLabel + : `${quorumNeededPercent}%`} - {engaged} / {quorumNeeded} + {engaged} / {quorumNeeded} {proposal.voterLabel.toLowerCase()} } @@ -312,7 +331,9 @@ const ProposalChamber: React.FC = () => { {yesPercentOfQuorum}% / {passingNeededPercent}% - Yes within quorum + {referendumVote + ? `${Math.ceil(engaged * 0.666)} yes votes needed at current participation` + : "Yes within quorum"} } diff --git a/src/pages/proposals/ProposalReferendum.tsx b/src/pages/proposals/ProposalReferendum.tsx new file mode 100644 index 0000000..83d9f6c --- /dev/null +++ b/src/pages/proposals/ProposalReferendum.tsx @@ -0,0 +1,336 @@ +import { useCallback, useEffect, useState } from "react"; +import { useParams } from "react-router"; + +import { PageHint } from "@/components/PageHint"; +import { ProposalPageHeader } from "@/components/ProposalPageHeader"; +import { VoteButton } from "@/components/VoteButton"; +import { + ProposalInvisionInsightCard, + ProposalSummaryCard, + ProposalTeamMilestonesCard, + ProposalTimelineCard, +} from "@/components/ProposalSections"; +import { StatTile } from "@/components/StatTile"; +import { Surface } from "@/components/Surface"; +import { + apiProposalReferendumPage, + apiProposalTimeline, + apiReferendumVote, +} from "@/lib/apiClient"; +import { formatLoadError } from "@/lib/errorFormatting"; +import type { + ChamberProposalPageDto, + ProposalTimelineItemDto, +} from "@/types/api"; +import { + useProposalStageSync, + useProposalTransitionNotice, +} from "./useProposalStageSync"; + +const ProposalReferendum: React.FC = () => { + const { id } = useParams(); + const [proposal, setProposal] = useState(null); + const [loadError, setLoadError] = useState(null); + const [submitError, setSubmitError] = useState(null); + const [submitting, setSubmitting] = useState(false); + const [timeline, setTimeline] = useState([]); + const [timelineError, setTimelineError] = useState(null); + const syncProposalStage = useProposalStageSync(id); + const transitionNotice = useProposalTransitionNotice(); + + const loadPage = useCallback(async () => { + if (!id) return; + const page = await apiProposalReferendumPage(id); + setProposal(page); + setLoadError(null); + }, [id]); + + useEffect(() => { + if (!id) return; + let active = true; + (async () => { + try { + const [pageResult, timelineResult] = await Promise.allSettled([ + apiProposalReferendumPage(id), + apiProposalTimeline(id), + ]); + if (!active) return; + if (pageResult.status === "fulfilled") { + setProposal(pageResult.value); + setLoadError(null); + } else { + setProposal(null); + setLoadError( + pageResult.reason?.message ?? "Failed to load referendum", + ); + } + if (timelineResult.status === "fulfilled") { + setTimeline(timelineResult.value.items); + setTimelineError(null); + } else { + setTimeline([]); + setTimelineError( + timelineResult.reason?.message ?? "Failed to load timeline", + ); + } + } catch (error) { + if (!active) return; + setProposal(null); + setLoadError((error as Error).message); + } + })(); + return () => { + active = false; + }; + }, [id]); + + if (!proposal) { + return ( +
+ + {transitionNotice ? ( + + {transitionNotice} + + ) : null} + + {loadError + ? `Referendum unavailable: ${formatLoadError(loadError, "Failed to load referendum.")}` + : "Loading referendum…"} + +
+ ); + } + + const yesTotal = proposal.votes.yes; + const noTotal = proposal.votes.no; + const abstainTotal = proposal.votes.abstain; + const totalVotes = yesTotal + noTotal + abstainTotal; + const engaged = proposal.engagedVoters ?? proposal.engagedGovernors; + const eligibleVoters = Math.max( + 1, + proposal.eligibleVoters ?? proposal.activeGovernors, + ); + const quorumRuleLabel = "33.3% + 1"; + const quorumPercent = Math.round((engaged / eligibleVoters) * 100); + const yesPercentOfTotal = + totalVotes > 0 ? Math.round((yesTotal / totalVotes) * 100) : 0; + const noPercentOfTotal = + totalVotes > 0 ? Math.round((noTotal / totalVotes) * 100) : 0; + const abstainPercentOfTotal = + totalVotes > 0 ? Math.round((abstainTotal / totalVotes) * 100) : 0; + const yesPercentOfQuorum = + engaged > 0 ? Math.round((yesTotal / engaged) * 100) : 0; + const passingNeededPercent = 66.6; + + const [filledSlots, totalSlots] = proposal.teamSlots + .split("/") + .map((v) => Number(v.trim())); + const openSlots = Math.max(totalSlots - filledSlots, 0); + + const handleVote = async (choice: "yes" | "no" | "abstain") => { + if (!id || submitting) return; + setSubmitting(true); + setSubmitError(null); + try { + const result = await apiReferendumVote({ + proposalId: id, + choice, + }); + if (result.systemReset) { + window.location.assign("/app"); + return; + } + const redirected = await syncProposalStage(); + if (redirected) return; + await loadPage(); + } catch (error) { + setSubmitError((error as Error).message); + } finally { + setSubmitting(false); + void syncProposalStage(); + } + }; + + return ( +
+ + {transitionNotice ? ( + + {transitionNotice} + + ) : null} + + + All active human nodes can vote + +
+ handleVote("yes")} + /> + handleVote("no")} + /> + handleVote("abstain")} + /> +
+ {submitError ? ( + + {formatLoadError(submitError)} + + ) : null} +
+ +
+

Referendum quorum

+
+ + + {quorumPercent}% / {quorumRuleLabel} + + + {engaged} / {proposal.quorumNeeded} human nodes + + + } + variant="panel" + className="flex min-h-24 flex-col items-center justify-center gap-1 py-4" + valueClassName="flex flex-col items-center gap-1 text-2xl font-semibold" + /> + + + {yesPercentOfTotal}% /{" "} + {noPercentOfTotal}%{" "} + / {abstainPercentOfTotal}% + + + {yesTotal} / {noTotal} / {abstainTotal} + + + } + variant="panel" + className="flex min-h-24 flex-col items-center justify-center gap-1 py-4" + valueClassName="flex flex-col items-center gap-1 text-2xl font-semibold" + /> + + + + {yesPercentOfQuorum}% / {passingNeededPercent}% + + + {Math.ceil(engaged * 0.666)} yes votes needed at current + participation + + + } + variant="panel" + className="flex min-h-24 flex-col items-center justify-center gap-1 py-4" + valueClassName="flex flex-col items-center gap-1 text-2xl font-semibold" + /> +
+
+ + + + + + + + {timelineError ? ( + + Timeline unavailable: {formatLoadError(timelineError)} + + ) : ( + + )} +
+ ); +}; + +export default ProposalReferendum; diff --git a/src/pages/proposals/Proposals.tsx b/src/pages/proposals/Proposals.tsx index fe05467..d4e35ae 100644 --- a/src/pages/proposals/Proposals.tsx +++ b/src/pages/proposals/Proposals.tsx @@ -792,7 +792,8 @@ const Proposals: React.FC = () => { proposer={proposal.proposer} proposerId={proposal.proposerId} primaryHref={ - proposal.stage === "pool" + proposal.href ?? + (proposal.stage === "pool" ? `/app/proposals/${proposal.id}/pp` : proposal.stage === "vote" ? `/app/proposals/${proposal.id}/chamber` @@ -804,7 +805,7 @@ const Proposals: React.FC = () => { ? proposal.summaryPill === "Finished" ? `/app/proposals/${proposal.id}/finished` : `/app/proposals/${proposal.id}/formation` - : `/app/proposals/${proposal.id}/pp` + : `/app/proposals/${proposal.id}/pp`) } primaryLabel={proposal.ctaPrimary} /> diff --git a/src/pages/proposals/useProposalStageSync.ts b/src/pages/proposals/useProposalStageSync.ts index 03fc386..76539fb 100644 --- a/src/pages/proposals/useProposalStageSync.ts +++ b/src/pages/proposals/useProposalStageSync.ts @@ -49,6 +49,9 @@ export function formatProposalStageTransitionMessage( ? `Milestone M${status.pendingMilestoneIndex} entered chamber vote.` : "Milestone entered chamber vote."; } + if (status.redirectReason === "referendum_open") { + return "Legitimacy referendum opened."; + } if (status.redirectReason === "formation_completed") { return "Project finished and moved to Finished."; } diff --git a/src/types/api.ts b/src/types/api.ts index f22f626..741c9b6 100644 --- a/src/types/api.ts +++ b/src/types/api.ts @@ -324,6 +324,14 @@ export type TierProgressDto = { export type GetMyGovernanceResponse = { eraActivity: MyGovernanceEraActivityDto; myChamberIds: string[]; + legitimacy: { + percent: number; + objecting: boolean; + objectingHumanNodes: number; + eligibleHumanNodes: number; + referendumTriggered: boolean; + triggerThresholdPercent: number; + }; tier?: TierProgressDto; rollup?: { era: number; @@ -382,6 +390,7 @@ export type ProposalListItemDto = { date: string; votes: number; activityScore: number; + href?: string; ctaPrimary: string; ctaSecondary: string; }; @@ -553,7 +562,10 @@ export type ChamberProposalPageDto = { proposer: string; proposerId: string; chamber: string; - scoreLabel: "CM" | "MM"; + voteKind: "chamber" | "milestone" | "referendum"; + voterLabel: "Governors" | "Human nodes"; + scoreLabel: "CM" | "MM" | null; + scoreEnabled: boolean; milestoneIndex: number | null; budget: string; formationEligible: boolean; @@ -566,6 +578,8 @@ export type ChamberProposalPageDto = { passingRule: string; engagedGovernors: number; activeGovernors: number; + engagedVoters: number; + eligibleVoters: number; attachments: { id: string; title: string }[]; teamLocked: { name: string; role: string }[]; openSlotNeeds: { title: string; desc: string }[];