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 0000000..252ba7b
--- /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 0000000..0bdb4fb
--- /dev/null
+++ b/src/app/toronto/vote/2026/candidates/[candidate]/page.tsx
@@ -0,0 +1,583 @@
+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 {
+ BIO_QUESTION_ID,
+ comparedQuestions,
+} 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 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, 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
+ 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 && !selfBio && (
+
+ 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. */}
+
+
+
+ Days until polls open
+
+
+
+
+
+ {/* ── 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.
+
+ 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) && (
+
+
+ {/* 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.
+
+
+
+ {/* 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. */}
+
+ {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. */
+/* 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 (
+
+ );
+}
+
+/** `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 c9d62db..67362d4 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 0f67070..d21a78e 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 25f18c7..0000000
--- 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 0000000..8dc5ce5
--- /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 0000000..fede25d
--- /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 0e3e0bb..0000000
--- 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 14c02f9..46f9956 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 fdcf0ff..880d563 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/alignment.ts b/src/lib/elections/alignment.ts
index ccfc3e2..2514dc8 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 af099af..ecd2bf5 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/candidate-profile.ts b/src/lib/elections/candidate-profile.ts
new file mode 100644
index 0000000..8ea693e
--- /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 4e98f40..e7edee5 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 d70ecbd..f5fef49 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 6473fe8..3e57827 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",
};
diff --git a/src/lib/elections/survey-answers.ts b/src/lib/elections/survey-answers.ts
index 5e6e2c9..4b1057b 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: {} };
}
}