feat: off-chain proposals, voting and visualization (DEV-1131) - #2087
Draft
brunod-e wants to merge 16 commits into
Draft
feat: off-chain proposals, voting and visualization (DEV-1131)#2087brunod-e wants to merge 16 commits into
brunod-e wants to merge 16 commits into
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 <[email protected]>
…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 <[email protected]>
Co-Authored-By: Claude Opus 5 <[email protected]>
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 <[email protected]>
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 <[email protected]>
Co-Authored-By: Claude Opus 5 <[email protected]>
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 <[email protected]>
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 <[email protected]>
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 <[email protected]>
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 <[email protected]>
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 <[email protected]>
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 <[email protected]>
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 <[email protected]>
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 <[email protected]>
|
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 <[email protected]>
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 <[email protected]>
Contributor
|
🔍 Vercel preview: https://anticapture-7i8hcxzpl-ful.vercel.app |
|
🚅 Deployed to the anticapture-pr-2087 environment in anticapture-infra
|
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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