From c2f8f24cc844132961c2b935455964a5647756f7 Mon Sep 17 00:00:00 2001 From: Adam Masiarek Date: Thu, 30 Jul 2026 07:15:11 -0400 Subject: [PATCH 1/2] feat(ballot): tell voters when preliminary results are public MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Part of #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 , 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. --- .../Election/PreliminaryResultsNotice.tsx | 43 +++++++++++++++++++ .../components/Election/Voting/VotePage.tsx | 5 +++ packages/frontend/src/i18n/en.yaml | 12 ++++++ 3 files changed, 60 insertions(+) create mode 100644 packages/frontend/src/components/Election/PreliminaryResultsNotice.tsx diff --git a/packages/frontend/src/components/Election/PreliminaryResultsNotice.tsx b/packages/frontend/src/components/Election/PreliminaryResultsNotice.tsx new file mode 100644 index 000000000..2e534e2f6 --- /dev/null +++ b/packages/frontend/src/components/Election/PreliminaryResultsNotice.tsx @@ -0,0 +1,43 @@ +import { Link, Typography } from "@mui/material"; +import ElectionStateWarning from "./ElectionStateWarning"; +import useElection from "../ElectionContextProvider"; + +// docs/help/preliminary_results.md, as published by the docs site. +const ARTICLE_URL = 'https://docs.bettervoting.com/help/preliminary_results.html'; + +export default function PreliminaryResultsNotice() { + const { t, election } = useElection(); + + // public_results does two jobs. While voting is open it means "live tally + // visible", which is what this notice is about. Once the election closes it + // means "final results published", which carries none of the same inference + // risk — so the notice must not follow the flag alone. + const showsLiveTally = election.settings.public_results === true + && (election.state === 'open' || election.state === 'draft'); + + if (!showsLiveTally) return <>; + + // Read voter_access directly rather than going through + // getVoterAuthenticationMode(), which throws on a non-canonical settings + // shape — that would take the whole ballot down with it. + const isClosedList = election.settings.voter_access === 'closed'; + + return + {isClosedList && + + {t('preliminary_results_notice.closed_list')} + + } + + {/* Explicit target, rather than a markdown link inside the i18n value: + the shared link renderer defaults anchors to _self, and navigating + away from the ballot discards every score the voter has entered. */} + + {t('preliminary_results_notice.link_text')} + + + +} diff --git a/packages/frontend/src/components/Election/Voting/VotePage.tsx b/packages/frontend/src/components/Election/Voting/VotePage.tsx index 4131e98cf..a6d5552ed 100644 --- a/packages/frontend/src/components/Election/Voting/VotePage.tsx +++ b/packages/frontend/src/components/Election/Voting/VotePage.tsx @@ -19,6 +19,7 @@ import { useSubstitutedTranslation } from "~/components/util"; import DraftWarning from "../DraftWarning"; import SupportBlurb from "../SupportBlurb"; import ElectionStateWarning from "../ElectionStateWarning" +import PreliminaryResultsNotice from "../PreliminaryResultsNotice" import WriteInSection from "./WriteInSection" import { NOTA_ID, makeWriteInCandidateId, isWriteInCandidate } from "@equal-vote/star-vote-shared/utils/makeID"; @@ -263,6 +264,10 @@ const VotePage = () => { state="archived" title="archived_warning.title" description="archived_warning.description"/> + {/* Sits above BallotPageSelector so it renders once per ballot rather than + once per race, and so it also covers DraggableIRVBallotView, which + bypasses GenericBallotView entirely. */} + + Anyone with a link to this {{election}} can see the results before voting closes, + and those results update as {{votes}} are submitted. In a small {{election}} — or + if only a few people vote in a short window — it can be possible to work out how + someone voted. + closed_list: > + This {{election}} uses a voter list. Administrators can see which voters have voted + and when, which alongside live results can narrow that down further. + link_text: What preliminary results reveal + temporary_access_warning: title: Unlock more with a free bettervoting account! description: > From 407810098d048eaed3373d3051c6a8c2b731e95a Mon Sep 17 00:00:00 2001 From: Adam Masiarek Date: Thu, 30 Jul 2026 07:52:30 -0400 Subject: [PATCH 2/2] feat(ballot): carry the preliminary-results line into the submit dialog MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Answers the open question on #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. --- docker-compose.override.local.yml | 7 +++++++ package-lock.json | 10 ++++----- .../Election/PreliminaryResultsNotice.tsx | 21 ++++++++++++------- .../components/Election/Voting/VotePage.tsx | 11 +++++++++- packages/frontend/src/i18n/en.yaml | 3 +++ 5 files changed, 39 insertions(+), 13 deletions(-) create mode 100644 docker-compose.override.local.yml diff --git a/docker-compose.override.local.yml b/docker-compose.override.local.yml new file mode 100644 index 000000000..cc288bd90 --- /dev/null +++ b/docker-compose.override.local.yml @@ -0,0 +1,7 @@ +services: + keycloak: + ports: + - "8081:8080" + my-db: + ports: + - "5433:5432" diff --git a/package-lock.json b/package-lock.json index 74f3f4889..57704ed6e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -24,11 +24,11 @@ "rimraf": "^5.0.5" }, "optionalDependencies": { - "@rspack/binding-darwin-arm64": "*", - "@rspack/binding-darwin-x64": "*", - "@rspack/binding-linux-arm64-gnu": "*", - "@rspack/binding-linux-x64-gnu": "*", - "@rspack/binding-linux-x64-musl": "*" + "@rspack/binding-darwin-arm64": "latest", + "@rspack/binding-darwin-x64": "latest", + "@rspack/binding-linux-arm64-gnu": "latest", + "@rspack/binding-linux-x64-gnu": "latest", + "@rspack/binding-linux-x64-musl": "latest" } }, "node_modules/@ai-hero/sandcastle": { diff --git a/packages/frontend/src/components/Election/PreliminaryResultsNotice.tsx b/packages/frontend/src/components/Election/PreliminaryResultsNotice.tsx index 2e534e2f6..2f5a5b025 100644 --- a/packages/frontend/src/components/Election/PreliminaryResultsNotice.tsx +++ b/packages/frontend/src/components/Election/PreliminaryResultsNotice.tsx @@ -5,15 +5,22 @@ import useElection from "../ElectionContextProvider"; // docs/help/preliminary_results.md, as published by the docs site. const ARTICLE_URL = 'https://docs.bettervoting.com/help/preliminary_results.html'; +// Shared by this notice and the line in the submit-confirm dialog, so the two +// cannot drift apart — a voter who is shown one has to be shown the other. +// +// public_results does two jobs. While voting is open it means "live tally +// visible", which is what the notice is about. Once the election closes it means +// "final results published", which carries none of the same inference risk — so +// neither surface can follow the flag alone. +export function useShowsLiveTally() { + const { election } = useElection(); + return election.settings.public_results === true + && (election.state === 'open' || election.state === 'draft'); +} + export default function PreliminaryResultsNotice() { const { t, election } = useElection(); - - // public_results does two jobs. While voting is open it means "live tally - // visible", which is what this notice is about. Once the election closes it - // means "final results published", which carries none of the same inference - // risk — so the notice must not follow the flag alone. - const showsLiveTally = election.settings.public_results === true - && (election.state === 'open' || election.state === 'draft'); + const showsLiveTally = useShowsLiveTally(); if (!showsLiveTally) return <>; diff --git a/packages/frontend/src/components/Election/Voting/VotePage.tsx b/packages/frontend/src/components/Election/Voting/VotePage.tsx index a6d5552ed..d8aad6a26 100644 --- a/packages/frontend/src/components/Election/Voting/VotePage.tsx +++ b/packages/frontend/src/components/Election/Voting/VotePage.tsx @@ -19,7 +19,7 @@ import { useSubstitutedTranslation } from "~/components/util"; import DraftWarning from "../DraftWarning"; import SupportBlurb from "../SupportBlurb"; import ElectionStateWarning from "../ElectionStateWarning" -import PreliminaryResultsNotice from "../PreliminaryResultsNotice" +import PreliminaryResultsNotice, { useShowsLiveTally } from "../PreliminaryResultsNotice" import WriteInSection from "./WriteInSection" import { NOTA_ID, makeWriteInCandidateId, isWriteInCandidate } from "@equal-vote/star-vote-shared/utils/makeID"; @@ -240,6 +240,7 @@ const VotePage = () => { } const {t} = useSubstitutedTranslation(election.settings.term_type) + const showsLiveTally = useShowsLiveTally() if(pages.length == 0){ @@ -339,6 +340,14 @@ const VotePage = () => { {t('ballot.dialog_submit_title')} + {/* The banner above the ballot is scrollable-past, and it is greyed out + behind this dialog. This is the only surface the voter must actively + confirm, so it carries one sentence rather than nothing. */} + {showsLiveTally && + + {t('ballot.dialog_preliminary_results')} + + } {!receiptEmail && + Results for this {{election}} are public while voting is open. + warnings: skipped_rank: Do not skip rankings. Rank candidates in order to clearly show preferences. Candidates left blank are ranked last. duplicate_rank: Do not rank multiple candidates equally. (Ranking candidates equally can void your ballot.)