feat(ballot): tell voters when preliminary results are public - #1466
Draft
masiarek wants to merge 2 commits into
Draft
feat(ballot): tell voters when preliminary results are public#1466masiarek wants to merge 2 commits into
masiarek wants to merge 2 commits into
Conversation
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.
✅ Deploy Preview for bettervoting ready!
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.
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.
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
PreliminaryResultsNoticerenders above the ballot wheneverpublic_resultsis on and voting is open (or the election is still a draft). It reusesElectionStateWarning, so it matchesDraftWarningand the archived warning already on that page. It carries a link todocs/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, aboveBallotContext.Provider. That's the only spot that renders once per ballot rather than once per race, and coversDraggableIRVBallotView— which bypassesGenericBallotViewentirely 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.tsxrenders markdown links withtarget={v['newWindow'] ? '_blank' : '_self'}— same-tab by default — andElectionStateWarningcan't passnewWindowbecause it callst()with no values object.VotePageholds 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_resultsANDstate in (open, draft), not the flag alone.public_resultsdoes 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 —ElectionSettingsswaps the switch label,ViewElectionResultsswaps PRELIMINARY/OFFICIAL — so the notice follows suit.Translations
The banner keys sit beside
draft_warningandarchived_warningin PRIORITY 99, matching their siblings, so no obligation there. The dialog key isballot.dialog_preliminary_results, which follows itsballot.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 --noEmitandnpm run build -w frontendboth pass clean.Also run against a local stack (seeded
4xtfb4, whose head row is convenientlystate=open,public_results=true,voter_access=open), exercising all four branches of the gate: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/votewith both scores intact and the notice still rendered. The anchor comes out astarget="_blank" rel="noreferrer".Both renderer cases are now exercised too:
Draggable ranked ballot (race switched to
IRVwithdraggable_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 inGenericBallotView, 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_idis 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.tsandelection-without-rolls.spec.ts, since both create elections withpublic_results: trueand 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')andgetByText('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.