From 2726a43b7e2d3186ef8c6179956c7b182ce6ce6e Mon Sep 17 00:00:00 2001
From: Mikaal Naik
Date: Wed, 9 Sep 2026 10:49:22 -0400
Subject: [PATCH 1/4] Give every Toronto candidate a page of their own
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
A candidate's name was a link to their campaign site, everywhere a name
appeared. So the one element that identified a person was also the exit,
and a reader who clicked it landed on a campaign's own account of the
candidate with the ward, the questionnaire answers and the ballot they
are on all left behind.
Names now lead to a page of ours at /toronto/vote/2026/candidates/:slug,
and the campaign site is a link on it — one fact among the race they are
standing in, the answers they gave us, and where the rest of their
ballot line is. 378 pages, slugged from the name, since the roster is
rebuilt from the City Clerk's feed daily and carries no candidate ids.
One shared CandidateNameLink replaced every outbound name link: the
questionnaire column heads and rosters, the mayoral cards and city-wide
race rows, the trustee cards, and the mayoral roster page. It falls back
to the old outbound link for regions with no candidate route, gated on a
new `candidateProfiles` registry flag, so a name never points at a 404.
The questionnaire reads through the ward pages' own cards — the same
QuestionnaireRail and QuestionnaireCards, given a roster of one — so the
two can never disagree about how a questionnaire reads. That retired
CandidateSurveyAnswers, which the profile page was the only consumer of,
and with it AnswerOptionList, which nothing imported any more.
Analytics keep the funnel whole: `candidate_website_clicked` still fires
from the profile page's outbound link, with `candidate_profile_clicked`
in front of it, same property names so a breakdown by `candidate_key`
reads across the pair.
Two things the roster forced, both verified against the live feed:
· Ten candidates registered in one race, withdrew, and registered in
another. Profiles take their identity from a race they are still
standing in — otherwise Dianne Saxe's page read "Withdrawn" while
she stands in Toronto-Danforth — and name the abandoned race
separately.
· Toronto's trustee races carry their district as a school-board ward
number that RaceView dropped, so those pages read "City-wide · Every
ward votes". RaceView now surfaces `districtNumber` and they read
"Trustee — Toronto Catholic District School Board, Ward 9".
Co-Authored-By: Claude Opus 5 (1M context)
---
.../[candidate]/opengraph-image.tsx | 62 +++
.../vote/2026/candidates/[candidate]/page.tsx | 467 ++++++++++++++++++
src/app/toronto/vote/2026/data.ts | 60 ++-
.../vote/2026/mayor/candidates/page.tsx | 20 +-
src/components/elections/AnswerOptionList.tsx | 126 -----
.../elections/CandidateNameLink.tsx | 119 +++++
.../elections/CandidateProfileLink.tsx | 62 +++
.../elections/CandidateSurveyAnswers.tsx | 295 -----------
src/components/elections/ElectionLanding.tsx | 70 +--
.../elections/QuestionnaireCards.tsx | 28 +-
src/components/elections/SurveyGrid.tsx | 35 +-
src/components/elections/WardDetail.tsx | 20 +-
src/lib/elections/candidate-profile.ts | 38 ++
src/lib/elections/election-data.ts | 150 +++++-
src/lib/elections/names.ts | 25 +
src/lib/elections/registry.ts | 10 +
16 files changed, 1052 insertions(+), 535 deletions(-)
create mode 100644 src/app/toronto/vote/2026/candidates/[candidate]/opengraph-image.tsx
create mode 100644 src/app/toronto/vote/2026/candidates/[candidate]/page.tsx
delete mode 100644 src/components/elections/AnswerOptionList.tsx
create mode 100644 src/components/elections/CandidateNameLink.tsx
create mode 100644 src/components/elections/CandidateProfileLink.tsx
delete mode 100644 src/components/elections/CandidateSurveyAnswers.tsx
create mode 100644 src/lib/elections/candidate-profile.ts
diff --git a/src/app/toronto/vote/2026/candidates/[candidate]/opengraph-image.tsx b/src/app/toronto/vote/2026/candidates/[candidate]/opengraph-image.tsx
new file mode 100644
index 00000000..252ba7ba
--- /dev/null
+++ b/src/app/toronto/vote/2026/candidates/[candidate]/opengraph-image.tsx
@@ -0,0 +1,62 @@
+import { ImageResponse } from "next/og";
+import { ElectionOGImage, OG_SIZE, logoDataUri } from "../../election-og";
+import { getToronto2026Candidate } from "../../data";
+
+export const alt = "Toronto 2026 Election candidate — Build Canada";
+export const size = OG_SIZE;
+export const contentType = "image/png";
+
+/* No generateStaticParams, deliberately — unlike the 25 ward images beside it.
+ The ballot is 388 candidates, and prerendering a PNG for every one would put
+ 388 satori renders in the build for images that are only ever fetched when
+ somebody shares that particular candidate. They render on demand and cache
+ from then on. */
+
+export default async function Image({
+ params,
+}: {
+ params: Promise<{ candidate: string }>;
+}) {
+ const { candidate: slug } = await params;
+ const [logoSrc, profile] = await Promise.all([
+ logoDataUri(),
+ getToronto2026Candidate(slug),
+ ]);
+
+ if (!profile) {
+ return new ImageResponse(
+ ,
+ { ...size },
+ );
+ }
+
+ const { candidate, races, wards } = profile;
+ const ward = wards[0];
+ const race = races[0];
+
+ /* The ward locator earns its place here: for a council candidate it answers
+ "is this my ward" before the reader has read the name. A mayoral candidate
+ gets the whole map unfilled, because every ward is theirs. */
+ return new ImageResponse(
+ ,
+ { ...size },
+ );
+}
diff --git a/src/app/toronto/vote/2026/candidates/[candidate]/page.tsx b/src/app/toronto/vote/2026/candidates/[candidate]/page.tsx
new file mode 100644
index 00000000..010b83b8
--- /dev/null
+++ b/src/app/toronto/vote/2026/candidates/[candidate]/page.tsx
@@ -0,0 +1,467 @@
+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 { 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 { comparedQuestions } from "@/lib/elections/candidate-answers";
+import { rosterSurvey } from "@/lib/elections/survey-answers";
+import { daysUntil } from "@/lib/elections/dates";
+import { possessive } from "@/lib/elections/names";
+import type { CandidateProfile, RaceView } from "@/lib/elections/election-data";
+import {
+ ELECTION,
+ candidateSlug,
+ getToronto2026Candidate,
+ getToronto2026Candidates,
+} from "../../data";
+
+/* One candidate, on a page of their own.
+ *
+ * WHY IT EXISTS
+ * A candidate's name was a link to their campaign site — on the landing
+ * page, on every ward page, in every questionnaire roster. So the one thing
+ * on the page that identifies a person was also the way off the site, and a
+ * reader who clicked it left holding a campaign's own account of the
+ * candidate: no ward, no questionnaire answers, no indication of whether
+ * they had answered at all, and no way back but the back button.
+ *
+ * Everything we know about a candidate now has somewhere to live. The name
+ * goes here; the campaign site is a link on this page, one fact among the
+ * ward they are running in, the answers they gave us, and where the rest of
+ * their ballot line is. The outbound link is still instrumented exactly as
+ * it was (CandidateSiteLink), so the click-through funnel survives with a
+ * step in front of it.
+ *
+ * THE SLUG
+ * `candidateSlug(name)` — the roster is rebuilt from the City Clerk's feed
+ * daily and carries no candidate ids, so the URL has to come from the name.
+ * That makes the set of valid URLs a function of today's roster: a name the
+ * Clerk corrects is a new URL, and a slug naming nobody 404s rather than
+ * rendering an empty profile.
+ *
+ * WHAT IT DOES NOT DO
+ * It does not editorialise. Bios are hand-written where we have them and
+ * absent for most of a fifty-three-name ballot; where there is no bio, no
+ * site and no questionnaire, the page says plainly that this is a registered
+ * candidate we know little about, rather than padding the gap. */
+
+/**
+ * One race, named in full: "Mayor", "Councillor — Etobicoke North", "Trustee —
+ * Toronto Catholic District School Board, Ward 2".
+ *
+ * `race.label` is enough for the two races the rest of the site covers, and
+ * not enough for a school board: the boards publish no district names, so a
+ * trustee race's label is the bare word "Trustee" and its district lives in
+ * `districtNumber`, in the board's own ward numbering.
+ */
+function raceTitle(race: RaceView): string {
+ if (race.districtName) return race.label;
+ if (race.officeBody) {
+ return race.districtNumber !== null
+ ? `${race.seat} — ${race.officeBody}, Ward ${race.districtNumber}`
+ : `${race.seat} — ${race.officeBody}`;
+ }
+ return race.seat;
+}
+
+/** The races they are standing in — `races` has already set aside the ones
+ * they withdrew from, which the page names separately. */
+function raceLabel(profile: CandidateProfile): string {
+ return profile.races.map(raceTitle).join(" and ");
+}
+
+export async function generateStaticParams() {
+ const profiles = await getToronto2026Candidates();
+ return profiles.map((profile) => ({
+ candidate: candidateSlug(profile.candidate.name),
+ }));
+}
+
+export async function generateMetadata({
+ params,
+}: {
+ params: Promise<{ candidate: string }>;
+}): Promise {
+ const { candidate: slug } = await params;
+ const profile = await getToronto2026Candidate(slug);
+ if (!profile) return { title: "Candidate not found" };
+
+ const { name } = profile.candidate;
+ const race = raceLabel(profile);
+
+ 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.`,
+ alternates: { canonical: `${ELECTION.basePath}/candidates/${slug}` },
+ openGraph: {
+ title: `${name} — Toronto 2026 Election`,
+ description: `Where ${name} stands, and how to reach their campaign.`,
+ type: "profile",
+ },
+ };
+}
+
+export default async function CandidatePage({
+ params,
+}: {
+ params: Promise<{ candidate: string }>;
+}) {
+ const { candidate: slug } = await params;
+ const profile = await getToronto2026Candidate(slug);
+ if (!profile) notFound();
+
+ const { candidate, races, wards } = profile;
+ const ward = wards[0];
+ 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 } = await rosterSurvey(ELECTION.slug, new Set([candidate.key]));
+ const surveyAnswers = answers[candidate.key];
+
+ /* 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
+ councillor, the mayoral field for a mayoral candidate. */
+ const ballotHref = ward
+ ? `${ELECTION.basePath}/wards/${ward.n}`
+ : raceKind === "mayor"
+ ? `${ELECTION.basePath}/mayor/candidates`
+ : ELECTION.basePath;
+ const ballotLabel = ward
+ ? `Everyone running in ${ward.name}`
+ : raceKind === "mayor"
+ ? "Everyone running for mayor"
+ : "The whole ballot";
+
+ return (
+
+
+ Toronto 2026
+
+ /
+ {/* A link only where the crumb has its own page. Toronto's
+ school-board races have none — the site covers the mayor and the
+ 25 council wards — so a board crumb that pointed back at the
+ election index would have been a link to the crumb beside it. */}
+ {ballotHref === ELECTION.basePath ? (
+
+ {race.officeBody ?? race.seat}
+
+ ) : (
+
+ {ward ? `Ward ${ward.number}` : race.seat}
+
+ )}
+ /
+ {candidate.name}
+
+
+ {/* ── Hero ───────────────────────────────────────────── */}
+ {/* No eyebrow over the name. It printed the race — "Councillor —
+ Toronto Centre" — which the row directly beneath now says twice
+ over, as "Running for / Councillor" and "Ward 13 / Toronto
+ Centre". The breadcrumb above has already placed the reader too.
+ `raceTitle` still writes the page title and its description, where
+ the race is the one thing distinguishing one candidate from
+ another. */}
+
+
+ Registered and since withdrawn — {candidate.name} cannot be
+ voted for. The page is kept because the name still appears on
+ lists elsewhere.
+
+ )}
+
+ {/* A candidate who registered somewhere, withdrew, and
+ registered somewhere else. Ten people on Toronto's 2026
+ ballot have done it, and the page above names the race they
+ are actually standing in — this is the rest of the story, for
+ a reader who arrived from the ward they left. */}
+ {profile.withdrawnFrom.length > 0 && (
+
+ {`Previously registered for ${profile.withdrawnFrom
+ .map(raceTitle)
+ .join(" and ")}, and withdrawn.`}
+
+ )}
+
+ {candidate.bio && (
+
+ {candidate.bio}
+
+ )}
+
+ {/* Where we have nothing hand-written, say so rather than leave
+ the reader to read absence as a judgement. Most of a
+ fifty-three-name ballot is in exactly this state, and it is
+ the ordinary condition of a municipal candidate. */}
+ {!candidate.bio && !candidate.website && (
+
+ A registered candidate on the City Clerk’s list. We have
+ no campaign site or profile for {candidate.name} yet — this
+ page fills in as we learn more.
+
+
+
+ {/* ── Where they're running ──────────────────────────── */}
+
+
+ {/* Only a genuinely at-large race is city-wide. A school-board
+ district is neither a city ward nor the whole city, and its own
+ ward number is the only name it has. */}
+ {/* The caption names which district, the line under it says what
+ that district is: "Ward 13" over "Toronto Centre". A city-wide
+ race is the same pair the other way about — "City-wide" is the
+ district's name and "Every ward votes" is what it amounts to —
+ so it swaps rather than putting a whole statement in the caption
+ slot. */}
+
+
+ {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.
+
+
+
+
+ >
+ ) : (
+
+ {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.
+
+ Registered candidates from the City Clerk’s official list,
+ refreshed daily. Campaign sites are linked as published by the
+ candidate; a link is not an endorsement.
+
+
+
+ {/* ── Elsewhere ──────────────────────────────────────── */}
+
+
+
+
+ {ballotLabel}
+
+
+
+
+ Where the whole field stands
+
+
+
+
+
+
+ );
+}
+
+/* A cell of the "where they're running" row: what the cell is, then what it
+ says — "Running for" over "Councillor", not "Councillor" over "Running for".
+ The stat rows elsewhere on the site put the figure first because the figure
+ is the point and the caption is a unit ("53" / "On the ballot"). These cells
+ are not figures: "Councillor" arriving above the words telling you what it
+ is means the reader meets an answer before the question, and reads the cell
+ twice. */
+function Stat({ value, label }: { value: string; label: string }) {
+ return (
+
+
+ {label}
+
+
+ {value}
+
+
+ );
+}
+
+/** `social_links[].name` is an open vocabulary ("web", "facebook", "tiktok",
+ * …), so unknown names are title-cased rather than dropped. */
+function socialLabel(name: string): string {
+ if (name.toLowerCase() === "web") return "Website";
+ return name.charAt(0).toUpperCase() + name.slice(1);
+}
diff --git a/src/app/toronto/vote/2026/data.ts b/src/app/toronto/vote/2026/data.ts
index c9d62dbd..67362d4d 100644
--- a/src/app/toronto/vote/2026/data.ts
+++ b/src/app/toronto/vote/2026/data.ts
@@ -13,17 +13,20 @@
import { WARD_SHAPES } from "./wardGeo";
import {
+ getCandidateProfiles,
getElectionView,
getNominationCloseLabel,
getWardDetail,
initialsFor,
nameKey,
+ type CandidateProfile,
type CandidateView,
type ElectionDataOptions,
type ElectionView,
type WardDetail,
type WardRosterEntry,
} from "@/lib/elections/election-data";
+import { candidateSlug } from "@/lib/elections/candidate-profile";
import { getElection } from "@/lib/elections/registry";
import {
MAYORAL_CANDIDATES,
@@ -33,7 +36,8 @@ import {
} from "./candidates";
export type { MayoralCandidate, CouncillorCandidate };
-export { initialsFor, nameKey };
+export { initialsFor, nameKey, candidateSlug };
+export type { CandidateProfile };
export const ELECTION = getElection("toronto-2026");
@@ -145,6 +149,7 @@ export async function getToronto2026Ward(
label: `Councillor — ${ward.name}`,
officeBody: null,
districtName: ward.name,
+ districtNumber: number,
wardNumbers: [number],
atLarge: false,
candidates,
@@ -154,3 +159,56 @@ export async function getToronto2026Ward(
trusteeRaces: [],
};
}
+
+// ── Candidate pages ────────────────────────────────────────────────────────
+
+/* Every candidate has a page of their own at ./candidates/[candidate], keyed
+ by `candidateSlug(name)`. Two things follow from the roster being rebuilt
+ from the Clerk's feed rather than stored: the slug has to be derivable from
+ the name, and a candidate who withdraws — or a name the Clerk corrects —
+ changes the set of valid URLs. So the routes are generated from whatever the
+ roster says today and anything outside it 404s. */
+
+/** Every candidate in the election, mayoral and council alike, each with the
+ * race they are in. Falls back to the local roster when the API is down —
+ * which means the mayoral field only, since the fallback's ward races are
+ * assembled per ward. */
+export async function getToronto2026Candidates(): Promise {
+ const live = await getCandidateProfiles(ELECTION.slug, OPTIONS);
+ if (live) return live;
+
+ const view = fallbackView();
+ return view.mayoral.map((candidate) => ({
+ candidate,
+ races: [
+ {
+ id: "mayor||at-large",
+ seat: "Mayor",
+ label: "Mayor",
+ officeBody: null,
+ districtName: null,
+ districtNumber: null,
+ wardNumbers: [],
+ atLarge: true,
+ candidates: view.mayoral,
+ registeredCount: view.mayoral.length,
+ },
+ ],
+ officeTypes: ["mayor"],
+ wards: [],
+ withdrawnFrom: [],
+ }));
+}
+
+/** One candidate by their URL slug, or null for a slug that names nobody on
+ * the current roster. */
+export async function getToronto2026Candidate(
+ slug: string,
+): Promise {
+ const profiles = await getToronto2026Candidates();
+ return (
+ profiles.find(
+ (profile) => candidateSlug(profile.candidate.name) === slug,
+ ) ?? null
+ );
+}
diff --git a/src/app/toronto/vote/2026/mayor/candidates/page.tsx b/src/app/toronto/vote/2026/mayor/candidates/page.tsx
index 0f670705..d21a78e3 100644
--- a/src/app/toronto/vote/2026/mayor/candidates/page.tsx
+++ b/src/app/toronto/vote/2026/mayor/candidates/page.tsx
@@ -3,10 +3,8 @@ import Image from "next/image";
import Link from "next/link";
import { ArrowLeft, ArrowRight } from "lucide-react";
-import {
- IncumbentBadge,
- SiteLink,
-} from "@/components/elections/ElectionLanding";
+import { IncumbentBadge } from "@/components/elections/ElectionLanding";
+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";
@@ -286,19 +284,17 @@ function Roster({ candidates }: { candidates: CandidateView[] }) {
candidate.withdrawn ? "line-through decoration-1" : ""
}`}
>
- {candidate.name}
-
- {candidate.tag === "Incumbent" && }
-
- {candidate.website && (
-
-
- )}
+ {candidate.tag === "Incumbent" && }
+
))}
diff --git a/src/components/elections/AnswerOptionList.tsx b/src/components/elections/AnswerOptionList.tsx
deleted file mode 100644
index 25f18c7c..00000000
--- a/src/components/elections/AnswerOptionList.tsx
+++ /dev/null
@@ -1,126 +0,0 @@
-import { WedgeGlyph } from "@/components/charts/trilemma";
-
-/* The options a question offered, as a list beside its chart.
- *
- * Shared by the ward pages and the survey's alignment view so the two cannot
- * drift: an answer only means something read against the alternatives, and
- * both places have to put them in the same order, with the same counts, and
- * with the same glyph tying each row to its slice of the chart.
- *
- * The glyph is the chart's own wedge for that option, which is what lets the
- * list be read straight onto the dial or bar without a legend.
- */
-
-export type OptionMark = {
- /** which option this marks */
- index: number;
- /** e.g. "Their answer", "Your answer" */
- label: string;
-};
-
-export function AnswerOptionList({
- options,
- details,
- counts,
- colors,
- marks = [],
- markColor,
- names,
- showCounts = true,
- valueFormat = String,
-}: {
- options: string[];
- /** parallel to `options`; a null entry simply has no expansion */
- details?: (string | null)[];
- counts: number[];
- /** parallel to `options` — the chart's fill for each, muted where unchosen */
- colors: string[];
- /** badges pinned to particular options */
- marks?: OptionMark[];
- /**
- * Who picked each option, parallel to `options` — the comparison view lists
- * the ward's candidates under the option they chose, which is the whole
- * point of that page and the one thing a chart of shares cannot say.
- */
- names?: string[][];
- /** badge and count colour; defaults to each option's own chart colour */
- markColor?: string;
- /**
- * The count column. Off where the chart beside the list already direct-labels
- * every option with its count, which would otherwise print each number twice
- * a centimetre apart.
- */
- showCounts?: boolean;
- /**
- * How a count reads. Defaults to the number itself; pass `percentOf(total)`
- * to match a chart beside the list that prints shares — the same quantity
- * printed two ways a centimetre apart is worse than either.
- */
- valueFormat?: (n: number) => string;
-}) {
- const marked = new Set(marks.map((mark) => mark.index));
-
- return (
-
- {options.map((option, i) => {
- const tone = markColor ?? colors[i];
- const own = marks.filter((mark) => mark.index === i);
-
- return (
- /* An option nobody's badge is on still has to be readable: knowing
- what a candidate turned down is half of knowing what they picked.
- The distinction is carried by the type colour alone — charcoal
- against the chosen option's near-black — rather than by a colour
- AND a blanket opacity on top of it, which compounded into text
- around a third of the contrast of the line above it. */
-
- {/* The chart's own wedge for this option, muted where unchosen —
- which is where the fade now lives, on the swatch rather than on
- the words. */}
-
-
-
-
-
- {option}
- {details?.[i] && (
- : {details[i]}
- )}
- {own.map((mark) => (
-
- {mark.label}
-
- ))}
- {names?.[i]?.length ? (
-
- {names[i].join(", ")}
-
- ) : null}
-
-
- {showCounts && (
-
- {/* Charcoal rather than the faintest token: an unchosen
- option's share is the comparison, not a footnote to it. */}
-
- {valueFormat(counts[i])}
-
-
- )}
-
- );
- })}
-
- );
-}
diff --git a/src/components/elections/CandidateNameLink.tsx b/src/components/elections/CandidateNameLink.tsx
new file mode 100644
index 00000000..8dc5ce5e
--- /dev/null
+++ b/src/components/elections/CandidateNameLink.tsx
@@ -0,0 +1,119 @@
+"use client";
+
+import { ArrowRight, ArrowUpRight } from "lucide-react";
+
+import { CandidateProfileLink } from "./CandidateProfileLink";
+import { CandidateSiteLink } from "./CandidateSiteLink";
+import { candidateProfilePath } from "@/lib/elections/candidate-profile";
+
+/* A candidate's name, as a link to the candidate.
+ *
+ * WHERE THE NAME USED TO GO
+ * Out. A name with a campaign site was a link to that site, everywhere a
+ * name appeared — the mayoral cards, the ward rosters, the questionnaire
+ * column heads. So the one element that identified a person was also the
+ * exit, and the reader who clicked it landed on a campaign's own account of
+ * the candidate with the ward, the questionnaire answers and the ballot they
+ * are on all left behind.
+ *
+ * Now the name goes to our page for that candidate, and the campaign site is
+ * a link on it. Nothing is lost from the funnel: the outbound click is still
+ * `candidate_website_clicked`, fired from the profile page by the same
+ * CandidateSiteLink, with `candidate_profile_clicked` in front of it.
+ *
+ * WHERE THERE IS NO PAGE
+ * Regions we cover but have not built candidate pages for (everywhere but
+ * Toronto — see `candidateProfiles` in the registry) keep the outbound name
+ * link exactly as it was. A name pointing at a 404 is worse than a name
+ * pointing outward, so this degrades rather than assumes.
+ *
+ * THE ARROW
+ * It is the whole tell that a name is a link, so it appears only where there
+ * is somewhere to go — and it points the way it goes: right for a page of
+ * ours, up-and-out for a campaign site.
+ */
+
+export type LinkableCandidate = {
+ /** `nameKey(name)` — the analytics candidate key */
+ key: string;
+ name: string;
+ website?: string;
+ tag?: string;
+};
+
+export function CandidateNameLink({
+ candidate,
+ election,
+ race,
+ ward,
+ wardName,
+ className = "",
+ linkClassName = "",
+ arrow = true,
+}: {
+ candidate: LinkableCandidate;
+ /** York Factory election slug — decides whether a profile page exists */
+ election: string;
+ race: "mayor" | "councillor" | "trustee";
+ ward?: string;
+ wardName?: string;
+ /** typography for the name, applied whether or not it links */
+ className?: string;
+ /** extra classes for the link state only (hover colour, group name) */
+ linkClassName?: string;
+ /** drop the trailing arrow where the surrounding card is already a link
+ * target, or where the row has no width for it */
+ arrow?: boolean;
+}) {
+ const profileHref = candidateProfilePath(election, candidate.name);
+
+ if (profileHref) {
+ return (
+
+ {candidate.name}
+ {arrow && (
+
+ )}
+
+ );
+ }
+
+ if (candidate.website) {
+ return (
+
+ {candidate.name}
+ {arrow && (
+
+ )}
+
+ );
+ }
+
+ return {candidate.name};
+}
diff --git a/src/components/elections/CandidateProfileLink.tsx b/src/components/elections/CandidateProfileLink.tsx
new file mode 100644
index 00000000..fede25d4
--- /dev/null
+++ b/src/components/elections/CandidateProfileLink.tsx
@@ -0,0 +1,62 @@
+"use client";
+
+import Link from "next/link";
+import posthog from "posthog-js";
+import type { ReactNode } from "react";
+
+/* An internal link to a candidate's own page, instrumented the way
+ CandidateSiteLink instruments the outbound one.
+
+ The two events are deliberately separate and both worth having.
+ `candidate_profile_clicked` is interest in a candidate, measured on our own
+ pages; `candidate_website_clicked` still fires from the campaign-site link
+ on the profile page, so the outbound funnel is intact — it just has a step
+ in front of it now. Same property names in both, so a breakdown by
+ `candidate_key` reads across the pair. */
+
+interface CandidateProfileLinkProps {
+ href: string;
+ candidate: string;
+ candidateKey: string;
+ race: "mayor" | "councillor" | "trustee";
+ /** York Factory election slug, e.g. "toronto-2026" */
+ election: string;
+ tag?: string;
+ ward?: string;
+ wardName?: string;
+ className?: string;
+ children: ReactNode;
+}
+
+export function CandidateProfileLink({
+ href,
+ candidate,
+ candidateKey,
+ race,
+ election,
+ tag,
+ ward,
+ wardName,
+ className,
+ children,
+}: CandidateProfileLinkProps) {
+ return (
+ {
+ posthog.capture("candidate_profile_clicked", {
+ candidate,
+ candidate_key: candidateKey,
+ race,
+ election,
+ tag,
+ ward,
+ ward_name: wardName,
+ });
+ }}
+ >
+ {children}
+
+ );
+}
diff --git a/src/components/elections/CandidateSurveyAnswers.tsx b/src/components/elections/CandidateSurveyAnswers.tsx
deleted file mode 100644
index 0e3e0bb5..00000000
--- a/src/components/elections/CandidateSurveyAnswers.tsx
+++ /dev/null
@@ -1,295 +0,0 @@
-"use client";
-
-import { ChevronDown } from "lucide-react";
-
-import { WedgeGlyph, percentOf } from "@/components/charts/trilemma";
-import { AnswerChart, optionColors, sharedRadius } from "./AnswerChart";
-import { AnswerOptionList } from "./AnswerOptionList";
-import { firstName, lastName, possessive } from "@/lib/elections/names";
-import {
- Collapsible,
- CollapsibleContent,
- CollapsibleTrigger,
-} from "@/components/ui/collapsible";
-import type {
- CandidateAnswer,
- CandidateAnswers,
-} from "@/lib/elections/candidate-answers";
-
-/* What one candidate told the questionnaire, on their roster card.
- *
- * FORM
- * Most of the questionnaire is a three-way choice between competing
- * alternatives, and those get a dial: a circle in equal thirds, one wedge
- * per option, reaching out as far as the number of candidates who picked it.
- * The wedge this candidate chose carries its colour and the rest go neutral,
- * so the answer reads as a silhouette — where they landed, and whether the
- * field landed with them.
- *
- * The five direct questions are not that shape. "Yes / Yes, with conditions
- * / No" is ordered, and a dial would put those three at 120° from each other
- * as though they were rival options rather than points on a scale. They get
- * a segmented bar, which keeps the order and shows the split. So do the two
- * four-option questions, which cannot be a trilemma at all.
- *
- * Every chart on the card shares one value scale (`fieldSize`), so a wedge
- * that reaches the rim always means the same thing. Per-question scales
- * would make every answer look unanimous.
- *
- * The answer itself is written out under the question, before the chart:
- * what they picked, in the words they were offered, with that option's wedge
- * glyph beside it. The chart is what the field did — reading a single
- * candidate's answer off it means hunting the coloured third and then its
- * rim label, which is a step too many between a question and its answer.
- *
- * The chart stays the biggest thing in the row all the same, and it carries
- * its own key: the question sits above it as the row's heading, and
- * each wedge is direct-labelled with the option's name and the number of
- * candidates who picked it. A shape with the meaning parked in a column
- * beside it is a decoration; a shape that names its own thirds is the answer.
- *
- * Beside each chart the options are listed as they were offered, in full
- * wording with their expansions, each with a glyph of its own wedge so the
- * list reads straight onto the dial. Counts are left to the chart, which
- * prints them at the rim — the list carries what the chart cannot fit.
- *
- * Expanded by default. The answers are the reason to look a candidate up;
- * hiding them behind a click made the card a promise rather than an answer.
- * Still collapsible, because a ward with a dozen candidates is a long page.
- *
- * COLOUR
- * The chart palette's own corner hues, which is what makes the option
- * position legible: the first option is the same colour on all 32 questions,
- * so "they picked the first one again" is visible without reading. Safe here
- * in a way it is not in the survey's alignment view, which spends pine and
- * copper on agree/differ and so has to stay two-tone.
- */
-
-export function CandidateSurveyAnswers({
- answers,
- candidateName,
-}: {
- answers: CandidateAnswers;
- candidateName: string;
-}) {
- const radius = sharedRadius(answers.groups.flatMap((group) => group.answers));
-
- return (
-
-
-
- Survey answers ({answers.answered})
-
-
-
-
- {answers.groups.map((group) => (
-
- {/* The questionnaire's own sections, and the only landmarks in a
- card that runs to thirty-odd answers. They take the house
- section rule — a heavy line and a real heading — rather than
- the faint eyebrow they had, which read as a caption on the
- answer above it rather than as the start of something. */}
-
- {group.stepTitle}
-
-
- {/* Two answers to a row where there is width for it: stacked
- chart-over-options, each answer is a tall narrow block, and
- one per row left a column of white space beside every dial. */}
-
- {group.answers.map((answer) => (
-
- ))}
-
-
- ))}
-
-
-
- );
-}
-
-function Answer({
- answer,
- candidateName,
- fieldSize,
- radius,
-}: {
- answer: CandidateAnswer;
- candidateName: string;
- fieldSize: number;
- /** the card's shared outer radius, from `sharedRadius` */
- radius?: number;
-}) {
- const { full, colors } = optionColors(answer.options.length, answer.choice);
- // Candidates whose answer landed in a bucket — not the whole field, since a
- // transcribed answer is in none of them.
- const counted = answer.counts.reduce((a, b) => a + b, 0);
- // Shares of the field, not head counts: the reader has no idea whether nine
- // is most of the ward or a corner of it, and every chart on the card is
- // scaled against the same field, so the same denominator is already implied
- // by the geometry.
- const share = percentOf(fieldSize);
-
- return (
- /* Boxed, and a full-height column rather than a content-height block.
- Two answers share a row, and a hairline above each was enough to
- separate a single column but not a grid: with a neighbour alongside,
- a rule at the top of both reads as one line under the pair, and where
- one answer runs longer than the other there was nothing to say which
- question the leftover text belonged to. A box closes each one.
-
- Stretched to the row, with the chart pushed to the bottom, every answer
- frames the same way — which is what makes any one of them croppable on
- its own. */
-
- {/* The question, above the chart rather than beside it: the chart is
- wider now, and a heading in its own column would have made the row a
- pair of narrow strips. */}
-
-
- {answer.question}
-
-
- {/* What they said, in words, directly under the question and ahead of
- the chart. The chart shows where the field went and which third is
- theirs, but reading it means finding the coloured wedge and then
- its rim label — a step between the question and its answer. This
- says it outright; the chart then answers "and who else?".
-
- The glyph is the chart's own wedge for that option, so the eye can
- carry the colour from this line down onto the dial. */}
-
-
- {/* Options then chart, stacked rather than side by side — at half a
- card's width the two columns were a pair of strips too narrow for
- either. The chart goes last because it is the slowest thing to read:
- the question, the answer in words, and the alternatives it was chosen
- from are the whole story for most readers, and the field's shape is
- what you stay for. Ending the row on it also puts every dial on a
- consistent line above the next question's heading. */}
-
-
- {/* The options as they were offered, so the answer is read against
- the alternatives rather than on its own. Shares stay on the
- chart below, which direct-labels every one of them. */}
-
-
- {answer.explanation && (
-
-
- {possessive(firstName(candidateName))} note
-
-
- {answer.explanation}
-
-
- )}
-
-
- `${option} ${share(answer.counts[i])}`)
- .join(", ")}; ${candidateName} chose ${answer.answer}`}
- />
-
- {/* Whose chart this is, said under it. On a card of thirty-odd
- boxed answers — and in a screenshot of any one of them, cropped
- away from the card's header — the dial otherwise arrives with no
- owner: three coloured thirds and no statement of what the coloured
- one belongs to. */}
- {counted > 0 && (
-
- {possessive(lastName(candidateName))} responses vs all other
- candidates
-
- )}
-
-
-
- );
-}
-
-/**
- * What every chart on the page is counting, said once at the foot of it.
- *
- * This used to sit under each chart, where it was true but relentless: a ward
- * page carries a dozen candidates at thirty-odd answers each, so the same four
- * lines were set several hundred times, and a note repeated that often stops
- * being read at all. It is a property of the whole questionnaire — the same
- * field, the same denominator, on every chart — so it belongs where a source
- * note belongs, at the bottom, once.
- */
-export function SurveyChartNote({ candidateCount }: { candidateCount: number }) {
- return (
-
- Reading the charts. Each
- third of a dial is one of the options, reaching further the more of this
- ward’s candidates picked it; on the segmented bars each band is one
- option, as wide as the share that picked it. Counts are out of the{" "}
- {candidateCount} candidates in this ward who returned the
- questionnaire, not the whole ballot. A candidate who answered in their
- own words rather than picking an option is counted on no option, and is
- named under the question instead.
-
- );
-}
diff --git a/src/components/elections/ElectionLanding.tsx b/src/components/elections/ElectionLanding.tsx
index 14c02f96..46f9956a 100644
--- a/src/components/elections/ElectionLanding.tsx
+++ b/src/components/elections/ElectionLanding.tsx
@@ -1,10 +1,10 @@
import Image from "next/image";
import Link from "next/link";
import { Suspense, type ReactNode } from "react";
-import { ArrowRight, ArrowUpRight } from "lucide-react";
+import { ArrowRight } from "lucide-react";
import CountdownDays from "./CountdownDays";
import LiveCountdown from "./LiveCountdown";
-import { CandidateSiteLink } from "./CandidateSiteLink";
+import { CandidateNameLink } from "./CandidateNameLink";
import { PledgeButton } from "./PledgeButton";
import { SurveyCta } from "./SurveyCta";
import { ResidencyModal } from "./ResidencyModal";
@@ -830,14 +830,18 @@ function MayoralCard({
candidate.initials
)}
-
-
-
- {candidate.name}
-
- {candidate.tag === "Incumbent" && }
-
-
+ {/* The name is the link. It used to be plain text with "Campaign site"
+ on the line beneath it, which spent a second line saying that the
+ thing above it led somewhere — and led off the site. */}
+
);
}
@@ -959,43 +964,6 @@ export function IncumbentBadge() {
);
}
-/** The campaign-site link, or the placeholder shown when we have no URL. */
-export function SiteLink({
- candidate,
- election,
- race,
- ward,
- wardName,
-}: {
- candidate: CandidateView;
- election: string;
- race: "mayor" | "councillor" | "trustee";
- ward?: string;
- wardName?: string;
-}) {
- /* No site, no line. "Profile to come" was a promise we do not control — a
- candidate with no web presence may never acquire one — and printed under
- every third name it read as a column of missing things rather than as the
- ordinary state of a municipal candidate. The absence says it already. */
- if (!candidate.website) return null;
- return (
-
- Campaign site
-
-
- );
-}
-
/** `social_links[].name` is an open vocabulary ("web", "facebook", "tiktok",
* …), so unknown names are title-cased rather than dropped. */
function socialLabel(name: string): string {
diff --git a/src/components/elections/QuestionnaireCards.tsx b/src/components/elections/QuestionnaireCards.tsx
index fdcf0fff..880d5636 100644
--- a/src/components/elections/QuestionnaireCards.tsx
+++ b/src/components/elections/QuestionnaireCards.tsx
@@ -1,4 +1,5 @@
import Link from "next/link";
+import type { ReactNode } from "react";
import { ArrowRight } from "lucide-react";
import type { Heading } from "@/components/custom/signpost/config";
@@ -77,6 +78,7 @@ export function QuestionnaireCards({
notes = true,
yourKey,
idPrefix,
+ answerNote,
}: {
groups: ComparedGroup[];
/** the candidates who returned the questionnaire */
@@ -100,6 +102,11 @@ export function QuestionnaireCards({
* element called "housing" in the document and the scroll rail would only
* ever find the first. */
idPrefix?: string;
+ /** replaces the sentence at the foot explaining how to read the cards. The
+ * default is written for a race — "options nobody in this ward picked are
+ * not shown" — and a page whose cards hold a single candidate has a
+ * different thing to say about what is missing from them. */
+ answerNote?: ReactNode;
}) {
const silentNames = silent.map((candidate) => ({
key: candidate.key,
@@ -140,7 +147,11 @@ export function QuestionnaireCards({
))}
-
+
);
}
@@ -151,9 +162,12 @@ export function QuestionnaireCards({
function WardAnswerNote({
issuesHref,
notes,
+ note,
}: {
issuesHref?: string;
notes?: boolean;
+ /** an override for the sentence — see `answerNote` */
+ note?: ReactNode;
/** the reader's own row, where they have answered the same questionnaire —
* see QuestionRollCall. */
yourKey?: string;
@@ -167,10 +181,14 @@ function WardAnswerNote({
return (
- Candidates are grouped by the answer they gave. Options nobody in this
- ward picked are not shown, and a candidate who answered in their own
- words sits on no option.
- {notes ? " Notes are the candidates’ own words." : ""}
+ {note ?? (
+ <>
+ Candidates are grouped by the answer they gave. Options nobody in
+ this ward picked are not shown, and a candidate who answered in
+ their own words sits on no option.
+ {notes ? " Notes are the candidates’ own words." : ""}
+ >
+ )}
);
diff --git a/src/lib/elections/candidate-profile.ts b/src/lib/elections/candidate-profile.ts
new file mode 100644
index 00000000..8ea693e6
--- /dev/null
+++ b/src/lib/elections/candidate-profile.ts
@@ -0,0 +1,38 @@
+// Where a candidate's own page lives, for the components that link to it.
+//
+// A candidate's name used to be a link to their campaign site, everywhere a
+// name appeared. That made the name a way off the site: the reader clicked the
+// one thing on the page that identified a person and landed on a campaign's
+// own framing of them, with the questionnaire answers, the ward, and whether
+// they had even registered all left behind. Now the name goes to a page of
+// ours, and the campaign site is a link on it — one of the things we know
+// about the candidate rather than the only thing we point at.
+//
+// Kept apart from ./registry so that file stays plain configuration, and free
+// of ./election-data so client components can import it: the path is derived
+// from the name alone, which is all a rendered roster has.
+
+import { candidateSlug } from "./names";
+import { SUPPORTED_ELECTIONS } from "./registry";
+
+export { candidateSlug };
+
+/**
+ * The internal page for one candidate, or null where this region has none —
+ * only Toronto has the route (see `candidateProfiles`), and every other region
+ * keeps the outbound name link it has always had rather than pointing at a
+ * 404.
+ *
+ * Looked up by exact slug rather than `getElection`, so an unknown election
+ * gets no path instead of quietly inheriting Toronto's.
+ */
+export function candidateProfilePath(
+ electionSlug: string,
+ candidateName: string,
+): string | null {
+ const election = SUPPORTED_ELECTIONS[electionSlug];
+ if (!election?.candidateProfiles) return null;
+ const slug = candidateSlug(candidateName);
+ if (!slug) return null;
+ return `${election.basePath}/candidates/${slug}`;
+}
diff --git a/src/lib/elections/election-data.ts b/src/lib/elections/election-data.ts
index 4e98f403..e7edee56 100644
--- a/src/lib/elections/election-data.ts
+++ b/src/lib/elections/election-data.ts
@@ -21,6 +21,7 @@
import { fetchElection, type ApiCandidate, type ApiRace } from "@/lib/api/elections";
import { daysUntil, parseDateOnly } from "./dates";
+import { nameKey } from "./names";
import { SUPPORTED_ELECTIONS } from "./registry";
export { daysUntil, parseDateOnly };
@@ -57,19 +58,10 @@ export function initialsFor(name: string): string {
return (first + last).toUpperCase();
}
-/** Matching key for enrichment lookups: lowercase, diacritics and punctuation
- * stripped, so the Clerk's "Ala'a Adib" matches a local entry written "Alaa
- * Adib". Also the stable candidate key in analytics events, since the clerks'
- * feeds carry no candidate IDs. */
-export function nameKey(name: string): string {
- return name
- .normalize("NFD")
- .replace(new RegExp("[\\u0300-\\u036f]", "g"), "")
- .toLowerCase()
- .replace(/[^a-z0-9 ]+/g, "")
- .replace(/\s+/g, " ")
- .trim();
-}
+/** Matching key for enrichment lookups — re-exported from ./names, which the
+ * client components that link to a candidate's own page also need and which
+ * cannot import this module (it reaches the API). */
+export { nameKey };
/**
* "First Last" display name. `first_name` is null for mononymous candidates,
@@ -156,6 +148,14 @@ export type RaceView = {
officeBody: string | null;
/** e.g. "Etobicoke North" or "Wards 1, 5"; null when at-large */
districtName: string | null;
+ /**
+ * The API's `district_number`, null when at-large. Safe to print only where
+ * `districtName` is null and the district is not a city ward — which in
+ * practice is the school boards, whose own ward numbering is the only name
+ * their districts have. For a council race it is the lowest ward of the
+ * district and `wardNumbers` is what to show instead.
+ */
+ districtNumber: number | null;
/** city wards this race covers; empty when at-large or unmapped */
wardNumbers: number[];
atLarge: boolean;
@@ -290,6 +290,7 @@ function toRaceView(race: ApiRace, enrichment?: EnrichmentMap): RaceView {
label: race.district_name ? `${seat} — ${race.district_name}` : seat,
officeBody: race.office_body,
districtName: race.district_name,
+ districtNumber: race.district_number,
wardNumbers: wardsFor(race),
atLarge: race.district_type === "at_large",
candidates,
@@ -442,3 +443,126 @@ export async function getWardDetail(
.map((race) => toRaceView(race)),
};
}
+
+// ── Candidate profiles ─────────────────────────────────────────────────────
+
+/** One race a candidate appears in, and whether they are still standing in it. */
+type Appearance = {
+ candidate: CandidateView;
+ race: RaceView;
+ officeType: ApiRace["office_type"];
+ wards: WardView[];
+};
+
+/**
+ * One candidate, with the race context their own page needs.
+ *
+ * A name is not a key: candidates are joined on `nameKey`, the clerks' feeds
+ * carry no candidate ids, and one person turns up in more than one race often
+ * enough that it has to be handled — on Toronto's 2026 ballot ten candidates
+ * registered somewhere, withdrew, and registered somewhere else. So a profile
+ * carries the races the candidate is actually standing in, and names the ones
+ * they left separately rather than letting a stale ward speak for them.
+ */
+export type CandidateProfile = {
+ /** their details, taken from a race they are still standing in where there
+ * is one — so a candidate who moved wards does not read as withdrawn */
+ candidate: CandidateView;
+ /** the races they are standing in; the withdrawn ones only when every race
+ * they appear in is withdrawn, so this is never empty */
+ races: RaceView[];
+ /** the office kinds those races are for, aligned with `races` */
+ officeTypes: ApiRace["office_type"][];
+ /** the region's wards those races cover, resolved against its roster */
+ wards: WardView[];
+ /** races they registered in and withdrew from, where they are standing
+ * somewhere else — empty for all but a handful of candidates */
+ withdrawnFrom: RaceView[];
+};
+
+/**
+ * Every candidate in the election, each with their race — the roster a
+ * per-candidate route generates its static params from and looks a slug up
+ * in. Null when the API is unreachable, so callers can fall back or 404.
+ *
+ * Built in one pass over the races rather than by walking `getWardDetail` for
+ * each ward: enrichment is per-race anyway, and 25 ward fetches to answer
+ * "who is this person" is 25 recomputations of the same election.
+ */
+export async function getCandidateProfiles(
+ slug: string,
+ options: ElectionDataOptions = {},
+): Promise {
+ const election = await fetchElection(slug);
+ if (!election) return null;
+
+ const view = await getElectionView(slug, options);
+ if (!view) return null;
+
+ const wardByNumber = new Map(view.wards.map((ward) => [ward.number, ward]));
+
+ /* Keyed by `nameKey`, which is `CandidateView.key`, so a name in two races
+ accumulates rather than overwriting. */
+ const appearances = new Map();
+
+ for (const apiRace of election.races) {
+ const wardNumbers = wardsFor(apiRace);
+
+ /* Toronto's hand-maintained extras are held per ward, and its council
+ races are one ward each. A race spanning several wards (Brampton's) has
+ no single ward's enrichment to draw on, and no region that shape has
+ any. */
+ const enrichment =
+ apiRace.office_type === "mayor"
+ ? options.mayoralEnrichment
+ : apiRace.office_type === "councillor" && wardNumbers.length === 1
+ ? options.councillorEnrichment?.(wardNumbers[0])
+ : undefined;
+
+ const race = toRaceView(apiRace, enrichment);
+ const wards = wardNumbers
+ .map((number) => wardByNumber.get(number))
+ .filter((ward): ward is WardView => ward !== undefined);
+
+ for (const candidate of race.candidates) {
+ const list = appearances.get(candidate.key);
+ const appearance: Appearance = {
+ candidate,
+ race,
+ officeType: apiRace.office_type,
+ wards,
+ };
+ if (list) list.push(appearance);
+ else appearances.set(candidate.key, [appearance]);
+ }
+ }
+
+ return [...appearances.values()].map(toProfile);
+}
+
+/** One candidate's appearances folded into their profile: the races they are
+ * standing in lead, and the ones they left are set aside. */
+function toProfile(appearances: Appearance[]): CandidateProfile {
+ const standing = appearances.filter((a) => !a.candidate.withdrawn);
+ /* Withdrawn everywhere — the withdrawal is the whole story, so those races
+ are the profile's races rather than a footnote to an empty page. */
+ const active = standing.length > 0 ? standing : appearances;
+
+ const wards: WardView[] = [];
+ for (const appearance of active) {
+ for (const ward of appearance.wards) {
+ if (!wards.some((w) => w.number === ward.number)) wards.push(ward);
+ }
+ }
+
+ return {
+ candidate: active[0].candidate,
+ races: active.map((a) => a.race),
+ officeTypes: active.map((a) => a.officeType),
+ wards,
+ withdrawnFrom:
+ standing.length > 0
+ ? appearances.filter((a) => a.candidate.withdrawn).map((a) => a.race)
+ : [],
+ };
+}
diff --git a/src/lib/elections/names.ts b/src/lib/elections/names.ts
index d70ecbd1..f5fef49d 100644
--- a/src/lib/elections/names.ts
+++ b/src/lib/elections/names.ts
@@ -17,3 +17,28 @@ export const lastName = (name: string) => name.trim().split(/\s+/).at(-1) || nam
* ending in s takes the bare apostrophe. */
export const possessive = (name: string) =>
name.endsWith("s") || name.endsWith("S") ? `${name}’` : `${name}’s`;
+
+/**
+ * Matching key for a candidate: lowercase, diacritics and punctuation
+ * stripped, so the Clerk's "Ala'a Adib" matches a hand-written "Alaa Adib".
+ * Also the stable candidate key in analytics events and the roster joins,
+ * since the clerks' feeds carry no candidate IDs.
+ *
+ * Lives here rather than in election-data because the client components that
+ * link to a candidate's page need it, and election-data reaches the API.
+ */
+export function nameKey(name: string): string {
+ return name
+ .normalize("NFD")
+ .replace(new RegExp("[\\u0300-\\u036f]", "g"), "")
+ .toLowerCase()
+ .replace(/[^a-z0-9 ]+/g, "")
+ .replace(/\s+/g, " ")
+ .trim();
+}
+
+/** `nameKey` as a URL segment — "Brad Bradford" → "brad-bradford". The whole
+ * identity of a candidate's own page, so it must stay derivable from the name
+ * alone: the roster is rebuilt from the Clerk's feed daily and carries no ids
+ * we could key a URL to. */
+export const candidateSlug = (name: string) => nameKey(name).replace(/ /g, "-");
diff --git a/src/lib/elections/registry.ts b/src/lib/elections/registry.ts
index 6473fe81..3e57827f 100644
--- a/src/lib/elections/registry.ts
+++ b/src/lib/elections/registry.ts
@@ -74,6 +74,15 @@ export type SupportedElection = {
* section can offer the lookup. False is not "no map": it is "asking for a
* postal code here would answer with boundary_data_unavailable". */
wardLookup?: boolean;
+ /**
+ * The region has a page per candidate under `${basePath}/candidates/:slug`.
+ * Where it does, a candidate's name is a link to that page and the campaign
+ * site is one of the things on it; where it doesn't, the name links straight
+ * out to the campaign site as it always did. Only Toronto has the route, and
+ * a name linking to a 404 is worse than a name linking outward, so this is
+ * opt-in rather than assumed from `basePath`.
+ */
+ candidateProfiles?: boolean;
};
const TORONTO_2026: SupportedElection = {
@@ -94,6 +103,7 @@ const TORONTO_2026: SupportedElection = {
advanceVote: { iso: "2026-10-06", label: "Oct 6 – 11" },
mailIn: { iso: "2026-09-24", label: "Thu, Sept 24" },
wardLookup: true,
+ candidateProfiles: true,
themeClass: "theme-election",
};
From b7e088ac676204564b8962b9dd03c6296c971671 Mon Sep 17 00:00:00 2001
From: Mikaal Naik
Date: Wed, 9 Sep 2026 10:55:16 -0400
Subject: [PATCH 2/4] Publish the bios candidates wrote us
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
55 of the 58 candidates who returned the questionnaire wrote a bio in
it, and the site showed none of them.
The bio lives in the questionnaire's `about-you` step, which
`comparableQuestions` excludes as non-policy — correctly, for a pivot
that exists to compare choices against a field. But nothing ever picked
the written half back up, so the only bio a candidate page could print
was the hand-maintained one in candidates.ts, which is empty for all but
a handful. We held 55 self-authored biographies and published zero.
`writtenQuestions` is the complement of `comparableQuestions`, and
`candidateWriting` pivots it per candidate the way `candidateAnswers`
pivots the choices — with no counts, charts or comparison, because a
paragraph cannot be counted against a field. `rosterSurvey` returns it
alongside the answers, narrowed to the roster on the same rule: prose we
cannot place on a candidate is prose we must not attribute.
TEXTAREA ONLY, WHICH IS THE PRIVACY GUARD
Not "everything that is not a choice". Every free-text field in the
resident survey is identity — postal code, first name, last name, email
— and all four are `text` or `email`. Selecting the long-form type is
what keeps a contact field added in the CMS from ever arriving here as
publishable prose. It is also the guard that does the work: postal_code
sits in `about-you`, which the consent-step exclusion deliberately lets
through so a candidate's bio can pass.
ON THE PAGE
A section of its own between the ballot facts and the questionnaire, in
the order the page actually reads — who is this, then what do they
think. Not the hero: these run to a median of 800 characters, half are
several paragraphs, and the longest is 1,800, which in the hero pushed
the ward and the answers off the screen.
Attributed before it is read. A candidate's account of themselves set in
the same type as the rest of the page would read as ours, so the section
says who wrote it and that it is not our description of them. Blank
lines are kept as paragraph breaks and single newlines as typed.
`ward_commitment_target` — the numerical target and deadline, answered
by 21 — comes through the same mechanism and prints under its question.
Co-Authored-By: Claude Opus 5 (1M context)
---
.../vote/2026/candidates/[candidate]/page.tsx | 102 +++++++++++++++++-
src/lib/elections/alignment.ts | 52 +++++++++
src/lib/elections/candidate-answers.ts | 62 ++++++++++-
src/lib/elections/survey-answers.ts | 14 ++-
4 files changed, 224 insertions(+), 6 deletions(-)
diff --git a/src/app/toronto/vote/2026/candidates/[candidate]/page.tsx b/src/app/toronto/vote/2026/candidates/[candidate]/page.tsx
index 010b83b8..cc920ec1 100644
--- a/src/app/toronto/vote/2026/candidates/[candidate]/page.tsx
+++ b/src/app/toronto/vote/2026/candidates/[candidate]/page.tsx
@@ -12,10 +12,13 @@ import {
import { QuestionnaireRail } from "@/components/elections/QuestionnaireRail";
import CountdownDays from "@/components/elections/CountdownDays";
import { IncumbentBadge } from "@/components/elections/ElectionLanding";
-import { comparedQuestions } from "@/lib/elections/candidate-answers";
+import {
+ BIO_QUESTION_ID,
+ comparedQuestions,
+} from "@/lib/elections/candidate-answers";
import { rosterSurvey } from "@/lib/elections/survey-answers";
import { daysUntil } from "@/lib/elections/dates";
-import { possessive } from "@/lib/elections/names";
+import { firstName, possessive } from "@/lib/elections/names";
import type { CandidateProfile, RaceView } from "@/lib/elections/election-data";
import {
ELECTION,
@@ -127,9 +130,28 @@ export default async function CandidatePage({
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 } = await rosterSurvey(ELECTION.slug, new Set([candidate.key]));
+ const { answers, written } = await rosterSurvey(
+ ELECTION.slug,
+ new Set([candidate.key]),
+ );
const surveyAnswers = answers[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,
+ and until now the page showed none of them: the bio sits in the
+ questionnaire's `about-you` step, which the policy pivot excludes, so the
+ only bio this page could print was the hand-maintained one in
+ 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. */
+ const prose = written[candidate.key] ?? [];
+ const selfBio = prose.find(
+ (entry) => entry.questionId === BIO_QUESTION_ID,
+ )?.text;
+ const otherProse = prose.filter(
+ (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
@@ -269,7 +291,7 @@ export default async function CandidatePage({
the reader to read absence as a judgement. Most of a
fifty-three-name ballot is in exactly this state, and it is
the ordinary condition of a municipal candidate. */}
- {!candidate.bio && !candidate.website && (
+ {!candidate.bio && !candidate.website && !selfBio && (
A registered candidate on the City Clerk’s list. We have
no campaign site or profile for {candidate.name} yet — this
@@ -358,6 +380,54 @@ export default async function CandidatePage({
+ {/* ── In their own words ─────────────────────────────── */}
+ {/* Their bio, and any other prose the questionnaire asked them to
+ write, in one place and plainly attributed.
+
+ A section of its own rather than a paragraph in the hero: these run
+ to a median of 800 characters and half of them are several
+ paragraphs, so in the hero the longest of them pushed the ballot
+ facts and the questionnaire off the screen. Here they have the room
+ the length needs, in the reading order a candidate page actually
+ has — who is this, then what do they think. */}
+ {(selfBio || otherProse.length > 0) && (
+
+
In their own words
+
+ About {firstName(candidate.name)}
+
+ {/* Whose words these are, said before they are read. A candidate's
+ account of themselves set in the same type as the rest of the
+ page would read as ours. */}
+
+ Written by {candidate.name} in answer to our questionnaire, and
+ published as given. Not our description of them.
+
@@ -446,6 +516,30 @@ export default async function CandidatePage({
are not figures: "Councillor" arriving above the words telling you what it
is means the reader meets an answer before the question, and reads the cell
twice. */
+/* A written answer, as the candidate typed it.
+ *
+ * Blank lines are paragraph breaks — half of these bios have them, and run
+ * together into one block the reader gets a wall of prose that reads as though
+ * we had transcribed it carelessly. Single newlines inside a paragraph are
+ * kept by `whitespace-pre-line` rather than collapsed, because in these
+ * answers they are usually a deliberate list. */
+function Prose({ text, className }: { text: string; className?: string }) {
+ const paragraphs = text
+ .split(/\n\s*\n/)
+ .map((paragraph) => paragraph.trim())
+ .filter(Boolean);
+
+ return (
+
diff --git a/src/lib/elections/alignment.ts b/src/lib/elections/alignment.ts
index ccfc3e25..2514dc8e 100644
--- a/src/lib/elections/alignment.ts
+++ b/src/lib/elections/alignment.ts
@@ -33,6 +33,14 @@ const NON_POLICY_STEPS = new Set(["about-you", "stay-in-touch"]);
*/
const NON_POLICY_QUESTIONS = new Set(["volunteer", "updates"]);
+/**
+ * The steps that are consent and contact rather than substance. A subset of
+ * NON_POLICY_STEPS: `about-you` is not policy either, but a candidate's bio in
+ * it is publishable prose, where anything in `stay-in-touch` is a private
+ * arrangement between the respondent and us. See `writtenQuestions`.
+ */
+const CONSENT_STEPS = new Set(["stay-in-touch"]);
+
/** Question types with a fixed option list, so two answers can be compared. */
const CHOICE_TYPES = new Set(["yesno", "radio", "select"]);
@@ -173,6 +181,50 @@ export function comparableQuestions(
);
}
+/**
+ * The prose a respondent wrote, in the order they were asked to write it.
+ *
+ * The complement of `comparableQuestions`, and the reason both exist: a
+ * questionnaire is a set of choices plus the things the respondent said in
+ * their own words, and the choices are the only half anything could pivot,
+ * chart or count. So the written half was filtered out at the first step and
+ * then never picked up again — which meant the candidate questionnaire's `bio`,
+ * answered by 55 of the 58 candidates who returned it, was data we held and
+ * published nowhere.
+ *
+ * TEXTAREA ONLY, AND THAT IS A PRIVACY RULE
+ * `textarea` is the type the CMS uses for a paragraph the respondent means to
+ * be read. The short free-text types are where identity lives — `text` and
+ * `email` hold a name, an email, a postal code — and the resident survey is
+ * full of them. Selecting the long-form type rather than "everything that is
+ * not a choice" means a contact field added in the CMS can never arrive here
+ * as publishable prose.
+ *
+ * The consent questions are excluded on top of that, by step and by id, the
+ * same way `comparableQuestions` excludes them. `about-you` is NOT excluded
+ * here, unlike there: a candidate's bio is the one thing in that step which
+ * is a public statement rather than a fact about a private person.
+ */
+export function writtenQuestions(
+ survey: Survey,
+): { question: SurveyQuestion; stepId: string; stepTitle: string }[] {
+ return survey.steps.flatMap((step) =>
+ CONSENT_STEPS.has(step.id)
+ ? []
+ : step.questions
+ .filter(
+ (question) =>
+ question.type === "textarea" &&
+ !NON_POLICY_QUESTIONS.has(question.id),
+ )
+ .map((question) => ({
+ question,
+ stepId: step.id,
+ stepTitle: step.title,
+ })),
+ );
+}
+
/**
* Compares one set of resident answers against several candidate responses.
*
diff --git a/src/lib/elections/candidate-answers.ts b/src/lib/elections/candidate-answers.ts
index af099af6..ecd2bf55 100644
--- a/src/lib/elections/candidate-answers.ts
+++ b/src/lib/elections/candidate-answers.ts
@@ -15,7 +15,7 @@
// carry it: most wards have one or two respondents, where a per-ward count
// says only that the candidate agrees with themselves.
-import { comparableQuestions, isYesNoScale } from "./alignment";
+import { comparableQuestions, isYesNoScale, writtenQuestions } from "./alignment";
import type { CandidateSurveyResponse } from "./alignment";
import { nameKey } from "./election-data";
import { lastName } from "./names";
@@ -463,3 +463,63 @@ export function rollCall(
return { groups, verbatim, unanswered };
}
+
+/* ------------------------------------------------------------------ */
+/* What they wrote */
+/* ------------------------------------------------------------------ */
+
+/** The question a candidate's bio is asked under. Its own constant because
+ * the profile page treats it differently from every other written answer —
+ * a biography belongs beside the portrait, not among the policy answers. */
+export const BIO_QUESTION_ID = "bio";
+
+/** One free-text answer, as the candidate wrote it. */
+export type WrittenAnswer = {
+ questionId: string;
+ /** the question as it was put to them, for the answers that need it */
+ question: string;
+ stepId: string;
+ stepTitle: string;
+ /** their words, trimmed; never empty — blanks are dropped */
+ text: string;
+};
+
+/**
+ * The prose each candidate wrote, keyed by `nameKey` and in the order the
+ * questionnaire asked for it.
+ *
+ * Separate from `candidateAnswers` because the two halves of a questionnaire
+ * are different kinds of thing: a choice can be counted against the field and
+ * a paragraph cannot, so nothing here carries counts, charts or a comparison.
+ * It is what the candidate said, attributed, and that is all it can be.
+ *
+ * Candidates who wrote nothing are absent rather than present and empty.
+ */
+export function candidateWriting(
+ survey: Survey,
+ responses: CandidateSurveyResponse[],
+): Record {
+ const questions = writtenQuestions(survey);
+ const out: Record = {};
+
+ for (const response of responses) {
+ const written = questions.flatMap(({ question, stepId, stepTitle }) => {
+ const text = (response.answers[question.id] ?? "").trim();
+ return text
+ ? [
+ {
+ questionId: question.id,
+ question: question.label,
+ stepId,
+ stepTitle,
+ text,
+ },
+ ]
+ : [];
+ });
+
+ if (written.length > 0) out[nameKey(response.candidateName)] = written;
+ }
+
+ return out;
+}
diff --git a/src/lib/elections/survey-answers.ts b/src/lib/elections/survey-answers.ts
index 5e6e2c96..4b1057b6 100644
--- a/src/lib/elections/survey-answers.ts
+++ b/src/lib/elections/survey-answers.ts
@@ -15,9 +15,11 @@
import {
byCandidateKey,
candidateAnswers,
+ candidateWriting,
questionnaireShape,
type CandidateAnswers,
type ComparedGroup,
+ type WrittenAnswer,
} from "./candidate-answers";
import {
CANDIDATE_QUESTIONNAIRE_SLUG,
@@ -31,6 +33,9 @@ export type RosterSurvey = {
/** every question, with nobody attached — the grid's shape when the whole
* roster stayed quiet */
shape: ComparedGroup[];
+ /** the roster's free-text answers, keyed by `nameKey`. The choices are what
+ * a grid can compare; this is what the candidates wrote. */
+ written: Record;
};
/**
@@ -58,11 +63,18 @@ export async function rosterSurvey(
const entries = candidateAnswers(survey, responses).filter((entry) =>
candidateKeys.has(entry.key),
);
+ const written = candidateWriting(survey, responses);
+
return {
answers: byCandidateKey(entries),
shape: questionnaireShape(survey, responses),
+ /* Narrowed to the roster on the same rule the answers are: prose we
+ cannot place on a candidate is prose we must not attribute. */
+ written: Object.fromEntries(
+ Object.entries(written).filter(([key]) => candidateKeys.has(key)),
+ ),
};
} catch {
- return { answers: {}, shape: [] };
+ return { answers: {}, shape: [], written: {} };
}
}
From 934de7dc611daf5162b3cf93d20081704c708444 Mon Sep 17 00:00:00 2001
From: Mikaal Naik
Date: Wed, 9 Sep 2026 11:02:33 -0400
Subject: [PATCH 3/4] Lay the bio section out in two columns
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Set as one column it was 640px of serif — the width a paragraph can be
read at — sitting in a 1300px band, so two-thirds of the section was air
with a heading floating at the top of it.
The heading and the attribution now take a rail of their own and the
prose keeps its measure beside them, which spends the width on structure
rather than on a line too long to read. The rail is sticky, so the line
saying these are the candidate's words and not ours stays beside the
prose it qualifies instead of being something the reader passes once and
scrolls away from. Below lg it stacks as before.
Also drops the section's "In their own words" eyebrow, which sat
directly on top of a line already saying the candidate wrote this and we
did not — the label and its own caption, stacked.
Co-Authored-By: Claude Opus 5 (1M context)
---
.../vote/2026/candidates/[candidate]/page.tsx | 80 +++++++++++--------
1 file changed, 48 insertions(+), 32 deletions(-)
diff --git a/src/app/toronto/vote/2026/candidates/[candidate]/page.tsx b/src/app/toronto/vote/2026/candidates/[candidate]/page.tsx
index cc920ec1..1a92e5d7 100644
--- a/src/app/toronto/vote/2026/candidates/[candidate]/page.tsx
+++ b/src/app/toronto/vote/2026/candidates/[candidate]/page.tsx
@@ -389,42 +389,58 @@ export default async function CandidatePage({
paragraphs, so in the hero the longest of them pushed the ballot
facts and the questionnaire off the screen. Here they have the room
the length needs, in the reading order a candidate page actually
- has — who is this, then what do they think. */}
+ has — who is this, then what do they think.
+
+ TWO COLUMNS, because prose has a measure and a band does not. Set
+ as one column this was 640px of serif — the width a paragraph can
+ be read at — sitting in a 1300px section, so two-thirds of the band
+ was air with a heading floating at the top of it. The heading and
+ the attribution take a rail of their own and the prose keeps its
+ measure beside them, which spends the width on structure rather
+ than on a line too long to read. */}
{(selfBio || otherProse.length > 0) && (
-
-
In their own words
-
- About {firstName(candidate.name)}
-
- {/* Whose words these are, said before they are read. A candidate's
- account of themselves set in the same type as the rest of the
- page would read as ours. */}
-
- Written by {candidate.name} in answer to our questionnaire, and
- published as given. Not our description of them.
-
+
+
+ {/* No eyebrow. "In their own words" over a line that already
+ says the candidate wrote this and we did not was the label
+ and its own caption, stacked. */}
+
+ About {firstName(candidate.name)}
+
+ {/* Whose words these are, said before they are read. A
+ candidate's account of themselves set in the same type as the
+ rest of the page would read as ours. In the rail it stays
+ beside the prose it qualifies rather than becoming a line the
+ reader passes once and scrolls away from. */}
+
+ Written by {candidate.name} in answer to our questionnaire, and
+ published as given. Not our description of them.
+
)}
From adbd888cd22f0629e19f344286b7c6927419a8ba Mon Sep 17 00:00:00 2001
From: Mikaal Naik
Date: Wed, 9 Sep 2026 11:03:41 -0400
Subject: [PATCH 4/4] Let the bio prose fill the width
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
The measure cap was leaving a third of the band empty; filling the width
is the call. So the prose runs the full width of its column, and the
question headings on the follow-up answers lose their cap too.
Leading goes up with the line length — 1.6 to 1.75 on the bio, 1.55 to
1.7 on the rest. A long line needs more space beneath it for the eye to
find the start of the next one, which is the one thing available for
readability that does not narrow the column again.
Co-Authored-By: Claude Opus 5 (1M context)
---
.../vote/2026/candidates/[candidate]/page.tsx | 14 ++++++++++----
1 file changed, 10 insertions(+), 4 deletions(-)
diff --git a/src/app/toronto/vote/2026/candidates/[candidate]/page.tsx b/src/app/toronto/vote/2026/candidates/[candidate]/page.tsx
index 1a92e5d7..0bdb4fb0 100644
--- a/src/app/toronto/vote/2026/candidates/[candidate]/page.tsx
+++ b/src/app/toronto/vote/2026/candidates/[candidate]/page.tsx
@@ -418,11 +418,17 @@ export default async function CandidatePage({
-
+ {/* No measure cap. The prose runs the full width of its column:
+ capped at 70ch it left a third of the band empty, and filling
+ the width was the call. Leading goes up with the line length —
+ a long line needs more space beneath it for the eye to find
+ the start of the next one, which is the one thing that can be
+ done for readability without narrowing the column again. */}
+