From 691151914626556317d48ec90b0b127697841c65 Mon Sep 17 00:00:00 2001 From: Mikaal Naik Date: Fri, 11 Sep 2026 15:47:25 -0400 Subject: [PATCH 1/2] Show the pre-questionnaire ballot until the survey launches MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Hiding the answers left the pages that used to publish them standing: a questionnaire heading over a notice that there is nothing under it, a roster labelled by who wrote back with everyone in one half, and a "Candidate survey coming soon" in the place a reader came to read. What we want in the meantime is the UI these pages had before the answers existed — the ballot — and that is what this does. Nothing is reverted; the flags stay, and this is the presentation on top of them. WHAT EACH PAGE SHOWS NOW The ward pages are back to `Candidates`: one card a candidate, the name a link to their own page, the incumbent badged, their bio under it. No questionnaire rail, no roster split, no survey card, and no notice — who is standing in a ward is a fact about the election, and the page is complete without saying what else it is not printing yet. /mayor/candidates is one flat ballot in surname order. The split was by who wrote back, so with nothing to read back it was a scoreboard reading nil-all and every word of it our doing. The stats row loses the count of answers for the same reason. A candidate's page keeps everything that is theirs — their links, where they are running, and the bio they wrote us, still attributed — and drops the questionnaire section under it. The landing page's explore grid is one card, the mayoral ballot. Its blurb opened "We put the same questions to every candidate on the ballot. See how they answered", which is a promise the grid beneath it cannot keep, so it now says what the cards actually go to. The closing band was already the pledge. THE TWO PAGES THAT ARE ONLY THE ANSWERS /issues is the whole field read question by question and /mayor is the mayoral field's answers; neither has anything left when those are gone, so there is no ballot-only version of them to write. They stay in the repo exactly as built and come down at the router instead, each landing on the nearest ballot — /issues on the election page, /mayor on the mayoral roster. Temporary 307s, not 308s: a permanent redirect would sit in a reader's browser and skip the server after launch, when these are the pages we most want them to reach. WHAT STAYS `surveyClosed` and `questionnaireHidden` are untouched and still gate the answers at the source — the read proxy and `rosterSurvey` — so no answer can reach the markup through a route this commit missed. The bio still rides in on the same response and is still published, because a self-description is not one of the positions being held back. Their registry docs are rewritten to describe the ballot the pages now show rather than the withheld notice they used to. `ANSWERS_WITHHELD` is kept for the two pages above, which are the only things left that reference it. Everything built since the questionnaire is kept: the candidate pages, the ward descriptions and Census profiles, the live countdown, the redesigned landing page. This is the old reading of the ballot, not the old site. Co-Authored-By: Claude Opus 5 (1M context) --- next.config.ts | 27 ++ .../vote/2026/candidates/[candidate]/page.tsx | 127 ++------- .../vote/2026/mayor/candidates/page.tsx | 102 ++----- src/app/toronto/vote/2026/page.tsx | 32 +-- .../toronto/vote/2026/wards/[ward]/page.tsx | 11 - src/components/elections/ElectionLanding.tsx | 73 ++--- src/components/elections/WardDetail.tsx | 258 +++--------------- src/lib/elections/registry.ts | 61 +++-- 8 files changed, 181 insertions(+), 510 deletions(-) diff --git a/next.config.ts b/next.config.ts index e21945e9..75c0380d 100644 --- a/next.config.ts +++ b/next.config.ts @@ -124,6 +124,33 @@ const nextConfig: NextConfig = { destination: "/vote", permanent: false, }, + // The two pages that exist only to publish the candidates' answers are + // switched off until the questionnaire launches — the sibling of + // `questionnaireHidden` and `surveyClosed` in the election registry, and + // temporary in the same way. Both pages stay in the repo exactly as + // built; what comes down is the way to them. + // + // A redirect rather than an emptied page, because neither page has + // anything left when the answers are gone: /issues is the whole field + // read question by question, and /mayor is the mayoral field's answers. + // The ballot they were read against is still published — every + // candidate for mayor at /mayor/candidates, every ward at /wards/:n — + // so each lands on the ballot nearest to what it was about rather than + // on a page apologising for itself. + // + // Temporary, so a 307: a permanent redirect would sit in a reader's + // browser and skip the server after launch, when these are the pages we + // most want them to reach. + { + source: "/toronto/vote/2026/issues", + destination: "/toronto/vote/2026", + permanent: false, + }, + { + source: "/toronto/vote/2026/mayor", + destination: "/toronto/vote/2026/mayor/candidates", + permanent: false, + }, // Toronto's get-involved page is switched off. It stays in the repo but // sends people to the election landing instead. The legacy /elections // shape gets its own rule so it lands there directly rather than diff --git a/src/app/toronto/vote/2026/candidates/[candidate]/page.tsx b/src/app/toronto/vote/2026/candidates/[candidate]/page.tsx index b7996a9a..5b19d141 100644 --- a/src/app/toronto/vote/2026/candidates/[candidate]/page.tsx +++ b/src/app/toronto/vote/2026/candidates/[candidate]/page.tsx @@ -2,23 +2,15 @@ import type { Metadata } from "next"; import Image from "next/image"; import Link from "next/link"; import { notFound } from "next/navigation"; -import { ArrowLeft, ArrowRight, ArrowUpRight } from "lucide-react"; +import { ArrowLeft, ArrowUpRight } from "lucide-react"; import { CandidateSiteLink } from "@/components/elections/CandidateSiteLink"; -import { - QuestionnaireCards, - questionnaireHeadings, -} from "@/components/elections/QuestionnaireCards"; -import { QuestionnaireRail } from "@/components/elections/QuestionnaireRail"; import CountdownDays from "@/components/elections/CountdownDays"; import { IncumbentBadge } from "@/components/elections/ElectionLanding"; -import { - BIO_QUESTION_ID, - comparedQuestions, -} from "@/lib/elections/candidate-answers"; +import { BIO_QUESTION_ID } from "@/lib/elections/candidate-answers"; import { rosterSurvey } from "@/lib/elections/survey-answers"; import { daysUntil } from "@/lib/elections/dates"; -import { firstName, possessive } from "@/lib/elections/names"; +import { firstName } from "@/lib/elections/names"; import type { CandidateProfile, RaceView } from "@/lib/elections/election-data"; import { ELECTION, @@ -103,7 +95,7 @@ export async function generateMetadata({ return { title: `${name} — ${race}`, - description: `${name} is a registered candidate for ${race} in Toronto's October 26, 2026 municipal election. Their campaign site, and how they answered our questionnaire.`, + description: `${name} is a registered candidate for ${race} in Toronto's October 26, 2026 municipal election — the ward they are standing in, and how to reach their campaign.`, alternates: { canonical: `${ELECTION.basePath}/candidates/${slug}` }, openGraph: { title: `${name} — Toronto 2026 Election`, @@ -127,14 +119,14 @@ export default async function CandidatePage({ const race = races[0]; /* The one-candidate case of what every roster page does: the questionnaire - is fetched for the whole election — the counts beside each answer are the - field's split — and narrowed to this candidate by key. A missing - questionnaire costs the answers, not the page. */ - const { answers, written } = await rosterSurvey( - ELECTION.slug, - new Set([candidate.key]), - ); - const surveyAnswers = answers[candidate.key]; + is fetched for the whole election and narrowed to this candidate by key. A + missing questionnaire costs the prose, not the page. + + Only the prose is read off it. The answers are not published yet, so the + page has no cards to draw — what it still wants is the bio a candidate + wrote in the questionnaire's own words, which `rosterSurvey` is left + holding while `questionnaireHidden` is set. */ + const { written } = await rosterSurvey(ELECTION.slug, new Set([candidate.key])); /* What the candidate wrote, as against what they picked. 55 of the 58 candidates who returned the questionnaire wrote a bio in it, @@ -144,12 +136,6 @@ 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, @@ -158,26 +144,6 @@ export default async function CandidatePage({ (entry) => entry.questionId !== BIO_QUESTION_ID, ); - /* The ward pages' cards, given a roster of one. - `comparedQuestions` is the same pivot a ward runs — question first, the - candidates filed under the answer they gave — so this page's cards are - literally the ward's cards with a field of one person in them. A card - therefore shows the one option this candidate picked, in the option's own - colour, with their note printed in the open underneath their name plate. - What it cannot show is the split, since the other candidates are not in - the roster; "How the whole city answered" at the foot is the way to it. */ - const roster = [ - { - key: candidate.key, - name: candidate.name, - website: candidate.website, - bio: candidate.bio || undefined, - }, - ]; - const groups = surveyAnswers - ? comparedQuestions([surveyAnswers], roster) - : []; - const raceKind = profile.officeTypes[0] === "mayor" ? "mayor" : profile.officeTypes[0] === "trustee" ? "trustee" : "councillor"; /* Where the rest of this candidate's ballot line is — the ward page for a @@ -456,61 +422,6 @@ export default async function CandidatePage({ )} - {/* ── Questionnaire ──────────────────────────────────── */} -
-

Our questionnaire

-

- {surveyAnswers - ? `Where ${candidate.name} stands` - : 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"} -

- - {surveyAnswers ? ( - <> -

- {possessive(candidate.name)} own answers to the questions we put - to every candidate, published as given — including, where they - wrote one, their reasoning in their own words. -

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

- {candidate.name} has not returned our questionnaire. We publish - answers as they arrive, so check back — and{" "} - - see where the rest of the field stands - {" "} - in the meantime. -

- )} -
- {/* ── Source note ────────────────────────────────────── */}

@@ -521,7 +432,10 @@ export default async function CandidatePage({

{/* ── Elsewhere ──────────────────────────────────────── */} -
+ {/* The rest of this candidate's ballot line, and nothing else. The + second way out of here was the field read question by question, + which is switched off with the rest of the questionnaire. */} +
- - - Where the whole field stands - - -
diff --git a/src/app/toronto/vote/2026/mayor/candidates/page.tsx b/src/app/toronto/vote/2026/mayor/candidates/page.tsx index f7635d2a..10756175 100644 --- a/src/app/toronto/vote/2026/mayor/candidates/page.tsx +++ b/src/app/toronto/vote/2026/mayor/candidates/page.tsx @@ -8,8 +8,6 @@ import { CandidateNameLink } from "@/components/elections/CandidateNameLink"; 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"; @@ -27,14 +25,14 @@ import { ELECTION, getToronto2026 } from "../../data"; * questions by every column — which answers "what did they say" and never * answers "who is running". A reader who wants the ballot got a grid. * - * ANSWERED FIRST, AND SAID SO - * The field splits in two: the candidates who returned our questionnaire and - * the candidates who have not. That is the most useful sort available — it - * is the difference between a name and a position — and `surveyRoster` - * already orders it that way, so the page prints the boundary rather than - * leaving the reader to infer it from a missing link. Within each group, - * surname order, because the alternative is a ranking nobody asked us to - * make. + * ONE LIST, IN SURNAME ORDER + * The field used to split in two — the candidates who returned our + * questionnaire and the candidates who had not — which is the most useful + * sort available while those answers are published. They are not, until the + * questionnaire launches, so the split would be a scoreboard reading nil-all + * and every word of it our doing. Flat, the page is what it says it is: + * everyone running, in surname order, because the alternative is a ranking + * nobody asked us to make. * * Withdrawn candidates keep a group at the foot rather than vanishing. Some * clerks never drop them, they appear on lists elsewhere, and a reader who @@ -44,12 +42,12 @@ import { ELECTION, getToronto2026 } from "../../data"; export const metadata: Metadata = { title: "Every candidate for Mayor of Toronto", description: - "The full field for Mayor of Toronto in the October 26, 2026 election: every registered candidate, their campaign site, and whether they answered our questionnaire.", + "The full field for Mayor of Toronto in the October 26, 2026 election: every registered candidate, in surname order, with their campaign site.", alternates: { canonical: `${ELECTION.basePath}/mayor/candidates` }, openGraph: { title: "Every candidate for Mayor — Toronto 2026 Election", description: - "The full field for Mayor of Toronto: who is running, and who has told us where they stand.", + "The full field for Mayor of Toronto: everyone registered to run for the city's top job.", type: "website", }, }; @@ -57,25 +55,10 @@ export const metadata: Metadata = { export default async function MayoralCandidatesPage() { const view = await getToronto2026(); - /* Same two-step the questionnaire grid uses: the roster names the field, and - the survey fetch is keyed to it, so a response from someone who is not on - the ballot cannot put a stranger on this page. */ - const named = surveyRoster(view.mayoral); - const { answers } = await rosterSurvey( - ELECTION.slug, - new Set(named.map((candidate) => candidate.key)), - ); - const roster = surveyRoster(view.mayoral, 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); + /* The ballot, in surname order. `surveyRoster` with no answers to sort by is + exactly that — it is the same call the ward pages make, and the ordering + is the part of it this page still needs. */ + const roster = surveyRoster(view.mayoral); const withdrawn = view.mayoral.filter((candidate) => candidate.withdrawn); const sites = roster.filter((candidate) => candidate.website).length; @@ -102,20 +85,13 @@ export default async function MayoralCandidatesPage() {

The one race every Toronto voter votes in, and the longest ballot in - the city.{" "} - {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.`} - + the city. {roster.length} candidates have registered.

{/* ── Key stats ──────────────────────────────────────── */} -
+
- {/* A count of the answers, which at nil reads as the claim that - nobody gave any. */} - {!withheld && }
- {/* ── Answered ───────────────────────────────────────── */} - {answered.length > 0 && ( -
- - -
- )} - - {/* ── Yet to respond, or simply the ballot ───────────── */} - {quiet.length > 0 && ( + {/* ── The ballot ────────────────────────────────────── */} + {roster.length > 0 && (
- +
)} @@ -186,19 +142,13 @@ export default async function MayoralCandidatesPage() {
{/* ── Elsewhere ──────────────────────────────────────── */} -
- - - Where the whole field stands - - - + {/* One way on, not two: the other was the field read question by + question, which is switched off with the rest of the + questionnaire. */} +
diff --git a/src/app/toronto/vote/2026/page.tsx b/src/app/toronto/vote/2026/page.tsx index 1ecde692..35791bf4 100644 --- a/src/app/toronto/vote/2026/page.tsx +++ b/src/app/toronto/vote/2026/page.tsx @@ -32,7 +32,6 @@ export default async function Toronto2026ElectionPage() { - Toronto votes Monday, October 26. Answer the questions we put to the - candidates and see which of them line up with you. + Toronto votes Monday, October 26. Put your name on the record, then + find out who is running in your ward. ), /* WHAT IS ONLY HERE A card earns its place by going somewhere a reader would not - otherwise get to, and by being the thing they came for. What is left - is the two questionnaire reads — one race, then every race — with - the survey between them; ElectionLanding supplies the mayoral card - and the survey card itself. + otherwise get to, and by being the thing they came for. Until the + questionnaire is published that is the mayoral ballot: fifty-odd + names, on a page of their own because the field is too long to + print here. ElectionLanding supplies that card off + `mayorRosterPath`, so this list is empty rather than carrying the + two questionnaire reads it used to — the field read question by + question, and the mayoral field's own answers — which are both + switched off (see next.config.ts). Everything else is reachable from the section that owns it, which is - where a reader looks for it anyway: the question set is linked from - the survey and from every questionnaire page, the pledge from the - closing band, and the wards from the ward grid two hundred pixels - below. */ - explore: [ - { - eyebrow: "Every race", - title: "Where the candidates stand", - blurb: - "Mayor and council together, question by question: where the field agrees, and where it splits.", - href: `${ELECTION.basePath}/issues`, - }, - ], + where a reader looks for it anyway: the pledge from the closing + band, and the wards from the ward grid two hundred pixels below. */ guideLinks: [ { label: "See all key dates", href: KEY_DATES_PATH }, { label: "How to vote in Toronto", href: HOW_TO_VOTE_PATH }, diff --git a/src/app/toronto/vote/2026/wards/[ward]/page.tsx b/src/app/toronto/vote/2026/wards/[ward]/page.tsx index 9eea2486..15586ce1 100644 --- a/src/app/toronto/vote/2026/wards/[ward]/page.tsx +++ b/src/app/toronto/vote/2026/wards/[ward]/page.tsx @@ -2,7 +2,6 @@ import type { Metadata } from "next"; import { notFound } from "next/navigation"; import { WardDetail } from "@/components/elections/WardDetail"; import { WardMap, WardMapDefs } from "@/components/elections/WardMap"; -import { rosterSurvey } from "@/lib/elections/survey-answers"; import { ELECTION, WARD_NUMBERS, getToronto2026, getToronto2026Ward } from "../../data"; import { WARD_GEO, WARD_SHAPES } from "../../wardGeo"; import { wardProfile } from "../../wardProfiles"; @@ -43,21 +42,11 @@ export default async function WardDetailPage({ ]); if (!data) notFound(); - const candidateKeys = new Set( - data.councilRaces.flatMap((race) => race.candidates.map((c) => c.key)), - ); - const { answers: surveyAnswers, shape: surveyShape } = await rosterSurvey( - ELECTION.slug, - candidateKeys, - ); - return ( - {mayorSurveyPath && ( - - How they answered our questionnaire - - - )} @@ -666,11 +651,6 @@ 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 && ( @@ -684,16 +664,13 @@ 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. */} + {/* What the cards below actually go to, and nothing more. This used to + open "We put the same questions to every candidate on the ballot" — + a promise about answers, which is what the reader then went looking + for in a grid that has none of them until the questionnaire is + published. */}

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

diff --git a/src/components/elections/WardDetail.tsx b/src/components/elections/WardDetail.tsx index 628b5657..550aed41 100644 --- a/src/components/elections/WardDetail.tsx +++ b/src/components/elections/WardDetail.tsx @@ -3,25 +3,9 @@ import Image from "next/image"; import type { ReactNode } from "react"; import { ArrowLeft, ArrowRight } from "lucide-react"; import CountdownDays from "./CountdownDays"; -import { CandidateRoster } from "./CandidateRoster"; -import { - QuestionnaireCards, - questionnaireHeadings, -} 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"; -import { - comparedQuestions, - surveyRoster, -} from "@/lib/elections/candidate-answers"; -import type { - CandidateAnswers, - ComparedGroup, -} from "@/lib/elections/candidate-answers"; import { daysUntil } from "@/lib/elections/dates"; import type { SupportedElection } from "@/lib/elections/registry"; import type { @@ -45,8 +29,6 @@ export function WardDetail({ nominationCloseLabel, wardMapDefs, wardMap, - surveyAnswers, - surveyShape, profile, }: { election: SupportedElection; @@ -57,19 +39,6 @@ export function WardDetail({ wardMapDefs?: ReactNode; /** this region's locator map for this ward, when it has ward geometry */ wardMap?: ReactNode; - /** - * Published questionnaire answers for this ward's candidates, keyed by - * `nameKey`. A candidate with no entry simply shows no answers — for most of - * the campaign that is most of them. - */ - surveyAnswers?: Record; - /** - * The questionnaire's questions with nobody's answers on them, used where a - * ward's whole field stayed quiet — there are no returned questionnaires to - * read the questions off, and a ward of non-respondents still deserves to - * show which questions they did not answer. - */ - surveyShape?: ComparedGroup[]; /** * What this ward is, above the race to represent it — a short brief and the * Census statistics behind it. Only regions that maintain ward profiles @@ -86,29 +55,6 @@ export function WardDetail({ // grid runs straight under it — which is how Toronto's page has always read. const showRaceHeadings = councilRaces.length > 1; - /* The whole council ballot, and the part of it that wrote back. - - The questionnaire grid is the ward's candidate list now — there is no - separate roster of cards above it to agree or disagree with. So its - columns are every candidate still standing, and one we never heard from is - a column that says exactly that, which is more use to a voter than a name - quietly left out of the comparison. - - Withdrawn candidates are the exception, and are dropped: they cannot be - voted for, so a column of theirs is a column of a ballot line that does - not exist, and in a grid this wide every column costs the reader a drag. */ - const councilCandidates = councilRaces - .flatMap((race) => race.candidates) - .filter((candidate) => !candidate.withdrawn); - const respondents = councilCandidates.filter( - (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 (
@@ -171,90 +117,53 @@ export function WardDetail({
- {/* ── Questionnaire ──────────────────────────────────── */} -
- {/* The heading, what it amounts to, and the ballot it is about — - one column, with the survey beside it. The ballot used to sit in - a band of its own under this one, which left the heading's column - as a line of type and a sentence against a survey card three - times its height: a rectangle of nothing exactly where the names - a reader came for should have been. */} -
-
-

- Know Your Candidates -

- {/* Only the empty states get a sentence. Where candidates have - answered, the roster underneath names both halves of the - ballot — "2 of 11 answered" was the same count, spelled out, - immediately above the list it was counting. */} - {respondents.length === 0 && ( -

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

- )} - {councilCandidates.length > 0 && ( - !surveyAnswers?.[candidate.key], - ) - } - respondentsLabel={ - withheld ? "On the ballot" : undefined - } - election={election.slug} - race="councillor" - ward={ward.n} - wardName={ward.name} - /> - )} -
- - {/* 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) && ( - - )} + {/* ── Council candidates ─────────────────────────────── */} + {/* The ward's ballot, one card a candidate: who is standing, whether + they hold the seat, and the way to their own campaign. That is a + fact about the election rather than anything a candidate told us, + so it is what the page is made of until the questionnaire is + published. */} +
+
+

+ Candidates +

- {councilCandidates.length === 0 ? ( + {councilRaces.length === 0 && ( - ) : ( - councilRaces.map((race) => ( - - )) )} + {councilRaces.map((race) => ( +
+ {showRaceHeadings && } + {race.candidates.length === 0 ? ( + + ) : ( + race.candidates.map((cand) => ( + + )) + )} +
+ ))} +

- Registered candidates from the City Clerk’s list, less - anyone who has withdrawn. The field is not final until nominations - close{nominationCloseLabel ? ` on ${nominationCloseLabel}` : ""}. + Registered candidates from the City Clerk’s list. The field + is not final until nominations close + {nominationCloseLabel ? ` on ${nominationCloseLabel}` : ""}.

@@ -325,95 +234,6 @@ export function WardDetail({ ); } -/** - * One race's questionnaire, question by question. - * - * One per race rather than one for the ward, because a ward can elect more - * than one councillor — Brampton's wards elect a city and a regional - * councillor — and two rival fields read together would compare candidates who - * are not running against each other. - * - * A race nobody answered has no questions to draw, since the questions come - * from the returned questionnaires. That case still names the candidates: they - * are on the ballot, and the page is now the only place that says so. - */ -function RaceQuestionnaire({ - race, - 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 - questions can group, and the rest are named beside them — a reader can - still see everyone on their ballot and go to their site, without a - ward's dozen registrants turning thirty questions into three hundred - cells of "did not respond". */ - const roster = surveyRoster( - race.candidates.map((candidate) => ({ - ...candidate, - // "" for most of the ballot; the grid only draws the row when something - // in it is non-empty, so pass through rather than filtering here. - bio: candidate.bio || undefined, - })), - surveyAnswers, - ); - const answered = roster.filter((candidate) => candidate.answers); - const silent = roster.filter((candidate) => !candidate.answers); - const groups = comparedQuestions( - answered.map((candidate) => candidate.answers!), - answered, - surveyShape, - ); - - return ( -
- {showHeading && } -
- {groups.length > 0 ? ( - - - - ) : ( - /* 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." - : 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(", ")}.`} -

- )} -
-
- ); -} - function RaceHeading({ race }: { race: RaceView }) { return (
@@ -450,8 +270,8 @@ function EmptyRace({ ); } -/* A candidate card, which only the school-board races use now: the council - ballot is the questionnaire grid, and trustees have no questionnaire. */ +/* A candidate card — one per candidate, in both the council and the + school-board races. */ function CouncilCandidate({ candidate, election, diff --git a/src/lib/elections/registry.ts b/src/lib/elections/registry.ts index 4c6116ef..4026f057 100644 --- a/src/lib/elections/registry.ts +++ b/src/lib/elections/registry.ts @@ -103,21 +103,35 @@ export type SupportedElection = { * 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. + * and nothing deleted. It shuts the answers off at the source — the read + * proxy that serves them to the browser, and `rosterSurvey`, which every + * page reading them goes through. Turning them back on is deleting this + * flag. * - * 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 + * A candidate's own bio stays, 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. + * WHAT THE PAGES SHOW INSTEAD + * + * The pre-questionnaire ballot, which is what they showed before there were + * answers to publish: who is running, in which ward, with their campaign + * site — every one of which is a fact about the election rather than + * anything a candidate told us. The ward pages list their candidates, + * /mayor/candidates lists the mayoral field flat, and a candidate's own page + * keeps their bio and their links. + * + * None of them says anything about the answers. An empty state written for + * the weeks before anybody had written back — "Nobody in this ward has + * answered yet" — is a claim about candidates who answered months ago, and + * a notice that the answers are coming is a promise on a page that is + * complete without one. + * + * The two pages that exist only to publish answers, /issues and /mayor, have + * nothing left when those are gone, so they are switched off at the router + * instead and land on the nearest ballot (see next.config.ts). They stay in + * the repo exactly as built, which is why `ANSWERS_WITHHELD` below is still + * here for them. */ questionnaireHidden?: boolean; }; @@ -125,20 +139,17 @@ export type SupportedElection = { /** * 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. + * Nothing a reader can reach says it today: the pages that publish the ballot + * are complete without the answers, and are left to say what they do know + * rather than what they are not saying yet — see `questionnaireHidden` above. + * It is still here because /issues and /mayor, the two pages that were nothing + * but the answers, are kept in the repo as built and switched off at the + * router; this is the line they print if either is ever served again. * - * 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. + * One phrase, in one place, because 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 + * it reads as a stutter rather than as a fuller explanation. */ export const ANSWERS_WITHHELD = "Candidate survey coming soon."; From 2e23d536fe2ad3bb1568b4fbecc823913e451572 Mon Sep 17 00:00:00 2001 From: Mikaal Naik Date: Fri, 11 Sep 2026 15:53:09 -0400 Subject: [PATCH 2/2] Cut the redirect note to what a reader of it needs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Seventeen lines for two redirects, most of them saying a second time what the entries below say for themselves — which page goes where, and that each page stays in the repo. What is left is the part that is not in the code: why these two routes are off, and why the 307 is deliberate rather than an oversight. Co-Authored-By: Claude Opus 5 (1M context) --- next.config.ts | 22 +++++----------------- 1 file changed, 5 insertions(+), 17 deletions(-) diff --git a/next.config.ts b/next.config.ts index 75c0380d..d5314e30 100644 --- a/next.config.ts +++ b/next.config.ts @@ -124,23 +124,11 @@ const nextConfig: NextConfig = { destination: "/vote", permanent: false, }, - // The two pages that exist only to publish the candidates' answers are - // switched off until the questionnaire launches — the sibling of - // `questionnaireHidden` and `surveyClosed` in the election registry, and - // temporary in the same way. Both pages stay in the repo exactly as - // built; what comes down is the way to them. - // - // A redirect rather than an emptied page, because neither page has - // anything left when the answers are gone: /issues is the whole field - // read question by question, and /mayor is the mayoral field's answers. - // The ballot they were read against is still published — every - // candidate for mayor at /mayor/candidates, every ward at /wards/:n — - // so each lands on the ballot nearest to what it was about rather than - // on a page apologising for itself. - // - // Temporary, so a 307: a permanent redirect would sit in a reader's - // browser and skip the server after launch, when these are the pages we - // most want them to reach. + // /issues and /mayor are nothing but the candidates' answers, which are + // hidden until the questionnaire launches (`questionnaireHidden` in the + // election registry). Both pages stay in the repo as built; each lands + // on the nearest ballot instead. Temporary, so a 307: a 308 would sit in + // a reader's browser and skip the server after launch. { source: "/toronto/vote/2026/issues", destination: "/toronto/vote/2026",