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
new file mode 100644
index 000000000..2f5a5b025
--- /dev/null
+++ b/packages/frontend/src/components/Election/PreliminaryResultsNotice.tsx
@@ -0,0 +1,50 @@
+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';
+
+// 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();
+ const showsLiveTally = useShowsLiveTally();
+
+ 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..d8aad6a26 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, { useShowsLiveTally } from "../PreliminaryResultsNotice"
import WriteInSection from "./WriteInSection"
import { NOTA_ID, makeWriteInCandidateId, isWriteInCandidate } from "@equal-vote/star-vote-shared/utils/makeID";
@@ -239,6 +240,7 @@ const VotePage = () => {
}
const {t} = useSubstitutedTranslation(election.settings.term_type)
+ const showsLiveTally = useShowsLiveTally()
if(pages.length == 0){
@@ -263,6 +265,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. */}
+ {
{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.)
@@ -851,6 +854,18 @@ draft_warning:
All ballots will be counted as test votes and
shall be reset prior to the final {{election}}.
+preliminary_results_notice:
+ title: Results are public while voting is open
+ description: >
+ 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: >