Skip to content

feat(ballot): tell voters when preliminary results are public - #1466

Draft
masiarek wants to merge 2 commits into
Equal-Vote:mainfrom
masiarek:feat/preliminary-results-ballot-notice
Draft

feat(ballot): tell voters when preliminary results are public#1466
masiarek wants to merge 2 commits into
Equal-Vote:mainfrom
masiarek:feat/preliminary-results-ballot-notice

Conversation

@masiarek

@masiarek masiarek commented Jul 30, 2026

Copy link
Copy Markdown
Collaborator

Part of #1350. Adds the on-ballot notice, so a voter learns this election publishes a live tally before they cast — rather than discovering it on the thank-you page afterwards.

Opening as a draft because two decisions in it are yours, not mine (see the bottom).

What it does

PreliminaryResultsNotice renders above the ballot whenever public_results is on and voting is open (or the election is still a draft). It reuses ElectionStateWarning, so it matches DraftWarning and the archived warning already on that page. It carries a link to docs/help/preliminary_results.md.

The article needed no writing — that page has been in the repo, covering exactly what this issue asks an article to cover (what a live tally exposes, that admins can always see who voted, delta-analysis de-anonymization, the editable-ballot interaction, and that turning the setting off doesn't un-reveal). It was simply linked from nowhere in packages/.

Three decisions, each forced by the code rather than by taste

1. Placement: VotePage, above BallotContext.Provider. That's the only spot that renders once per ballot rather than once per race, and covers DraggableIRVBallotView — which bypasses GenericBallotView entirely and re-implements its own instructions block with no footer. Putting the notice inside the ballot view would silently skip ranked elections while the STAR ballot looked perfect. (You can see the same asymmetry today: "Learn more about STAR Voting" renders on a STAR ballot and has no equivalent on a draggable ranked one.)

2. The link is an explicit <Link target='_blank' rel='noreferrer'>, not a markdown link in the i18n value. util.tsx renders markdown links with target={v['newWindow'] ? '_blank' : '_self'} — same-tab by default — and ElectionStateWarning can't pass newWindow because it calls t() with no values object. VotePage holds the races in React state with no draft persistence, so a same-tab navigation would discard every score the voter had entered. That seemed worth avoiding by construction rather than by convention.

3. The gate is public_results AND state in (open, draft), not the flag alone. public_results does two jobs: a live tally while voting is open, and published final results once closed. Only the first carries the inference risk this notice describes. The codebase already makes that split — ElectionSettings swaps the switch label, ViewElectionResults swaps PRELIMINARY/OFFICIAL — so the notice follows suit.

Translations

The banner keys sit beside draft_warning and archived_warning in PRIORITY 99, matching their siblings, so no obligation there. The dialog key is ballot.dialog_preliminary_results, which follows its ballot.dialog_* siblings into PRIORITY 0 — so that one does create real translator work. Flagging the asymmetry rather than hiding it.

Everything renders correctly for polls via the existing interpolation — "…results update as responses are submitted", "Results for this poll are public…".

Verified

tsc --noEmit and npm run build -w frontend both pass clean.

Also run against a local stack (seeded 4xtfb4, whose head row is conveniently state=open, public_results=true, voter_access=open), exercising all four branches of the gate:

Fixture Base notice Closed-list paragraph
flag on, open, open access shown correctly absent
flag on, open, voter_access=closed shown shown
flag off, open correctly absent absent
flag on, state=closed correctly absent absent

And the data-loss case from decision 2 above, which is the one I most wanted to confirm rather than assert: entered scores on the ballot (candidate1 = 5, candidate2 = 3), clicked the disclosure link, and afterwards the tab was still on /4xtfb4/vote with both scores intact and the notice still rendered. The anchor comes out as target="_blank" rel="noreferrer".

Both renderer cases are now exercised too:

Draggable ranked ballot (race switched to IRV with draggable_ballot: true, confirmed to be the draggable renderer and not the fallback grid) — the notice renders once. Worth noting what isn't on that page: "Learn more about STAR Voting" is absent, because it lives in GenericBallotView, which this renderer bypasses. That's the asymmetry decision 1 is about, visible side by side.

Multi-race (second race added) — the notice renders exactly once on race 1, and once again on race 2 after paging forward. Never duplicated on a screen, never missing before submit.

Submit dialog — the sentence renders above the Receipt Email field, on a two-race ballot, with the banner also present.

Not exercised: the non-English locales.

Two things I'd like your call on

The closed-list paragraph. It deliberately claims only what the code supports: administrators see who voted and when, and that timing alongside a live tally can narrow down how. It does not say admins can read a ballot — ballot_id is scrubbed from every roll response and the voter→ballot join is only reachable from the edit-vote path. I think that's the honest version, but it's a privacy claim about your product printed on a ballot, and someone who owns messaging should sign the sentence rather than an engineer.

Whether the submit-confirm dialog should carry a line too. Now included — pushed in a follow-up commit, and easy to drop if you disagree. The argument that changed my mind is visible rather than theoretical: with the dialog open, the banner is greyed out behind it and no longer readable, so at the one moment the voter must actively confirm, nothing on screen mentions results being public. One sentence above the receipt-email field.

The gate is extracted as useShowsLiveTally() and shared by the banner and the dialog rather than duplicated — a voter shown one has to be shown the other, and two copies of that condition is how it drifts.

Still separate and not in this PR: qualifying the article's own claim that BetterVoting hides the voter↔ballot link (true of the API, needs a caveat for the deployment).

The existing Playwright specs should pass unchanged

I initially expected selector churn in election-with-rolls.spec.ts and election-without-rolls.spec.ts, since both create elections with public_results: true and will now render the notice. Checking the specs rather than assuming: they shouldn't break. Every selector is role- plus accessible-name based (getByRole('radio', {name: 'Score Candidate 1 0'}), getByRole('button', {name: 'Submit'})), and the notice adds no button, radio or link whose name collides. Of the two substring text matchers that run on the ballot page — getByText('Do not skip rankings. Rank') and getByText('Do not rank multiple') — neither string appears in the new copy. The one loose matcher, getByText('open'), runs on Admin Home, where the notice doesn't render.

Worth someone else's eyes, since it's a static read rather than a test run — CI doesn't execute the suite on PRs.

Part of Equal-Vote#1350. Adds the on-ballot notice, so a voter learns that this
election publishes a live tally BEFORE they cast, rather than discovering it
on the thank-you page afterwards.

Three decisions worth reviewing, each forced by something in the code rather
than by taste:

1. Placement — VotePage, above BallotContext.Provider. That is the only spot
   that (a) renders once per ballot rather than once per race, and (b) covers
   DraggableIRVBallotView, which bypasses GenericBallotView entirely and
   re-implements its own instructions block with no footer. Putting the notice
   in the ballot view instead would silently skip ranked elections while the
   STAR ballot looked correct.

2. The article link is an explicit <Link target='_blank' rel='noreferrer'>,
   not a markdown link inside the i18n value. The shared renderer defaults
   anchors to _self and ElectionStateWarning cannot pass newWindow (it calls
   t() with no values object), and VotePage holds the races in React state
   with no draft persistence — so a same-tab navigation would discard every
   score the voter had entered.

3. The gate is public_results AND state in (open, draft), not the flag alone.
   public_results does two jobs: a live tally while voting is open, and
   published final results once closed. Only the first carries the inference
   risk this notice describes. The codebase already splits these at
   ElectionSettings (the switch label) and ViewElectionResults (the results
   heading).

The article itself needed no writing — docs/help/preliminary_results.md has
been in the repo, covering exactly what the issue asks for, linked from
nowhere in packages/.

i18n sits beside draft_warning and archived_warning in PRIORITY 99, matching
its siblings, so no new translator obligation. Renders correctly for polls
too ("...as responses are submitted") via the existing interpolation.

The closed-list paragraph deliberately claims only what the code supports:
admins see WHO voted and WHEN, and timing plus a live tally can narrow down
HOW. It does not claim admins can read a ballot — ballot_id is scrubbed from
every roll response and the voter->ballot join is only reachable from the
edit-vote path. That wording still wants sign-off from someone who owns
messaging; see the discussion on the issue.
@netlify

netlify Bot commented Jul 30, 2026

Copy link
Copy Markdown

Deploy Preview for bettervoting ready!

Name Link
🔨 Latest commit 4078100
🔍 Latest deploy log https://app.netlify.com/projects/bettervoting/deploys/6a6b3b022fb0390008e2e0e8
😎 Deploy Preview https://deploy-preview-1466--bettervoting.netlify.app
📱 Preview on mobile
Toggle QR Code...

QR Code

Use your smartphone camera to open QR code link.
🤖 Make changes Run an agent on this branch

To edit notification comments on pull requests, go to your Netlify project configuration.

Answers the open question on Equal-Vote#1350 about whether the banner alone is enough.
It isn't: the banner sits above the ballot, is scrollable-past, and is greyed
out behind the submit dialog — so at the one moment the voter has to actively
confirm, nothing on screen mentions it. One sentence, above the receipt-email
field.

The gate is extracted as useShowsLiveTally() and shared by the banner and the
dialog, rather than duplicated. A voter shown one must be shown the other, and
two copies of the condition is exactly how that drifts.

Verified against a local stack: the sentence appears in the dialog above the
Receipt Email field, on a two-race ballot, with the banner also present.

Note this key lands in en.yaml's PRIORITY 0 band, unlike the banner keys —
ballot.dialog_* siblings live there, so it follows them. That does create real
translator work, where the banner keys (PRIORITY 99, beside draft_warning) did
not.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant