You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
A documentation and localisation program for the help site and the app's own text, submitted as a batch of pull requests. This issue is the index: what each PR does, a suggested merge order, the structure they assume, and the decisions taken along the way.
The short version: the help site had 7 pages, all written for the person running an election. There was nothing addressed to voters — who are the larger audience, and who the app links into these docs from en.yaml. This adds roughly 25 pages, gives both audiences a front door, documents features that had no written explanation anywhere, and takes the app from 4 languages to 7.
Nothing here needs to merge as a set. Every page PR links only to pages already on main, precisely so merge order never matters.
Suggested merge order
1. Two one-line config changes — merge first, they make everything cheaper
Without it, redirect_from: is silently discarded and renaming any published page is a permanent 404. Eight doc URLs are hardcoded in the app. This is the safety net for every future change.
Completes Polish. 149 strings were still sitting in English, including the star-column headers and every method's ballot instructions — the most-read screen in the product. Now 28, all legitimately English (brand and method names). Builds on #1562; merge that first
Polish was the worst affected: a Polish voter has been reading their ballot instructions in English. #1565 fixes that.
Every locale file was validated against English programmatically: key count, missing keys, extra keys, and every {{interpolation}}, $t() and !tip() compared token-by-token. All seven now sit at 255/255 with zero token mismatches.
#1562 also repaired pre-existing damage found on the way: !tip(random_tie_order) and {{n}} had gone missing from strings in all three files, and Polish ordinals were still English-shaped (1st/2nd/3rd).
These are AI-produced translations. The contribution guide requires proof-reading by at least one native speaker, and that hasn't happened. Each PR names the specific choices a native reviewer should rule on. Please treat them as drafts that pass the mechanical checks, not as finished work.
"Invalid Voter ID" shown before the voter types anything — frontend compared against "Voter ID Required", backend sends "Voter ID Required for closed elections"; the error branch was true on first paint in every ID-list election
CSV export column collision — overvote_rank/has_duplicate_rank weren't race-prefixed, so with two ranked races every duplicate column showed the last race's values; the demo harness showed a real Mayor overvote erased and a Council duplicate falsely attributed. Keys now always race-scoped; single-race exports stay byte-identical
The lying "Email invite status" roll column — only the legacy sendInvites endpoints wrote email_data.inviteResponse, the field the roll table reads, so invitations sent via the current email-blast tool left every row on "Not Sent". The blast path now records it through the same helper the legacy path uses, gated to invitation-template blasts only (a free-form update marking a voter "Sent" would be the reverse bug). Backend suite green: 25 suites / 177 tests, 3 new
The draggable ranked ballot ignoring the ranking cap — a voter could rank past max_rankings and build a ballot the server then rejects. The drop is now refused at the limit with a self-clearing warning and an "N of M rankings used" counter; the limit logic is extracted into one getMaxRankings() shared with the classic ranked view, matching the server rule. Bonus fix found while in there: the draggable early-return sat above the classic view's hooks — a React hooks-order violation that white-screens when paging from a draggable IRV race to another ranked race
Two systemic findings from the translation work are now issues of their own: #1574 (interpolated nouns can't be grammatical in gendered languages — hit independently by de, fr, it and pl) and #1575 (a ~20-line CI check for translation values still byte-identical to English — the check that caught Polish shipping its ballot instructions in English). The unclaimable "Publish Now" quick poll is a product question, asked in this thread below.
The structure these assume
BetterVoting Documentation
├── For Voters ← landing page
└── Running an Election ← landing page
├── Setting Up Your Election
├── Counting and Results
└── Reference
Role first, then lifecycle. Every election has one admin and many voters, and the app already sends voters here from the results page. Those readers currently land in an admin manual.
Comparable services organise by object and have no voter home at all; election authorities split by role, because their audience includes voters. So does BetterVoting's.
Note on sequencing. Every content PR currently carries parent: BetterVoting Documentation so it can merge independently. Once #1543 lands, a single follow-up re-parents all of them into the tree above. That map is done and verified locally against a full build — 31 pages, none lost from the nav. If a content PR merges before#1543, its page still appears, just at the top level. Nothing breaks either way.
How pages were written
Every page was written from the source code, not from assumption, and each PR body cites the files behind its claims. Where the code is definite the page is definite — no "usually" or "typically" for behaviour a controller makes certain.
Each page was built locally with the real github-pages toolchain and checked for dangling links before submission.
One mechanical note:jekyll-relative-links only rewrites links whose target exists. A link to a page not in the build ships the raw href="foo.md". That's why cross-references between these pages are deliberately thin — a single follow-up PR wiring them up is the natural last step, once every target exists. A consistency review across the whole set has already produced that list: roughly 52 link insertions across 24 pages.
A consistency review has been run across all of it
Around 25 pages written in parallel by eleven authors who couldn't see each other's work. Reading them as a set found 9 contradictions, each checked against origin/main rather than guessed at. All are now fixed in their respective PRs. The most serious:
Two pages said the ballot ID appears on the confirmation screen. It's deliberately scrubbed there, with the comment "prevent voters from creating receipts (vote buying/coercion)". Documenting it as visible would have quietly undermined an intentional anti-coercion measure.
The glossary contradicted itself on who can archive (owner-only).
A voter page hedged ballot secrecy as configuration-dependent. The scrub is unconditional.
Still outstanding from that review, and better done after merging: nine duplication trims, terminology alignment ("organiser" vs "admin" splits three ways across voter pages), and the wiring PR.
Decisions taken, and the alternative rejected
Offered so reviewers can disagree with the reasoning rather than guess at it.
Named the lean. The method chooser states outright that BetterVoting is built by Equal Vote, which advocates STAR. Rejected: silent neutrality. Readers detect a lean anyway; naming it keeps the page usable by someone who has already chosen another method.
RCV introduced once as instant-runoff, with Ranked Robin named as the other way to count the same ballot.
Editable ballots framed as a genuine trade-off, with Estonia's re-voting (a coercion countermeasure, verified against valimised.ee) as the counterweight. Rejected: "bad practice, but we shipped it."
Method comparisons state limits as well as strengths — including STAR's.
The secrecy and verification pages state what they do not prove. A verification page that overclaims is worse than none.
The tie-break shuffle is explained rather than deferred. The old page said "randomly" and pointed at the source tree. The truth is better: it's deterministic, the full order is published, and it's reproducible.
Open questions for maintainers
Genuine questions, not rhetorical ones.
Deleting an election — the controller has a permission check but no state gate, so an open election with cast ballots can be deleted. Intended? Not documented either way pending an answer.
Roll states — flag and invalidate are permissioned admin actions. Should they affect anything beyond bookkeeping? The Managing Your Voters page makes no enforcement claim.
break_ties_randomly appears in exactly one place — a display toggle for the results title — and doesn't control whether ties are broken randomly. Left undocumented rather than guessed at.
Skipped-rank exhaustion isn't exposed in the settings UI and defaults to effectively never. Deliberate?
Nav label — "Running an Election" versus "For Administrators". Task phrasing chosen over the role noun; trivially changeable.
Interpolated nouns break grammar in gendered languages. German lost a string entirely, Polish can't satisfy the accusative, French needed six rephrasings, Italian used invariant constructions throughout. results.details is the clearest case: no single string works for both the election and poll vocabularies because the two race words have different genders. This is a limit of the i18n design, not translator error, and it will hit every gendered language. The fix would be separate keys per vocabulary. Happy to open a separate issue if useful.
Register is inconsistent across languages. Italian uses informal tu; German and Spanish use formal. Nobody has made that call deliberately.
Bugs noticed while writing
Explaining a feature turns out to be an effective way to find it misbehaving. None are documented as features; several are one-line fixes.
A "Publish Now" quick poll can never be managed — created already finalized with no owner, so it can't be edited, closed, or claimed even by its creator. The wizard stores a claim-key cookie that can never work.
The Adding Documentation page hands contributors a broken example (docs: fix the parent example that makes a new page vanish #1557). It warns "make sure this matches exactly otherwise your file won't be accessible" and then shows a parent: value missing a space that the real title contains. Follow the docs exactly and your page silently vanishes from the nav.
A page listing known documentation gaps is worth publishing in its own right — it stops readers hunting for something that doesn't exist, and turns each gap into a claimable task for the Writers contribution path. Happy to add one, with the convention that the PR filling a gap removes its line from the list.
Feedback welcome, including "this is wrong"
These were written without maintainer input, deliberately — a blank page attracts no feedback, a written one attracts corrections. Every page is a starting point. Rewrite, reject, or reshape freely; the structure in #1543 is front-matter only, so re-parenting anything costs one line.
What this is
A documentation and localisation program for the help site and the app's own text, submitted as a batch of pull requests. This issue is the index: what each PR does, a suggested merge order, the structure they assume, and the decisions taken along the way.
The short version: the help site had 7 pages, all written for the person running an election. There was nothing addressed to voters — who are the larger audience, and who the app links into these docs from
en.yaml. This adds roughly 25 pages, gives both audiences a front door, documents features that had no written explanation anywhere, and takes the app from 4 languages to 7.Nothing here needs to merge as a set. Every page PR links only to pages already on
main, precisely so merge order never matters.Suggested merge order
1. Two one-line config changes — merge first, they make everything cheaper
jekyll-redirect-fromredirect_from:is silently discarded and renaming any published page is a permanent 404. Eight doc URLs are hardcoded in the app. This is the safety net for every future change.Neither installs anything — both ship inside the
github-pagesbundle already in use.2. Structure
3. Small fixes
parent:value that doesn't work — see below4. Pages for voters
5. Pages for administrators
6. Results and counting
7. Ties — three pages, three audiences
#random-tie-breakersanchor the app links to8. Reference
CONTRIBUTING.md9. Localisation — 4 languages to 7
election_historysection none of them hadde) — all 255 Priority 0 stringsfr) — all 255it) — all 255Polish was the worst affected: a Polish voter has been reading their ballot instructions in English. #1565 fixes that.
Every locale file was validated against English programmatically: key count, missing keys, extra keys, and every
{{interpolation}},$t()and!tip()compared token-by-token. All seven now sit at 255/255 with zero token mismatches.#1562 also repaired pre-existing damage found on the way:
!tip(random_tie_order)and{{n}}had gone missing from strings in all three files, and Polish ordinals were still English-shaped (1st/2nd/3rd).These are AI-produced translations. The contribution guide requires proof-reading by at least one native speaker, and that hasn't happened. Each PR names the specific choices a native reviewer should rule on. Please treat them as drafts that pass the mechanical checks, not as finished work.
10. App text
11. Fixes for bugs the documentation work found
The bug list at the bottom of this issue is no longer just a list — the top items now have PRs:
"Voter ID Required", backend sends"Voter ID Required for closed elections"; the error branch was true on first paint in every ID-list electionovervote_rank/has_duplicate_rankweren't race-prefixed, so with two ranked races every duplicate column showed the last race's values; the demo harness showed a real Mayor overvote erased and a Council duplicate falsely attributed. Keys now always race-scoped; single-race exports stay byte-identicalsendInvitesendpoints wroteemail_data.inviteResponse, the field the roll table reads, so invitations sent via the current email-blast tool left every row on "Not Sent". The blast path now records it through the same helper the legacy path uses, gated to invitation-template blasts only (a free-form update marking a voter "Sent" would be the reverse bug). Backend suite green: 25 suites / 177 tests, 3 newmax_rankingsand build a ballot the server then rejects. The drop is now refused at the limit with a self-clearing warning and an "N of M rankings used" counter; the limit logic is extracted into onegetMaxRankings()shared with the classic ranked view, matching the server rule. Bonus fix found while in there: the draggable early-return sat above the classic view's hooks — a React hooks-order violation that white-screens when paging from a draggable IRV race to another ranked raceTwo systemic findings from the translation work are now issues of their own: #1574 (interpolated nouns can't be grammatical in gendered languages — hit independently by de, fr, it and pl) and #1575 (a ~20-line CI check for translation values still byte-identical to English — the check that caught Polish shipping its ballot instructions in English). The unclaimable "Publish Now" quick poll is a product question, asked in this thread below.
The structure these assume
Role first, then lifecycle. Every election has one admin and many voters, and the app already sends voters here from the results page. Those readers currently land in an admin manual.
Comparable services organise by object and have no voter home at all; election authorities split by role, because their audience includes voters. So does BetterVoting's.
Note on sequencing. Every content PR currently carries
parent: BetterVoting Documentationso it can merge independently. Once #1543 lands, a single follow-up re-parents all of them into the tree above. That map is done and verified locally against a full build — 31 pages, none lost from the nav. If a content PR merges before #1543, its page still appears, just at the top level. Nothing breaks either way.How pages were written
Every page was written from the source code, not from assumption, and each PR body cites the files behind its claims. Where the code is definite the page is definite — no "usually" or "typically" for behaviour a controller makes certain.
Each page was built locally with the real
github-pagestoolchain and checked for dangling links before submission.One mechanical note:
jekyll-relative-linksonly rewrites links whose target exists. A link to a page not in the build ships the rawhref="foo.md". That's why cross-references between these pages are deliberately thin — a single follow-up PR wiring them up is the natural last step, once every target exists. A consistency review across the whole set has already produced that list: roughly 52 link insertions across 24 pages.A consistency review has been run across all of it
Around 25 pages written in parallel by eleven authors who couldn't see each other's work. Reading them as a set found 9 contradictions, each checked against
origin/mainrather than guessed at. All are now fixed in their respective PRs. The most serious:Still outstanding from that review, and better done after merging: nine duplication trims, terminology alignment ("organiser" vs "admin" splits three ways across voter pages), and the wiring PR.
Decisions taken, and the alternative rejected
Offered so reviewers can disagree with the reasoning rather than guess at it.
Open questions for maintainers
Genuine questions, not rhetorical ones.
break_ties_randomlyappears in exactly one place — a display toggle for the results title — and doesn't control whether ties are broken randomly. Left undocumented rather than guessed at.results.detailsis the clearest case: no single string works for both the election and poll vocabularies because the two race words have different genders. This is a limit of the i18n design, not translator error, and it will hit every gendered language. The fix would be separate keys per vocabulary. Happy to open a separate issue if useful.Bugs noticed while writing
Explaining a feature turns out to be an effective way to find it misbehaving. None are documented as features; several are one-line fixes.
"Voter ID Required"while the backend returns"Voter ID Required for closed elections".parent:value missing a space that the real title contains. Follow the docs exactly and your page silently vanishes from the nav.A small number of further observations are better discussed privately than in a public issue — happy to share them with a maintainer on request.
What's deliberately not covered
A page listing known documentation gaps is worth publishing in its own right — it stops readers hunting for something that doesn't exist, and turns each gap into a claimable task for the Writers contribution path. Happy to add one, with the convention that the PR filling a gap removes its line from the list.
Feedback welcome, including "this is wrong"
These were written without maintainer input, deliberately — a blank page attracts no feedback, a written one attracts corrections. Every page is a starting point. Rewrite, reject, or reshape freely; the structure in #1543 is front-matter only, so re-parenting anything costs one line.