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/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/candidates/[candidate]/page.tsx b/src/app/toronto/vote/2026/candidates/[candidate]/page.tsx
index 0bdb4fb0..b7996a9a 100644
--- a/src/app/toronto/vote/2026/candidates/[candidate]/page.tsx
+++ b/src/app/toronto/vote/2026/candidates/[candidate]/page.tsx
@@ -144,6 +144,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 +462,16 @@ export default async function CandidatePage({
{surveyAnswers
? `Where ${candidate.name} stands`
- : "Yet to answer"}
+ : withheld
+ ? /* 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"}
{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 73b57964..033b40d8 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 { ANSWERS_WITHHELD, surveyHref } from "@/lib/elections/registry";
import CountdownDays from "@/components/elections/CountdownDays";
import { fieldSentiment } from "@/lib/elections/field-sentiment";
import {
@@ -63,16 +64,28 @@ 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
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;
@@ -131,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 ? (
+ <>
+ 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 ? (
@@ -183,61 +210,77 @@ 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."}
)}
{/* ── 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.
+
- 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/candidates/page.tsx b/src/app/toronto/vote/2026/mayor/candidates/page.tsx
index d21a78e3..f7635d2a 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.`}
+
{/* ── 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/app/toronto/vote/2026/mayor/page.tsx b/src/app/toronto/vote/2026/mayor/page.tsx
index 29e6f850..a9175914 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 { ANSWERS_WITHHELD, surveyHref } from "@/lib/elections/registry";
import CountdownDays from "@/components/elections/CountdownDays";
import {
byCandidateKey,
@@ -67,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
@@ -80,6 +88,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
@@ -123,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
+ ? `${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
@@ -196,29 +235,38 @@ 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.
+
- 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/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..628b5657 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 { ANSWERS_WITHHELD, surveyHref } from "@/lib/elections/registry";
import { IncumbentBadge } from "./ElectionLanding";
import { CandidateNameLink } from "./CandidateNameLink";
import { WardProfileSection, type WardProfile } from "./WardProfile";
@@ -103,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 (
@@ -186,15 +192,29 @@ 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 && (
!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}
@@ -203,7 +223,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 ? (
@@ -219,6 +244,7 @@ export function WardDetail({
surveyAnswers={surveyAnswers}
surveyShape={surveyShape}
showHeading={showRaceHeadings}
+ withheld={withheld}
issuesHref={`${election.basePath}/issues`}
/>
))
@@ -316,12 +342,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
@@ -360,15 +390,23 @@ 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
+ ? /* 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 3e57827f..4c6116ef 100644
--- a/src/lib/elections/registry.ts
+++ b/src/lib/elections/registry.ts
@@ -83,8 +83,79 @@ 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;
+ /**
+ * 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.
+ *
+ * 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 = "Candidate survey coming soon.";
+
+/**
+ * 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 +176,8 @@ const TORONTO_2026: SupportedElection = {
wardLookup: true,
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),