From 9aa3f3c2a57f2fdf55e97a51f1ebe7f3fe85c5f1 Mon Sep 17 00:00:00 2001 From: Mikaal Naik Date: Fri, 11 Sep 2026 15:07:31 -0400 Subject: [PATCH 1/4] Close the Toronto voter survey, temporarily MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit One flag on the election registry — `surveyClosed` — takes down the form, the results it shows a reader afterwards, and every invitation to it. Nothing else moves: the questions, the responses already collected and all the code that reads them stay where they are, so turning it back on is deleting the flag. It is a flag rather than five edits because the tracker invites a reader to the survey from five places — the landing page's explore card, its closing call to action, the mayoral page, the issues page and every ward page — and a survey withdrawn from four of them is a survey a reader still finds from the fifth. `surveyHref` returns nothing while it is closed, so a call site has no path to link and the invitation has to disappear rather than be left pointing at a page that will not answer. The whole "Now answer them yourself" band goes from the mayoral and issues pages, not just its button: that heading over a paragraph telling the reader they can, with nothing to answer, is worse than silence. It takes the issues page's countdown with it, which is that page's only clock — not worth keeping a band alive for, since the days to polls are on the landing page, the mayoral page and every ward page. The landing page's explore blurb ends "then answer them yourself and find out who lines up with you", which is a promise about a survey. It now makes that promise only where the grid has a survey card to back it, read off the cards themselves so the two cannot disagree. Closed at the route and at the endpoint, not only at the links. A reader holding the URL still reached the form, and a tab opened before it came down still held a filled-in one and a working POST — a submission accepted there is a response recorded against a survey we have stopped running. Both answer 404 now, as the site's position is that there is no survey here. Co-Authored-By: Claude Opus 5 (1M context) --- src/app/api/elections/survey/route.ts | 11 ++++ src/app/toronto/vote/2026/issues/page.tsx | 60 +++++++++++++------- src/app/toronto/vote/2026/mayor/page.tsx | 34 +++++++---- src/app/toronto/vote/2026/page.tsx | 6 +- src/app/toronto/vote/2026/survey/page.tsx | 9 ++- src/components/elections/ElectionLanding.tsx | 15 ++++- src/components/elections/WardDetail.tsx | 8 ++- src/lib/elections/registry.ts | 31 ++++++++++ 8 files changed, 135 insertions(+), 39 deletions(-) diff --git a/src/app/api/elections/survey/route.ts b/src/app/api/elections/survey/route.ts index 3bc9653e..fb5336f3 100644 --- a/src/app/api/elections/survey/route.ts +++ b/src/app/api/elections/survey/route.ts @@ -66,6 +66,17 @@ export async function POST(req: NextRequest) { : DEFAULT_ELECTION_SLUG; const config = getElection(electionSlug); + /* A closed survey does not take answers, whoever is asking. + The page 404s and every link to it is gone, but neither of those closes + this: a tab opened before the survey came down still holds a filled-in + form and a working endpoint, and a submission accepted here is a + response recorded against a survey we have stopped running. Same status + as the page, because as far as the site is concerned there is no survey + at this election to post to. */ + if (config.surveyClosed) { + return NextResponse.json({ error: "Survey not found" }, { status: 404 }); + } + if (typeof survey_slug !== "string" || !SLUG_PATTERN.test(survey_slug)) { return NextResponse.json( { error: "A survey_slug is required" }, diff --git a/src/app/toronto/vote/2026/issues/page.tsx b/src/app/toronto/vote/2026/issues/page.tsx index 73b57964..12242236 100644 --- a/src/app/toronto/vote/2026/issues/page.tsx +++ b/src/app/toronto/vote/2026/issues/page.tsx @@ -8,6 +8,7 @@ import { } from "@/components/elections/QuestionnaireCards"; import { QuestionnaireRail } from "@/components/elections/QuestionnaireRail"; import { SurveyCta } from "@/components/elections/SurveyCta"; +import { surveyHref } from "@/lib/elections/registry"; import CountdownDays from "@/components/elections/CountdownDays"; import { fieldSentiment } from "@/lib/elections/field-sentiment"; import { @@ -73,6 +74,11 @@ export default async function IssuesPage() { ballot and drops anyone who returned the form without answering a policy question. The cards themselves come from the same pivot the ward and mayoral pages use, over the whole city's entries rather than one race's. */ + /* Nothing while the voter survey is closed — see `surveyClosed` in the + registry. Named for the invitation because `survey` here is already the + candidate questionnaire. */ + const surveyInvite = surveyHref(ELECTION); + const field = survey ? fieldSentiment(survey, responses) : null; const respondents = field?.respondents ?? []; const mayoral = respondents.filter((r) => r.race === "mayor").length; @@ -190,29 +196,39 @@ export default async function IssuesPage() { )} {/* ── Your turn ──────────────────────────────────────── */} -
-
-

- Now answer them yourself -

-

- These are the same questions we asked the candidates. Answer them - and see which of the {respondents.length} line up with you — and - where you sit against the field you just read. -

-
- - - Days until polls open - + {/* The whole band goes while the survey is closed, not just its + button: "Now answer them yourself" over a paragraph promising the + reader they can, with nothing to answer, is worse than silence. + + The countdown goes with it, which is the one thing here worth + regretting — it is this page's only clock. It is not worth keeping + a band alive for: the days to polls are on the landing page, the + mayoral page and every ward page. */} + {surveyInvite && ( +
+
+

+ Now answer them yourself +

+

+ These are the same questions we asked the candidates. Answer + them and see which of the {respondents.length} line up with you + — and where you sit against the field you just read. +

+
+ + + Days until polls open + +
-
- -
+ + + )} {/* ── Method ─────────────────────────────────────────── */}
diff --git a/src/app/toronto/vote/2026/mayor/page.tsx b/src/app/toronto/vote/2026/mayor/page.tsx index 29e6f850..3271ce8b 100644 --- a/src/app/toronto/vote/2026/mayor/page.tsx +++ b/src/app/toronto/vote/2026/mayor/page.tsx @@ -9,6 +9,7 @@ import { } from "@/components/elections/QuestionnaireCards"; import { QuestionnaireRail } from "@/components/elections/QuestionnaireRail"; import { SurveyCta } from "@/components/elections/SurveyCta"; +import { surveyHref } from "@/lib/elections/registry"; import CountdownDays from "@/components/elections/CountdownDays"; import { byCandidateKey, @@ -80,6 +81,10 @@ export default async function MayorPage() { and the roster narrows who gets named, exactly as the ward pages do. */ const ballot = view.mayoral.filter((candidate) => !candidate.withdrawn); const registered = ballot.length; + /* Nothing while the voter survey is closed — see `surveyClosed` in the + registry. Named for the invitation rather than the survey, because + `survey` here is already the candidate questionnaire. */ + const surveyInvite = surveyHref(ELECTION); const ballotKeys = new Set(ballot.map((candidate) => candidate.key)); const answers = survey @@ -196,18 +201,23 @@ export default async function MayorPage() { )} {/* ── Your turn ──────────────────────────────────────── */} -
-
-

- Now answer them yourself -

-

- These are the same questions we put to the field. Answer them and - see which candidates line up with you. -

-
- -
+ {/* The whole band goes while the survey is closed, not just its + button: "Now answer them yourself" over a paragraph promising the + reader they can, with nothing to answer, is worse than silence. */} + {surveyInvite && ( +
+
+

+ Now answer them yourself +

+

+ These are the same questions we put to the field. Answer them + and see which candidates line up with you. +

+
+ +
+ )} {/* ── Method ─────────────────────────────────────────── */}
diff --git a/src/app/toronto/vote/2026/page.tsx b/src/app/toronto/vote/2026/page.tsx index 3270dc1d..1ecde692 100644 --- a/src/app/toronto/vote/2026/page.tsx +++ b/src/app/toronto/vote/2026/page.tsx @@ -3,6 +3,7 @@ import { ElectionLanding } from "@/components/elections/ElectionLanding"; import { WardMap, WardMapDefs } from "@/components/elections/WardMap"; import { WARD_GEO } from "./wardGeo"; import { ELECTION, getToronto2026 } from "./data"; +import { surveyHref } from "@/lib/elections/registry"; import { ADVANCE_VOTING_PATH, ELECTION_DAY, @@ -36,7 +37,10 @@ export default async function Toronto2026ElectionPage() { // Toronto publishes its poll hours, so the band's headline counter is // the live timer from the /toronto hero rather than a whole-day count. electionDay={ELECTION_DAY} - surveyPath={`${ELECTION.basePath}/survey`} + /* Undefined while the survey is closed, which drops both the explore + grid's survey card and the closing call to action — ElectionLanding + already falls back to the pledge for regions that run no survey. */ + surveyPath={surveyHref(ELECTION)} wardMapDefs={} renderWardMap={(ward) => ( = Object.fromEntries( // than an honest miss, and keeping a hard-coded copy here to fall back on is // the drift this move was meant to end. async function loadSurvey(): Promise { + /* Closed means closed at the route, not only at the links into it. Dropping + the invitations alone would leave the form standing for anyone holding the + URL — a bookmark, a share, a search result — and still taking answers and + still showing a reader their alignment, which is the half of this we were + asked to stop. See `surveyClosed` in the registry. */ + if (getElection(DEFAULT_ELECTION_SLUG).surveyClosed) return null; + try { return await fetchSurvey(DEFAULT_ELECTION_SLUG, CITY_PRIORITIES_SLUG); } catch { diff --git a/src/components/elections/ElectionLanding.tsx b/src/components/elections/ElectionLanding.tsx index 46f9956a..de2d8f0c 100644 --- a/src/components/elections/ElectionLanding.tsx +++ b/src/components/elections/ElectionLanding.tsx @@ -666,6 +666,11 @@ function ExploreSection({ * lands here whenever the mayoral cards do */ anchorCandidates?: boolean; }) { + /* Read off the cards rather than taken as a prop: the survey card IS the + invite, so the blurb and the grid cannot disagree about whether there is + one. */ + const invitesSurvey = items.some((item) => item.tone === "invite"); + return (
{anchorCandidates && ( @@ -679,10 +684,16 @@ function ExploreSection({

Explore the election

+ {/* The second half of this is a promise about the survey, so it is + only made where there is a survey to make it about. Read under a + grid with no survey card in it, "then answer them yourself" sends + a reader hunting the page for something that is not on it. */}

We put the same questions to every candidate on the ballot. See how - they answered — then answer them yourself and find out who lines up - with you. + they answered + {invitesSurvey + ? " — then answer them yourself and find out who lines up with you." + : ", question by question and ward by ward."}

diff --git a/src/components/elections/WardDetail.tsx b/src/components/elections/WardDetail.tsx index e22d1cd9..52ce9d63 100644 --- a/src/components/elections/WardDetail.tsx +++ b/src/components/elections/WardDetail.tsx @@ -10,6 +10,7 @@ import { } from "./QuestionnaireCards"; import { QuestionnaireRail } from "./QuestionnaireRail"; import { SurveyCta } from "./SurveyCta"; +import { surveyHref } from "@/lib/elections/registry"; import { IncumbentBadge } from "./ElectionLanding"; import { CandidateNameLink } from "./CandidateNameLink"; import { WardProfileSection, type WardProfile } from "./WardProfile"; @@ -203,7 +204,12 @@ export function WardDetail({ )} - + {/* Absent entirely while the survey is closed. The column beside + it is the heading and the ballot, which stand on their own — + this was always the ask, not part of the ward's own facts. */} + {surveyHref(election) && ( + + )} {councilCandidates.length === 0 ? ( diff --git a/src/lib/elections/registry.ts b/src/lib/elections/registry.ts index 3e57827f..2bded811 100644 --- a/src/lib/elections/registry.ts +++ b/src/lib/elections/registry.ts @@ -83,8 +83,38 @@ export type SupportedElection = { * opt-in rather than assumed from `basePath`. */ candidateProfiles?: boolean; + /** + * The region's voter survey is off for now. + * + * Temporary and deliberately one line: the survey route stops answering and + * every invitation to it disappears, while the questions, the submissions + * already taken and the code that reads them all stay exactly where they + * are. Turning it back on is deleting this flag. + * + * Set it rather than unpicking the call sites. There are four separate + * invitations to the survey across the tracker — the landing page's card, + * its closing call to action, the mayoral page and the issues page — plus + * every ward page, and a survey withdrawn from three of them is a survey a + * reader still finds from the fourth. `surveyHref` below is what they all + * ask, so the rule lives in one place and no call site can forget it. + */ + surveyClosed?: boolean; }; +/** + * Where this region's voter survey lives, or nothing while it is closed. + * + * Returning `undefined` rather than a path plus a flag to check is what makes + * the closed case hard to get wrong: a caller has nothing to link to, so the + * invitation has to disappear rather than being left pointing at a page that + * will not answer. + */ +export function surveyHref( + election: SupportedElection, +): string | undefined { + return election.surveyClosed ? undefined : `${election.basePath}/survey`; +} + const TORONTO_2026: SupportedElection = { slug: "toronto-2026", jurisdictionSlug: "toronto", @@ -105,6 +135,7 @@ const TORONTO_2026: SupportedElection = { wardLookup: true, candidateProfiles: true, themeClass: "theme-election", + surveyClosed: true, }; const BRAMPTON_2026: SupportedElection = { From 98f4fd721f9693fea268e4b417072ed018e84e5a Mon Sep 17 00:00:00 2001 From: Mikaal Naik Date: Fri, 11 Sep 2026 15:18:02 -0400 Subject: [PATCH 2/4] Hide the candidates' questionnaire answers, temporarily MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The sibling of `surveyClosed` and the same bargain: one flag, `questionnaireHidden`, and nothing deleted. Every published answer comes down — the cards on the ward, mayoral and candidate pages, the whole-field view on /issues, and the read proxy that serves them to the browser. The ballot stays up: who is running, in which ward, with their campaign site, which is a fact about the election rather than anything a candidate told us. A candidate's own bio stays too, though it arrives in the same response. It is a self-description rather than a position, and it is the only thing standing between most candidate pages and an empty one. `rosterSurvey` narrows the prose to the bio alone rather than passing it through: the rest of it answers a policy question — the ward-commitment target is one — and letting that through under "About" would publish an answer by another door. WHAT THE PAGES SAY INSTEAD Every one of these pages already had an empty state, written for the weeks before anybody had written back, and reaching for those would have been quicker and would have told the reader something untrue. "Nobody in this ward has answered yet", over a ward whose candidates answered months ago, is a claim about those candidates and it is ours, not theirs — and on a candidate page it sits under a named person's photograph. So the withheld case is told apart from the quiet one everywhere it appears, and says "Coming soon." The counts go with the answers. Two of the mayoral page's four stats and all four of the issues page's count what the field said, and at zero they do not read as less information — they read as the claim that nobody answered anything. /issues keeps its route and shows the notice, rather than 404ing with the survey: the page is the answers, but the URL is linked from the landing page, the ward pages and /mayor, and those links are still worth following to something that explains itself. Closed at the proxy as well as the pages. /api/elections/candidate- responses is a read proxy for exactly the answers the pages have stopped drawing, and left open it would serve the whole comparison as JSON to anyone who asked. Co-Authored-By: Claude Opus 5 (1M context) --- .../elections/candidate-responses/route.ts | 10 ++ .../vote/2026/candidates/[candidate]/page.tsx | 21 +++- src/app/toronto/vote/2026/issues/page.tsx | 111 +++++++++++------- src/app/toronto/vote/2026/mayor/page.tsx | 72 +++++++++--- src/components/elections/WardDetail.tsx | 34 ++++-- src/lib/elections/registry.ts | 37 ++++++ src/lib/elections/survey-answers.ts | 28 +++++ 7 files changed, 245 insertions(+), 68 deletions(-) diff --git a/src/app/api/elections/candidate-responses/route.ts b/src/app/api/elections/candidate-responses/route.ts index 72cb1e4b..9b98cb71 100644 --- a/src/app/api/elections/candidate-responses/route.ts +++ b/src/app/api/elections/candidate-responses/route.ts @@ -6,6 +6,7 @@ import { } from "@/lib/elections/candidate-responses"; import { DEFAULT_ELECTION_SLUG, + getElection, isSupportedElection, } from "@/lib/elections/registry"; import { @@ -53,6 +54,15 @@ export async function GET(req: NextRequest) { return NextResponse.json({ error: "Invalid ward" }, { status: 400 }); } + /* The answers are held back everywhere, and "everywhere" has to include the + door the pages do not come through. This is a read proxy for exactly the + answers the ward and mayoral pages have stopped drawing, and left open it + would serve the whole comparison as JSON to anyone who asked. Its only + caller is the survey page, which is closed too. */ + if (getElection(election).questionnaireHidden) { + return NextResponse.json({ error: "Not found" }, { status: 404 }); + } + const toronto = election === TORONTO_2026_SLUG; const wardToken = ward.padStart(2, "0"); diff --git a/src/app/toronto/vote/2026/candidates/[candidate]/page.tsx b/src/app/toronto/vote/2026/candidates/[candidate]/page.tsx index 0bdb4fb0..58a5092a 100644 --- a/src/app/toronto/vote/2026/candidates/[candidate]/page.tsx +++ b/src/app/toronto/vote/2026/candidates/[candidate]/page.tsx @@ -17,6 +17,7 @@ import { comparedQuestions, } from "@/lib/elections/candidate-answers"; import { rosterSurvey } from "@/lib/elections/survey-answers"; +import { ANSWERS_WITHHELD } from "@/lib/elections/registry"; import { daysUntil } from "@/lib/elections/dates"; import { firstName, possessive } from "@/lib/elections/names"; import type { CandidateProfile, RaceView } from "@/lib/elections/election-data"; @@ -144,6 +145,12 @@ export default async function CandidatePage({ candidates.ts — which is empty for all but a handful. Theirs is a self description and ours is not, so it is attributed rather than merged into the same paragraph. */ + /* The answers come back empty either way, so the page has to ask rather + than infer — see `questionnaireHidden` in the registry. The bio survives + it: a self-description is not one of the positions being held back, and + without it most of these pages have nothing on them. */ + const withheld = ELECTION.questionnaireHidden ?? false; + const prose = written[candidate.key] ?? []; const selfBio = prose.find( (entry) => entry.questionId === BIO_QUESTION_ID, @@ -456,7 +463,9 @@ export default async function CandidatePage({

{surveyAnswers ? `Where ${candidate.name} stands` - : "Yet to answer"} + : withheld + ? "Coming soon" + : "Yet to answer"}

{surveyAnswers ? ( @@ -481,6 +490,16 @@ export default async function CandidatePage({ /> + ) : withheld ? ( + /* Held back, which is not the same as never sent — and this page + of all of them must not confuse the two. "X has not returned our + questionnaire" printed under a named person's photograph is a + claim about that person, and here it would be our doing and + untrue. Nor does it point at /issues, which is showing the same + nothing. */ +

+ {ANSWERS_WITHHELD} +

) : (

{candidate.name} has not returned our questionnaire. We publish diff --git a/src/app/toronto/vote/2026/issues/page.tsx b/src/app/toronto/vote/2026/issues/page.tsx index 12242236..50e81f39 100644 --- a/src/app/toronto/vote/2026/issues/page.tsx +++ b/src/app/toronto/vote/2026/issues/page.tsx @@ -8,7 +8,7 @@ import { } from "@/components/elections/QuestionnaireCards"; import { QuestionnaireRail } from "@/components/elections/QuestionnaireRail"; import { SurveyCta } from "@/components/elections/SurveyCta"; -import { surveyHref } from "@/lib/elections/registry"; +import { ANSWERS_WITHHELD, surveyHref } from "@/lib/elections/registry"; import CountdownDays from "@/components/elections/CountdownDays"; import { fieldSentiment } from "@/lib/elections/field-sentiment"; import { @@ -64,11 +64,18 @@ export default async function IssuesPage() { /* Unlike the ward and mayoral pages, the questionnaire is not a nice-to-have here — it is the entire page. A failed fetch has nothing to fall back to, so it renders as the empty state rather than as a roster. */ - const [survey, responses] = await Promise.all([ + const [survey, published] = await Promise.all([ fetchSurvey(ELECTION.slug, CANDIDATE_QUESTIONNAIRE_SLUG).catch(() => null), fetchCandidateResponses(ELECTION.slug), ]); + /* Held back at the top of the page rather than at each place that draws + them — see `questionnaireHidden` in the registry. This page is nothing but + the answers, so emptying the array empties the page; what is left is the + masthead saying so. */ + const withheld = ELECTION.questionnaireHidden ?? false; + const responses = withheld ? [] : published; + /* `fieldSentiment` is still what tells us who counts as a respondent and what seat they are running for — it reads the responses against the ballot and drops anyone who returned the form without answering a policy @@ -137,25 +144,39 @@ export default async function IssuesPage() { Where the candidates stand

- The same {questionCount} questions, put to everyone - running for mayor and for council. Read across the whole field, the - answers show what no single ballot can: what Toronto’s next - council already agrees on, and what it will spend four years - fighting over. + {withheld ? ( + <> + {ANSWERS_WITHHELD} This page reads the whole field’s + answers across every issue we asked about. + + ) : ( + <> + The same {questionCount} questions, put to everyone running for + mayor and for council. Read across the whole field, the answers + show what no single ballot can: what Toronto’s next + council already agrees on, and what it will spend four years + fighting over. + + )}

{/* ── Key stats ──────────────────────────────────────── */} -
- - - - -
+ {/* Every one of these four counts the answers, so with them withheld + the row is four zeros — a page-wide claim that nobody answered + anything. It goes rather than lies. */} + {!withheld && ( +
+ + + + +
+ )} {/* ── The field, question by question ────────────────── */} {groups.length > 0 && respondents.length > 0 ? ( @@ -189,8 +210,9 @@ export default async function IssuesPage() { ) : (

- No candidate answers have been published yet. Responses appear - here as they are reviewed and released. + {withheld + ? ANSWERS_WITHHELD + : "No candidate answers have been published yet. Responses appear here as they are reviewed and released."}

)} @@ -231,29 +253,34 @@ export default async function IssuesPage() { )} {/* ── Method ─────────────────────────────────────────── */} -
-

- Each card is one question, drawn as the share of the field that - gave each answer. The options are listed in full under the band, - in the wording the candidates were shown, with the number who chose - each. Options nobody picked are not shown, and a candidate who - answered in their own words is counted in the unshaded segment - rather than on any option. Shares are of the candidates who - answered that particular question, not of the whole field — a - questionnaire can come back half filled in, so the number behind a - card is the counts in its own legend added up. Hover or select any - answer to see the candidates who gave it, with the seat each is - running for. -

-

- The note most candidates wrote to explain their answer lives on - the ward and mayoral pages — thirty notes under every question is - more reading than this page can carry, and it is on those pages - that a reader has a ballot to weigh them against. Answers appear as - candidates return the questionnaire and staff review them, so the - field shown here grows through the campaign. -

-
+ {/* How to read cards that are not on the page is not method, it is + noise — and the second paragraph explains where the notes went, + which is a distinction with nothing to draw it between. */} + {!withheld && ( +
+

+ Each card is one question, drawn as the share of the field that + gave each answer. The options are listed in full under the band, + in the wording the candidates were shown, with the number who + chose each. Options nobody picked are not shown, and a candidate + who answered in their own words is counted in the unshaded segment + rather than on any option. Shares are of the candidates who + answered that particular question, not of the whole field — a + questionnaire can come back half filled in, so the number behind a + card is the counts in its own legend added up. Hover or select any + answer to see the candidates who gave it, with the seat each is + running for. +

+

+ The note most candidates wrote to explain their answer lives on + the ward and mayoral pages — thirty notes under every question is + more reading than this page can carry, and it is on those pages + that a reader has a ballot to weigh them against. Answers appear + as candidates return the questionnaire and staff review them, so + the field shown here grows through the campaign. +

+
+ )} {/* ── Elsewhere ──────────────────────────────────────── */}
diff --git a/src/app/toronto/vote/2026/mayor/page.tsx b/src/app/toronto/vote/2026/mayor/page.tsx index 3271ce8b..7ae611cd 100644 --- a/src/app/toronto/vote/2026/mayor/page.tsx +++ b/src/app/toronto/vote/2026/mayor/page.tsx @@ -9,7 +9,7 @@ import { } from "@/components/elections/QuestionnaireCards"; import { QuestionnaireRail } from "@/components/elections/QuestionnaireRail"; import { SurveyCta } from "@/components/elections/SurveyCta"; -import { surveyHref } from "@/lib/elections/registry"; +import { ANSWERS_WITHHELD, surveyHref } from "@/lib/elections/registry"; import CountdownDays from "@/components/elections/CountdownDays"; import { byCandidateKey, @@ -68,12 +68,19 @@ export const metadata: Metadata = { }; export default async function MayorPage() { - const [view, survey, responses] = await Promise.all([ + const [view, survey, published] = await Promise.all([ getToronto2026(), fetchSurvey(ELECTION.slug, CANDIDATE_QUESTIONNAIRE_SLUG).catch(() => null), fetchCandidateResponses(ELECTION.slug), ]); + /* Held back at the top of the page rather than at each place that draws + them — see `questionnaireHidden` in the registry. Everything downstream is + derived from this array, so emptying it here is what guarantees no answer + reaches the markup by a route nobody remembered to check. */ + const withheld = ELECTION.questionnaireHidden ?? false; + const responses = withheld ? [] : published; + /* The ballot line, and the part of it that wrote back. The whole election's responses come back from one fetch — the counts a @@ -128,9 +135,11 @@ export default async function MayorPage() { How the mayoral field answered

- {mayoral.length > 0 - ? `${mayoral.length} of the ${registered} candidates for mayor returned our questionnaire. Their answers, question by question — the mayoral field on each one.` - : `No one running for mayor has answered our questionnaire yet. ${registered} candidates have registered for the race.`} + {withheld + ? `${ANSWERS_WITHHELD} ${registered} candidates have registered for the race — the ballot is below.` + : mayoral.length > 0 + ? `${mayoral.length} of the ${registered} candidates for mayor returned our questionnaire. Their answers, question by question — the mayoral field on each one.` + : `No one running for mayor has answered our questionnaire yet. ${registered} candidates have registered for the race.`}

{/* ── Key stats ──────────────────────────────────────── */} -
- + {/* Two of these four count the answers, and while those are withheld + both would read zero — which is not a smaller version of the truth, + it is a different claim: that nobody answered. So the row drops to + what it can still say honestly, the ballot and the clock. */} +
+ {!withheld && } - + {!withheld && }
{/* ── The field, question by question ────────────────── */} - {groups.length > 0 && mayoral.length > 0 ? ( + {/* The ballot outlives the answers. A page about the mayoral race that + names nobody in it is no use to a reader who came with a name in + mind, and who is running is a fact about the election rather than + anything a candidate told us. */} + {withheld ? ( +
+

+ {ANSWERS_WITHHELD} +

+ +
+ ) : groups.length > 0 && mayoral.length > 0 ? (
{/* THE WHOLE BALLOT, ONCE @@ -221,14 +255,18 @@ export default async function MayorPage() { {/* ── Method ─────────────────────────────────────────── */}
-

- Every bar is the mayoral field that answered, one cell per - candidate: filled with the option that candidate picked, hollow - where they did not answer that question. Candidates who never - returned the questionnaire are not in these counts — they are on the - roster. Open a card for the names behind the bars and what each of - them wrote, published verbatim. -

+ {/* How to read cards that are not on the page is not method, it + is noise. */} + {!withheld && ( +

+ Every bar is the mayoral field that answered, one cell per + candidate: filled with the option that candidate picked, hollow + where they did not answer that question. Candidates who never + returned the questionnaire are not in these counts — they are on + the roster. Open a card for the names behind the bars and what + each of them wrote, published verbatim. +

+ )}

Registered candidates come from the City Clerk’s list, less anyone who has withdrawn. The field is not final until nominations diff --git a/src/components/elections/WardDetail.tsx b/src/components/elections/WardDetail.tsx index 52ce9d63..a0a2f8c5 100644 --- a/src/components/elections/WardDetail.tsx +++ b/src/components/elections/WardDetail.tsx @@ -10,7 +10,7 @@ import { } from "./QuestionnaireCards"; import { QuestionnaireRail } from "./QuestionnaireRail"; import { SurveyCta } from "./SurveyCta"; -import { surveyHref } from "@/lib/elections/registry"; +import { ANSWERS_WITHHELD, surveyHref } from "@/lib/elections/registry"; import { IncumbentBadge } from "./ElectionLanding"; import { CandidateNameLink } from "./CandidateNameLink"; import { WardProfileSection, type WardProfile } from "./WardProfile"; @@ -104,6 +104,11 @@ export function WardDetail({ (candidate) => surveyAnswers?.[candidate.key], ); + /* The answers arrive empty either way, so the page cannot tell a quiet field + from a withheld one by looking at them — see `questionnaireHidden` in the + registry. It has to ask. */ + const withheld = election.questionnaireHidden ?? false; + return (

@@ -187,7 +192,9 @@ export function WardDetail({

{councilCandidates.length === 0 ? "No one has registered in this ward yet." - : "Nobody in this ward has answered yet. These are the questions we asked."} + : withheld + ? ANSWERS_WITHHELD + : "Nobody in this ward has answered yet. These are the questions we asked."}

)} {councilCandidates.length > 0 && ( @@ -225,6 +232,7 @@ export function WardDetail({ surveyAnswers={surveyAnswers} surveyShape={surveyShape} showHeading={showRaceHeadings} + withheld={withheld} issuesHref={`${election.basePath}/issues`} /> )) @@ -322,12 +330,16 @@ function RaceQuestionnaire({ surveyAnswers, surveyShape, showHeading, + withheld = false, issuesHref, }: { race: RaceView; surveyAnswers?: Record; surveyShape?: ComparedGroup[]; showHeading: boolean; + /** the answers are being held back, so an empty grid is our doing and not + * a field that stayed quiet — see `questionnaireHidden` in the registry */ + withheld?: boolean; issuesHref?: string; }) { /* Two lists, not one. The candidates who wrote back are the ones the @@ -366,15 +378,21 @@ function RaceQuestionnaire({ /> ) : ( - /* Only two ways to get here now: nobody has filed for the seat, or - the questionnaire itself could not be fetched. Either way there is - no grid to draw, and the candidates are still worth naming. */ + /* Nobody has filed for the seat, the questionnaire could not be + fetched, or the answers are being held back. Either way there is + no grid to draw, and the candidates are still worth naming — but + only the first two let us say the field has yet to respond, which + is why the third has to be told apart from them. */

{roster.length === 0 ? "No one has filed for this seat yet." - : `On the ballot, and yet to respond to us: ${roster - .map((candidate) => candidate.name) - .join(", ")}.`} + : withheld + ? `${ANSWERS_WITHHELD} On the ballot: ${roster + .map((candidate) => candidate.name) + .join(", ")}.` + : `On the ballot, and yet to respond to us: ${roster + .map((candidate) => candidate.name) + .join(", ")}.`}

)}
diff --git a/src/lib/elections/registry.ts b/src/lib/elections/registry.ts index 2bded811..0dd58be0 100644 --- a/src/lib/elections/registry.ts +++ b/src/lib/elections/registry.ts @@ -99,8 +99,44 @@ export type SupportedElection = { * ask, so the rule lives in one place and no call site can forget it. */ surveyClosed?: boolean; + /** + * The candidates' questionnaire answers are off for now. + * + * The sibling of `surveyClosed` and the same bargain: temporary, one line, + * and nothing deleted. What comes down is every published answer — the cards + * on the ward, mayoral and candidate pages, the whole-field view on /issues, + * and the read proxy that serves them to the browser. What stays up is the + * ballot: who is running, in which ward, with their campaign site, which is + * a fact about the election rather than anything a candidate told us. + * + * A candidate's own bio stays too, though it arrives in the same response. + * It is a self-description rather than a position, and it is the only thing + * standing between most candidate pages and an empty one. + * + * The pages need no empty states written for this: they already have the + * ones built for the weeks before anybody had written back — "Nobody in this + * ward has answered yet", "No answers from the mayoral field have been + * published yet" — and hiding the answers at the source puts every page into + * exactly that state. + */ + questionnaireHidden?: boolean; }; +/** + * What a page says where the candidates' answers would be. + * + * One phrase, in one place, because it appears on the ward pages, the mayoral + * page, /issues and every candidate page, and four hand-written versions of it + * would be four different accounts of the same fact. + * + * What it must not do is reuse the empty states these pages already had for + * the weeks before anybody had written back. "Nobody in this ward has answered + * yet", printed over a ward whose candidates answered months ago, is a claim + * about those candidates and it is ours, not theirs — and on a candidate page + * it sits under a named person's photograph. + */ +export const ANSWERS_WITHHELD = "Coming soon."; + /** * Where this region's voter survey lives, or nothing while it is closed. * @@ -136,6 +172,7 @@ const TORONTO_2026: SupportedElection = { candidateProfiles: true, themeClass: "theme-election", surveyClosed: true, + questionnaireHidden: true, }; const BRAMPTON_2026: SupportedElection = { diff --git a/src/lib/elections/survey-answers.ts b/src/lib/elections/survey-answers.ts index 4b1057b6..5fe59578 100644 --- a/src/lib/elections/survey-answers.ts +++ b/src/lib/elections/survey-answers.ts @@ -13,6 +13,7 @@ // is nothing to show at all. import { + BIO_QUESTION_ID, byCandidateKey, candidateAnswers, candidateWriting, @@ -25,6 +26,7 @@ import { CANDIDATE_QUESTIONNAIRE_SLUG, fetchCandidateResponses, } from "./candidate-responses"; +import { getElection } from "./registry"; import { fetchSurvey } from "./survey"; export type RosterSurvey = { @@ -65,6 +67,32 @@ export async function rosterSurvey( ); const written = candidateWriting(survey, responses); + /* Answers withheld, prose kept — see `questionnaireHidden` in the + registry. The responses are still fetched because the bio rides in on + them, and a candidate's account of themselves is not one of the answers + being held back. Every page reading this is left with the empty state it + already had for a field that has not written back yet. + + `written` is narrowed to the bio alone rather than passed through. The + rest of it is prose answering a policy question — the questionnaire's + ward-commitment target is one — and letting that through under the + heading "About" would publish an answer by another door. */ + if (getElection(electionSlug).questionnaireHidden) { + return { + answers: {}, + shape: [], + written: Object.fromEntries( + Object.entries(written) + .filter(([key]) => candidateKeys.has(key)) + .map(([key, entries]) => [ + key, + entries.filter((entry) => entry.questionId === BIO_QUESTION_ID), + ]) + .filter(([, entries]) => (entries as WrittenAnswer[]).length > 0), + ), + }; + } + return { answers: byCandidateKey(entries), shape: questionnaireShape(survey, responses), From 3fa767c1fd1e5a126cb7d04f6d3939bd026732df Mon Sep 17 00:00:00 2001 From: Mikaal Naik Date: Fri, 11 Sep 2026 15:21:43 -0400 Subject: [PATCH 3/4] Show one flat ballot while the answers are hidden MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three of the rosters sort the field by whether a candidate wrote back, which is the most useful sort available when the answers are on the page and an empty one when they are not. With nothing to read back the "answered" half empties and the whole ballot slides into the other one — so a ward's candidates sat under "Also on the ballot, yet to answer our questionnaire" and the mayoral field under "Yet to respond", which they are not. It is a scoreboard reading nil-all, and every word of it ours. Flat, and labelled for what it is: "On the ballot". The ward roster and the mayoral roster page both collapse to one list, and /mayor/candidates drops its "Answered us" stat for the same reason the other two pages dropped theirs — a count of the answers at nil is not a smaller truth, it is a different claim. The withdrawn list stays separate: that is a fact about the ballot, not about who answered. The ward's race block no longer repeats the names either. It was printing "Coming soon" followed by the ballot as a run of plain text, directly under the same ballot as a list of linked names. Co-Authored-By: Claude Opus 5 (1M context) --- .../vote/2026/mayor/candidates/page.tsx | 27 ++++++++++++++---- src/components/elections/WardDetail.tsx | 28 ++++++++++++++----- 2 files changed, 42 insertions(+), 13 deletions(-) diff --git a/src/app/toronto/vote/2026/mayor/candidates/page.tsx b/src/app/toronto/vote/2026/mayor/candidates/page.tsx index d21a78e3..0e553ee5 100644 --- a/src/app/toronto/vote/2026/mayor/candidates/page.tsx +++ b/src/app/toronto/vote/2026/mayor/candidates/page.tsx @@ -9,6 +9,7 @@ import CountdownDays from "@/components/elections/CountdownDays"; import { surveyRoster } from "@/lib/elections/candidate-answers"; import { daysUntil } from "@/lib/elections/dates"; import { rosterSurvey } from "@/lib/elections/survey-answers"; +import { ANSWERS_WITHHELD } from "@/lib/elections/registry"; import type { CandidateView } from "@/lib/elections/election-data"; import { ELECTION, getToronto2026 } from "../../data"; @@ -66,8 +67,15 @@ export default async function MayoralCandidatesPage() { ); const roster = surveyRoster(view.mayoral, answers); - const answered = roster.filter((candidate) => candidate.answers); - const quiet = roster.filter((candidate) => !candidate.answers); + /* One list, not two, while the answers are withheld — see + `questionnaireHidden` in the registry. The split here is by whether a + candidate wrote back, so with nothing to read back it collapses on its + own: `answered` empties and the entire ballot lands under "Yet to + respond", which is a scoreboard reading nil-all and every word of it our + doing. Flat, the page is what it says it is — everyone running. */ + const withheld = ELECTION.questionnaireHidden ?? false; + const answered = withheld ? [] : roster.filter((candidate) => candidate.answers); + const quiet = withheld ? roster : roster.filter((candidate) => !candidate.answers); const withdrawn = view.mayoral.filter((candidate) => candidate.withdrawn); const sites = roster.filter((candidate) => candidate.website).length; @@ -98,13 +106,16 @@ export default async function MayoralCandidatesPage() { {roster.length > 0 && answered.length > 0 ? `${roster.length} candidates have registered; ${answered.length} of them have told us where they stand.` : `${roster.length} candidates have registered.`} + {withheld ? ` ${ANSWERS_WITHHELD}` : ""}

{/* ── Key stats ──────────────────────────────────────── */}
- + {/* A count of the answers, which at nil reads as the claim that + nobody gave any. */} + {!withheld && }
)} - {/* ── Yet to respond ─────────────────────────────────── */} + {/* ── Yet to respond, or simply the ballot ───────────── */} {quiet.length > 0 && (
diff --git a/src/components/elections/WardDetail.tsx b/src/components/elections/WardDetail.tsx index a0a2f8c5..90afd10d 100644 --- a/src/components/elections/WardDetail.tsx +++ b/src/components/elections/WardDetail.tsx @@ -199,10 +199,22 @@ export function WardDetail({ )} {councilCandidates.length > 0 && ( !surveyAnswers?.[candidate.key], - )} + /* One list while the answers are withheld. The split is by + who wrote back, so with nothing to read back everyone + falls into the second half and the ward's whole ballot + sits under "yet to answer our questionnaire" — which they + did answer. Flat, and labelled for what it is. */ + respondents={withheld ? councilCandidates : respondents} + silent={ + withheld + ? [] + : councilCandidates.filter( + (candidate) => !surveyAnswers?.[candidate.key], + ) + } + respondentsLabel={ + withheld ? "On the ballot" : undefined + } election={election.slug} race="councillor" ward={ward.n} @@ -387,9 +399,11 @@ function RaceQuestionnaire({ {roster.length === 0 ? "No one has filed for this seat yet." : withheld - ? `${ANSWERS_WITHHELD} On the ballot: ${roster - .map((candidate) => candidate.name) - .join(", ")}.` + ? /* Just the notice. The ballot is already above this, as a + list of linked names — repeating it here as a run of + plain text is the same names twice, the second time + worse. */ + ANSWERS_WITHHELD : `On the ballot, and yet to respond to us: ${roster .map((candidate) => candidate.name) .join(", ")}.`} From 311b2cc988820dd49a6b08b00d152255e2ea75bf Mon Sep 17 00:00:00 2001 From: Mikaal Naik Date: Fri, 11 Sep 2026 15:24:55 -0400 Subject: [PATCH 4/4] Say "Candidate survey coming soon", once per page MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit "Coming soon." on its own says a thing is coming without saying what, and on a page that has just dropped a section it is the reader's job to work out which. It now names the survey. And once. Four of the pages printed it twice — in the masthead and again where the cards would be — which reads as a stutter rather than as a fuller explanation, so each page keeps it in the one place a reader looks for the thing that is gone. The ward pages keep it beside the ballot, and their race block prints nothing rather than repeating it once per race under the notice that already covers them all. The mayoral and issues heroes go back to describing their page and leave the notice to the body. The candidate page says it in the heading alone: the eyebrow, the heading and a line under it were three ways of saying one thing. Co-Authored-By: Claude Opus 5 (1M context) --- .../vote/2026/candidates/[candidate]/page.tsx | 22 ++++++++----------- src/app/toronto/vote/2026/issues/page.tsx | 4 ++-- .../vote/2026/mayor/candidates/page.tsx | 2 +- src/app/toronto/vote/2026/mayor/page.tsx | 2 +- src/components/elections/WardDetail.tsx | 10 ++++----- src/lib/elections/registry.ts | 7 +++++- 6 files changed, 24 insertions(+), 23 deletions(-) diff --git a/src/app/toronto/vote/2026/candidates/[candidate]/page.tsx b/src/app/toronto/vote/2026/candidates/[candidate]/page.tsx index 58a5092a..b7996a9a 100644 --- a/src/app/toronto/vote/2026/candidates/[candidate]/page.tsx +++ b/src/app/toronto/vote/2026/candidates/[candidate]/page.tsx @@ -17,7 +17,6 @@ import { comparedQuestions, } from "@/lib/elections/candidate-answers"; import { rosterSurvey } from "@/lib/elections/survey-answers"; -import { ANSWERS_WITHHELD } from "@/lib/elections/registry"; import { daysUntil } from "@/lib/elections/dates"; import { firstName, possessive } from "@/lib/elections/names"; import type { CandidateProfile, RaceView } from "@/lib/elections/election-data"; @@ -464,7 +463,14 @@ export default async function CandidatePage({ {surveyAnswers ? `Where ${candidate.name} stands` : withheld - ? "Coming soon" + ? /* Held back, which is not the same as never sent — and this + page of all of them must not confuse the two. "Yet to + answer", over a named person's photograph, is a claim + about that person that we would be making for them. The + heading carries it alone: a line under it repeating the + same four words is the eyebrow, the heading and the body + all saying one thing. */ + "Candidate survey coming soon" : "Yet to answer"} @@ -490,17 +496,7 @@ export default async function CandidatePage({ /> - ) : withheld ? ( - /* Held back, which is not the same as never sent — and this page - of all of them must not confuse the two. "X has not returned our - questionnaire" printed under a named person's photograph is a - claim about that person, and here it would be our doing and - untrue. Nor does it point at /issues, which is showing the same - nothing. */ -

- {ANSWERS_WITHHELD} -

- ) : ( + ) : withheld ? null : (

{candidate.name} has not returned our questionnaire. We publish answers as they arrive, so check back — and{" "} diff --git a/src/app/toronto/vote/2026/issues/page.tsx b/src/app/toronto/vote/2026/issues/page.tsx index 50e81f39..033b40d8 100644 --- a/src/app/toronto/vote/2026/issues/page.tsx +++ b/src/app/toronto/vote/2026/issues/page.tsx @@ -146,8 +146,8 @@ export default async function IssuesPage() {

{withheld ? ( <> - {ANSWERS_WITHHELD} This page reads the whole field’s - answers across every issue we asked about. + This page reads the whole field’s answers across every + issue we asked about. ) : ( <> diff --git a/src/app/toronto/vote/2026/mayor/candidates/page.tsx b/src/app/toronto/vote/2026/mayor/candidates/page.tsx index 0e553ee5..f7635d2a 100644 --- a/src/app/toronto/vote/2026/mayor/candidates/page.tsx +++ b/src/app/toronto/vote/2026/mayor/candidates/page.tsx @@ -106,7 +106,7 @@ export default async function MayoralCandidatesPage() { {roster.length > 0 && answered.length > 0 ? `${roster.length} candidates have registered; ${answered.length} of them have told us where they stand.` : `${roster.length} candidates have registered.`} - {withheld ? ` ${ANSWERS_WITHHELD}` : ""} +

diff --git a/src/app/toronto/vote/2026/mayor/page.tsx b/src/app/toronto/vote/2026/mayor/page.tsx index 7ae611cd..a9175914 100644 --- a/src/app/toronto/vote/2026/mayor/page.tsx +++ b/src/app/toronto/vote/2026/mayor/page.tsx @@ -136,7 +136,7 @@ export default async function MayorPage() {

{withheld - ? `${ANSWERS_WITHHELD} ${registered} candidates have registered for the race — the ballot is below.` + ? `${registered} candidates have registered for the race. The ballot is below.` : mayoral.length > 0 ? `${mayoral.length} of the ${registered} candidates for mayor returned our questionnaire. Their answers, question by question — the mayoral field on each one.` : `No one running for mayor has answered our questionnaire yet. ${registered} candidates have registered for the race.`} diff --git a/src/components/elections/WardDetail.tsx b/src/components/elections/WardDetail.tsx index 90afd10d..628b5657 100644 --- a/src/components/elections/WardDetail.tsx +++ b/src/components/elections/WardDetail.tsx @@ -399,11 +399,11 @@ function RaceQuestionnaire({ {roster.length === 0 ? "No one has filed for this seat yet." : withheld - ? /* Just the notice. The ballot is already above this, as a - list of linked names — repeating it here as a run of - plain text is the same names twice, the second time - worse. */ - ANSWERS_WITHHELD + ? /* Nothing. The notice is already up beside the ballot at the + top of this section, and a ward with two races would + otherwise print it once per race under the one that + covers them all. */ + null : `On the ballot, and yet to respond to us: ${roster .map((candidate) => candidate.name) .join(", ")}.`} diff --git a/src/lib/elections/registry.ts b/src/lib/elections/registry.ts index 0dd58be0..4c6116ef 100644 --- a/src/lib/elections/registry.ts +++ b/src/lib/elections/registry.ts @@ -129,13 +129,18 @@ export type SupportedElection = { * page, /issues and every candidate page, and four hand-written versions of it * would be four different accounts of the same fact. * + * Once per page. It names what is missing rather than only promising a return, + * so on a page that says it twice — in the masthead and again where the cards + * would be — it reads as a stutter rather than as a fuller explanation. Each + * page keeps it in the one place a reader looks for the thing that is gone. + * * What it must not do is reuse the empty states these pages already had for * the weeks before anybody had written back. "Nobody in this ward has answered * yet", printed over a ward whose candidates answered months ago, is a claim * about those candidates and it is ours, not theirs — and on a candidate page * it sits under a named person's photograph. */ -export const ANSWERS_WITHHELD = "Coming soon."; +export const ANSWERS_WITHHELD = "Candidate survey coming soon."; /** * Where this region's voter survey lives, or nothing while it is closed.