feat: off-chain proposals, voting and visualization (DEV-1131) - #2087
Conversation
Each Snapshot vote type now renders its own designed ballot instead of the generic option list (DEV-1131, Figma frames 01-04 and 07): - shared BallotSection shell owns the "Your vote" label row, so ballots can place a counter or chip beside it, and adds a filter input plus a fixed-height scroll area once a list passes 8 options - approval shows an "N of M selected" counter - weighted/quadratic share an allocation ballot: per-option stepper, chart-token dots, stacked allocation bar, amber "N% Remaining" chip, running total and "Must equal 100% to vote"; the stepper cannot exceed 100% - ranked adds a drag grip and drag-to-reorder, keeping the chevrons as the keyboard path, plus the reorder helper line - comment field now uses the design-system Textarea and FormLabel Allocation colors come from the existing --base-chart-* tokens, no raw hex. Storybook stories cover each ballot against its Figma frame. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…mistic states Adds the standalone CURRENT RESULTS card that off-chain proposals never had (DEV-1131, Figma frames 06, 10 and 11). OffchainVotesContent stays a per-voter table; this card is the tally. - ranked rows with rank number, voting power and share; leading option gets the brand-orange bar and orange percentage, no LEADING chip and no quorum row, matching the final frame - shutter aware: encrypted while voting is open, reveal-pending once it closes with the tally still empty, then the normal card once the reveal lands. Both concealed states render dashes over empty tracks and never zeros, drop the rank numbers, and keep the authored choice order so nothing leaks the winner - optimistic votes: optimisticScores apply on top of the indexed tally so a vote shows the moment the signature returns, and useOffchainVoteIndexing drives the header chip through "Indexing your vote..." to "Indexed" and out after 1.2s, falling back to the indexer-catching-up chip past two minutes Storybook stories cover every state against its Figma frame. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Off-chain proposals derived their status through the on-chain enum, which is how a Snapshot vote could render as "Executed" (DEV-1131, Figma frame 12). getOffchainProposalStatus mirrors snapshot.box's getProposalState and implements the frame's derivation table row for row: - not started -> pending, voting open -> active - basic (For/Against/Abstain) resolves to passed/rejected and is quorum-aware: below quorum rejects even when For leads, and a rejection-type quorum inverts the test so reaching it rejects the proposal - ties reject, since passing needs strictly more For than Against - every other vote type closes and surfaces "winner: X . NN%" - no input can produce an executed state OffchainProposalBadge renders the five badges, with Active as a white pill on black text so it never reads as the green passed badge. The quorum branches are inert until the API exposes quorum and quorumType on OffchainProposal, which it does not today; the logic and its tests are in place for when it does. Label vocabulary is still OQ 2 and lives in one constant map. 16 unit tests, one per derivation row. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Casting an off-chain vote showed no indication of what the vote would do, while on-chain voting shows percentages and impact (DEV-1131, Figma frame 09). SingleChoiceOptions now takes an optional liveImpact, and when given renders a per-row bar, voting power and share, with a green up-arrow delta on the selected row. The projection grows the denominator too, so picking an option raises its share and dips every other row's, and the shares always sum to 100%. Opt-in by design: only single choice has a designed frame for this, so ballots 01-04 keep shipping without per-row results, and the approval preview waits on its design. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Nothing told a connected wallet it had already voted on an off-chain proposal, and the ballot reset to null on open with no prop carrying the prior choice (DEV-1131, Figma frames 05 and 13). - useMyOffchainVote reads the wallet's own vote through the votes endpoint's voterAddresses filter, so the prior choice is finally available - OffchainVotedModal is the read-only "Your Vote" state: green "You voted" banner with date and voting power, per-option bars with the chosen shares, unchosen options dimmed at 0%, the static comment, and Close / Change vote - OffchainVotedChip is the green "You voted X" chip with a hover tooltip that opens the read-only modal; long labels collapse through the existing presentOffchainVoteLabel, and rows without a vote get no placeholder deriveOffchainVoteWeights only returns shares for basic and single choice. Approval and ranked are not share-based, and weighted/quadratic weights are not recoverable because the API models `choice` as bare choice indices and drops the per-option weights Snapshot stores. Rather than invent numbers, the modal takes weights explicitly and falls back to listing the chosen options. Surfacing those weights needs an API change. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The status bug in the ticket had a single source: getOffchainProposalStatus returned ProposalStatus.EXECUTED whenever a basic Snapshot ballot had more For than Against, so a proposal that was never executed anywhere read "Executed" (DEV-1131, Figma frame 12). That util now delegates to the quorum-aware derivation and maps onto the view enum, which gains PASSED and REJECTED members with their own labels and colors. Both call sites are updated, so the fix lands on the proposals list and the proposal detail page, not just on new components. Status is derived once in ProposalSection and shared, so the badge, its winner copy, and the results card cannot disagree about the outcome. Also drops useMyOffchainVote: ProposalSection already queries the wallet's own vote through the same voterAddresses filter, and shipping a second hook for it would have meant two queries for one answer. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Replaces the ad-hoc off-chain "Current Results" block in ProposalInfoSection with OffchainResultsCard (DEV-1131, Figma frames 06, 10 and 11). This is what makes the shutter fix reach users: ProposalSection now resolves proposal privacy and passes isShutter through, so an encrypted or reveal-pending election renders dashes over empty tracks instead of the 0/0.0% it used to show. The old block's quorum row is not a loss: the off-chain adapter hardcodes quorum to "0", so that row could never render, and the final card drops it by design (OQ 6). VotingProgressBar stays. Drops the now-dead ChoiceIcon and its color helper import along with the replaced markup. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A wallet that already voted reopened the interactive ballot with its prior choice thrown away, because the modal reset to null on open (DEV-1131, frame 05). It now lands on the read-only "Your Vote" state and opts into editing through "Change vote", which swaps in the ballot. Closing either modal resets that choice, so the next open starts from the read-only state again. Shares come from deriveOffchainVoteWeights, which only answers for basic and single choice. Approval and ranked list their chosen options instead, and weighted stays unresolved until the API exposes the per-option weights. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The list rendered off-chain status as plain colored text through the on-chain helpers, so it could not show the white Active pill or the winner on a closed vote (DEV-1131, Figma frame 12). getOffchainProposalStatusView now also returns the off-chain status itself, and the list renders OffchainProposalBadge with it plus the winner. The on-chain path keeps using getStatusText and the color helpers, untouched. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Completes the has-voted indicator (DEV-1131, Figma frame 13). The header and the mobile bottom bar showed a "You voted" label stacked over a plain choice chip; both now render OffchainVotedChip, one green pill carrying the choice, with a tooltip quoting voting power and date and a click into the read-only modal. The chip needs more than the label, so ProposalHeader takes a single grouped OffchainVoteIndicator prop rather than threading three separate ones through two component levels. It is only built once the indexed vote exists, since the tooltip quotes its values, and both call sites fall back to the previous label rendering until then. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Closed Shutter elections showed 0 / 0.0% forever, e.g. "6.47 ENS Security Council" (DEV-1131). Root cause: both sync passes are forward-only on `created`. A Shutter proposal is ingested while its votes are still encrypted and its tally is zero, and the reveal only happens after voting closes, by which point the cursors have moved past it. Nothing ever re-read it. Adds a reveal reconciliation pass to each cycle: closed proposals inside the existing 14-day window whose tally is still all zeros are re-fetched by id along with their votes, then upserted without advancing either cursor. The upserts already overwrite choice, vp and scores, so the revealed values replace the encrypted ones with no schema change. A zero-tally proposal that genuinely had no votes is re-read cheaply and stays zero, so no extra state is needed to distinguish the two cases. Provider gains fetchProposalsByIds and fetchVotesByProposalIds, the latter paginated since one proposal can exceed a page of votes. Proposal and vote GraphQL field lists are extracted so the by-id queries cannot drift from the sync ones. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Weighted and quadratic ballots are stored as {choiceIndex: weight}, but the
response mapper ran Object.keys over that, keeping the picked indices and
throwing the weights away. The data was in the database the whole time; nothing
could read a voter's split back to them (DEV-1131).
Off-chain votes now also carry `weights`, null for the vote types that have no
weights. `choice` is unchanged, so existing consumers are unaffected. The
repository selects the same column twice under both aliases and each schema
interprets it its own way, so the two can never disagree.
This unblocks the weighted case of the read-only voted modal, which currently
falls back to listing chosen options because the weights were unavailable.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
The latest updates on your projects. Learn more about Vercel for GitHub.
1 Skipped Deployment
|
Four fixes from testing the real app (DEV-1131): - modal bodies were padded per section, so stacked sections produced 32px gaps where the frames specify 16px. One padded body with gap-4 now wraps unpadded sections, in both the ballot and the read-only voted modal - the results card had no height cap, so a 15-candidate election pushed the sidebar off screen. It caps at ~7 rows and scrolls, with the padding outside the scroll container so the designed 6-row card still fits without a scrollbar - ballot overflow used the native scrollbar while the results card used the design system's; both now use scrollbar-custom - ranked ballots no longer offer the filter: a ranking is an ordering, and hiding rows hides positions the voter still has to place Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Padding sits inside the scroll container so the scrollbar tracks the card border rather than floating inset, with the cap raised to 300px to absorb it. The designed 6-row card still does not scroll; a 15-candidate election does. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
🔍 Vercel preview: https://anticapture-erzoetjun-ful.vercel.app |
Both features were built, verified in Storybook and then never connected. They were exposed as optional props with silent defaults, so nothing failed: the preview never rendered and the indexing chip resolved to "idle", which returns null. Two acceptance criteria looked delivered and were not (DEV-1131, frames 09 and 10). - OffchainVotingModal now passes liveImpact to the single-choice ballot, built from the proposal's tally plus the voter's power. Suppressed on shutter proposals, where a preview would leak the concealed tally - the modal reports the voter's power spread across the choices they picked; weighted and quadratic split it by allocation, other types apply it in full to each pick, matching how Snapshot tallies them - ProposalSection records the signature time, runs useOffchainVoteIndexing and feeds the tally delta and chip state through to the results card - the delta is dropped as soon as the API returns the vote for the wallet, so it is never counted twice on top of the now-indexed tally Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
🚅 Deployed to the anticapture-pr-2087 environment in anticapture-infra
|
…s-dev-1131 # Conflicts: # apps/dashboard/features/governance/components/proposal-overview/ProposalItem.tsx # apps/dashboard/features/governance/components/proposal-overview/ProposalSection.tsx # apps/dashboard/features/governance/utils/offchainProposal.test.ts # apps/dashboard/features/governance/utils/offchainProposal.ts # apps/offchain-indexer/src/indexer.test.ts # apps/offchain-indexer/src/provider/dataProvider.ts # apps/offchain-indexer/src/repository/db.ts
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 27012dde0e
ℹ️ About Codex in GitHub
Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".
🎨 UI Review
Ranked-choice ballot (
|
The single-choice live impact preview printed the voter's power as a separate arrow figure while the bar stayed one solid fill at the projected total. Split the bar into the already-indexed share plus a green segment sized to the voter's power, and drop the arrow figure so the delta is stated once. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 396d37d7a7
ℹ️ About Codex in GitHub
Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".
…econd votes Two defects in the Shutter reveal reconciliation, both from codex review. getRevealPendingProposalIds windowed on `created`, so a proposal opened more than 14 days before it closes was never selected after its reveal and kept the encrypted zero tally forever, which is the exact case this pass exists to repair. It also selected proposals that ended long before the window as long as they were created inside it. The scan now bounds `end` on both sides. fetchVotesByProposalIds paginated with an exclusive `created_gt` cursor, which skipped any vote sharing the last returned vote's second that did not fit on that page, leaving a suffix of revealed votes out of the upsert. It now pages with `created_gte` and de-dupes the re-read overlap on (proposalId, voter), the votes table's primary key, so a revote seen twice collapses to its latest version exactly as the upsert would. Both are pinned by tests that fail against the previous code: the vote pagination test's mock filters the way Snapshot does and honors whichever operator the query sends, so an exclusive cursor really loses the sibling vote. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
… or wallet change The key-change guard only cleared localOffchainVoteLabel, but optimisticScores, signedAt and isChangingVote describe the same single wallet's vote on a single proposal. Signing an off-chain vote and then navigating away or switching wallets before the API indexed it left signedAt set, which kept the indexing hook optimistic, and the previous optimisticScores were then added on top of the next proposal's results. All four fields now reset together. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 4d3ab6cbb2
ℹ️ About Codex in GitHub
Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".
| <AllocationVoteOptions | ||
| {...props} | ||
| renderSecondaryLine={(weight) => | ||
| weight > 0 ? `${weight}² = ${weight * weight} credits` : null | ||
| } |
There was a problem hiding this comment.
Preserve quadratic vote amounts
For quadratic Snapshot proposals, this reuses the weighted percentage allocator, which caps every ballot at a 0–100 total and sends those percentages as the choice. Snapshot’s QV model lets voters allocate their actual voting-power token amounts across choices (for example, 9 tokens to A, 4 to B in the Snapshot docs), so a holder with more than 100 VP is forced to under-allocate and a holder with less than 100 VP can submit an over-budget ballot; keep quadratic values budgeted against the wallet’s voting power instead of the weighted 100% total.
Useful? React with 👍 / 👎.
| return choices.map( | ||
| (_, index) => (scores[index] ?? 0) + (optimisticScores[index] ?? 0), |
There was a problem hiding this comment.
Subtract the replaced off-chain vote
When an already-indexed voter changes their off-chain vote, scores still include that wallet’s previous choice until the indexer rewrites it, but the optimistic value being added here only contains the new positive delta. Independently of the indexing-state issue, this makes the results card count the same wallet on both the old and new choices while the replacement is pending; the optimistic replacement delta needs to remove the prior indexed ballot before adding the new one.
Useful? React with 👍 / 👎.
Closes DEV-1131.
Reworks the off-chain (Snapshot) proposal experience. All 13 final Figma frames
are implemented and mounted. Figma: 🗳️ Off-chain Proposals
Three root causes worth reading
1. "Executed" on a Snapshot vote came from a single line in
utils/offchainProposal.ts:if (for_ > against) return ProposalStatus.EXECUTED.Every passing basic ballot read "Executed". Fixed at the source, so the fix lands
on the proposals list and the detail page, not just on new components.
2. Shuttered elections stuck at 0 / 0.0% because both indexer syncs are
forward-only on
created, and a Shutter proposal only reveals its tally aftervoting closes, by which point the cursors have moved past it. Nothing ever
re-read it. A reveal reconciliation pass now re-fetches closed proposals whose
tally is still all zeros, plus their votes, and upserts without advancing cursors.
3. Weighted vote weights were in the database the whole time. The API mapper
ran
Object.keysover the stored{choiceIndex: weight}, keeping indices anddiscarding the weights, so a voter's split could not be read back.
What changed
N% Remainingchip and a total that must reach 100%; ranked gets drag-to-reorder with chevrons as the keyboard path. Lists past 8 options gain a filter and fixed-height scroll. Mobile bottom sheet comes from the design system'sModal→DrawerOffchainResultsCard, replacing the ad-hoc results block. Shutter-aware encrypted and reveal-pending states show dashes over empty tracks and never zeros. Optimistic votes render the moment the signature returns, with an indexing chip lifecyclegetProposalState, quorum-aware, implemented row for row from the frame's table. 16 unit tests, one per row, including one asserting no input can ever produceexecutedAllocation colors use the existing
--base-chart-*tokens, no raw hex. Two DSgaps the ticket lists were already closed in
main: the chart tokens andCheckbox. The off-chain non-voters API also already existed.Verification
typecheck14/14 tasks,lint0 errors, tests 246/246 dashboard,40/40 offchain-indexer, 1005/1005 api. Rebased onto
dev, no conflicts,re-verified after.
Every frame was checked in Storybook against its Figma node, comparing computed
styles rather than eyeballing: frame 06 reproduces the design's numbers exactly
(512.4K / 42.1%, 308.9K / 25.4%, …), the Active badge is the white pill on black
text that OQ 5 settled, and the shutter states assert zero zeros. Stories live
under
Governance/with a Design link per story.Not visually verified: the status badge's fit inside the list's flex row, and
the chip in the header and mobile bar. Both were mounted after their isolated
Storybook check, so spacing in the new context is unconfirmed.
Decisions that depart from the frames, needing a designer call
↑ 26.4K, the frame shows↑ 26.4. Without theunit,
26.4beside398.2Kreads as 26 tokens rather than 26 thousandframe shows its
%in orange. Highlighting a leader while values are dashedleaks who is winning. Concealed rows also keep the authored choice order for
the same reason
list), so the story uses self-consistent values
Sequencing and open items
weightsonly reaches@anticapture/clientonce the API ships and the Gatefulspec regenerates. Until then the voted modal lists chosen options for weighted
votes instead of drawing shares. Sequence, not a bug
quorum/quorumTypeare not exposed. The quorum branches areimplemented and tested but inert until the indexer stores those fields
(new columns + migration) and the API surfaces them
Labels sit in one
Record, a one-line changethe off-chain adapter hardcoded
quorumto"0"so that row never renderedAlso noticed: the repo's
FIGMA_TOKENwas expired, which silently breaks theStorybook
addon-designslinks for everyone, and barepnpm typecheckcan neverrun client codegen because the root script lacks
dotenv --. Neither is touchedhere.
🤖 Generated with Claude Code