diff --git a/next.config.ts b/next.config.ts index e21945e..d5314e3 100644 --- a/next.config.ts +++ b/next.config.ts @@ -124,6 +124,21 @@ const nextConfig: NextConfig = { destination: "/vote", permanent: false, }, + // /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", + 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 b7996a9..5b19d14 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 f7635d2..1075617 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 1ecde69..35791bf 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 9eea248..15586ce 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 628b565..550aed4 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 4c6116e..4026f05 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.";