Skip to content

feat: off-chain proposals, voting and visualization (DEV-1131) - #2087

Draft
brunod-e wants to merge 16 commits into
devfrom
feat/offchain-proposals-dev-1131
Draft

feat: off-chain proposals, voting and visualization (DEV-1131)#2087
brunod-e wants to merge 16 commits into
devfrom
feat/offchain-proposals-dev-1131

Conversation

@brunod-e

Copy link
Copy Markdown
Collaborator

Closes DEV-1131.

Reworks the off-chain (Snapshot) proposal experience. All 13 final Figma frames
are implemented and mounted. Figma: 🗳️ Off-chain Proposals

The ticket points at file key DEKMQifA8YOb3oxznHboSY, which only holds a Cover
page. The frames live in mUgy2KpQ3gJ07yZaUaXu8l, the value in the task's
🕸️ Figma field. Worth fixing on the ticket.

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 after
voting 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.keys over the stored {choiceIndex: weight}, keeping indices and
discarding the weights, so a voter's split could not be read back.

What changed

Frames Delivered
01–04, 07, 08 A designed ballot per vote type. Approval gets an "N of M selected" counter; weighted/quadratic share an allocation ballot with per-option steppers, chart-token dots, stacked bar, N% Remaining chip 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's ModalDrawer
06, 10, 11 Net-new OffchainResultsCard, 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 lifecycle
05, 13 Read-only "Your Vote" state, entered before the ballot for a wallet that already voted, and the green "You voted" chip on header and mobile bar with a tooltip into the modal
12 Status derivation mirroring snapshot.box getProposalState, quorum-aware, implemented row for row from the frame's table. 16 unit tests, one per row, including one asserting no input can ever produce executed
09 Live impact preview on the single-choice ballot, with the green delta on the selected row

Allocation colors use the existing --base-chart-* tokens, no raw hex. Two DS
gaps the ticket lists were already closed in main: the chart tokens and
Checkbox. The off-chain non-voters API also already existed.

Verification

typecheck 14/14 tasks, lint 0 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

  • Frame 09 delta renders ↑ 26.4K, the frame shows ↑ 26.4. Without the
    unit, 26.4 beside 398.2K reads as 26 tokens rather than 26 thousand
  • Shutter reveal-pending does not highlight the leading option, though the
    frame shows its % in orange. Highlighting a leader while values are dashed
    leaks who is winning. Concealed rows also keep the authored choice order for
    the same reason
  • Frame 09's own numbers are not internally consistent (3 rows of a longer
    list), so the story uses self-consistent values

Sequencing and open items

  • weights only reaches @anticapture/client once the API ships and the Gateful
    spec regenerates. Until then the voted modal lists chosen options for weighted
    votes instead of drawing shares. Sequence, not a bug
  • quorum / quorumType are not exposed. The quorum branches are
    implemented and tested but inert until the indexer stores those fields
    (new columns + migration) and the API surfaces them
  • OQ 2 still open (Passed/Rejected vs Approved/Defeated, and the winner copy).
    Labels sit in one Record, a one-line change
  • OQ 6 kept as designed: the final card has no quorum row. Nothing was lost,
    the off-chain adapter hardcoded quorum to "0" so that row never rendered

Also noticed: the repo's FIGMA_TOKEN was expired, which silently breaks the
Storybook addon-designs links for everyone, and bare pnpm typecheck can never
run client codegen because the root script lacks dotenv --. Neither is touched
here.

🤖 Generated with Claude Code

brunod-e and others added 14 commits July 27, 2026 14:58
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]>
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]>
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]>
@vercel

vercel Bot commented Jul 27, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
anticapture-storybook Ready Ready Preview, Comment Jul 27, 2026 7:22pm
1 Skipped Deployment
Project Deployment Actions Updated (UTC)
anticapture Ignored Ignored Jul 27, 2026 7:22pm

Request Review

brunod-e and others added 2 commits July 27, 2026 16:14
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]>
@github-actions

Copy link
Copy Markdown
Contributor

🔍 Vercel preview: https://anticapture-7i8hcxzpl-ful.vercel.app

@railway-app

railway-app Bot commented Jul 27, 2026

Copy link
Copy Markdown

🚅 Deployed to the anticapture-pr-2087 environment in anticapture-infra

Service Status Web Updated (UTC)
user-api ✅ Success (View Logs) Web Jul 29, 2026 at 5:48 pm
prometheus ✅ Success (View Logs) Jul 29, 2026 at 5:47 pm
compound-indexer-offchain ✅ Success (View Logs) Jul 28, 2026 at 1:21 pm
uniswap-indexer-offchain ✅ Success (View Logs) Jul 28, 2026 at 1:21 pm
shutter-indexer-offchain ✅ Success (View Logs) Jul 28, 2026 at 1:20 pm
gitcoin-indexer-offchain ✅ Success (View Logs) Jul 28, 2026 at 1:16 pm
ens-indexer-offchain ✅ Success (View Logs) Jul 28, 2026 at 1:16 pm
gitcoin-indexer ✅ Success (View Logs) Jul 28, 2026 at 12:12 am
grafana ✅ Success (View Logs) Web Jul 27, 2026 at 7:21 pm
otelcol ✅ Success (View Logs) Jul 27, 2026 at 7:21 pm
alertmanager ✅ Success (View Logs) Jul 27, 2026 at 7:21 pm
gateful ✅ Success (View Logs) Web Jul 27, 2026 at 7:21 pm
authful ✅ Success (View Logs) Web Jul 27, 2026 at 7:21 pm
tempo ✅ Success (View Logs) Jul 27, 2026 at 7:21 pm
loki ✅ Success (View Logs) Jul 27, 2026 at 7:20 pm
mcp ✅ Success (View Logs) Web Jul 27, 2026 at 6:13 pm
docs ✅ Success (View Logs) Web Jul 27, 2026 at 6:13 pm
aave-api ✅ Success (View Logs) Jul 27, 2026 at 6:13 pm
gitcoin-api ✅ Success (View Logs) Jul 27, 2026 at 6:12 pm
uniswap-api ✅ Success (View Logs) Jul 27, 2026 at 6:12 pm
shutter-indexer ✅ Success (View Logs) Jul 27, 2026 at 6:12 pm
lil-nouns-indexer ✅ Success (View Logs) Jul 27, 2026 at 6:12 pm
obol-api ✅ Success (View Logs) Jul 27, 2026 at 6:12 pm
tornado-indexer ✅ Success (View Logs) Jul 27, 2026 at 6:12 pm
fluid-indexer ✅ Success (View Logs) Jul 27, 2026 at 6:12 pm
fluid-api ✅ Success (View Logs) Jul 27, 2026 at 6:12 pm
nouns-indexer ✅ Success (View Logs) Jul 27, 2026 at 6:12 pm
uniswap-indexer ✅ Success (View Logs) Jul 27, 2026 at 6:12 pm
ens-indexer ✅ Success (View Logs) Jul 27, 2026 at 6:12 pm
compound-api ✅ Success (View Logs) Jul 27, 2026 at 6:12 pm
compound-indexer ✅ Success (View Logs) Jul 27, 2026 at 6:12 pm
aave-indexer ✅ Success (View Logs) Jul 27, 2026 at 6:12 pm
nouns-api ✅ Success (View Logs) Jul 27, 2026 at 6:12 pm
address-enrichment ✅ Success (View Logs) Web Jul 27, 2026 at 6:12 pm
shutter-api ✅ Success (View Logs) Jul 27, 2026 at 6:12 pm
tornado-api ✅ Success (View Logs) Jul 27, 2026 at 6:12 pm
lil-nouns-api ✅ Success (View Logs) Jul 27, 2026 at 6:12 pm
ens-api ✅ Success (View Logs) Jul 27, 2026 at 6:12 pm
scroll-indexer ✅ Success (View Logs) Jul 27, 2026 at 6:12 pm
obol-indexer ✅ Success (View Logs) Jul 27, 2026 at 6:11 pm
ens-relayer ✅ Success (View Logs) Jul 27, 2026 at 6:11 pm
erpc ✅ Success (View Logs) Web Jul 27, 2026 at 6:11 pm
nodeful ✅ Success (View Logs) Jul 27, 2026 at 6:11 pm
scroll-api ✅ Success (View Logs) Jul 27, 2026 at 6:10 pm

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant