Skip to content

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

Merged
pikonha merged 21 commits into
devfrom
feat/offchain-proposals-dev-1131
Jul 30, 2026
Merged

feat: off-chain proposals, voting and visualization (DEV-1131)#2087
pikonha merged 21 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 <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>
@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 Jul 30, 2026 5:13pm
1 Skipped Deployment
Project Deployment Actions Updated (UTC)
anticapture Ignored Ignored Jul 30, 2026 5:13pm

Request Review

@railway-app
railway-app Bot temporarily deployed to anticapture-infra / anticapture-pr-2087 July 27, 2026 18:08 Destroyed
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 <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>
@github-actions

github-actions Bot commented Jul 27, 2026

Copy link
Copy Markdown
Contributor

🔍 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>
@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)
shutter-indexer-offchain ✅ Success (View Logs) Jul 30, 2026 at 2:23 pm
ens-indexer-offchain ✅ Success (View Logs) Jul 30, 2026 at 2:23 pm
uniswap-indexer-offchain ✅ Success (View Logs) Jul 30, 2026 at 2:19 pm
gitcoin-indexer-offchain ✅ Success (View Logs) Jul 30, 2026 at 2:18 pm
compound-indexer-offchain ✅ Success (View Logs) Jul 30, 2026 at 2:18 pm
compound-indexer ✅ Success (View Logs) Jul 30, 2026 at 1:47 pm
tornado-api ✅ Success (View Logs) Jul 30, 2026 at 1:46 pm
compound-api ✅ Success (View Logs) Jul 30, 2026 at 1:46 pm
otelcol ✅ Success (View Logs) Jul 30, 2026 at 1:46 pm
ens-api ✅ Success (View Logs) Jul 30, 2026 at 1:46 pm
aave-api ✅ Success (View Logs) Jul 30, 2026 at 1:46 pm
nouns-indexer ✅ Success (View Logs) Jul 30, 2026 at 1:46 pm
nouns-api ✅ Success (View Logs) Jul 30, 2026 at 1:46 pm
docs ✅ Success (View Logs) Web Jul 30, 2026 at 1:46 pm
grafana ✅ Success (View Logs) Web Jul 30, 2026 at 1:46 pm
shutter-api ✅ Success (View Logs) Jul 30, 2026 at 1:46 pm
lil-nouns-api ✅ Success (View Logs) Jul 30, 2026 at 1:46 pm
obol-api ✅ Success (View Logs) Jul 30, 2026 at 1:46 pm
shutter-indexer ✅ Success (View Logs) Jul 30, 2026 at 1:45 pm
prometheus ✅ Success (View Logs) Jul 30, 2026 at 1:45 pm
aave-indexer ✅ Success (View Logs) Jul 30, 2026 at 1:45 pm
ens-indexer ✅ Success (View Logs) Jul 30, 2026 at 1:45 pm
alertmanager ✅ Success (View Logs) Jul 30, 2026 at 1:45 pm
uniswap-api ✅ Success (View Logs) Jul 30, 2026 at 1:45 pm
gitcoin-api ✅ Success (View Logs) Jul 30, 2026 at 1:45 pm
fluid-api ✅ Success (View Logs) Jul 30, 2026 at 1:45 pm
gitcoin-indexer ✅ Success (View Logs) Jul 30, 2026 at 1:45 pm
obol-indexer ✅ Success (View Logs) Jul 30, 2026 at 1:45 pm
tempo ✅ Success (View Logs) Jul 30, 2026 at 1:44 pm
lil-nouns-indexer ✅ Success (View Logs) Jul 30, 2026 at 1:44 pm
scroll-api ✅ Success (View Logs) Jul 30, 2026 at 1:44 pm
scroll-indexer ✅ Success (View Logs) Jul 30, 2026 at 1:44 pm
uniswap-indexer ✅ Success (View Logs) Jul 30, 2026 at 1:44 pm
loki ✅ Success (View Logs) Jul 30, 2026 at 1:44 pm
gateful ✅ Success (View Logs) Web Jul 30, 2026 at 1:44 pm
user-api ✅ Success (View Logs) Web Jul 30, 2026 at 1:44 pm
authful ✅ Success (View Logs) Web Jul 30, 2026 at 1:44 pm
tornado-indexer ✅ Success (View Logs) Jul 30, 2026 at 1:44 pm
fluid-indexer ✅ Success (View Logs) Jul 30, 2026 at 1:44 pm
mcp ✅ Success (View Logs) Web Jul 30, 2026 at 1:44 pm
address-enrichment ✅ Success (View Logs) Web Jul 27, 2026 at 6:12 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

…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
@railway-app
railway-app Bot temporarily deployed to anticapture-infra / anticapture-pr-2087 July 30, 2026 13:43 Destroyed
@brunod-e
brunod-e marked this pull request as ready for review July 30, 2026 13:45

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 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".

Comment thread apps/offchain-indexer/src/repository/db.ts Outdated
Comment thread apps/offchain-indexer/src/provider/dataProvider.ts Outdated

Copy link
Copy Markdown
Collaborator

🎨 UI Review

Automated review · Figma: 🗳️ Off-chain Proposals (Voting + Visualization) · Spec (DEV-1131) · Preview: not reachable

Figma was fetched and cross-checked per frame (all 13 nodes). Vercel previews (app + Storybook) both return 403 (SAML-gated) with no bypass token configured in this environment — review is Figma + diff only, no live regression pass. All findings below are grounded in the design context pulled from the Figma nodes listed in the ClickUp task, not from generic heuristics.


Ranked-choice ballot (RankedChoiceOptions.tsx, frame 04)

Must-fix

  • Rank badge doesn't match the frame: Figma's badge is a filled 20×20px square, bg-surfaces/contrast (#27272a), no border, Roboto Mono / Medium / 12px, text/primary (#fafafa). The implementation renders a bordered square (border-border-contrast) with no background fill, font-inter instead of mono, and text-secondary instead of primary. Fix: swap to bg-surface-contrast fill, font-mono, text-primary, drop the border. (apps/dashboard/features/governance/components/modals/vote-options/RankedChoiceOptions.tsx:1188) [Figma-confirmed]

Nice-to-have

  • Dragged row only picks up the orange border-highlight — Figma's "dragging" state also fills the row with bg-surfaces/contrast (#27272a) and adds a drop shadow (0px 4px 12px rgba(0,0,0,0.5)). Currently the row background stays whatever it was and there's no shadow, so the drag feedback is weaker than designed. (RankedChoiceOptions.tsx, ballotRowClassName) [Figma-confirmed]

Single-choice ballot — live impact preview (SingleChoiceOptions.tsx, frame 09)

Nice-to-have

  • Progress-bar track uses bg-surface-contrast (--base-muted, #27272a dark). Figma's in-ballot live-impact bar specifically uses the lighter surfaces/hover token (--base-border, #3f3f46 dark) — distinct from the results-card bars, which do use contrast. Swap the track to bg-surface-hover for this one component. (SingleChoiceOptions.tsx) [Figma-confirmed, verified against apps/dashboard/app/globals.css token values, not just the fallback hex]

Question for author / Isadora

  • Delta shows ↑ 26.4K; the frame shows ↑ 26.4 with no unit. Already flagged in the PR description as a deliberate departure — raising it here so it gets an explicit yes/no rather than living only in the PR body. Worth keeping the K for legibility (bare "26.4" next to "398.2K" does read as 26 tokens), but that's a call for Isadora, not an engineering default. [Figma-confirmed]

Voted-state modal (OffchainVotedModal.tsx, frame 05)

Nice-to-have

  • Unchosen (0%) options render an empty bg-surface-contrast bar track under the row. Figma's 0% row (Grants program) has no bar track element at all for that state — just the row header. Minor, but worth dropping the track entirely when percent === 0 to match. [Figma-confirmed]

Shutter-encrypted results (OffchainResultsCard.tsx, frame 11)

Question for author / Isadora

  • Also already flagged in the PR: no leading-option highlight while concealed, and authored order is kept instead of sorting. That is the right call for not leaking the winner — but the raw Figma frame (both "voting open" and "reveal pending" states) still renders the first row's percent value in text/link orange even though the values themselves are dashed. That's either (a) an intentional "always highlight row 1 regardless of concealment" instruction, or (b) a leftover artifact from copy-pasting the results-card frame (06) as the base for frame 11 and not stripping the orange styling. Worth a quick confirm with Isadora on which it is, so the Figma file gets cleaned up either way (currently ambiguous evidence for future readers of the frame). [Figma-confirmed]

Status badges (OffchainProposalBadge.tsx, frame 12) — validated

  • Pending/Active/Passed/Rejected/Closed variants (dimmed / primary w/ white-pill-black-text override / success / error / outline) match the frame's badge specimens exactly, including the Active-is-a-white-pill decision from OQ 5.
  • winner: X · NN% in font-mono text-xs text-secondary matches the frame's Roboto Mono / 12px / secondary treatment.
  • Reminder: OQ 2 (Passed/Rejected vs Approved/Defeated label vocabulary + winner copy wording) is still explicitly open on the ClickUp task, owned by Alex — not blocking, just flagging so it doesn't get lost now that the derivation logic itself has shipped.
  • Reminder: OQ 6 (no quorum row on the final results card) is owned jointly by Isadora/Alex per the ticket. The PR addresses it head-on ("kept as designed... nothing was lost, quorum was hardcoded to 0") — surfacing here only so the ClickUp OQ gets formally closed rather than left open with the behavior already shipped.

Has-voted indicator (OffchainVotedChip.tsx / ProposalHeader.tsx / ProposalSection.tsx, frame 13)

Must-fix

  • The green "✓ You voted For" chip is wired into the proposal header and the mobile bottom bar, but not into the proposals list (ProposalItem.tsx). Frame 13 is explicitly labeled "PROPOSALS LIST" in Figma and shows the chip inline in a list row's detail line, and the ClickUp AC says the chip must be "visible on header + list." The PR description's own summary table narrows this to "header and mobile bar" without calling out the list as a scope cut — worth confirming whether that's intentional or just missed, since as written it doesn't meet the AC. [Figma-confirmed] [ClickUp-AC-confirmed]

Weighted / Quadratic ballot (AllocationVoteOptions.tsx, frame 03) — validated

  • Allocation dot + bar-segment colors resolve to --base-chart-1 (#3b82f6), -2 (#ec4899), -3 (#f59e0b) — exact match to the frame's raw hex, and the DS gap the ticket called out (chart tokens, no raw hex) is genuinely closed.
  • Stepper sizing (26px buttons, 48px value cell), "20% Remaining" badge, "Total: 80%" amber-when-incomplete / green-when-complete, and the 2px gap between bar segments all match the frame.

Approval ballot (ApprovalVoteOptions.tsx, frame 02) / Overflow (BallotSection.tsx, frame 07) — validated

  • Selected-row orange border + filled checkbox, "N of M selected" counter placement, filter input + fixed-height scroll + "Scroll to see all N options" helper all match their frames structurally and by copy.

Mobile

Not independently re-verified against frame 08 (mobile bottom sheet) or a live preview — the PR reuses the existing DS ModalDrawer conversion, which is the correct pattern per the spec, but the "not visually verified" caveat the author already flagged for the header/mobile-bar chip fit stands; worth a quick pass once the preview is reachable.


Validated against Figma — no change needed

  • Status badge palette and Active-pill treatment (frame 12).
  • Allocation colors (chart tokens, frame 03).
  • Approval ballot selection styling and counter (frame 02).
  • Overflow filter/scroll pattern (frame 07).
  • winner: X · NN% mono styling (frames 11/12).
  • "You voted For" badge variant/icon (frame 13).

Summary: 2 must-fix, 3 nice-to-have, 3 open questions for Isadora/the author (all tied to decisions the PR itself flagged as needing a designer call, plus the OQ 2/OQ 6 reminders already sitting on the ClickUp task).


Generated by Claude Code

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>

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 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".

Comment thread apps/offchain-indexer/src/indexer.ts
brunod-e and others added 2 commits July 30, 2026 14:10
…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>
@railway-app
railway-app Bot temporarily deployed to anticapture-infra / anticapture-pr-2087 July 30, 2026 17:11 Destroyed
@pikonha
pikonha merged commit cc33b55 into dev Jul 30, 2026
21 of 22 checks passed
@pikonha
pikonha deleted the feat/offchain-proposals-dev-1131 branch July 30, 2026 17:15

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 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".

Comment on lines +16 to +20
<AllocationVoteOptions
{...props}
renderSecondaryLine={(weight) =>
weight > 0 ? `${weight}² = ${weight * weight} credits` : null
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge 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 👍 / 👎.

Comment on lines +51 to +52
return choices.map(
(_, index) => (scores[index] ?? 0) + (optimisticScores[index] ?? 0),

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge 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 👍 / 👎.

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.

3 participants