diff --git a/package.json b/package.json
index f7700727..0f50c328 100644
--- a/package.json
+++ b/package.json
@@ -36,6 +36,10 @@
"chartjs-adapter-date-fns": "^3.0.0",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
+ "d3-color": "^3.1.0",
+ "d3-format": "^3.1.2",
+ "d3-scale": "^4.0.2",
+ "d3-shape": "^3.2.0",
"date-fns": "^4.1.0",
"dayjs": "^1.11.18",
"fast-xml-parser": "^5.3.8",
@@ -70,6 +74,10 @@
},
"devDependencies": {
"@tailwindcss/postcss": "^4",
+ "@types/d3-color": "^3.1.3",
+ "@types/d3-format": "^3.0.4",
+ "@types/d3-scale": "^4.0.9",
+ "@types/d3-shape": "^3.2.0",
"@types/hast": "^3.0.5",
"@types/node": "^20",
"@types/react": "^19",
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 987fc889..7b2b8928 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -68,6 +68,18 @@ importers:
clsx:
specifier: ^2.1.1
version: 2.1.1
+ d3-color:
+ specifier: ^3.1.0
+ version: 3.1.0
+ d3-format:
+ specifier: ^3.1.2
+ version: 3.1.2
+ d3-scale:
+ specifier: ^4.0.2
+ version: 4.0.2
+ d3-shape:
+ specifier: ^3.2.0
+ version: 3.2.0
date-fns:
specifier: ^4.1.0
version: 4.1.0
@@ -165,6 +177,18 @@ importers:
'@tailwindcss/postcss':
specifier: ^4
version: 4.2.2
+ '@types/d3-color':
+ specifier: ^3.1.3
+ version: 3.1.3
+ '@types/d3-format':
+ specifier: ^3.0.4
+ version: 3.0.4
+ '@types/d3-scale':
+ specifier: ^4.0.9
+ version: 4.0.9
+ '@types/d3-shape':
+ specifier: ^3.2.0
+ version: 3.2.0
'@types/hast':
specifier: ^3.0.5
version: 3.0.5
@@ -1801,6 +1825,24 @@ packages:
'@types/cookie@0.6.0':
resolution: {integrity: sha512-4Kh9a6B2bQciAhf7FSuMRRkUWecJgJu9nPnx3yzpsfXX/c50REIqpHY4C82bXP90qrLtXtkDxTZosYO3UpOwlA==}
+ '@types/d3-color@3.1.3':
+ resolution: {integrity: sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==}
+
+ '@types/d3-format@3.0.4':
+ resolution: {integrity: sha512-fALi2aI6shfg7vM5KiR1wNJnZ7r6UuggVqtDA+xiEdPZQwy/trcQaHnwShLuLdta2rTymCNpxYTiMZX/e09F4g==}
+
+ '@types/d3-path@3.1.1':
+ resolution: {integrity: sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg==}
+
+ '@types/d3-scale@4.0.9':
+ resolution: {integrity: sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw==}
+
+ '@types/d3-shape@3.2.0':
+ resolution: {integrity: sha512-kVd74ta9eof3eJOvbNd1vGKS/XERRyQbT26Og63hIsvDO84cjD5gEOhsXf26w3FSoNlPVz84DOFcKv/oou+fMw==}
+
+ '@types/d3-time@3.0.4':
+ resolution: {integrity: sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g==}
+
'@types/debug@4.1.13':
resolution: {integrity: sha512-KSVgmQmzMwPlmtljOomayoR89W4FynCAi3E8PPs7vmDVPe84hT+vGPKkJfThkmXs0x0jAaa9U8uW8bbfyS2fWw==}
@@ -6372,6 +6414,22 @@ snapshots:
'@types/cookie@0.6.0':
optional: true
+ '@types/d3-color@3.1.3': {}
+
+ '@types/d3-format@3.0.4': {}
+
+ '@types/d3-path@3.1.1': {}
+
+ '@types/d3-scale@4.0.9':
+ dependencies:
+ '@types/d3-time': 3.0.4
+
+ '@types/d3-shape@3.2.0':
+ dependencies:
+ '@types/d3-path': 3.1.1
+
+ '@types/d3-time@3.0.4': {}
+
'@types/debug@4.1.13':
dependencies:
'@types/ms': 2.1.0
diff --git a/src/app/api/elections/candidate-responses/route.ts b/src/app/api/elections/candidate-responses/route.ts
new file mode 100644
index 00000000..72cb1e4b
--- /dev/null
+++ b/src/app/api/elections/candidate-responses/route.ts
@@ -0,0 +1,105 @@
+import { NextRequest, NextResponse } from "next/server";
+
+import {
+ CANDIDATE_QUESTIONNAIRE_SLUG,
+ fetchCandidateResponses,
+} from "@/lib/elections/candidate-responses";
+import {
+ DEFAULT_ELECTION_SLUG,
+ isSupportedElection,
+} from "@/lib/elections/registry";
+import {
+ TORONTO_2026_SLUG,
+ getToronto2026,
+ getToronto2026Ward,
+ nameKey,
+} from "@/app/toronto/vote/2026/data";
+
+// Published candidate questionnaire answers for one ward.
+//
+// A read proxy, not a data source: everything it returns is already public
+// through York Factory's own endpoint, and it exists because the survey page
+// only learns which ward to ask about after the API has placed the respondent
+// from their postal code. Fetching it server-side at page load would mean
+// shipping every ward's answers to every visitor to use one ward's worth.
+//
+// The mayoral field rides along too. A voter marks two ballots — one for their
+// councillor, one for mayor — so a comparison that answers only half of that is
+// answering the smaller half: the mayoral race is the one every voter in the
+// city votes in.
+//
+// The ward's roster rides along with the answers. The comparison names every
+// candidate on the ballot, not only the ones who wrote back — a reader wants to
+// know that the candidate they are considering said nothing as much as they
+// want to know what the others said — and the roster is the only place that
+// fact lives. Toronto is the only region with a survey page, so it is the only
+// region this looks one up for; everyone else gets an empty roster and a
+// comparison of respondents alone.
+//
+// `election` is checked against the registry so a client cannot aim this at an
+// arbitrary slug, matching the submit route.
+
+const WARD_PATTERN = /^\d{1,2}$/;
+
+export async function GET(req: NextRequest) {
+ const { searchParams } = new URL(req.url);
+ const election = searchParams.get("election") ?? DEFAULT_ELECTION_SLUG;
+ const ward = searchParams.get("ward") ?? "";
+
+ if (!isSupportedElection(election)) {
+ return NextResponse.json({ error: "Unknown election" }, { status: 400 });
+ }
+ if (!WARD_PATTERN.test(ward)) {
+ return NextResponse.json({ error: "Invalid ward" }, { status: 400 });
+ }
+
+ const toronto = election === TORONTO_2026_SLUG;
+ const wardToken = ward.padStart(2, "0");
+
+ const [data, detail, view, everyResponse] = await Promise.all([
+ fetchCandidateResponses(election, {
+ ward,
+ surveySlug: CANDIDATE_QUESTIONNAIRE_SLUG,
+ }),
+ toronto ? getToronto2026Ward(wardToken).catch(() => null) : null,
+ toronto ? getToronto2026().catch(() => null) : null,
+ // Unfiltered, because the mayoral field is on no ward: the responses are
+ // narrowed to the mayoral roster by name below, the same join every other
+ // surface uses.
+ toronto
+ ? fetchCandidateResponses(election, {
+ surveySlug: CANDIDATE_QUESTIONNAIRE_SLUG,
+ })
+ : [],
+ ]);
+
+ const roster = (detail?.councilRaces ?? [])
+ .flatMap((race) => race.candidates)
+ .map(rosterEntry);
+
+ const mayoralRoster = (view?.mayoral ?? []).map(rosterEntry);
+ const mayoralKeys = new Set(mayoralRoster.map((candidate) => candidate.key));
+ const mayoralData = everyResponse.filter((response) =>
+ mayoralKeys.has(nameKey(response.candidateName)),
+ );
+
+ return NextResponse.json({
+ data,
+ roster,
+ mayoral: { data: mayoralData, roster: mayoralRoster },
+ });
+}
+
+function rosterEntry(candidate: {
+ key: string;
+ name: string;
+ website?: string;
+ withdrawn: boolean;
+}) {
+ return {
+ key: candidate.key,
+ name: candidate.name,
+ website: candidate.website,
+ withdrawn: candidate.withdrawn,
+ };
+}
diff --git a/src/app/api/elections/pledge/route.ts b/src/app/api/elections/pledge/route.ts
index 024d06ea..c6f71574 100644
--- a/src/app/api/elections/pledge/route.ts
+++ b/src/app/api/elections/pledge/route.ts
@@ -7,6 +7,7 @@ import {
isSupportedElection,
} from "@/lib/elections/registry";
import { forwardedHubspotContext } from "@/lib/hubspot-context";
+import { normalizePostalCode } from "@/lib/elections/postal-code";
// "Pledge to vote" submissions — same low-friction pattern as /api/subscribe.
// Forwards {email, name, region, postal_code} to York Factory, which signs the
@@ -19,17 +20,6 @@ import { forwardedHubspotContext } from "@/lib/hubspot-context";
// defaults to the election's jurisdiction ("toronto", "brampton", …).
const REGION_PATTERN = /^[a-z0-9-]{1,50}$/;
-const POSTAL_PATTERN = /^[A-Za-z]\d[A-Za-z] ?\d[A-Za-z]\d$/;
-
-// "M5V1A1" / "m5v 1a1" → "M5V 1A1"; anything malformed is dropped rather
-// than stored dirty
-function normalizePostalCode(raw: unknown): string | undefined {
- if (typeof raw !== "string" || !POSTAL_PATTERN.test(raw.trim())) {
- return undefined;
- }
- const compact = raw.trim().toUpperCase().replace(" ", "");
- return `${compact.slice(0, 3)} ${compact.slice(3)}`;
-}
export async function POST(req: NextRequest) {
try {
diff --git a/src/app/api/elections/survey/route.ts b/src/app/api/elections/survey/route.ts
new file mode 100644
index 00000000..3bc9653e
--- /dev/null
+++ b/src/app/api/elections/survey/route.ts
@@ -0,0 +1,125 @@
+import { NextRequest, NextResponse } from "next/server";
+
+import { API_URL } from "@/lib/api/client";
+import {
+ DEFAULT_ELECTION_SLUG,
+ getElection,
+ isSupportedElection,
+} from "@/lib/elections/registry";
+import { normalizePostalCode } from "@/lib/elections/postal-code";
+import { forwardedHubspotContext } from "@/lib/hubspot-context";
+
+// Resident-survey submissions — same shape as /api/elections/pledge. Forwards
+// to York Factory, which signs the email up as a subscriber and records one
+// response per subscriber per survey per election (re-submitting replaces the
+// answers).
+//
+// `election` is checked against the registry before it reaches the API, so a
+// client can't aim this at an arbitrary slug. The answers themselves are
+// passed through untouched: the question set is York Factory's, served from
+// there and rendered by the survey page, so validating question ids in this
+// proxy would only add a third copy of them to keep in step. York Factory
+// applies structural limits (count, key and value length) and owns the
+// question ids on both sides of the round trip.
+
+const SLUG_PATTERN = /^[a-z0-9-]{1,100}$/;
+const REGION_PATTERN = /^[a-z0-9-]{1,50}$/;
+
+export async function POST(req: NextRequest) {
+ try {
+ const body = await req.json();
+ const {
+ email,
+ name,
+ answers,
+ survey_slug,
+ survey_version,
+ region,
+ postal_code,
+ election,
+ } = body;
+
+ if (!email || typeof email !== "string") {
+ return NextResponse.json({ error: "Email is required" }, { status: 400 });
+ }
+
+ const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
+ if (!emailRegex.test(email)) {
+ return NextResponse.json(
+ { error: "Invalid email format" },
+ { status: 400 },
+ );
+ }
+
+ if (!answers || typeof answers !== "object" || Array.isArray(answers)) {
+ return NextResponse.json(
+ { error: "Answers are required" },
+ { status: 400 },
+ );
+ }
+
+ if (election !== undefined && !isSupportedElection(election)) {
+ return NextResponse.json({ error: "Unknown election" }, { status: 400 });
+ }
+ const electionSlug = isSupportedElection(election)
+ ? election
+ : DEFAULT_ELECTION_SLUG;
+ const config = getElection(electionSlug);
+
+ if (typeof survey_slug !== "string" || !SLUG_PATTERN.test(survey_slug)) {
+ return NextResponse.json(
+ { error: "A survey_slug is required" },
+ { status: 400 },
+ );
+ }
+
+ // A malformed region is dropped rather than rejected — the ward is a
+ // nice-to-have for cutting results, not worth failing a completed survey
+ // over. The postal code is kept on the response so it can be re-derived.
+ const safeRegion =
+ typeof region === "string" && REGION_PATTERN.test(region)
+ ? region
+ : undefined;
+
+ const res = await fetch(
+ `${API_URL}/elections/${electionSlug}/survey_responses`,
+ {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({
+ email,
+ name: typeof name === "string" ? name.slice(0, 100) : undefined,
+ answers,
+ survey_slug,
+ survey_version:
+ typeof survey_version === "string" ? survey_version : undefined,
+ region: safeRegion,
+ postal_code: normalizePostalCode(postal_code),
+ ...forwardedHubspotContext(body, req),
+ }),
+ cache: "no-store",
+ },
+ );
+
+ if (!res.ok) {
+ const errorData = await res.json().catch(() => ({}));
+ return NextResponse.json(
+ { error: errorData.errors?.[0] || "Survey submission failed" },
+ { status: res.status },
+ );
+ }
+
+ const data = await res.json();
+
+ return NextResponse.json({
+ success: true,
+ election: config.slug,
+ surveySlug: data.survey_slug ?? survey_slug,
+ region: data.region ?? null,
+ derivedRegion: data.derived_region ?? null,
+ submittedAt: data.submitted_at ?? null,
+ });
+ } catch (err) {
+ return NextResponse.json({ error: String(err) }, { status: 500 });
+ }
+}
diff --git a/src/app/globals.css b/src/app/globals.css
index cdd3e968..fa23adbc 100644
--- a/src/app/globals.css
+++ b/src/app/globals.css
@@ -117,6 +117,28 @@ html {
100% { transform: rotate(0deg); }
}
+/* ─── Multi-step form transitions ─── */
+
+/* Each field in a step rises into place as it fades in. Staggered by an
+ inline animation-delay from the field's index. `forwards` plus the
+ reduced-motion override above means these still end up visible when
+ animation is disabled. */
+@keyframes stepFieldIn {
+ from {
+ opacity: 0;
+ transform: translateY(12px);
+ }
+ to {
+ opacity: 1;
+ transform: translateY(0);
+ }
+}
+
+.step-field {
+ opacity: 0;
+ animation: stepFieldIn 340ms cubic-bezier(0.22, 1, 0.36, 1) forwards;
+}
+
.accordion-expand {
display: grid;
grid-template-rows: 1fr;
diff --git a/src/app/ottawa/vote/2026/page.tsx b/src/app/ottawa/vote/2026/page.tsx
index adb06573..4ad827c7 100644
--- a/src/app/ottawa/vote/2026/page.tsx
+++ b/src/app/ottawa/vote/2026/page.tsx
@@ -109,7 +109,9 @@ function PreRoster() {
Polls open{" "}
- {ELECTION.voteDayLabel}, 2026
+
+ {ELECTION.voteDayLabel}, 2026
+
, {ELECTION.pollHoursLabel}.
@@ -134,23 +136,29 @@ function PreRoster() {
-
- {WARDS.map((ward) => (
-
- }
- className="border-b border-r border-border-light"
- />
- ))}
+ {/* Cells rule after themselves and the overhang is clipped, so the
+ grid closes once on every side — see CardGrid in ElectionLanding,
+ which this mirrors by hand rather than importing a component out
+ of a shared page. */}
+
+
+ {WARDS.map((ward) => (
+
+ }
+ className="border-b border-r border-border-light"
+ />
+ ))}
+
@@ -158,8 +166,8 @@ function PreRoster() {
Ward boundaries are the City of Ottawa’s published 2022–2026
- wards. The candidate list will follow the City Clerk’s official
- registered-candidate listing once nominations open.
+ wards. The candidate list will follow the City Clerk’s
+ official registered-candidate listing once nominations open.
diff --git a/src/app/toronto/page.tsx b/src/app/toronto/page.tsx
index e5484b49..413b13c9 100644
--- a/src/app/toronto/page.tsx
+++ b/src/app/toronto/page.tsx
@@ -293,7 +293,7 @@ function ElectionCardsSection() {
w.n);
diff --git a/src/app/toronto/vote/2026/issues/page.tsx b/src/app/toronto/vote/2026/issues/page.tsx
new file mode 100644
index 00000000..9de52097
--- /dev/null
+++ b/src/app/toronto/vote/2026/issues/page.tsx
@@ -0,0 +1,276 @@
+import type { Metadata } from "next";
+import Link from "next/link";
+import { ArrowLeft, ArrowRight } from "lucide-react";
+
+import {
+ QuestionnaireCards,
+ questionnaireHeadings,
+} from "@/components/elections/QuestionnaireCards";
+import { QuestionnaireRail } from "@/components/elections/QuestionnaireRail";
+import { SurveyCta } from "@/components/elections/SurveyCta";
+import CountdownDays from "@/components/elections/CountdownDays";
+import { fieldSentiment } from "@/lib/elections/field-sentiment";
+import {
+ candidateAnswers,
+ comparedQuestions,
+ questionnaireShape,
+} from "@/lib/elections/candidate-answers";
+import {
+ CANDIDATE_QUESTIONNAIRE_SLUG,
+ fetchCandidateResponses,
+} from "@/lib/elections/candidate-responses";
+import { fetchSurvey } from "@/lib/elections/survey";
+import { daysUntil } from "@/lib/elections/dates";
+import { ELECTION } from "../data";
+
+/* Where the field stands, across every issue we asked about.
+ *
+ * The ward pages and the mayoral page each show one ballot's answers as a
+ * grid. This page shows all of them at once, and drops the grid to do it:
+ * thirty-two respondents will not fit as columns, and the city-wide question
+ * is not "what did this candidate say" anyway. It is where the people running
+ * to govern Toronto converge, and where they split.
+ *
+ * A card per question, with the candidates filed under the answer they gave —
+ * the same roll-call form the ward and mayoral pages use, so a reader who has
+ * learned to read one page of the tracker can read all of them. The only
+ * difference here is scale: the field is the whole city, so a name plate also
+ * carries the seat that candidate is running for.
+ *
+ * The shell around them is deliberately short. Two dozen questions is the
+ * page; every band of prose above them is a band the reader scrolls past to
+ * reach it, so the hero states the premise once and the stats row carries the
+ * rest of what a masthead would say.
+ */
+
+export const metadata: Metadata = {
+ title: "Where the candidates stand",
+ description:
+ "Every candidate answer to our Toronto 2026 questionnaire, read across the whole field: where the candidates agree, and where they split.",
+ alternates: { canonical: `${ELECTION.basePath}/issues` },
+ openGraph: {
+ title: "Where the candidates stand — Toronto 2026 Election",
+ description:
+ "How the field answered our questionnaire, issue by issue: the consensus, and the fights.",
+ type: "website",
+ },
+};
+
+export default async function IssuesPage() {
+ /* Unlike the ward and mayoral pages, the questionnaire is not a
+ nice-to-have here — it is the entire page. A failed fetch has nothing to
+ fall back to, so it renders as the empty state rather than as a roster. */
+ const [survey, responses] = await Promise.all([
+ fetchSurvey(ELECTION.slug, CANDIDATE_QUESTIONNAIRE_SLUG).catch(() => null),
+ fetchCandidateResponses(ELECTION.slug),
+ ]);
+
+ /* `fieldSentiment` is still what tells us who counts as a respondent and
+ what seat they are running for — it reads the responses against the
+ ballot and drops anyone who returned the form without answering a policy
+ question. The cards themselves come from the same pivot the ward and
+ mayoral pages use, over the whole city's entries rather than one race's. */
+ const field = survey ? fieldSentiment(survey, responses) : null;
+ const respondents = field?.respondents ?? [];
+ const mayoral = respondents.filter((r) => r.race === "mayor").length;
+ const council = respondents.length - mayoral;
+ const wards = new Set(respondents.filter((r) => r.ward).map((r) => r.ward))
+ .size;
+
+ /* The roster the cards name, in the order `fieldSentiment` sorted it
+ (surname), and the seat each of them is running for — which is both what
+ prints on a plate and what splits each answer into its two races. */
+ const roster = respondents.map((r) => ({ key: r.key, name: r.name }));
+ const seats = Object.fromEntries(
+ respondents.map((r) => [
+ r.key,
+ r.ward
+ ? { race: "councillor" as const, label: `Ward ${r.ward}` }
+ : { race: "mayor" as const },
+ ]),
+ );
+
+ const entries = survey ? candidateAnswers(survey, responses) : [];
+ const groups = comparedQuestions(
+ entries,
+ roster,
+ survey ? questionnaireShape(survey, responses) : undefined,
+ );
+ /* Counted off the cards rather than off the questionnaire: a question
+ nobody has answered yet draws no card, and a stats row that claims one
+ more question than the page shows is a stats row a reader can catch. */
+ const questionCount = groups.reduce(
+ (n, group) => n + group.questions.length,
+ 0,
+ );
+
+ return (
+
+
+ {/* ── Breadcrumb ─────────────────────────────────────── */}
+
+
+ Toronto 2026
+
+ /
+ Where they stand
+
+
+ {/* ── Hero ───────────────────────────────────────────── */}
+
+ The whole field
+
+ Where the candidates stand
+
+
+ The same {questionCount} questions, put to everyone
+ running for mayor and for council. Read across the whole field, the
+ answers show what no single ballot can: what Toronto’s next
+ council already agrees on, and what it will spend four years
+ fighting over.
+
+
+
+ {/* ── Key stats ──────────────────────────────────────── */}
+
+
+ {/* ── The field, question by question ────────────────── */}
+ {groups.length > 0 && respondents.length > 0 ? (
+
+ {/* No roster above the cards, unlike the ward and mayoral pages.
+ Theirs names one ballot line and links each candidate to their
+ campaign; this page's field is thirty-odd people across a
+ mayoral race and two dozen wards, and a flat list of them is a
+ list with no ballot behind it. The seat on each plate is the
+ pointer instead. */}
+
+
+
+
+ ) : (
+
+
+ No candidate answers have been published yet. Responses appear
+ here as they are reviewed and released.
+
+
+ )}
+
+ {/* ── Your turn ──────────────────────────────────────── */}
+
+
+
+ Now answer them yourself
+
+
+ These are the same questions we asked the candidates. Answer them
+ and see which of the {respondents.length} line up with you — and
+ where you sit against the field you just read.
+
+
+
+
+ Days until polls open
+
+
+
+
+
+
+ {/* ── Method ─────────────────────────────────────────── */}
+
+
+ Each card is one question, and each block inside it is one of the
+ answers offered, printed in the wording the candidates were shown.
+ Under it are the candidates who gave that answer, with the seat
+ they are running for. Options nobody picked are not shown, and a
+ candidate who answered in their own words sits on no option.
+
+
+ Most candidates also wrote a note explaining their answer. Thirty
+ of them under every question is more reading than this page can
+ carry, so the notes live on the ward and mayoral pages, where the
+ field is small enough to read them in full. Answers appear as
+ candidates return the questionnaire and staff review them, so the
+ field shown here grows through the campaign.
+
+
+
+ {/* ── Elsewhere ──────────────────────────────────────── */}
+
+
+
+ The mayoral field, side by side
+
+
+
+
+
+
+ Find your ward
+
+
+
+
+
+ );
+}
+
+function Stat({
+ value,
+ label,
+ small = false,
+ last = false,
+}: {
+ value: number | string;
+ label: string;
+ small?: boolean;
+ last?: boolean;
+}) {
+ return (
+
+
+ {value}
+
+
+ {label}
+
+
+ );
+}
diff --git a/src/app/toronto/vote/2026/mayor/candidates/page.tsx b/src/app/toronto/vote/2026/mayor/candidates/page.tsx
new file mode 100644
index 00000000..0f670705
--- /dev/null
+++ b/src/app/toronto/vote/2026/mayor/candidates/page.tsx
@@ -0,0 +1,307 @@
+import type { Metadata } from "next";
+import Image from "next/image";
+import Link from "next/link";
+import { ArrowLeft, ArrowRight } from "lucide-react";
+
+import {
+ IncumbentBadge,
+ SiteLink,
+} from "@/components/elections/ElectionLanding";
+import CountdownDays from "@/components/elections/CountdownDays";
+import { surveyRoster } from "@/lib/elections/candidate-answers";
+import { daysUntil } from "@/lib/elections/dates";
+import { rosterSurvey } from "@/lib/elections/survey-answers";
+import type { CandidateView } from "@/lib/elections/election-data";
+import { ELECTION, getToronto2026 } from "../../data";
+
+/* Everyone running for mayor, on a page of their own.
+ *
+ * WHY IT EXISTS
+ * The roster lived on the landing page as a grid of portrait cards, and the
+ * field is now fifty-odd people: half a screen of the city's front page
+ * spent on a list whose reader either wants one name or wants all of them,
+ * and in both cases is better served somewhere they can look properly. The
+ * landing page's job is to point at races. This is the race.
+ *
+ * It is also the page /mayor never was. That route is titled "Candidates for
+ * Mayor" and holds the questionnaire read across the field — thirty-odd
+ * questions by every column — which answers "what did they say" and never
+ * answers "who is running". A reader who wants the ballot got a grid.
+ *
+ * ANSWERED FIRST, AND SAID SO
+ * The field splits in two: the candidates who returned our questionnaire and
+ * the candidates who have not. That is the most useful sort available — it
+ * is the difference between a name and a position — and `surveyRoster`
+ * already orders it that way, so the page prints the boundary rather than
+ * leaving the reader to infer it from a missing link. Within each group,
+ * surname order, because the alternative is a ranking nobody asked us to
+ * make.
+ *
+ * Withdrawn candidates keep a group at the foot rather than vanishing. Some
+ * clerks never drop them, they appear on lists elsewhere, and a reader who
+ * comes here holding a name needs to find out it is no longer a candidate.
+ */
+
+export const metadata: Metadata = {
+ title: "Every candidate for Mayor of Toronto",
+ description:
+ "The full field for Mayor of Toronto in the October 26, 2026 election: every registered candidate, their campaign site, and whether they answered our questionnaire.",
+ alternates: { canonical: `${ELECTION.basePath}/mayor/candidates` },
+ openGraph: {
+ title: "Every candidate for Mayor — Toronto 2026 Election",
+ description:
+ "The full field for Mayor of Toronto: who is running, and who has told us where they stand.",
+ type: "website",
+ },
+};
+
+export default async function MayoralCandidatesPage() {
+ const view = await getToronto2026();
+
+ /* Same two-step the questionnaire grid uses: the roster names the field, and
+ the survey fetch is keyed to it, so a response from someone who is not on
+ the ballot cannot put a stranger on this page. */
+ const named = surveyRoster(view.mayoral);
+ const { answers } = await rosterSurvey(
+ ELECTION.slug,
+ new Set(named.map((candidate) => candidate.key)),
+ );
+ const roster = surveyRoster(view.mayoral, answers);
+
+ const answered = roster.filter((candidate) => candidate.answers);
+ const quiet = roster.filter((candidate) => !candidate.answers);
+ const withdrawn = view.mayoral.filter((candidate) => candidate.withdrawn);
+ const sites = roster.filter((candidate) => candidate.website).length;
+
+ return (
+
+
+ {/* ── Breadcrumb ─────────────────────────────────────── */}
+
+
+ Toronto 2026
+
+ /
+ Candidates for Mayor
+
+
+ {/* ── Hero ───────────────────────────────────────────── */}
+
+ City of Toronto
+
+ Everyone running for mayor
+
+
+ The one race every Toronto voter votes in, and the longest ballot in
+ the city.{" "}
+ {roster.length > 0 && answered.length > 0
+ ? `${roster.length} candidates have registered; ${answered.length} of them have told us where they stand.`
+ : `${roster.length} candidates have registered.`}
+
+
+
+ {/* ── Key stats ──────────────────────────────────────── */}
+
+
+
+
+
+
+
+ Days until polls open
+
+
+
+
+ {/* ── Answered ───────────────────────────────────────── */}
+ {answered.length > 0 && (
+
+ )}
+
+ {/* ── Yet to respond ─────────────────────────────────── */}
+ {quiet.length > 0 && (
+
+ )}
+
+ {/* ── Withdrawn ──────────────────────────────────────── */}
+ {withdrawn.length > 0 && (
+
+ )}
+
+ {/* ── Source note ────────────────────────────────────── */}
+
+
+ Registered candidates from the City Clerk’s official list,
+ refreshed daily. The field is not final until nominations close
+ {view.nominationCloseLabel
+ ? ` on ${view.nominationCloseLabel}`
+ : ""}
+ .
+
+
+
+ {/* ── Elsewhere ──────────────────────────────────────── */}
+
+
+
+ Where the whole field stands
+
+
+
+
+
+
+ Find your ward
+
+
+
+
+
+ );
+}
+
+function Stat({ value, label }: { value: number; label: string }) {
+ return (
+
+
+ {value}
+
+
+ {label}
+
+
+ );
+}
+
+function GroupHead({
+ eyebrow,
+ title,
+ blurb,
+ action,
+}: {
+ eyebrow: string;
+ title: string;
+ blurb: string;
+ action?: { label: string; href: string };
+}) {
+ return (
+
+
+
{eyebrow}
+
+ {title}
+
+
+ {blurb}
+
+
+ {action && (
+
+ {action.label}
+
+
+ )}
+
+ );
+}
+
+/** The field as a grid of rows.
+ *
+ * A row, not the landing page's card: fifty-three of anything is a list, and
+ * the portrait was the tallest thing on a tile whose text is a name and a
+ * link. Kept small it still does the one job a portrait does here — making a
+ * name a person — without setting the height of the page. */
+function Roster({ candidates }: { candidates: CandidateView[] }) {
+ return (
+
+ {candidates.map((candidate) => (
+
+
+ {candidate.image ? (
+
+ ) : (
+ candidate.initials
+ )}
+
+
+
+
+
+ {candidate.name}
+
+ {candidate.tag === "Incumbent" && }
+
+ {candidate.website && (
+
+
+
+ )}
+
+
+ ))}
+
+ );
+}
diff --git a/src/app/toronto/vote/2026/mayor/page.tsx b/src/app/toronto/vote/2026/mayor/page.tsx
new file mode 100644
index 00000000..29e6f850
--- /dev/null
+++ b/src/app/toronto/vote/2026/mayor/page.tsx
@@ -0,0 +1,270 @@
+import type { Metadata } from "next";
+import Link from "next/link";
+import { ArrowLeft, ArrowRight } from "lucide-react";
+
+import { CandidateRoster } from "@/components/elections/CandidateRoster";
+import {
+ QuestionnaireCards,
+ questionnaireHeadings,
+} from "@/components/elections/QuestionnaireCards";
+import { QuestionnaireRail } from "@/components/elections/QuestionnaireRail";
+import { SurveyCta } from "@/components/elections/SurveyCta";
+import CountdownDays from "@/components/elections/CountdownDays";
+import {
+ byCandidateKey,
+ candidateAnswers,
+ comparedQuestions,
+ questionnaireShape,
+ surveyRoster,
+} from "@/lib/elections/candidate-answers";
+import {
+ CANDIDATE_QUESTIONNAIRE_SLUG,
+ fetchCandidateResponses,
+} from "@/lib/elections/candidate-responses";
+import { fetchSurvey } from "@/lib/elections/survey";
+import { daysUntil } from "@/lib/elections/dates";
+import { ELECTION, getToronto2026 } from "../data";
+
+/* How the mayoral field answered — question first.
+ *
+ * WHAT THIS PAGE USED TO BE, AND WHY IT CHANGED
+ * A grid: every question a row, every candidate a column, every candidate on
+ * the ballot given a column whether they wrote back or not. That shape is
+ * right for a ward, where a field of four fits across a laptop and an empty
+ * column is a visible fact about a named person.
+ *
+ * Toronto's mayoral ballot is fifty-three people. The grid came out 12,558px
+ * wide — thirteen screens of sideways drag — and forty-four of those columns
+ * were empty, because nine candidates answered. The reader had to haul past
+ * forty-four blanks to compare the nine. An empty column stops being a
+ * finding somewhere around the tenth one; after that it is furniture.
+ *
+ * So the page turns ninety degrees. The question becomes the object and the
+ * candidates are filed inside it, under the answer each one gave — the same
+ * cards the ward pages use, so the two can never disagree about how a
+ * questionnaire reads. Nine respondents is a comfortable fit: it is a
+ * handful of names under each answer, which is what the form was built for,
+ * where the fifty-three-column grid was thirteen screens of drag.
+ *
+ * The forty-four who have not answered are not named on the cards. On a
+ * ward, where the field is a dozen, every card names its silent candidates;
+ * here that would be fifteen hundred names saying one thing. They are on
+ * the roster page, which groups the field by exactly that line, and this
+ * page links to it from the hero and names the count in its stats.
+ */
+
+export const metadata: Metadata = {
+ title: "How the mayoral candidates answered",
+ description:
+ "How the candidates for Mayor of Toronto answered our questionnaire in the October 26, 2026 election — question by question, with the whole mayoral field on each one.",
+ alternates: { canonical: `${ELECTION.basePath}/mayor` },
+ openGraph: {
+ title: "How the mayoral candidates answered — Toronto 2026 Election",
+ description:
+ "Every answer the mayoral field gave us, read question by question.",
+ type: "website",
+ },
+};
+
+export default async function MayorPage() {
+ const [view, survey, responses] = await Promise.all([
+ getToronto2026(),
+ fetchSurvey(ELECTION.slug, CANDIDATE_QUESTIONNAIRE_SLUG).catch(() => null),
+ fetchCandidateResponses(ELECTION.slug),
+ ]);
+
+ /* The ballot line, and the part of it that wrote back.
+
+ The whole election's responses come back from one fetch — the counts a
+ candidate's answer is measured against are the field's, not this race's —
+ and the roster narrows who gets named, exactly as the ward pages do. */
+ const ballot = view.mayoral.filter((candidate) => !candidate.withdrawn);
+ const registered = ballot.length;
+
+ const ballotKeys = new Set(ballot.map((candidate) => candidate.key));
+ const answers = survey
+ ? byCandidateKey(
+ candidateAnswers(survey, responses).filter((entry) =>
+ ballotKeys.has(entry.key),
+ ),
+ )
+ : {};
+ const roster = surveyRoster(ballot, answers);
+ const mayoral = roster.filter((candidate) => candidate.answers);
+ const groups = comparedQuestions(
+ mayoral.map((candidate) => candidate.answers!),
+ mayoral,
+ survey ? questionnaireShape(survey, responses) : undefined,
+ );
+ const questionCount = groups.reduce(
+ (n, group) => n + group.questions.length,
+ 0,
+ );
+
+ return (
+
+
+ {/* ── Breadcrumb ─────────────────────────────────────── */}
+
+
+ Toronto 2026
+
+ /
+ Mayor
+
+
+ {/* ── Hero ───────────────────────────────────────────── */}
+
+ City of Toronto
+
+ How the mayoral field answered
+
+
+ {mayoral.length > 0
+ ? `${mayoral.length} of the ${registered} candidates for mayor returned our questionnaire. Their answers, question by question — the mayoral field on each one.`
+ : `No one running for mayor has answered our questionnaire yet. ${registered} candidates have registered for the race.`}
+
+
+ Every candidate for mayor
+
+
+
+
+ {/* ── Key stats ──────────────────────────────────────── */}
+
+
+
+
+
+
+
+ Days until polls open
+
+
+
+
+ {/* ── The field, question by question ────────────────── */}
+ {groups.length > 0 && mayoral.length > 0 ? (
+
+ {/* THE WHOLE BALLOT, ONCE
+
+ This listed the nine who answered and nobody else, under the
+ heading "Answered our questionnaire" — which is a page about
+ the mayoral race that never names most of the people in it. A
+ reader arriving with a name in mind, and most do, could not
+ find out from this page whether that person is running.
+
+ So it is the ballot: every registered candidate, in surname
+ order, each linked to their campaign. Who answered is what the
+ thirty-four cards below are made of, and does not need saying
+ twice. Still one list rather than two — a second heading of
+ forty-four names reading "yet to answer" is a scoreboard, and
+ the cards keep that score already. */}
+
+
+
+
+
+ ) : (
+
+
+ No answers from the mayoral field have been published yet.
+ Responses appear here as they are reviewed and released.
+
+
+ )}
+
+ {/* ── Your turn ──────────────────────────────────────── */}
+
+
+
+ Now answer them yourself
+
+
+ These are the same questions we put to the field. Answer them and
+ see which candidates line up with you.
+
+
+
+
+
+ {/* ── Method ─────────────────────────────────────────── */}
+
+
+ Every bar is the mayoral field that answered, one cell per
+ candidate: filled with the option that candidate picked, hollow
+ where they did not answer that question. Candidates who never
+ returned the questionnaire are not in these counts — they are on the
+ roster. Open a card for the names behind the bars and what each of
+ them wrote, published verbatim.
+
+
+ Registered candidates come from the City Clerk’s list, less
+ anyone who has withdrawn. The field is not final until nominations
+ close
+ {view.nominationCloseLabel
+ ? ` on ${view.nominationCloseLabel}`
+ : ""}
+ .
+
+
+
+ {/* ── Elsewhere ──────────────────────────────────────── */}
+
+
+
+ The whole field, mayoral and council
+
+
+
+
+
+
+ Find your ward
+
+
+
+
+
+ );
+}
+
+function Stat({ value, label }: { value: number; label: string }) {
+ return (
+
+
+ {value}
+
+
+ {label}
+
+
+ );
+}
diff --git a/src/app/toronto/vote/2026/page.tsx b/src/app/toronto/vote/2026/page.tsx
index b39fc3f2..3270dc1d 100644
--- a/src/app/toronto/vote/2026/page.tsx
+++ b/src/app/toronto/vote/2026/page.tsx
@@ -5,6 +5,7 @@ import { WARD_GEO } from "./wardGeo";
import { ELECTION, getToronto2026 } from "./data";
import {
ADVANCE_VOTING_PATH,
+ ELECTION_DAY,
HOW_TO_VOTE_PATH,
KEY_DATES_PATH,
VOTE_BY_MAIL_PATH,
@@ -30,6 +31,12 @@ export default async function Toronto2026ElectionPage() {
}
renderWardMap={(ward) => (
- Toronto votes Monday, October 26. Add your name — then bring someone
- with you.
+ Toronto votes Monday, October 26. Answer the questions we put to the
+ candidates and see which of them line up with you.
>
),
+ /* WHAT IS ONLY HERE
+ A card earns its place by going somewhere a reader would not
+ otherwise get to, and by being the thing they came for. What is left
+ is the two questionnaire reads — one race, then every race — with
+ the survey between them; ElectionLanding supplies the mayoral card
+ and the survey card itself.
+
+ Everything else is reachable from the section that owns it, which is
+ where a reader looks for it anyway: the question set is linked from
+ the survey and from every questionnaire page, the pledge from the
+ closing band, and the wards from the ward grid two hundred pixels
+ below. */
+ explore: [
+ {
+ eyebrow: "Every race",
+ title: "Where the candidates stand",
+ blurb:
+ "Mayor and council together, question by question: where the field agrees, and where it splits.",
+ href: `${ELECTION.basePath}/issues`,
+ },
+ ],
guideLinks: [
{ label: "See all key dates", href: KEY_DATES_PATH },
{ label: "How to vote in Toronto", href: HOW_TO_VOTE_PATH },
diff --git a/src/app/toronto/vote/2026/survey/AgreementChart.tsx b/src/app/toronto/vote/2026/survey/AgreementChart.tsx
new file mode 100644
index 00000000..64b7ce09
--- /dev/null
+++ b/src/app/toronto/vote/2026/survey/AgreementChart.tsx
@@ -0,0 +1,188 @@
+import type { Alignment, CandidateScore } from "@/lib/elections/alignment";
+
+/* How much of the questionnaire each candidate answered the way you did.
+ *
+ * WHAT THIS REPLACED
+ * A card per candidate, each carrying a grid of thirty-three little squares —
+ * one per question, coloured by the option they picked and faded where it was
+ * not yours. It was a handsome object and the wrong one. Three faults:
+ *
+ * · It answered the wrong question. A reader arriving here has just spent ten
+ * minutes on a questionnaire and wants a ranking: who is closest to me. The
+ * card grid gave a ranking you had to assemble by reading a two-digit
+ * figure out of every card and holding all of them in your head, because
+ * the cards were laid across four columns and the eye cannot compare down a
+ * grid.
+ * · The squares were unreadable at their size. Thirty-three cells across a
+ * card three hundred pixels wide are seven pixels each; which question a
+ * cell stood for was recoverable only by hovering it one at a time.
+ * · It matched nothing else on the site. Every other page in the tracker is
+ * rules, plates and house type. This was a heatmap.
+ *
+ * WHAT IT IS NOW
+ * A ranked list, one candidate per ruled row, each with a single bar divided
+ * into how you and they compare. The ranking is the layout: the longest bar
+ * is at the top and a reader gets the order by looking down the left edge,
+ * which is the thing they came for.
+ *
+ * The bar is the whole questionnaire every time, so the rows are comparable —
+ * a candidate who answered nine questions and agreed on all nine does not
+ * draw the same bar as one who answered thirty-three and agreed on all of
+ * them. Silence takes up its own share of the bar, which is the honest
+ * picture of a candidate who barely filled the thing in.
+ *
+ * WHERE THE DETAIL WENT
+ * Nowhere it was not already. The question cards below this chart file every
+ * candidate under the answer they gave — and the reader's own plate sits in
+ * those blocks too, so "where do we differ" is answered by scrolling rather
+ * than by hovering thirty-three squares in turn. A hover panel here was the
+ * same information in a worse format, and it reserved five empty lines under
+ * every card to say "Hover a square for the question".
+ */
+
+/* The bands, in the order they stack along the bar: agreement first, from the
+ left edge, because that is the quantity being ranked and a reader compares
+ the length of the run that starts in the same place on every row.
+
+ Agreement takes the accent. Everything else is the same ink at descending
+ strengths rather than four hues — the bar is one quantity split up, not four
+ competing ones, and a palette here would say that disagreeing and not
+ answering are different in kind rather than different in degree. */
+const BANDS = [
+ {
+ key: "agreed",
+ label: "Same as you",
+ className: "bg-accent",
+ },
+ {
+ key: "differed",
+ label: "Different",
+ className: "bg-dark/30",
+ },
+ {
+ key: "unclear",
+ label: "Their own words",
+ className: "bg-dark/12",
+ },
+ {
+ key: "unanswered",
+ label: "No answer",
+ className: "bg-dark/6",
+ },
+] as const;
+
+type BandKey = (typeof BANDS)[number]["key"];
+
+export function AgreementChart({
+ alignment,
+ scores,
+}: {
+ alignment: Alignment;
+ /** the candidates in the order they should print; defaults to the
+ * alignment's own ranking, which is by agreement */
+ scores?: CandidateScore[];
+}) {
+ const { rows } = alignment;
+ const ordered = scores ?? alignment.scores;
+ if (rows.length === 0 || ordered.length === 0) return null;
+
+ const total = rows.length;
+
+ /* A band that never occurs is left out of the key. Most fields produce three
+ of the four, and a key naming a colour nobody can find on the page is a
+ colour the reader goes looking for. */
+ const present = BANDS.filter((band) =>
+ ordered.some((score) => score[band.key] > 0),
+ );
+
+ return (
+
+
+ Every bar is the same {total} questions. The filled run is how many of
+ them that candidate answered the way you did.
+
+
+
+ {ordered.map((score) => (
+
+ ))}
+
+
+ {/* The key, under the rows rather than over them: a reader looks at the
+ bars first and comes here only when a band needs naming. */}
+
+ {present.map((band) => (
+
+
+ {band.label}
+
+ ))}
+
+
+ );
+}
+
+/* One candidate.
+ *
+ * Three tracks on a wide screen — name, bar, figures — so the bars all start
+ * and end on the same two lines and their lengths can be read against each
+ * other without a gridline to help. Below `sm` the name takes its own line
+ * above the bar, because a name column narrow enough to leave a usable bar is
+ * a name column that wraps every candidate onto three lines. */
+function Row({ score, total }: { score: CandidateScore; total: number }) {
+ const share = score.share === null ? null : Math.round(score.share * 100);
+
+ return (
+
+
+ {score.candidateName}
+
+
+
+
+ {/* The count is the honest figure and leads; the share follows it in
+ muted ink. "24 of 33" is a fact about a questionnaire, where a bare
+ 73% invites being read as a poll result. */}
+
+
+ {score.agreed}
+
+
+ of {total}
+ {share !== null && ` · ${share}%`}
+
+
+
+ );
+}
+
+function Bar({ score, total }: { score: CandidateScore; total: number }) {
+ /* Everything the candidate did with the questionnaire, summing to its whole
+ length — including the questions they never reached, which is why the
+ denominator here is the question count and not `compared`. */
+ const segments = BANDS.map((band) => ({
+ ...band,
+ n: score[band.key as BandKey],
+ })).filter((segment) => segment.n > 0);
+
+ return (
+ `${segment.n} ${segment.label.toLowerCase()}`)
+ .join(", ")}
+ >
+ {segments.map((segment) => (
+
+ ))}
+
+ );
+}
diff --git a/src/app/toronto/vote/2026/survey/AlignmentResults.tsx b/src/app/toronto/vote/2026/survey/AlignmentResults.tsx
new file mode 100644
index 00000000..8185e838
--- /dev/null
+++ b/src/app/toronto/vote/2026/survey/AlignmentResults.tsx
@@ -0,0 +1,377 @@
+"use client";
+
+import { useMemo, useState } from "react";
+import { ChevronDown } from "lucide-react";
+
+import { Select } from "@/components/ui/select";
+
+import {
+ Collapsible,
+ CollapsibleContent,
+ CollapsibleTrigger,
+} from "@/components/ui/collapsible";
+
+import {
+ QuestionnaireCards,
+ questionnaireHeadings,
+} from "@/components/elections/QuestionnaireCards";
+import { QuestionnaireRail } from "@/components/elections/QuestionnaireRail";
+import type { Heading } from "@/components/custom/signpost/config";
+import type { Seat } from "@/components/elections/QuestionRollCall";
+import { AgreementChart } from "./AgreementChart";
+import {
+ byCandidateKey,
+ candidateAnswers,
+ comparedQuestions,
+ questionnaireShape,
+ surveyRoster,
+} from "@/lib/elections/candidate-answers";
+import { lastName } from "@/lib/elections/names";
+import type { CandidateSurveyResponse } from "@/lib/elections/alignment";
+import type { Survey } from "@/lib/elections/survey";
+import type { Alignment, CandidateScore } from "@/lib/elections/alignment";
+
+/* Alignment between one resident's answers and their ward's candidates.
+ *
+ * FORM
+ * The same grid the ward and mayoral pages draw, with one column added: the
+ * reader's own. Questions run down, candidates run across, and "You" is the
+ * first column, next to the question and pinned beside it — so the
+ * comparison the page exists to make is two cells on one line rather than
+ * two blocks a scroll apart.
+ *
+ * It was a block per question, with the ward's candidates filed under the
+ * option each picked and the reader's own pick badged among them. That read
+ * well for one question and did not compose: the reader's answer was
+ * restated thirty times, and the candidates who never returned the
+ * questionnaire — most of a ward's ballot, most of the campaign — were
+ * nowhere on the page, so a reader could not tell a candidate who disagreed
+ * with them from one who had said nothing at all. The grid has a column for
+ * both, and says which is which in every row.
+ *
+ * The ranked list stays. "Who is closest to me" is the question that brought
+ * the reader here, and it is an answer no grid gives at a glance.
+ */
+
+/** A candidate on the ward's ballot, as the responses route hands them over. */
+export type SurveyRosterCandidate = {
+ key: string;
+ name: string;
+ website?: string;
+ withdrawn?: boolean;
+};
+
+/* The reader, as the cards name them. Named as the questionnaire would name a
+ candidate, because that is exactly what they are to the pivot — a respondent
+ with answers, run through the same code as everyone else's. */
+const YOU = "You";
+
+/* How the agreement chart is ordered.
+ *
+ * The cards below it are not sorted by this and should not be: a card files
+ * its candidates under the answer they gave, and inside a group they run by
+ * surname so a reader finds the same person in the same place on all thirty
+ * questions. A ranking belongs in the thing that is a ranking.
+ *
+ * Agreement first by default: the reader has just answered thirty questions,
+ * and "who is closest to me" is the question that brought them here. The rest
+ * are the orders a reader actually asks for next — the opposite end of the
+ * same list, someone they already have a name for, and who put the most on
+ * the record. */
+const SORTS = [
+ { value: "agreement", label: "Most in common with you" },
+ { value: "disagreement", label: "Least in common with you" },
+ { value: "answered", label: "Most questions answered" },
+ { value: "name", label: "Name (A–Z)" },
+] as const;
+
+type Sort = (typeof SORTS)[number]["value"];
+
+function sortScores(scores: CandidateScore[], sort: Sort): CandidateScore[] {
+ const byName = (a: CandidateScore, b: CandidateScore) =>
+ lastName(a.candidateName).localeCompare(lastName(b.candidateName)) ||
+ a.candidateName.localeCompare(b.candidateName);
+
+ /* A candidate with nothing comparable has no share to rank on — a null is
+ not a zero, and sorting it as one would put "we cannot say" among the
+ people who disagree with you. They go last either way. */
+ const share = (score: CandidateScore) => score.share ?? -1;
+
+ return [...scores].sort((a, b) => {
+ switch (sort) {
+ case "agreement":
+ return share(b) - share(a) || byName(a, b);
+ case "disagreement":
+ return (
+ (a.share === null ? 1 : 0) - (b.share === null ? 1 : 0) ||
+ share(a) - share(b) ||
+ byName(a, b)
+ );
+ case "answered":
+ return b.compared - a.compared || share(b) - share(a) || byName(a, b);
+ case "name":
+ return byName(a, b);
+ }
+ });
+}
+
+/** One ballot a voter marks: the race, its candidates, and how the reader
+ * lines up with them. */
+export type RaceComparison = {
+ key: string;
+ /** e.g. "For mayor" or "Ward 9 — Davenport" */
+ label: string;
+ alignment: Alignment;
+ /** that race's published responses, as fetched */
+ responses: CandidateSurveyResponse[];
+ /** everyone on that ballot, respondents or not */
+ roster: SurveyRosterCandidate[];
+};
+
+export default function AlignmentResults({
+ races,
+ survey,
+ answers,
+}: {
+ /** in ballot order — mayor first, then the ward */
+ races: RaceComparison[];
+ survey: Survey;
+ /** the reader's own answers, by question id */
+ answers: Record;
+}) {
+ /* Memoised, not just filtered inline: `grids` keys off this, and a fresh
+ array every render would rebuild both pivots on every keystroke the page
+ ever sees. */
+ const shown = useMemo(
+ () =>
+ races.filter(
+ (race) =>
+ race.alignment.scores.length > 0 && race.alignment.rows.length > 0,
+ ),
+ [races],
+ );
+
+ const grids = useMemo(
+ () => shown.map((race) => raceGrid(survey, race, answers)),
+ [shown, survey, answers],
+ );
+
+ /* The rail's list: each race's sections, in ballot order, namespaced by
+ race. Both races ask the same questionnaire, so without the prefix the
+ document would hold two elements called "housing" and the rail would
+ scroll to whichever came first, whatever you clicked.
+
+ A race's own heading takes the level-2 slot and its sections hang under
+ it as level 3, which is the one place in the tracker where the rail
+ nests — there are two questionnaires on this page and the sections would
+ otherwise read as one run of eighteen.
+
+ A closed race unmounts its sections and the rail simply cannot find them:
+ the spy skips a heading with no element and the jump no-ops. Both races
+ open by default, so that is the state after a reader has folded one away,
+ which is a reader saying they do not want those sections. */
+ const headings: Heading[] = useMemo(
+ () =>
+ shown.flatMap((race, i) => [
+ { id: race.key, text: race.label, level: 2 as const },
+ ...questionnaireHeadings(grids[i].groups, { prefix: race.key }).map(
+ (heading) => ({ ...heading, level: 3 as const }),
+ ),
+ ]),
+ [shown, grids],
+ );
+
+ if (shown.length === 0) return null;
+
+ /* No heading of its own. Once the answers are in, the page is this
+ comparison and nothing else, so the card's masthead says so — a second
+ title under the first was two announcements of one thing, with an accent
+ rule and a rule-off between them. What the masthead carries now used to
+ live here: see `SurveyClient`. */
+ return (
+
+
+
+ {shown.map((race, i) => (
+
+ ))}
+
+
+
+ {/* One note for the whole page rather than one under every grid: it is
+ the same reading in both races. */}
+
+ Reading these. Each card
+ is one question, with the candidates filed under the answer they gave —
+ and your own answer among them, marked. Options nobody picked are not
+ shown. The bars above count only the candidates who returned the
+ questionnaire, not the whole ballot.
+
+
+ );
+}
+
+/* The cards, and the reader inside them.
+ *
+ * `candidateAnswers` does the pivoting for both — the reader is passed through
+ * it as a response of their own, so their answers are built by the same code
+ * that builds everyone else's and cannot disagree with them.
+ *
+ * Computed by the parent rather than inside the block that renders it, because
+ * the scroll rail has to list both races' sections before either block has
+ * rendered, and the sections are a property of this result. One computation,
+ * two readers. */
+function raceGrid(
+ survey: Survey,
+ race: RaceComparison,
+ answers: Record,
+) {
+
+ const entries = candidateAnswers(survey, race.responses);
+ const byKey = byCandidateKey(entries);
+ const field = surveyRoster(
+ race.roster.length > 0
+ ? race.roster
+ : entries.map((entry) => ({
+ key: entry.key,
+ name: entry.candidateName,
+ })),
+ byKey,
+ );
+
+ const [you] = candidateAnswers(survey, [
+ {
+ candidateName: YOU,
+ ward: "",
+ surveySlug: survey.slug,
+ surveyVersion: "",
+ answers,
+ explanations: {},
+ source: "form" as const,
+ },
+ ]);
+
+ /* Every name the cards can print, the reader first. The order here is
+ only the order the pivot hands the rows over in — inside a card the
+ candidates are filed under the answer they gave and sorted by surname,
+ so a reader looking for one person finds them in the same place on all
+ thirty questions. The agreement sort above drives the chart, which is
+ where a ranking belongs: it is a ranking. */
+ const candidates = [
+ ...(you ? [{ key: you.key, name: YOU }] : []),
+ ...field.map(({ key, name, website }) => ({ key, name, website })),
+ ];
+
+ /* The ballot line each plate belongs to.
+
+ A race block is one race, so its heading already says which — but the
+ two blocks sit one under the other on a page a reader scrolls through
+ with a candidate's name in mind, and a plate that says only a name is
+ a plate whose race depends on remembering which heading you passed.
+ Every plate carries its own. The reader's own plate carries none: they
+ are not running for anything. */
+ const seat: Seat =
+ race.key === "mayor"
+ ? { race: "mayor", label: "Mayor" }
+ : { race: "councillor", label: "Councillor" };
+ const seats = Object.fromEntries(
+ field.map((candidate) => [candidate.key, seat]),
+ );
+
+ return {
+ yourKey: you?.key,
+ seats,
+ /* The reader counts as a respondent: they answered the questionnaire,
+ which is the whole reason there is a page. */
+ respondents: [
+ ...(you ? [{ key: you.key, name: YOU }] : []),
+ ...field.filter((candidate) => candidate.answers),
+ ],
+ silent: field
+ .filter((candidate) => !candidate.answers)
+ .map(({ key, name, website }) => ({ key, name, website })),
+ groups: comparedQuestions(
+ you ? [you, ...entries] : entries,
+ candidates,
+ questionnaireShape(survey, race.responses),
+ ),
+ };
+}
+
+type RaceGrid = ReturnType;
+
+/* ── One race: who is closest, then everyone question by question ─── */
+
+function RaceBlock({
+ race,
+ grid,
+}: {
+ race: RaceComparison;
+ grid: RaceGrid;
+}) {
+ const [sort, setSort] = useState("agreement");
+ const scores = useMemo(
+ () => sortScores(race.alignment.scores, sort),
+ [race.alignment.scores, sort],
+ );
+
+
+
+ /* Both races open to begin with. A reader who has just answered thirty
+ questions is owed the answer to them, not two closed doors — the fold is
+ here so they can put one ballot away while they read the other, which is
+ a different thing from making them ask for either. */
+ return (
+
+
+
+ {race.label}
+
+ {/* What is behind the fold, so a closed race still says whether it
+ is worth opening. */}
+
+ {race.alignment.scores.length} answered
+
+
+
+
+
+
+
+ Order the chart by
+
+ setSort(value as Sort)}
+ options={SORTS.map((option) => ({ ...option }))}
+ className="max-w-[280px]"
+ />
+
+
+
+
+
+
+
+ );
+}
diff --git a/src/app/toronto/vote/2026/survey/SurveyClient.tsx b/src/app/toronto/vote/2026/survey/SurveyClient.tsx
new file mode 100644
index 00000000..d6e23ed4
--- /dev/null
+++ b/src/app/toronto/vote/2026/survey/SurveyClient.tsx
@@ -0,0 +1,700 @@
+"use client";
+
+import { useEffect, useMemo, useState, type CSSProperties } from "react";
+import Link from "next/link";
+import { ArrowRight } from "lucide-react";
+
+import { Select } from "@/components/ui/select";
+
+import {
+ alignToCandidates,
+ wardKeyFromRegion,
+} from "@/lib/elections/alignment";
+import type { CandidateSurveyResponse } from "@/lib/elections/alignment";
+import { DEFAULT_ELECTION_SLUG } from "@/lib/elections/registry";
+import type { Survey, SurveyQuestion } from "@/lib/elections/survey";
+
+import AlignmentResults, {
+ type RaceComparison,
+ type SurveyRosterCandidate,
+} from "./AlignmentResults";
+import { submitSurvey, type SurveySubmission } from "./submitSurvey";
+
+export type SurveyAnswers = Record;
+
+const EMAIL_PATTERN = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
+const POSTAL_PATTERN = /^[A-Za-z]\d[A-Za-z] ?\d[A-Za-z]\d$/;
+
+const FIELD_CLASS =
+ "w-full border border-border-light bg-white px-4 py-3.5 font-serif text-[17px] text-dark outline-none transition-colors focus:border-dark placeholder:text-text-muted";
+const CHOICE_CLASS =
+ "flex cursor-pointer items-center gap-3 border border-border-light bg-white px-4 py-3.5 text-[17px] transition-colors hover:border-dark has-checked:border-dark";
+const RADIO_CLASS = "size-[17px] m-0 accent-accent";
+
+/** Copy the CMS doesn't have to supply. Only the fallbacks live here — the
+ * survey's own meta wins whenever it sets a field. */
+/* Used only when York Factory sends none of its own — the live title, intro
+ and thank-you are CMS copy, and this is what shows if they are missing. */
+const META_FALLBACK = {
+ title: "Toronto priorities survey",
+ intro:
+ "Thirty questions on what the next council should do. Answer them and see which candidates on your ballot agree with you.",
+ submitLabel: "Submit survey",
+} as const;
+
+/** Entrance delay for the nth element in a step, capped so a long step's last
+ * field doesn't sit blank waiting its turn. */
+function stagger(index: number): CSSProperties {
+ return { animationDelay: `${Math.min(index, 6) * 45}ms` };
+}
+
+/** Per-question error copy, or null when the answer passes. */
+function errorFor(question: SurveyQuestion, value: string): string | null {
+ const answered = value.trim().length > 0;
+
+ if (question.required && !answered) {
+ if (question.type === "radio") return "Choose one";
+ if (question.type === "yesno") return "Choose yes or no";
+ return "Required";
+ }
+ if (!answered) return null;
+
+ if (question.type === "email" && !EMAIL_PATTERN.test(value)) {
+ return "Enter a valid email";
+ }
+ if (question.id === "postal_code" && !POSTAL_PATTERN.test(value)) {
+ return "Enter a valid postal code (e.g. M5V 2T6)";
+ }
+ return null;
+}
+
+/* ── Developer fill ───────────────────────────────────────────
+ *
+ * TEMPORARY. Delete this block, the button that calls it, and nothing else
+ * when the results pages stop needing to be looked at.
+ *
+ * Reaching the comparison view by hand means answering thirty-odd questions,
+ * and every change to that view has to be checked against a filled-in survey.
+ * The button answers the whole questionnaire at random and drops the caller on
+ * the last step, so submitting is one more click.
+ *
+ * Gated on the build, not on a flag: `process.env.NODE_ENV` is inlined at
+ * build time, so in a production bundle the condition is `false` and the
+ * button and this function are dropped entirely.
+ */
+const DEV_TOOLS = process.env.NODE_ENV !== "production";
+
+/** A Toronto postal code that resolves to a ward, so the comparison renders. */
+const DEV_POSTAL = "M6H 1A1";
+
+function devFill(survey: Survey): SurveyAnswers {
+ const filled: SurveyAnswers = {};
+
+ for (const step of survey.steps) {
+ for (const question of step.questions) {
+ if (question.id === "postal_code") {
+ filled[question.id] = DEV_POSTAL;
+ continue;
+ }
+ if (question.options?.length) {
+ // Random rather than always the first: a survey answered entirely down
+ // the left-hand side agrees with nobody in a way that looks like a bug
+ // in the alignment rather than what it is.
+ const option =
+ question.options[Math.floor(Math.random() * question.options.length)];
+ filled[question.id] = option.value;
+ continue;
+ }
+ if (question.type === "email") {
+ filled[question.id] = "dev@example.com";
+ continue;
+ }
+ filled[question.id] = "Developer fill";
+ }
+ }
+
+ return filled;
+}
+
+export default function SurveyClient({
+ survey,
+ wardNames = {},
+}: {
+ survey: Survey;
+ /** {"09": "Davenport"} — passed in from the server so the ward geometry,
+ * which is a large generated file, stays out of this bundle. */
+ wardNames?: Record;
+}) {
+ const [step, setStep] = useState(0);
+ const [answers, setAnswers] = useState({});
+ const [errors, setErrors] = useState>({});
+ const [submitting, setSubmitting] = useState(false);
+ const [submitError, setSubmitError] = useState(null);
+ const [done, setDone] = useState(false);
+ const [submission, setSubmission] = useState(null);
+
+ // Everything about the shape of the form comes from the fetched survey, so a
+ // question added in the CMS shows up here with no change to this component.
+ const steps = survey.steps;
+ const stepCount = steps.length;
+ const meta = survey.meta;
+
+ const isLastStep = step === stepCount - 1;
+ const currentStep = steps[step];
+
+ /* The comparison against the ward's candidates. Keyed on the ward the API
+ actually recorded rather than one re-derived here, so the results a
+ respondent reads are the results filed under their response.
+
+ Null whenever there is nothing honest to show: no ward resolved from the
+ postal code, or a ward whose candidates have not answered the
+ questionnaire — which is most wards for most of the campaign. */
+ const ward = wardKeyFromRegion(
+ submission?.derivedRegion ?? submission?.region,
+ );
+
+ /* The ward's published candidate answers, fetched once the API has told us
+ which ward the response was filed under. Not loaded with the page: the
+ ward is not known until then, and pre-loading all 25 would ship every
+ ward's answers to every visitor to use one ward's worth. */
+ const [responses, setResponses] = useState(
+ null,
+ );
+ /* Everyone on the ward's ballot, which arrives with the answers. The
+ comparison gives a column to every candidate, not only the ones who wrote
+ back: that a candidate said nothing is as much a part of the comparison as
+ what the others said. */
+ const [roster, setRoster] = useState([]);
+ /* The mayoral field, which is on no ward and so arrives beside the ward's:
+ a voter marks two ballots, and the comparison answers for both. */
+ const [mayoral, setMayoral] = useState<{
+ data: CandidateSurveyResponse[];
+ roster: SurveyRosterCandidate[];
+ }>({ data: [], roster: [] });
+
+ useEffect(() => {
+ if (!ward) return;
+ let cancelled = false;
+
+ fetch(
+ `/api/elections/candidate-responses?election=${encodeURIComponent(
+ DEFAULT_ELECTION_SLUG,
+ )}&ward=${encodeURIComponent(ward)}`,
+ )
+ .then((res) => (res.ok ? res.json() : { data: [], roster: [] }))
+ .then((body) => {
+ if (cancelled) return;
+ setResponses(body.data ?? []);
+ setRoster(body.roster ?? []);
+ setMayoral({
+ data: body.mayoral?.data ?? [],
+ roster: body.mayoral?.roster ?? [],
+ });
+ })
+ // A comparison we could not load is simply not shown; the respondent
+ // still has their own answers and the thank-you.
+ .catch(() => {
+ if (cancelled) return;
+ setResponses([]);
+ setRoster([]);
+ setMayoral({ data: [], roster: [] });
+ });
+
+ return () => {
+ cancelled = true;
+ };
+ }, [ward]);
+
+ /* One comparison per ballot, in the order a voter marks them. A race with
+ no published answers is simply left out rather than printed empty. */
+ const comparison = useMemo(() => {
+ if (!ward || !responses) return null;
+ if (responses.length === 0 && mayoral.data.length === 0) return null;
+
+ const name = wardNames[ward];
+ const wardLabel = name
+ ? `Ward ${parseInt(ward, 10)} — ${name}`
+ : `Ward ${parseInt(ward, 10)}`;
+
+ const races: RaceComparison[] = [];
+ if (mayoral.data.length > 0) {
+ races.push({
+ key: "mayor",
+ label: "For mayor",
+ alignment: alignToCandidates(survey, answers, mayoral.data),
+ responses: mayoral.data,
+ roster: mayoral.roster,
+ });
+ }
+ if (responses.length > 0) {
+ races.push({
+ key: "council",
+ label: wardLabel,
+ alignment: alignToCandidates(survey, answers, responses),
+ responses,
+ roster,
+ });
+ }
+
+ return { races, wardLabel };
+ }, [ward, responses, roster, mayoral, survey, answers, wardNames]);
+
+ const set = (id: string, value: string) => {
+ setAnswers((prev) => ({ ...prev, [id]: value }));
+ // Clear the error as soon as they start fixing it; it comes back on Next.
+ setErrors((prev) => {
+ if (!prev[id]) return prev;
+ const rest = { ...prev };
+ delete rest[id];
+ return rest;
+ });
+ };
+
+ const scrollTop = () => window.scrollTo({ top: 0, behavior: "smooth" });
+
+ /** Validates the current step, surfacing every failing field at once. */
+ const validateStep = (): boolean => {
+ const found: Record = {};
+ for (const question of currentStep.questions) {
+ // Optional fields still get format-checked once they're filled in.
+ const message = errorFor(question, answers[question.id] ?? "");
+ if (message) found[question.id] = message;
+ }
+ setErrors(found);
+ return Object.keys(found).length === 0;
+ };
+
+ const next = () => {
+ if (!validateStep()) return;
+ setStep((s) => Math.min(stepCount - 1, s + 1));
+ scrollTop();
+ };
+
+ const back = () => {
+ setErrors({});
+ setSubmitError(null);
+ setStep((s) => Math.max(0, s - 1));
+ scrollTop();
+ };
+
+ const submit = async () => {
+ if (!validateStep()) return;
+ setSubmitting(true);
+ setSubmitError(null);
+ try {
+ setSubmission(await submitSurvey(survey, answers));
+ setDone(true);
+ scrollTop();
+ } catch {
+ // Answers stay on screen so they can just press submit again.
+ setSubmitError("Something went wrong. Please try again.");
+ } finally {
+ setSubmitting(false);
+ }
+ };
+
+ /** Answer everything and jump to the end. Development builds only. */
+ const fillForDev = () => {
+ setAnswers(devFill(survey));
+ setErrors({});
+ setSubmitError(null);
+ setStep(stepCount - 1);
+ scrollTop();
+ };
+
+ return (
+
+ {/* TEMPORARY developer affordance — see `devFill`. Dashed and labelled
+ so it cannot be mistaken for part of the survey, and compiled out of
+ production builds entirely. */}
+ {DEV_TOOLS && !done && (
+
+ Dev: fill survey
+
+ )}
+ {/* The form is a column of questions and stays at a reading measure; the
+ results are two races' worth of question cards, laid two to a row.
+ Held to the form's width they stack into one very long column, so the
+ card opens up to the window once there is something to show in it. */}
+
+ {/* ── Masthead ───────────────────────────────────────── */}
+ {/* Its own band, ruled off from what follows. It had no bottom padding
+ at all: the title sat on top of the progress bar, and the only
+ thing between them was whatever top padding the next block
+ happened to carry. The intro keeps a reading measure of its own —
+ once the card widens to hold the results, a line of intro type set
+ across seventeen hundred pixels is a line nobody finishes. */}
+
+ {/* One masthead, saying whichever of the two things the page is
+ currently for. Before the answers are in it introduces the
+ survey; after, it is the heading of the comparison — which used
+ to announce itself a second time, with its own accent rule, in a
+ band directly under this one. */}
+
+
+ {done && comparison
+ ? "How your answers compare"
+ : META_FALLBACK.title}
+
+ {done && comparison ? (
+ <>
+ {/* Two ballots, so two comparisons. A voter marks a councillor
+ and a mayor separately, and a page that answered only for
+ the ward would answer the smaller half of the question they
+ came with. */}
+
+ You vote twice: once for your councillor, once for mayor.
+ Candidates in both races answered the questions you just did.
+
+ {/* The ward is a postal-code lookup, whose stored point is the
+ centroid of a delivery area — a code on a ward line can
+ resolve to the neighbour. Said plainly rather than
+ presented as settled. */}
+
+ We placed you in {comparison.wardLabel} from your postal code.
+ That is a best guess, not a certainty, for codes that straddle
+ a ward boundary.
+
+ >
+ ) : (
+
+ {META_FALLBACK.intro}
+
+ )}
+
+
+ {/* The way out, in the masthead where a reader looks for it rather
+ than at the bottom of a page they have to finish first. */}
+
+ Explore the candidates
+
+
+
+
+ {done ? (
+ <>
+ {comparison && (
+
+ )}
+ >
+ ) : (
+ <>
+ {/* ── Progress ───────────────────────────────────── */}
+
+
+ Step {step + 1} of {stepCount}
+
+
+
+
+ {/* ── The current step ───────────────────────────── */}
+
+ >
+ )}
+
+
+ );
+}
+
+/* ── One question, rendered by type ─────────────────────────── */
+
+function Question({
+ question,
+ value,
+ error,
+ onChange,
+ style,
+}: {
+ question: SurveyQuestion;
+ value: string;
+ error?: string;
+ onChange: (value: string) => void;
+ /** staggered entrance delay from the parent step */
+ style?: CSSProperties;
+}) {
+ // Choice groups get a ; single inputs get a real .
+ const isGroup = question.type === "radio" || question.type === "yesno";
+
+ // Omitted entirely for free-text questions, so every choice renderer below
+ // reads it through this rather than asserting it exists.
+ const options = question.options ?? [];
+
+ // The question reads as a heading — one step down from the step title's 26px
+ // — rather than as a form label, because several of these are a sentence long
+ // and carry a context paragraph underneath.
+ const LABEL_CLASS =
+ "font-sans text-[19px] font-medium leading-[1.3] tracking-[-0.01em] text-dark text-pretty";
+
+ const labelNode = (
+ <>
+ {question.label}
+ {question.required ? (
+ *
+ ) : (
+
+ {" "}
+ (optional)
+
+ )}
+ >
+ );
+
+ // `help` sits under the context paragraph, so tie both to the field by
+ // reference rather than trusting proximity alone.
+ const contextId = `${question.id}-context`;
+ const helpId = `${question.id}-help`;
+ const describedBy =
+ [question.context && contextId, question.help && helpId]
+ .filter(Boolean)
+ .join(" ") || undefined;
+
+ return (
+
+ {isGroup ? (
+ {labelNode}
+ ) : (
+
+ {labelNode}
+
+ )}
+
+ {/* The briefing a voter needs before they can answer: directly under the
+ question, with a rule down its left edge so it reads as background
+ rather than as part of the ask. */}
+ {question.context && (
+
+ {question.context}
+
+ )}
+
+ {/* The field and its own guidance, grouped so the choices sit tighter to
+ each other than to the briefing above. */}
+
+ {question.help && (
+
+ {question.help}
+
+ )}
+
+ {question.type === "text" || question.type === "email" ? (
+
onChange(e.target.value)}
+ className={`${FIELD_CLASS} ${question.id === "postal_code" ? "max-w-[240px]" : "max-w-[420px]"}`}
+ />
+ ) : question.type === "textarea" ? (
+
+
+ );
+}
+
+/** Autofill hints for the handful of fields browsers can help with. */
+function autoCompleteFor(id: string): string | undefined {
+ if (id === "email") return "email";
+ if (id === "name") return "name";
+ if (id === "postal_code") return "postal-code";
+ return undefined;
+}
diff --git a/src/app/toronto/vote/2026/survey/page.tsx b/src/app/toronto/vote/2026/survey/page.tsx
new file mode 100644
index 00000000..841cfecf
--- /dev/null
+++ b/src/app/toronto/vote/2026/survey/page.tsx
@@ -0,0 +1,64 @@
+import type { Metadata } from "next";
+import { notFound } from "next/navigation";
+
+import { DEFAULT_ELECTION_SLUG } from "@/lib/elections/registry";
+import {
+ CITY_PRIORITIES_SLUG,
+ fetchSurvey,
+ type Survey,
+} from "@/lib/elections/survey";
+
+import { WARD_SHAPES } from "../wardGeo";
+
+import SurveyClient from "./SurveyClient";
+
+/**
+ * Ward number → name, for labelling the candidate comparison after submission.
+ *
+ * Built here rather than imported by SurveyClient because wardGeo.ts is a large
+ * generated file of SVG boundary paths; importing it from a client component
+ * would ship all of that to the browser to render the word "Davenport".
+ */
+const WARD_NAMES: Record = Object.fromEntries(
+ WARD_SHAPES.map((ward) => [ward.n, ward.name]),
+);
+
+// The questions come from York Factory, so this page is only as available as
+// that API — mitigated by the five-minute ISR cache in fetchSurvey, which keeps
+// serving the last good copy through a blip. A cold cache plus an outage is a
+// 404 rather than a half-rendered form: a survey with no questions is worse
+// than an honest miss, and keeping a hard-coded copy here to fall back on is
+// the drift this move was meant to end.
+async function loadSurvey(): Promise {
+ try {
+ return await fetchSurvey(DEFAULT_ELECTION_SLUG, CITY_PRIORITIES_SLUG);
+ } catch {
+ return null;
+ }
+}
+
+export async function generateMetadata(): Promise {
+ const survey = await loadSurvey();
+ const title = survey?.meta.title ?? "Toronto Priorities Survey";
+ const description =
+ survey?.meta.intro ??
+ "Answer thirty questions on what Toronto should do next, and see which candidates on your ballot agree with you.";
+
+ return {
+ title: `${title} — Toronto 2026`,
+ description,
+ alternates: { canonical: "/toronto/vote/2026/survey" },
+ openGraph: {
+ title: `${title} — Toronto 2026 | Build Canada`,
+ description,
+ type: "website",
+ },
+ };
+}
+
+export default async function SurveyPage() {
+ const survey = await loadSurvey();
+ if (!survey || survey.steps.length === 0) notFound();
+
+ return ;
+}
diff --git a/src/app/toronto/vote/2026/survey/submitSurvey.ts b/src/app/toronto/vote/2026/survey/submitSurvey.ts
new file mode 100644
index 00000000..1d15430a
--- /dev/null
+++ b/src/app/toronto/vote/2026/survey/submitSurvey.ts
@@ -0,0 +1,93 @@
+import posthog from "posthog-js";
+
+import { DEFAULT_ELECTION_SLUG } from "@/lib/elections/registry";
+import type { Survey } from "@/lib/elections/survey";
+import { hubspotPageContext } from "@/lib/hubspot-context";
+
+import type { SurveyAnswers } from "./SurveyClient";
+
+/**
+ * What the API tells us back about a recorded response.
+ *
+ * `derivedRegion` is the ward York Factory resolved from the postal code
+ * ("ward-9"), and on this survey it is the only ward there is — the question
+ * set asks for a postal code and nothing else about where someone lives. It is
+ * a best guess: a postal code's stored point is the centroid of its delivery
+ * area, so a code on a ward line can resolve to the neighbour. Null whenever
+ * the lookup missed, which is not an error — the response is still recorded.
+ */
+export type SurveySubmission = {
+ surveySlug: string;
+ /** self-reported ward, for a survey that collects one; null here */
+ region: string | null;
+ /** ward derived from the postal code, e.g. "ward-9" */
+ derivedRegion: string | null;
+ submittedAt: string | null;
+};
+
+/**
+ * Sends a completed survey to /api/elections/survey, which forwards it to York
+ * Factory (subscriber upsert + one response per subscriber per survey).
+ *
+ * The slug and version come from the survey that was rendered, not from
+ * constants here — they identify the question set these answers were actually
+ * given to. Sending a version the page never rendered is how answers end up
+ * filed under the wrong questions, which is exactly what stamping a version is
+ * meant to prevent.
+ *
+ * Throws on failure. The caller keeps the user's answers on screen and shows a
+ * retry, so a thrown error is a recoverable state rather than lost work — which
+ * is why nothing here swallows it.
+ *
+ * Returns what was recorded, because the ward in the reply is the one the
+ * results view compares against. Deriving it a second time on the client would
+ * be a second answer to the same question, free to disagree with the one
+ * actually stored on the response.
+ */
+export async function submitSurvey(
+ survey: Survey,
+ answers: SurveyAnswers,
+): Promise {
+ const res = await fetch("/api/elections/survey", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({
+ election: DEFAULT_ELECTION_SLUG,
+ survey_slug: survey.slug,
+ survey_version: survey.version,
+ // Promoted out of the answer bag: these three are how a response is
+ // identified and cut, and the API stores them as columns. They stay in
+ // `answers` too, so the raw submission is preserved as given.
+ email: answers.email,
+ name: answers.name,
+ region: answers.ward,
+ postal_code: answers.postal_code,
+ answers,
+ ...hubspotPageContext(),
+ }),
+ });
+
+ if (!res.ok) {
+ const data = await res.json().catch(() => ({}));
+ throw new Error(data.error || "Survey submission failed");
+ }
+
+ const data = await res.json().catch(() => ({}));
+
+ if (answers.email) {
+ posthog.identify(answers.email, { email: answers.email });
+ }
+ posthog.capture("survey_submitted", {
+ survey: survey.slug,
+ survey_version: survey.version,
+ election: DEFAULT_ELECTION_SLUG,
+ ward: data.derivedRegion ?? data.region ?? null,
+ });
+
+ return {
+ surveySlug: data.surveySlug ?? survey.slug,
+ region: data.region ?? null,
+ derivedRegion: data.derivedRegion ?? null,
+ submittedAt: data.submittedAt ?? null,
+ };
+}
diff --git a/src/app/toronto/vote/2026/wards/[ward]/page.tsx b/src/app/toronto/vote/2026/wards/[ward]/page.tsx
index aae3bd08..85b0fe52 100644
--- a/src/app/toronto/vote/2026/wards/[ward]/page.tsx
+++ b/src/app/toronto/vote/2026/wards/[ward]/page.tsx
@@ -2,6 +2,7 @@ import type { Metadata } from "next";
import { notFound } from "next/navigation";
import { WardDetail } from "@/components/elections/WardDetail";
import { WardMap, WardMapDefs } from "@/components/elections/WardMap";
+import { rosterSurvey } from "@/lib/elections/survey-answers";
import { ELECTION, WARD_NUMBERS, getToronto2026, getToronto2026Ward } from "../../data";
import { WARD_GEO, WARD_SHAPES } from "../../wardGeo";
@@ -41,11 +42,21 @@ export default async function WardDetailPage({
]);
if (!data) notFound();
+ const candidateKeys = new Set(
+ data.councilRaces.flatMap((race) => race.candidates.map((c) => c.key)),
+ );
+ const { answers: surveyAnswers, shape: surveyShape } = await rosterSurvey(
+ ELECTION.slug,
+ candidateKeys,
+ );
+
return (
}
wardMap={
string
+ /**
+ * Option names printed under the band, one per segment — the bar's answer to
+ * the dial's goal labels, so a bar can carry its own key instead of
+ * borrowing one from a list beside it. A segment too narrow for its name is
+ * left to the list.
+ */
+ showLabels?: boolean
+
+ /* ---- chrome ---- */
+ colors?: string[]
+ theme?: ThemeName
+ fontFamily?: string
+ /** Accessible name for the bar — normally the question itself. */
+ label?: string
+ className?: string
+}
+
+/**
+ * A segmented bar: one question's answers as a single 100% band.
+ *
+ * The companion to `TrilemmaDial` for questions a trilemma cannot describe —
+ * a two-option Yes/No, or a four-way split. Colours come from the same corner
+ * ramps the dial uses, so the two charts sit together on a page.
+ */
+export function OptionBar({
+ options,
+ counts,
+ highlight = null,
+ width: widthProp = 230,
+ height = 34,
+ responsive = false,
+ showCounts = true,
+ valueFormat = String,
+ showLabels = false,
+ colors: colorsProp,
+ theme = 'light',
+ fontFamily,
+ label,
+ className,
+}: OptionBarProps) {
+ const tokens = TOKENS[theme]
+ const fonts = resolveFonts(fontFamily)
+
+ const { ref, width: measured } = useMeasure(responsive)
+ const width = responsive ? measured || widthProp : widthProp
+
+ const full = colorsProp ?? palette(options.length, theme)
+ const colors = highlight === null ? full : full.map((c, i) => (i === highlight ? c : MUTED))
+
+ const total = counts.reduce((a, b) => a + b, 0) || 1
+
+ // Each segment's offset is the share of everything before it. Options number
+ // two to four, so the repeated prefix sum costs nothing and stays immutable.
+ const segments = counts.map((c, i) => {
+ const before = counts.slice(0, i).reduce((a, b) => a + b, 0)
+ return { i, x: (before / total) * width, w: (c / total) * width, count: c, color: colors[i] }
+ })
+
+ const aria = label ?? options.map((o, i) => `${o} ${valueFormat(counts[i])}`).join(', ')
+
+ // The label lines wrap into their own segment's width, which is the only
+ // room they have; a name that will not fit two lines there is dropped.
+ const labelLines = showLabels
+ ? segments.map(({ w }, i) => (w < 34 ? [] : wrapText(options[i], Math.max(6, Math.floor(w / 5.6)), 2)))
+ : []
+ const labelRows = labelLines.reduce((n, lines) => Math.max(n, lines.length), 0)
+ const labelBlock = labelRows > 0 ? 6 + labelRows * 13 : 0
+
+ return (
+
+
+ {segments.map(({ i, x: sx, w, count, color }) => (
+
+ {/* A 1px bite out of each segment keeps the joins visible without a stroke. */}
+
+ {showCounts && w > 26 && (
+
+ {valueFormat(count)}
+
+ )}
+ {labelLines[i]?.map((line, li) => (
+
+ {line}
+
+ ))}
+
+ ))}
+
+
+ )
+}
diff --git a/src/components/charts/trilemma/TrilemmaDial.tsx b/src/components/charts/trilemma/TrilemmaDial.tsx
new file mode 100644
index 00000000..dbe40461
--- /dev/null
+++ b/src/components/charts/trilemma/TrilemmaDial.tsx
@@ -0,0 +1,506 @@
+'use client'
+
+import { useMemo, useState } from 'react'
+import { scaleLinear, scaleSqrt } from 'd3-scale'
+import { lab } from 'd3-color'
+import { format } from 'd3-format'
+import { GOAL_WRAP, bisectorPoint, kitePath, layoutDial, wedgeAngles, wedgePath } from './dial'
+import { blendCorners } from './colour'
+import { CORNER_COLORS, TOKENS } from './theme'
+import { resolveFonts } from './fonts'
+import { useMeasure } from './useMeasure'
+import { wrapLabel } from './wrap'
+import type { AxisConfig, ThemeName, Triple } from './types'
+
+export type DialValues = Record | Triple
+
+export interface TrilemmaDialProps {
+ /** One entity's three scores. */
+ values: DialValues
+ axes: Triple
+ /** Name of the entity, shown in the middle of the dial. */
+ label?: string
+ /** Second line under the centre label. */
+ sublabel?: string
+ /** `auto` puts the name in the hub when it fits there, otherwise underneath. */
+ labelPlacement?: 'auto' | 'centre' | 'below'
+
+ /** A benchmark drawn as an outline behind the wedges. */
+ reference?: DialValues
+ referenceLabel?: string
+
+ /* ---- scale ---- */
+ /** Value range mapped to radius. Defaults to [0, max of the data]. */
+ domain?: [number, number]
+ /** `sqrt` makes wedge *area* proportional to value; `linear` makes radius. */
+ scale?: 'sqrt' | 'linear'
+ /** Degrees of extra rotation. 0 puts axes[0] straight up. */
+ rotation?: number
+
+ /* ---- layout ---- */
+ width?: number
+ height?: number
+ responsive?: boolean
+ padding?: number
+ /** Donut hole, as a fraction of the outer radius. */
+ innerRadius?: number
+ /**
+ * Force the outer radius instead of deriving it from the labels. Small
+ * multiples must share one, or their shared value scale is a fiction.
+ */
+ radiusOverride?: number
+ /** Gap between wedges, in degrees. */
+ gap?: number
+ cornerRadius?: number
+
+ /* ---- layers ---- */
+ showTrack?: boolean
+ showRings?: boolean
+ ringStep?: number
+ showRingLabels?: boolean
+ showKite?: boolean
+ showGoalLabels?: boolean
+ valueMode?: 'none' | 'inside' | 'outside'
+ valueFormat?: (n: number) => string
+
+ /* ---- chrome ---- */
+ colorBy?: 'corner' | 'single'
+ color?: string
+ cornerColors?: Triple
+ /**
+ * Colours for the direct-labelled values, where they should not follow the
+ * wedges. A caller that mutes two of three wedges to pick out one answer
+ * still needs all three numbers readable, and a muted neutral is a fill
+ * colour, not a text colour.
+ */
+ valueColors?: Triple
+ theme?: ThemeName
+ /**
+ * The paper this dial sits on. Defaults to the chart theme's own, which is
+ * right for a standalone figure and wrong inside a page that paints its own
+ * background — pass that page's colour there, e.g. `var(--color-bg)`.
+ *
+ * Sets the wedge outlines as well as the fill behind them: those outlines
+ * exist to read as gaps cut out of the paper, so a colour that is merely
+ * close leaves a pale hairline around every wedge.
+ */
+ paper?: string
+ fontFamily?: string
+ animate?: boolean
+ interactive?: boolean
+ onHover?: (corner: number | null) => void
+
+ title?: string
+ subtitle?: string
+ note?: string
+ source?: string
+ className?: string
+}
+
+const readTriple = (v: DialValues, axes: Triple): Triple =>
+ Array.isArray(v)
+ ? [v[0], v[1], v[2]]
+ : [v[axes[0].key] ?? 0, v[axes[1].key] ?? 0, v[axes[2].key] ?? 0]
+
+const defaultFormat = format(',.0f')
+
+/**
+ * The trilemma as a dial: one 120° wedge per goal, the first pointing straight
+ * up, each wedge reaching out as far as that goal scores. A circle filled to
+ * the rim would be all three at once — the point is that none of them is.
+ */
+export function TrilemmaDial(props: TrilemmaDialProps) {
+ const {
+ values,
+ axes,
+ label,
+ sublabel,
+ labelPlacement = 'auto',
+ reference,
+ referenceLabel = 'Benchmark',
+ domain,
+ scale = 'sqrt',
+ rotation = 0,
+ width: widthProp = 340,
+ height: heightProp,
+ responsive = false,
+ padding = 8,
+ innerRadius = 0.18,
+ radiusOverride,
+ gap = 1.5,
+ cornerRadius = 3,
+ showTrack = true,
+ showRings = true,
+ ringStep = 0.25,
+ showRingLabels = false,
+ showKite = false,
+ showGoalLabels = true,
+ valueMode = 'outside',
+ valueFormat = defaultFormat,
+ colorBy = 'corner',
+ color,
+ cornerColors: cornerColorsProp,
+ valueColors,
+ theme = 'light',
+ paper,
+ fontFamily,
+ animate = true,
+ interactive = true,
+ onHover,
+ title,
+ subtitle,
+ note,
+ source,
+ className,
+ } = props
+
+ const tokens = TOKENS[theme]
+ const fonts = resolveFonts(fontFamily)
+ const cornerColors: Triple = cornerColorsProp ?? [
+ axes[0].color ?? CORNER_COLORS[theme][0],
+ axes[1].color ?? CORNER_COLORS[theme][1],
+ axes[2].color ?? CORNER_COLORS[theme][2],
+ ]
+
+ const { ref, width: measured } = useMeasure(responsive)
+ const width = responsive ? measured || widthProp : widthProp
+ const height = heightProp ?? width
+
+ const [hovered, setHovered] = useState(null)
+
+ const v = useMemo(() => readTriple(values, axes), [values, axes])
+ const refV = useMemo(() => (reference ? readTriple(reference, axes) : null), [reference, axes])
+ const rot = (rotation * Math.PI) / 180
+
+ /**
+ * Room outside the rim. The two lower goal labels are the tall ones — they
+ * wrap, and a caption underneath has to clear whatever they take.
+ */
+ const { R, centreLabels, captionY } = layoutDial(
+ {
+ width,
+ height,
+ padding,
+ axes,
+ showGoalLabels,
+ showValues: valueMode === 'outside',
+ hasLabel: !!label,
+ },
+ radiusOverride,
+ )
+ const cx = width / 2
+ const cy = height / 2
+
+ const hi = domain?.[1] ?? Math.max(...v, ...(refV ?? [0]), 1)
+ const lo = domain?.[0] ?? 0
+ const radius = useMemo(() => {
+ const s = scale === 'sqrt' ? scaleSqrt() : scaleLinear()
+ return s.domain([lo, hi]).range([innerRadius * R, R]).clamp(true)
+ }, [scale, lo, hi, innerRadius, R])
+
+ const radii = v.map((x) => radius(x)) as Triple
+ const refRadii = refV ? (refV.map((x) => radius(x)) as Triple) : null
+ const padAngle = (gap * Math.PI) / 180
+ const inner = innerRadius * R
+ const fillFor = (i: number) => (colorBy === 'single' ? color ?? blendCorners(cornerColors, [1, 1, 1]) : cornerColors[i])
+
+ // The centre label lives in the donut hole when it fits; a long name gets a
+ // caption under the dial instead of a disc that eats the wedges.
+ const labelFont = 13
+ const labelWidth = Math.max(
+ (label?.length ?? 0) * labelFont * 0.58,
+ (sublabel?.length ?? 0) * 9.5 * 0.52,
+ )
+ const discR = Math.max(inner, labelWidth / 2 + 7)
+ const labelBelow =
+ !!label && (labelPlacement === 'below' || (labelPlacement === 'auto' && discR > R * 0.42))
+
+ /**
+ * Everything that has to read as the page behind the chart: the fill under
+ * the wedges, the outlines that make the gaps between them, the donut hole,
+ * and the knockout behind a label sitting over a wedge. One colour, because
+ * they are all the same surface — `bg` and `halo` are the same value in both
+ * themes, and splitting them here only invites them to drift apart.
+ */
+ const paperColor = paper ?? tokens.bg
+ const halo = paperColor
+ const onWedge = (i: number) => (lab(cornerColors[i]).l > 62 ? tokens.ink : halo)
+
+ const rings: number[] = []
+ if (showRings) for (let t = ringStep; t <= 1.0001; t += ringStep) rings.push(lo + (hi - lo) * t)
+
+ return (
+
+ {(title || subtitle) && (
+
+ {title && {title} }
+ {subtitle && (
+ {subtitle}
+ )}
+
+ )}
+
+
`${a.label} ${valueFormat(v[i])}`).join(', ')}`}
+ onMouseLeave={() => {
+ setHovered(null)
+ onHover?.(null)
+ }}
+ >
+
+ {showTrack &&
+ axes.map((_, i) => (
+
+ ))}
+
+ {axes.map((ax, i) => {
+ const dim = hovered !== null && hovered !== i
+ return (
+ {
+ setHovered(i)
+ onHover?.(i)
+ }
+ : undefined
+ }
+ onFocus={interactive ? () => setHovered(i) : undefined}
+ onBlur={interactive ? () => setHovered(null) : undefined}
+ />
+ )
+ })}
+
+ {/* Rings and the benchmark sit above the fills, so they stay readable
+ wherever a wedge happens to reach. */}
+ {showRings &&
+ rings.map((val, i) => (
+
+ ))}
+
+ {refRadii &&
+ axes.map((_, i) => (
+
+ ))}
+
+ {showKite && (
+
+ )}
+
+ {/* Wedge dividers, drawn over the fills so the thirds stay legible. */}
+ {axes.map((_, i) => {
+ const a = wedgeAngles(i, rot).start
+ return (
+
+ )
+ })}
+
+ {/* Ring labels run down the wedge boundary at 6 o'clock — the one
+ radius that never crosses a fill. */}
+ {showRingLabels &&
+ rings.map((val, i) => (
+
+ {valueFormat(val)}
+
+ ))}
+
+ {label && !labelBelow && (
+
+
+
+ {label}
+
+ {sublabel && (
+
+ {sublabel}
+
+ )}
+
+ )}
+
+ {label && labelBelow && (
+
+
+
+ {label}
+
+ {sublabel && (
+
+ {sublabel}
+
+ )}
+
+ )}
+
+ {showGoalLabels &&
+ axes.map((ax, i) => {
+ const p = bisectorPoint(i, R + (centreLabels ? 20 : 13), rot)
+ const anchor = centreLabels || Math.abs(p.x) < 6 ? 'middle' : p.x > 0 ? 'start' : 'end'
+ const lines = wrapLabel(ax.label, GOAL_WRAP)
+ const up = p.y < -6
+ const baseY = p.y + (up ? -(lines.length - 1) * 14 : centreLabels ? 8 : 4)
+ return (
+
+ {lines.map((ln, li) => (
+
+ {ln}
+
+ ))}
+ {valueMode === 'outside' && (
+
+ {valueFormat(v[i])}
+
+ )}
+
+ )
+ })}
+
+ {valueMode === 'inside' &&
+ axes.map((_, i) => {
+ const p = bisectorPoint(i, Math.max(inner + 16, radii[i] - 16), rot)
+ return (
+
+ {valueFormat(v[i])}
+
+ )
+ })}
+
+
+
+ {(note || source || refRadii) && (
+
+ )}
+
+ )
+}
diff --git a/src/components/charts/trilemma/TrilemmaDialGroup.tsx b/src/components/charts/trilemma/TrilemmaDialGroup.tsx
new file mode 100644
index 00000000..90f845e1
--- /dev/null
+++ b/src/components/charts/trilemma/TrilemmaDialGroup.tsx
@@ -0,0 +1,176 @@
+'use client'
+
+import { TrilemmaDial } from './TrilemmaDial'
+import { layoutDial } from './dial'
+import type { DialValues, TrilemmaDialProps } from './TrilemmaDial'
+import { BODY, CORNER_COLORS, DISPLAY, TOKENS } from './theme'
+import type { AxisConfig, Triple } from './types'
+
+export interface DialDatum {
+ id: string
+ label: string
+ sublabel?: string
+ values: DialValues
+ /**
+ * Per-panel axes, for small multiples of *different* three-way choices —
+ * one question per dial rather than one subject per dial.
+ */
+ axes?: Triple
+}
+
+export interface TrilemmaDialGroupProps
+ extends Omit {
+ data: DialDatum[]
+ axes: Triple
+ title?: string
+ subtitle?: string
+ /** Per-dial size. */
+ size?: number
+ /** Sort the panels by one goal, so the small multiples tell a story. */
+ sortBy?: number | 'none'
+ columns?: number
+ /**
+ * Repeating three goal names on every panel is wasted ink. By default the
+ * group prints them once, as a key.
+ */
+ goalLabels?: 'key' | 'each'
+}
+
+const readTriple = (v: DialValues, axes: Triple): Triple =>
+ Array.isArray(v)
+ ? [v[0], v[1], v[2]]
+ : [v[axes[0].key] ?? 0, v[axes[1].key] ?? 0, v[axes[2].key] ?? 0]
+
+/**
+ * Small multiples of the dial. Every panel shares one radius scale, which is
+ * the only way the shapes are comparable across panels.
+ */
+export function TrilemmaDialGroup(props: TrilemmaDialGroupProps) {
+ const {
+ data, axes, title, subtitle, note, source,
+ size = 250, sortBy = 'none', columns, domain, theme = 'light',
+ cornerColors, ...dial
+ } = props
+
+ // When every panel asks a different question, a shared key would be a lie:
+ // label each panel instead.
+ const perPanelAxes = data.some((d) => d.axes)
+ const goalLabels = props.goalLabels ?? (perPanelAxes ? 'each' : 'key')
+
+ const colors: Triple = cornerColors ?? [
+ axes[0].color ?? CORNER_COLORS[theme][0],
+ axes[1].color ?? CORNER_COLORS[theme][1],
+ axes[2].color ?? CORNER_COLORS[theme][2],
+ ]
+
+ const tokens = TOKENS[theme]
+ const shared: [number, number] =
+ domain ?? [0, Math.max(1, ...data.flatMap((d) => readTriple(d.values, d.axes ?? axes)))]
+
+ // One placement for the whole group, so the panels line up. Mirrors the
+ // dial's own fit test: does the longest name fit in the hub at this size?
+ const approxR = size / 2 - (props.padding ?? 8) - (goalLabels === 'each' ? 64 : 26)
+ const longest = Math.max(
+ ...data.map((d) => Math.max(d.label.length * 13, (d.sublabel?.length ?? 0) * 9.5)),
+ 0,
+ ) * 0.58
+ const labelPlacement =
+ props.labelPlacement ?? (longest / 2 + 7 > approxR * 0.42 ? 'below' : 'centre')
+
+ /**
+ * One radius for the whole grid — the smallest any panel can manage with its
+ * own labels. Otherwise a panel with short option names draws a bigger
+ * circle and the shared value scale stops meaning anything.
+ */
+ const sharedRadius = Math.min(
+ ...data.map(
+ (d) =>
+ layoutDial({
+ width: size,
+ height: props.height ?? size,
+ padding: props.padding ?? 8,
+ axes: d.axes ?? axes,
+ showGoalLabels: goalLabels === 'each',
+ showValues: (props.valueMode ?? 'outside') === 'outside',
+ hasLabel: !!d.label,
+ }).R,
+ ),
+ )
+
+ const panels =
+ sortBy === 'none'
+ ? data
+ : [...data].sort(
+ (a, b) => readTriple(b.values, b.axes ?? axes)[sortBy] - readTriple(a.values, a.axes ?? axes)[sortBy],
+ )
+
+ return (
+
+ {title &&
{title} }
+ {subtitle && (
+
+ {subtitle}
+
+ )}
+
+ {goalLabels === 'key' && (
+
+ {axes.map((a, i) => (
+
+
+ {a.label}
+
+ ))}
+
+ wedges clockwise from the top
+
+
+ )}
+
+
+ {panels.map((d) => (
+
+ ))}
+
+
+ {(note || source) && (
+
+ {note && {note}
}
+ {source && {source}
}
+
+ )}
+
+ )
+}
diff --git a/src/components/charts/trilemma/WedgeGlyph.tsx b/src/components/charts/trilemma/WedgeGlyph.tsx
new file mode 100644
index 00000000..5e134454
--- /dev/null
+++ b/src/components/charts/trilemma/WedgeGlyph.tsx
@@ -0,0 +1,50 @@
+import { arc as d3arc } from 'd3-shape'
+import { TOKENS } from './theme'
+import type { ThemeName } from './types'
+
+/**
+ * Key glyph: the same circle as the dial, with one option's slice filled — so
+ * a list of options reads directly onto the chart beside it.
+ *
+ * Slice `index` is centred on the same angle as wedge `index`, because the
+ * dial centres its first wedge at twelve o'clock rather than starting there.
+ */
+export function WedgeGlyph({
+ index,
+ count,
+ color,
+ size = 15,
+ theme = 'light',
+}: {
+ index: number
+ /** how many options the question offered — the number of slices */
+ count: number
+ /** fill for this slice; callers mute it for an option that was not chosen */
+ color: string
+ size?: number
+ theme?: ThemeName
+}) {
+ const r = size / 2
+ const span = (Math.PI * 2) / count
+ const start = index * span - span / 2
+ const path =
+ d3arc()({
+ innerRadius: 0,
+ outerRadius: r,
+ startAngle: start,
+ endAngle: start + span,
+ } as never) ?? ''
+
+ return (
+
+
+
+
+ )
+}
diff --git a/src/components/charts/trilemma/colour.ts b/src/components/charts/trilemma/colour.ts
new file mode 100644
index 00000000..c304e8db
--- /dev/null
+++ b/src/components/charts/trilemma/colour.ts
@@ -0,0 +1,22 @@
+import { lab } from 'd3-color'
+import type { Triple } from './types'
+
+/** Weighted mix of the three corner colours — the signature "where does this sit" fill. */
+export function blendCorners(colors: Triple, t: Triple, gamma = 1.6): string {
+ const w = t.map((v) => Math.pow(Math.max(0, v), gamma))
+ const sum = w[0] + w[1] + w[2] || 1
+ let l = 0
+ let a = 0
+ let b = 0
+ for (let i = 0; i < 3; i++) {
+ const c = lab(colors[i])
+ const k = w[i] / sum
+ l += c.l * k
+ a += c.a * k
+ b += c.b * k
+ }
+ // Averaging in Lab pulls mixtures toward grey; nudge the chroma back up so a
+ // three-way compromise still reads as a colour.
+ const boost = 1.25
+ return lab(l, a * boost, b * boost).formatHex()
+}
diff --git a/src/components/charts/trilemma/dial.ts b/src/components/charts/trilemma/dial.ts
new file mode 100644
index 00000000..aa38b63f
--- /dev/null
+++ b/src/components/charts/trilemma/dial.ts
@@ -0,0 +1,138 @@
+import { arc as d3arc } from 'd3-shape'
+import { wrapLabel } from './wrap'
+import type { AxisConfig, Triple } from './types'
+
+/** Wedge `i` is centred on -90° + i*120°, so axis 0 always points straight up. */
+export const WEDGE_SPAN = (Math.PI * 2) / 3
+
+export function wedgeAngles(i: number, rotation = 0) {
+ const centre = -Math.PI / 2 + i * WEDGE_SPAN + rotation
+ return { centre, start: centre - WEDGE_SPAN / 2, end: centre + WEDGE_SPAN / 2 }
+}
+
+/** Point on the wedge bisector at radius r, in SVG coordinates. */
+export function bisectorPoint(i: number, r: number, rotation = 0) {
+ const { centre } = wedgeAngles(i, rotation)
+ return { x: Math.cos(centre) * r, y: Math.sin(centre) * r }
+}
+
+/**
+ * d3-shape's arc() measures angles from 12 o'clock, clockwise, whereas the
+ * trig above is the usual maths convention. The quarter turn reconciles them.
+ */
+const toArcAngle = (a: number) => a + Math.PI / 2
+
+export function wedgePath(
+ i: number,
+ inner: number,
+ outer: number,
+ rotation = 0,
+ padAngle = 0,
+ cornerRadius = 0,
+) {
+ const { start, end } = wedgeAngles(i, rotation)
+ const builder = d3arc().cornerRadius(cornerRadius)
+ return (
+ builder({
+ innerRadius: Math.max(0, inner),
+ outerRadius: Math.max(0, outer),
+ startAngle: toArcAngle(start),
+ endAngle: toArcAngle(end),
+ padAngle,
+ padRadius: Math.max(outer, 1),
+ } as never) ?? ''
+ )
+}
+
+/** Straight-sided kite through the three value points — the shape of the trade-off. */
+export function kitePath(radii: Triple, rotation = 0) {
+ return (
+ radii
+ .map((r, i) => {
+ const p = bisectorPoint(i, r, rotation)
+ return `${i === 0 ? 'M' : 'L'}${p.x.toFixed(2)},${p.y.toFixed(2)}`
+ })
+ .join('') + 'Z'
+ )
+}
+
+export function cornerRadiusFor(outer: number) {
+ return Math.min(6, outer * 0.06)
+}
+
+
+/* ------------------------------------------------------------------ */
+/* Layout */
+/* ------------------------------------------------------------------ */
+
+export const GOAL_WRAP = 12
+const COS30 = Math.cos(Math.PI / 6)
+
+export interface DialLayoutInput {
+ width: number
+ height: number
+ padding: number
+ axes: Triple
+ showGoalLabels: boolean
+ showValues: boolean
+ hasLabel: boolean
+}
+
+export interface DialLayout {
+ R: number
+ /** Hang the two lower goal labels centred under the rim rather than beside it. */
+ centreLabels: boolean
+ topStack: number
+ bottomStack: number
+ captionY: number
+}
+
+/**
+ * Sizes the dial around whatever labels are switched on. The apex label stacks
+ * straight up from the rim; the two lower ones hang off the bisector points,
+ * which sit at half the radius, so they cost far less vertical room than they
+ * appear to. Pass `forcedR` to make a set of dials share one radius — without
+ * that, small multiples silently break their own shared scale.
+ */
+export function layoutDial(input: DialLayoutInput, forcedR?: number): DialLayout {
+ const { width, height, padding, axes, showGoalLabels, showValues, hasLabel } = input
+
+ const goalLines = Math.max(
+ wrapLabel(axes[1].label, GOAL_WRAP).length,
+ wrapLabel(axes[2].label, GOAL_WRAP).length,
+ )
+ const topLines = wrapLabel(axes[0].label, GOAL_WRAP).length
+ const valueLine = showValues ? 14 : 0
+ const topStack = showGoalLabels ? 16 + topLines * 14 + valueLine : 6
+ const bottomStack = showGoalLabels ? 22 + goalLines * 14 + valueLine : 6
+ const captionRoom = hasLabel ? 32 : 0
+ const sideText = showGoalLabels
+ ? Math.max(
+ ...[1, 2].flatMap((i) => wrapLabel(axes[i].label, GOAL_WRAP).map((l) => l.length)),
+ ) * 6.6 + 10
+ : 0
+
+ const halfBox = Math.min(width, height) / 2 - padding
+ const halfW = width / 2 - padding
+ const vertical = Math.min(halfBox - topStack, 2 * (halfBox - bottomStack - captionRoom))
+
+ // Beside the rim reads best but costs a full text width of side room;
+ // centring on the bisector costs half that, and only works while the two
+ // lower labels still clear each other.
+ const beside = Math.min(vertical, (halfW - sideText - 13) / COS30)
+ const centredMax = Math.min(vertical, (halfW - sideText / 2) / COS30)
+ const centredMin = (sideText / 2 + 8) / COS30
+
+ const R = Math.max(20, forcedR ?? (showGoalLabels && centredMax >= centredMin && centredMax > beside * 1.1
+ ? centredMax
+ : beside))
+
+ const centreLabels =
+ showGoalLabels &&
+ (forcedR === undefined
+ ? R === centredMax && centredMax > beside * 1.1
+ : // At a shared radius, centre only when the label will not otherwise fit.
+ R * COS30 + 13 + sideText > halfW && R * COS30 >= sideText / 2 + 8)
+
+ return { R, centreLabels, topStack, bottomStack, captionY: Math.max(R + 24, R / 2 + bottomStack + 20) }
+}
diff --git a/src/components/charts/trilemma/fonts.ts b/src/components/charts/trilemma/fonts.ts
new file mode 100644
index 00000000..7af87639
--- /dev/null
+++ b/src/components/charts/trilemma/fonts.ts
@@ -0,0 +1,21 @@
+import { BODY, DISPLAY, LABEL } from './theme'
+
+/**
+ * The three brand roles a chart actually needs:
+ *
+ * display names — titles, corner labels, series and point labels
+ * body prose — subtitles, sublabels, notes, annotations
+ * label figures — ticks, counts, tabular numbers (uppercase eyebrows too)
+ */
+export interface Fonts {
+ display: string
+ body: string
+ label: string
+}
+
+export const BRAND_FONTS: Fonts = { display: DISPLAY, body: BODY, label: LABEL }
+
+/** A single `fontFamily` override collapses all three roles onto one family. */
+export function resolveFonts(override?: string): Fonts {
+ return override ? { display: override, body: override, label: override } : BRAND_FONTS
+}
diff --git a/src/components/charts/trilemma/format.ts b/src/components/charts/trilemma/format.ts
new file mode 100644
index 00000000..393da00f
--- /dev/null
+++ b/src/components/charts/trilemma/format.ts
@@ -0,0 +1,15 @@
+/**
+ * Value labels as a share of the field rather than a raw count.
+ *
+ * "12" only means something to a reader who knows how many candidates there
+ * are; "34%" carries its own denominator. The total handed in is the same one
+ * the chart is scaled against — the dial's domain maximum — so the number a
+ * wedge prints and the distance it reaches say the same thing. Formatting
+ * against some other total would quietly put the label and the geometry into
+ * disagreement.
+ *
+ * Whole percents: these are fields of a few dozen candidates, where a decimal
+ * place would be precision the sample does not have.
+ */
+export const percentOf = (total: number) => (n: number) =>
+ `${Math.round((n / Math.max(1, total)) * 100)}%`
diff --git a/src/components/charts/trilemma/index.ts b/src/components/charts/trilemma/index.ts
new file mode 100644
index 00000000..3e6ada5b
--- /dev/null
+++ b/src/components/charts/trilemma/index.ts
@@ -0,0 +1,16 @@
+export { TrilemmaDial } from './TrilemmaDial'
+export type { DialValues, TrilemmaDialProps } from './TrilemmaDial'
+export { TrilemmaDialGroup } from './TrilemmaDialGroup'
+export type { DialDatum, TrilemmaDialGroupProps } from './TrilemmaDialGroup'
+export { OptionBar } from './OptionBar'
+export { WedgeGlyph } from './WedgeGlyph'
+export { percentOf } from './format'
+export type { OptionBarProps } from './OptionBar'
+export { bisectorPoint, kitePath, layoutDial, wedgeAngles, wedgePath } from './dial'
+export type { DialLayout, DialLayoutInput } from './dial'
+export { blendCorners } from './colour'
+export { wrapLabel, wrapText } from './wrap'
+export { MUTED, palette } from './palette'
+export { BODY, CORNER_COLORS, DISPLAY, GROUP_PALETTE, LABEL, NO_DATA, SANS, SERIF, TOKENS } from './theme'
+export type { Tokens } from './theme'
+export type { AxisConfig, Corner, ThemeName, Triple } from './types'
diff --git a/src/components/charts/trilemma/palette.ts b/src/components/charts/trilemma/palette.ts
new file mode 100644
index 00000000..5aa8f4c8
--- /dev/null
+++ b/src/components/charts/trilemma/palette.ts
@@ -0,0 +1,20 @@
+import { CORNER_COLORS, copper, nickel } from './theme'
+import type { ThemeName } from './types'
+
+/** Unchosen options fade to the design system's warm neutral. */
+export const MUTED = nickel[200]
+
+/**
+ * Option colours for a question with `n` choices.
+ *
+ * Three-option questions get the trilemma corners; the two- and four-option
+ * questions borrow from the same ramps so a page of mixed questions reads as
+ * one set. Two options take corners 0 and 2 — the pair furthest apart in hue,
+ * so Yes/No never reads as a gradient.
+ */
+export function palette(n: number, theme: ThemeName = 'light'): string[] {
+ const corners = CORNER_COLORS[theme]
+ if (n === 3) return [...corners]
+ if (n === 2) return [corners[0], corners[2]]
+ return [corners[0], corners[1], corners[2], theme === 'dark' ? copper[400] : copper[600]]
+}
diff --git a/src/components/charts/trilemma/theme.ts b/src/components/charts/trilemma/theme.ts
new file mode 100644
index 00000000..d109a46e
--- /dev/null
+++ b/src/components/charts/trilemma/theme.ts
@@ -0,0 +1,104 @@
+import type { ThemeName, Triple } from './types'
+
+/**
+ * Build Canada brand tokens.
+ *
+ * Colours are the @buildcanada/colours ramps as applied in src/styles/colours.css;
+ * type is the three-font system from src/styles/typography.css:
+ *
+ * display Söhne Kräftig headlines, corner labels — institutional
+ * body Financier Text prose and figures — financial-press serif
+ * label Founders Grotesk Mono eyebrows, ticks, counts — uppercase, tabular
+ */
+
+/* ----------------------------------------------------------- type ---- */
+
+export const DISPLAY = '"Soehne Kraftig", system-ui, -apple-system, sans-serif'
+export const BODY = '"Financier Text", Georgia, "Times New Roman", serif'
+export const LABEL = '"Founders Grotesk Mono", Menlo, monospace'
+
+/** Kept as the chart-internal names: headings/corner labels vs. small UI text. */
+export const SERIF = BODY
+export const SANS = DISPLAY
+
+/* -------------------------------------------------------- colours ---- */
+
+const linen = { 50: '#fbf6f1', 100: '#f6ece3', 200: '#ead2be' }
+const charcoal = {
+ 100: '#e7e7e7', 200: '#d1d1d1', 300: '#b0b0b0', 400: '#888888',
+ 500: '#6d6d6d', 600: '#5d5d5d', 700: '#4f4f4f', 900: '#3d3d3d',
+ 1000: '#272727', 1050: '#141414',
+}
+const auburn = { 400: '#e68383', 500: '#d85b5b', 600: '#c43e3e', 700: '#a43131' }
+const lake = { 300: '#7cd3f1', 400: '#36bae9', 600: '#0880b5', 700: '#0a6a95' }
+const pine = { 300: '#84cbaa', 400: '#48b183', 600: '#17794d', 700: '#15613f' }
+const copper = { 400: '#ee8e70', 500: '#e26b42', 600: '#cf5a31' }
+const steel = { 300: '#c2ccd6', 400: '#9eafc0', 600: '#627a8f', 700: '#516476' }
+/** Warm neutral: the charts package uses these for gridlines and axis lines. */
+const nickel = { 100: '#e5e4e2', 200: '#cbc9c4', 300: '#a9a79f', 400: '#8c8b81', 700: '#46463f', 800: '#3a3a35' }
+
+export interface Tokens {
+ bg: string
+ ink: string
+ inkSoft: string
+ inkFaint: string
+ frame: string
+ grid: string
+ panel: string
+ panelBorder: string
+ halo: string
+ /** Brand red, for eyebrows and editorial accents. */
+ accent: string
+}
+
+export const TOKENS: Record = {
+ light: {
+ bg: linen[50],
+ ink: charcoal[1000],
+ inkSoft: charcoal[700],
+ inkFaint: charcoal[600],
+ frame: charcoal[1000],
+ grid: nickel[200],
+ panel: 'rgba(251,246,241,0.97)',
+ panelBorder: charcoal[200],
+ halo: linen[50],
+ accent: auburn[700],
+ },
+ dark: {
+ bg: charcoal[1050],
+ ink: linen[100],
+ inkSoft: charcoal[300],
+ inkFaint: charcoal[400],
+ frame: nickel[200],
+ grid: nickel[800],
+ panel: 'rgba(39,39,39,0.97)',
+ panelBorder: charcoal[700],
+ halo: charcoal[1050],
+ accent: auburn[500],
+ },
+}
+
+/**
+ * The three corner hues. Drawn from the brand ramps rather than the charts
+ * package's categorical palette because a trilemma *mixes* its corners — the
+ * three have to stay distinguishable when blended, which adjacent hues do not.
+ */
+export const CORNER_COLORS: Record> = {
+ light: [auburn[600], pine[600], lake[700]],
+ dark: [auburn[400], pine[400], lake[400]],
+}
+
+/**
+ * Categorical series colour. This is the palette the design system's charts
+ * package sets for the build-canada theme (Grapher "Distinct lines"), which is
+ * the right call for many-series charts.
+ */
+export const GROUP_PALETTE: Record = {
+ light: ['#4c6a9c', '#b13507', '#996d39', '#2c8465', '#6d3e91', '#883039', '#00295b', '#a2559c'],
+ dark: ['#8ba4d4', '#e8814f', '#c9a26b', '#5cbb98', '#a684c9', '#c9737c', '#7d9cc9', '#c893c2'],
+}
+
+/** Reserved neutral for missing data; never assigned to a series. */
+export const NO_DATA = nickel[300]
+
+export { auburn, charcoal, copper, lake, linen, nickel, pine, steel }
diff --git a/src/components/charts/trilemma/types.ts b/src/components/charts/trilemma/types.ts
new file mode 100644
index 00000000..26eeb0f1
--- /dev/null
+++ b/src/components/charts/trilemma/types.ts
@@ -0,0 +1,16 @@
+/** The three corners, always in clockwise order starting at the apex. */
+export type Corner = 0 | 1 | 2
+export type Triple = [T, T, T]
+
+export interface AxisConfig {
+ /** Key used to look up the value on each datum. */
+ key: string
+ /** Corner label, e.g. "Affordable". */
+ label: string
+ /** Optional second line under the corner label. */
+ sublabel?: string
+ /** Overrides the palette colour for this corner. */
+ color?: string
+}
+
+export type ThemeName = 'light' | 'dark'
diff --git a/src/components/charts/trilemma/useMeasure.ts b/src/components/charts/trilemma/useMeasure.ts
new file mode 100644
index 00000000..d8f896bc
--- /dev/null
+++ b/src/components/charts/trilemma/useMeasure.ts
@@ -0,0 +1,21 @@
+import { useEffect, useRef, useState } from 'react'
+
+/** Width of a container element, tracked with a ResizeObserver. */
+export function useMeasure(enabled: boolean) {
+ const ref = useRef(null)
+ const [width, setWidth] = useState(0)
+
+ useEffect(() => {
+ if (!enabled || !ref.current) return
+ const el = ref.current
+ const ro = new ResizeObserver((entries) => {
+ const w = entries[0]?.contentRect.width ?? 0
+ setWidth(Math.round(w))
+ })
+ ro.observe(el)
+ setWidth(Math.round(el.getBoundingClientRect().width))
+ return () => ro.disconnect()
+ }, [enabled])
+
+ return { ref, width }
+}
diff --git a/src/components/charts/trilemma/wrap.ts b/src/components/charts/trilemma/wrap.ts
new file mode 100644
index 00000000..25a48049
--- /dev/null
+++ b/src/components/charts/trilemma/wrap.ts
@@ -0,0 +1,29 @@
+/** Greedy word wrap for corner labels, so a long goal name doesn't squeeze the plot. */
+export function wrapLabel(text: string, maxChars = 16): string[] {
+ const words = text.split(/\s+/)
+ const lines: string[] = []
+ let line = ''
+ for (const w of words) {
+ if (!line) line = w
+ else if ((line + ' ' + w).length <= maxChars) line += ' ' + w
+ else {
+ lines.push(line)
+ line = w
+ }
+ }
+ if (line) lines.push(line)
+ return lines
+}
+
+export const widestLine = (lines: string[]) => Math.max(...lines.map((l) => l.length))
+
+/**
+ * Wrap for a label with a hard ceiling on its lines — the segmented bar, where
+ * a name only has its own segment's width to sit in. Returns [] rather than a
+ * truncation when the name will not fit: half a label under a bar reads as a
+ * mistake, and the list beside the chart still carries it in full.
+ */
+export function wrapText(text: string, maxChars: number, maxLines: number): string[] {
+ const lines = wrapLabel(text, maxChars)
+ return lines.length <= maxLines && widestLine(lines) <= maxChars + 2 ? lines : []
+}
diff --git a/src/components/elections/AnswerChart.tsx b/src/components/elections/AnswerChart.tsx
new file mode 100644
index 00000000..eb5fcf9f
--- /dev/null
+++ b/src/components/elections/AnswerChart.tsx
@@ -0,0 +1,269 @@
+"use client";
+
+import {
+ MUTED,
+ OptionBar,
+ TOKENS,
+ TrilemmaDial,
+ layoutDial,
+ palette,
+ percentOf,
+ type AxisConfig,
+ type Triple,
+} from "@/components/charts/trilemma";
+
+/* How a questionnaire's field split on one question.
+ *
+ * Shared by the ward pages, which draw it per candidate, and the survey's
+ * comparison view, which draws it per question against the resident's own
+ * answer. Both are the same picture — the whole field's split with one
+ * position picked out of it — and the moment they were drawn by two files
+ * they started to differ: different radii, different labels, different
+ * denominators for the same percentages.
+ *
+ * FORM
+ * A three-way choice between competing alternatives gets a dial: a circle
+ * in equal thirds, one wedge per option, reaching out as far as the share
+ * that picked it. Everything else gets a segmented bar — "Yes / Yes, with
+ * conditions / No" is ordered, and a dial would set those three at 120° from
+ * each other as though they were rivals rather than points on a scale, and a
+ * four-option question cannot be a trilemma at all.
+ *
+ * One value scale across every chart on a page (`fieldSize`), so a wedge
+ * that reaches the rim always means the same thing. Per-question scales
+ * would make every answer look unanimous.
+ */
+
+/* Big enough for the option names to sit around the rim at a readable size,
+ * and to stay the loudest thing in its column. The bar matches its width so
+ * the two chart forms line up down a page of mixed questions. */
+export const CHART_SIZE = 360;
+
+/**
+ * The option's name, cut to something that can go around a rim.
+ *
+ * Most of the questionnaire names its options in a word or two — "Permission",
+ * "Regulator and enabler" — and those go on the chart whole. A handful put a
+ * whole sentence in the label instead ("Decrease after inflation, with some
+ * responsibilities and funding transferred to civilian services"), and there
+ * is no size of dial that reads with three of those around it. Those dials
+ * used to go unlabelled altogether, which left the reader a circle of three
+ * anonymous thirds and a list to match it against by colour alone.
+ *
+ * So the rim gets the head of the option — everything before its first comma,
+ * and at most a few words of that — with an ellipsis saying plainly that it is
+ * a stub. It is enough to tell the thirds apart ("Increase above inflation…",
+ * "Remain approximately constant…", "Decrease after inflation…"), which is all
+ * a rim label is for. The options are printed in full immediately above the
+ * chart, each with its own wedge glyph, so nothing is lost: the short form
+ * points at the full wording rather than replacing it.
+ */
+const RIM_LABEL_MAX = 30;
+
+/* A stub must not end on a word that is only pointing at the one that got cut:
+ * "Increase above inflation to…" reads as a sentence someone abandoned, where
+ * "Increase above inflation…" reads as a heading. */
+const DANGLING = new Set([
+ "a",
+ "an",
+ "and",
+ "at",
+ "by",
+ "for",
+ "from",
+ "in",
+ "of",
+ "on",
+ "or",
+ "than",
+ "the",
+ "to",
+ "with",
+]);
+
+export function rimLabel(option: string): string {
+ const head = option.split(",")[0].trim();
+ if (head === option && head.length <= RIM_LABEL_MAX) return option;
+
+ const words: string[] = [];
+ for (const word of head.split(/\s+/)) {
+ if (words.length && [...words, word].join(" ").length > RIM_LABEL_MAX) break;
+ words.push(word);
+ }
+ while (words.length > 1 && DANGLING.has(words.at(-1)!.toLowerCase()))
+ words.pop();
+
+ return `${words.join(" ")}…`;
+}
+
+/** A note pinned to one option's rim label — "· you", and nothing longer. */
+export type AxisMark = { index: number; text: string };
+
+/** The shape of a question, which is all the chart needs to choose its form. */
+export type ChartShape = {
+ options: string[];
+ /** the options are points on a scale rather than rival alternatives */
+ ordinal?: boolean;
+};
+
+export const isDial = (shape: ChartShape) =>
+ shape.options.length === 3 && !shape.ordinal;
+
+/* The mark goes on *after* the trim, or it is the first thing the trim eats:
+ * "Along corridors and transit" is already at the rim's limit, and a suffix
+ * added before shortening simply vanishes. */
+const dialAxes = (shape: ChartShape, mark?: AxisMark) =>
+ shape.options.map((option, i) => ({
+ key: String(i),
+ label:
+ i === mark?.index
+ ? `${rimLabel(option)} ${mark.text}`
+ : rimLabel(option),
+ })) as unknown as Triple;
+
+/**
+ * One outer radius for every dial on a page — the smallest any of them can
+ * manage with its own option names around the rim.
+ *
+ * Without this each dial sizes itself to its own labels, so a question with
+ * short options draws a bigger circle, and the shared value scale the page
+ * rests on quietly stops being true: the same count would reach further on one
+ * question than on another.
+ */
+export function sharedRadius(shapes: ChartShape[]): number | undefined {
+ const dials = shapes.filter(isDial);
+ if (dials.length === 0) return undefined;
+
+ return Math.min(
+ ...dials.map(
+ (shape) =>
+ layoutDial({
+ width: CHART_SIZE,
+ height: CHART_SIZE,
+ padding: 4,
+ axes: dialAxes(shape),
+ showGoalLabels: true,
+ showValues: true,
+ hasLabel: false,
+ }).R,
+ ),
+ );
+}
+
+/**
+ * The chart's fills for one question: the chosen option in its own corner
+ * hue, every other option in the neutral.
+ *
+ * The corner hues are what make the option's *position* legible — the first
+ * option is the same colour on all thirty-odd questions, so "they picked the
+ * first one again" is visible without reading. A choice of `null` (nobody's
+ * position to show, or an answer that matched no option) leaves the whole
+ * chart neutral: the field's shape still reads, but none of it is claimed.
+ */
+export function optionColors(count: number, choice: number | null) {
+ const full = palette(count);
+ return {
+ full,
+ colors: full.map((c, i) => (i === choice ? c : MUTED)),
+ // Every option's share has to be readable, including the ones drawn in the
+ // unchosen neutral — which is a fill, far too pale to set type in.
+ valueColors: full.map((c, i) => (i === choice ? c : TOKENS.light.inkFaint)),
+ };
+}
+
+export function AnswerChart({
+ shape,
+ counts,
+ choice,
+ fieldSize,
+ radius,
+ ariaLabel,
+ mark,
+ colors: colorsProp,
+ valueColors: valueColorsProp,
+ valueFormat,
+}: {
+ shape: ChartShape;
+ /** how many of the field picked each option, in the options' order */
+ counts: number[];
+ /** the option to pick out of the field, or null to leave it all neutral */
+ choice: number | null;
+ /** the whole field — the top of the shared value scale */
+ fieldSize: number;
+ /** the page's shared outer radius, from `sharedRadius` */
+ radius?: number;
+ ariaLabel: string;
+ /**
+ * A note on one option's rim label. The comparison view names the reader's
+ * own option there, since every wedge on that page already carries its
+ * option's colour and so cannot also carry "this one is yours".
+ */
+ mark?: AxisMark;
+ /** Wedge fills, where the default (only `choice` in colour) is not wanted. */
+ colors?: string[];
+ /** Colour for each printed value; defaults to matching the wedges. */
+ valueColors?: string[];
+ /**
+ * How a count reads at the rim. Defaults to a share of the field — right
+ * where the field is thirty-odd candidates, wrong where it is four, and the
+ * comparison view passes a plain count instead.
+ */
+ valueFormat?: (n: number) => string;
+}) {
+ const counted = counts.reduce((a, b) => a + b, 0);
+ if (counted === 0) return null;
+
+ const defaults = optionColors(shape.options.length, choice);
+ const colors = colorsProp ?? defaults.colors;
+ const valueColors = valueColorsProp ?? colorsProp ?? defaults.valueColors;
+ const format = valueFormat ?? percentOf(fieldSize);
+
+ return (
+ /* The chart's own accessible name would be the dial's generic fallback,
+ which names neither the question nor whose position is picked out.
+ Naming the group instead makes the svg inside presentational, so it is
+ announced once and in the terms a reader needs. */
+
+ {isDial(shape) ? (
+ }
+ domain={[0, Math.max(1, fieldSize)]}
+ width={CHART_SIZE}
+ /* The box closes up around the circle and its labels rather than
+ staying the square the width implies — at a forced radius that
+ square is mostly empty air, and on a phone, where the svg scales
+ to the column, the air scales with it. */
+ height={radius === undefined ? undefined : 2 * radius + 104}
+ radiusOverride={radius}
+ padding={4}
+ innerRadius={0.14}
+ /* The options around the rim and their shares under them: the chart
+ says what each third is and how many went there, without the
+ reader crossing to the list to find out. */
+ showGoalLabels
+ valueMode="outside"
+ valueFormat={format}
+ /* One quiet ring at half the field, so a wedge's reach can be read
+ as a share rather than only against its neighbours. */
+ showRings
+ ringStep={0.5}
+ cornerColors={colors as Triple}
+ valueColors={valueColors as Triple}
+ interactive={false}
+ paper="var(--color-bg)"
+ />
+ ) : (
+
+ )}
+
+ );
+}
diff --git a/src/components/elections/AnswerOptionList.tsx b/src/components/elections/AnswerOptionList.tsx
new file mode 100644
index 00000000..25f18c7c
--- /dev/null
+++ b/src/components/elections/AnswerOptionList.tsx
@@ -0,0 +1,126 @@
+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/CandidateRoster.tsx b/src/components/elections/CandidateRoster.tsx
new file mode 100644
index 00000000..0939d93d
--- /dev/null
+++ b/src/components/elections/CandidateRoster.tsx
@@ -0,0 +1,83 @@
+import { CandidateName } from "./SurveyGrid";
+import type { GridCandidate } from "./SurveyGrid";
+import { lastName } from "@/lib/elections/names";
+
+/* The ballot, in full, once.
+ *
+ * Respondents first and the rest under them, because "answered" and "did not"
+ * is the most useful sort available here — it is the difference between a name
+ * and a position — and it is the same split `surveyRoster` already applies. */
+export function CandidateRoster({
+ respondents,
+ silent,
+ election,
+ race,
+ ward,
+ wardName,
+ respondentsLabel = "Answered our questionnaire",
+ silentLabel = "Also on the ballot, yet to answer our questionnaire",
+}: {
+ respondents: GridCandidate[];
+ silent: GridCandidate[];
+ election: string;
+ race: "mayor" | "councillor" | "trustee";
+ ward?: string;
+ wardName?: string;
+ /** what the first list is. The split into answered and not is the useful
+ * one on a ward page, where both halves are a handful of names — but a page
+ * that hands over the whole ballot in one list needs to say so, rather than
+ * labelling fifty-three candidates as the nine who wrote back. */
+ respondentsLabel?: string;
+ silentLabel?: string;
+}) {
+ /* Surname order within each half. The clerk's order is a filing order, and
+ a reader checking whether their own councillor answered needs somewhere
+ to look — the same sort the cards use, so the two agree. */
+ const bySurname = (a: GridCandidate, b: GridCandidate) =>
+ lastName(a.name).localeCompare(lastName(b.name)) ||
+ a.name.localeCompare(b.name);
+
+ /* One list shape for both halves. They were a three-column grid and a
+ wrapped row, which made two lists of the same thing look like two
+ different kinds of thing — and the grid left a four-name field as one
+ short row and two empty columns. A run of names with separators is what
+ this is: a list of candidates. */
+ const list = (candidates: GridCandidate[]) => (
+
+ {candidates.map((candidate, i) => (
+
+ {i > 0 && (
+
+ ·
+
+ )}
+
+
+ ))}
+
+ );
+
+ return (
+
+ {respondents.length > 0 && (
+
+
{respondentsLabel}
+ {list([...respondents].sort(bySurname))}
+
+ )}
+
+ {silent.length > 0 && (
+
+
{silentLabel}
+ {list([...silent].sort(bySurname))}
+
+ )}
+
+ );
+}
diff --git a/src/components/elections/CandidateSurveyAnswers.tsx b/src/components/elections/CandidateSurveyAnswers.tsx
new file mode 100644
index 00000000..0e3e0bb5
--- /dev/null
+++ b/src/components/elections/CandidateSurveyAnswers.tsx
@@ -0,0 +1,295 @@
+"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. */}
+
+ {answer.choice !== null && (
+
+
+
+ )}
+
+ {answer.verbatim && <>“>}
+ {answer.answer}
+ {answer.verbatim && (
+ <>
+ ”
+
+ {" "}
+ — {possessive(firstName(candidateName))} wording, not
+ one of the options
+
+ >
+ )}
+
+
+
+
+ {/* 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 9c380647..14c02f96 100644
--- a/src/components/elections/ElectionLanding.tsx
+++ b/src/components/elections/ElectionLanding.tsx
@@ -3,12 +3,20 @@ import Link from "next/link";
import { Suspense, type ReactNode } from "react";
import { ArrowRight, ArrowUpRight } from "lucide-react";
import CountdownDays from "./CountdownDays";
+import LiveCountdown from "./LiveCountdown";
import { CandidateSiteLink } from "./CandidateSiteLink";
import { PledgeButton } from "./PledgeButton";
+import { SurveyCta } from "./SurveyCta";
import { ResidencyModal } from "./ResidencyModal";
import { WardCard } from "./WardCard";
import WardLookup from "./WardLookup";
-import { daysUntil, yearOf } from "@/lib/elections/dates";
+import {
+ breakdown,
+ daysUntil,
+ msUntil,
+ periodTiming,
+ yearOf,
+} from "@/lib/elections/dates";
import type { SupportedElection } from "@/lib/elections/registry";
import type {
CandidateView,
@@ -25,6 +33,26 @@ import type {
out rather than render empty: no advance-vote date in the registry means
no advance-vote counter. */
+/**
+ * The minute-accurate window for election day, where a region has published
+ * the times its polls open and close. The registry's `electionDateIso` is
+ * date-only, so a region that supplies this gets the same live
+ * days:hours:minutes:seconds timer the /toronto hero uses; a region that
+ * doesn't falls back to the whole-day counter.
+ *
+ * Structurally a subset of Toronto's `VotingPeriod`, so its key-dates module
+ * can pass `ELECTION_DAY` straight through without a mapping layer.
+ */
+export type ElectionDayPeriod = {
+ /** absolute instant polls open, e.g. "2026-10-26T10:00:00-04:00" */
+ opensAt?: string;
+ /** absolute instant polls close */
+ closesAt: string;
+ upcomingLabel: string;
+ openLabel: string;
+ closedLabel: string;
+};
+
export type LandingContent = {
heroTitle: ReactNode;
heroBlurb: ReactNode;
@@ -39,6 +67,30 @@ export type LandingContent = {
* Toronto has these pages so far. A list rather than one href per page, so
* adding the next guide doesn't mean another prop. */
guideLinks?: { label: string; href: string }[];
+ /** The tools and readings this region has published on top of the roster —
+ * the questionnaire read across the field, the reader's own alignment, the
+ * ward pages. Unset drops the section, which is what every region but
+ * Toronto does today. */
+ explore?: ExploreItem[];
+};
+
+/** One card in the explore grid. `meta` is the small figure under the title —
+ * a count, a date, whatever makes the card worth clicking. */
+export type ExploreItem = {
+ eyebrow: string;
+ title: ReactNode;
+ blurb: ReactNode;
+ href: string;
+ meta?: ReactNode;
+ /** the one card that is an ask rather than a page. Filled in the region's
+ * accent — Toronto blue on this tracker, since `.theme-election` overrides
+ * the token — so it is the only solid block of colour in the grid and the
+ * only thing in it that is not somewhere to go and read. Same fill as the
+ * SurveyCta at the foot of the page and beside every questionnaire on the
+ * site: one ask, one colour, wherever a reader meets it. `cta` replaces the
+ * "Open" foot, because this card is a control and should say what it does. */
+ tone?: "invite";
+ cta?: string;
};
export function ElectionLanding({
@@ -47,84 +99,196 @@ export function ElectionLanding({
content,
wardMapDefs,
renderWardMap,
+ mayorSurveyPath,
+ mayorRosterPath,
+ surveyPath,
+ electionDay,
}: {
election: SupportedElection;
view: ElectionView;
content: LandingContent;
+ /** poll-open/poll-close instants for election day. Supplied turns the band's
+ * headline counter into the live timer; omitted keeps the days counter. */
+ electionDay?: ElectionDayPeriod;
+ /** where the mayoral field's questionnaire grid lives, for the regions that
+ * have run one — the cards say who is running, that page says what they
+ * said */
+ mayorSurveyPath?: string;
+ /** the full mayoral roster, for a region whose field is too long to print
+ * here. Set it and this section keeps its heading and hands the list off;
+ * leave it unset and the section prints every candidate, which is the right
+ * answer for a field of eight and the wrong one for a field of fifty. */
+ mayorRosterPath?: string;
+ /** the voter survey, where the region runs one. It takes the closing call to
+ * action from the pledge: a pledge is a name on a list, where the survey
+ * hands the reader their own ballot back with the candidates ranked against
+ * it — a better thing to ask of someone who has just read the field, and a
+ * better first step towards voting than a promise to. */
+ surveyPath?: string;
/** rendered once, so per-ward maps can reference shared geometry */
wardMapDefs?: ReactNode;
/** this region's locator map for a ward, when it has ward geometry */
renderWardMap?: (ward: WardView) => ReactNode;
}) {
- // Pre-rendered here rather than inside the lookup, because the locator map
- // is server-side geometry the client component can't build.
+ /* THE MAYORAL RACE, WHERE IT IS A SIGNPOST RATHER THAN A LIST
+ A region with a roster page had a whole band of the front page — heading,
+ blurb, count, and two link rows — pointing at two other pages, which is
+ exactly what the explore grid is made of. Set as a section of its own it
+ pushed the wards a screen further down for no reading a card could not
+ carry.
+
+ One card, not two. The other pointed at the roster of every registered
+ candidate, which is a page that exists to be indexed rather than read:
+ fifty-three names and their campaign links, no answers. It is still
+ linked from the mayoral page it belongs to. What a reader on the front
+ page wants from this race is what the field said, so that is the card.
+
+ A region with no roster page keeps its own section below: its cards are
+ the candidates themselves, names and campaign links, which is a list
+ rather than a pointer and belongs nowhere near a grid of pages. */
+ const mayorCards: ExploreItem[] =
+ mayorRosterPath && mayorSurveyPath
+ ? [
+ {
+ eyebrow: "Mayor",
+ title: "The race for mayor",
+ blurb: "How the candidates for mayor answered our questions.",
+ href: mayorSurveyPath,
+ },
+ ]
+ : [];
+ /* The ask, in the middle of the grid rather than only at the foot of the
+ page. Everything else here is somewhere to go and read; this is the one
+ card that asks the reader for something, and a reader who has just seen
+ what the candidates said is the likeliest person on the page to have an
+ opinion about it. Middle, not first: the cards on either side are what
+ earn the ask. */
+ const invite: ExploreItem[] = surveyPath
+ ? [
+ {
+ eyebrow: "Voter survey",
+ title: "Where do you stand?",
+ blurb:
+ "Answer the same questions we put to the candidates and see which of them line up with you.",
+ href: surveyPath,
+ tone: "invite",
+ cta: "Take the survey",
+ },
+ ]
+ : [];
+
+ /* The tiles the lookup shows once it has an answer. Built here rather than
+ inside it, because a ward's locator map is server-rendered geometry a
+ client component cannot make. Only where the region has boundary data —
+ otherwise nothing renders them and the loop is a wasted pass over the
+ whole ward list. */
const wardCards: Record = {};
- for (const ward of view.wards) {
- wardCards[ward.number] = (
-
- );
+ if (election.wardLookup) {
+ for (const ward of view.wards) {
+ wardCards[ward.number] = (
+
+ );
+ }
}
+ const pages = [...mayorCards, ...(content.explore ?? [])];
+ const exploreItems = [
+ ...pages.slice(0, Math.ceil(pages.length / 2)),
+ ...invite,
+ ...pages.slice(Math.ceil(pages.length / 2)),
+ ];
+
return (
- {/* ── Hero ─────────────────────────────────────────────── */}
-
-
- {content.heroTitle}
-
-
- {content.heroBlurb}
-
-
+ {/* ── Hero: the election, and the clock on it ─────────── */}
+
- {/* ── Countdown + how to vote ──────────────────────────── */}
-
+ {/* ── Explore: the mayoral race, and what we've published ─ */}
+ {exploreItems.length > 0 && (
+
+ )}
{/* ── Candidates for mayor ─────────────────────────────── */}
-
-
-
- Candidates for Mayor
-
-
+ {/* Only where there is no roster page to point at. Toronto's mayoral
+ field is fifty-three people and lives on a page of its own, which
+ the explore grid above links to; a region running a race of eight
+ has nowhere else to put them, and printing the names here is the
+ right answer for a field that size. */}
+ {!mayorRosterPath && (
+
+
+
+
Mayor
+
+ Candidates for Mayor
+
+
+ {mayorSurveyPath && (
+
+ How they answered our questionnaire
+
+
+ )}
+
-
- {view.mayoral.map((cand) => (
-
- ))}
-
-
+
+ {view.mayoral.map((cand) => (
+
+ ))}
+
+
+ )}
{/* ── Wards ────────────────────────────────────────────── */}
-
+
-
City Council
-
+ City Council
+
Find your ward
-
+
{content.wardsBlurb}
+ {/* Twenty-five tiles is a list nobody reads to find their own
+ ward: they know their postal code and not their ward number,
+ which is the whole reason this section is called "find". The
+ grid below stays, for the reader who wants to browse and for
+ the one the lookup guesses wrong. */}
{election.wardLookup && (
-
+
+
+
)}
@@ -133,7 +297,7 @@ export function ElectionLanding({
{wardMapDefs}
-
+
{view.wards.map((ward) => (
))}
-
+
{/* ── Also city-wide (French-language school boards) ────── */}
{view.atLargeRaces.length > 0 && (
-
-
+
+
Also on every ballot
-
+
City-wide races
-
+
These seats are elected across the whole city, so every voter
sees them regardless of ward.
@@ -172,23 +336,40 @@ export function ElectionLanding({
)}
+ {/* ── Dates and how to vote ────────────────────────────── */}
+
+
{/* ── Closing CTA ──────────────────────────────────────── */}
-
-
+
+
{content.closingHeadline}
-
- {content.closingBlurb}
-
-
- Pledge to vote
-
-
-
+ {/* The survey card itself rather than a button in its colours, so
+ the ask looks the same wherever a reader meets it — here, and
+ beside every questionnaire on the site. It carries its own body
+ copy, which is why the section's blurb only prints where there is
+ no card to replace it. */}
+ {surveyPath ? (
+
+ ) : (
+ <>
+
+ {content.closingBlurb}
+
+
+ Pledge to vote
+
+
+ >
+ )}
+
{content.sourceNote}
@@ -200,119 +381,260 @@ export function ElectionLanding({
// ── Countdown band ─────────────────────────────────────────────────────────
/**
- * Election-day countdown, the advance-vote and vote-by-mail counters, and the
- * pledge CTA. Regions that haven't published their advance-vote or mail-in
- * dates get a two-column band instead of three, rather than empty cells.
+ * Seeds LiveCountdown from the server's reading of election day. Split out so
+ * the `targetInstant ?? 0` dance lives in one place, the same way Toronto's
+ * PeriodCountdown wraps it for the key-dates pages.
+ */
+function ElectionDayCountdown({ period }: { period: ElectionDayPeriod }) {
+ const now = new Date();
+ const timing = periodTiming(period, now);
+
+ return (
+
+ );
+}
+
+/**
+ * One of the band's secondary counters — advance voting, vote by mail.
+ *
+ * Same three-part shape as the headline: an eyebrow naming the thing, the
+ * number, the date underneath. The unit sits inline with the digits at a
+ * fraction of their size, which is how the live timer pairs "52" with "days".
+ * The old version hung a two-line caption off the number's baseline instead,
+ * and beside a four-slot timer that read as a different component.
+ */
+function DateCounter({
+ eyebrow,
+ targetIso,
+ dateLabel,
+}: {
+ eyebrow: string;
+ /** "YYYY-MM-DD" — the day being counted down to */
+ targetIso: string;
+ /** the human date or range beneath the number */
+ dateLabel: ReactNode;
+}) {
+ return (
+
+
{eyebrow}
+
+
+
+ days
+
+
+
+ {dateLabel}
+
+
+ );
+}
+
+/**
+ * The top of the page: what the election is, and how long is left of it.
+ *
+ * Hero and countdown used to be two stacked bands, which meant the headline
+ * and the number that gives it its urgency were separated by a rule and never
+ * read as one statement. They're one block now — eyebrow, headline, blurb,
+ * then the live timer under a hairline inside the same section — so the first
+ * screen answers "which election" and "how long do I have" together.
+ *
+ * The supporting calendar is not here. Advance polls, the mail-in cutoff and
+ * the how-to-vote guides used to run as a strip along the bottom of this
+ * band, which put three secondary deadlines between the reader and the field
+ * — on a first screen whose one job is which election, and how long. They are
+ * the last thing on the page now, in VotingCalendar, which is where someone
+ * who has read the candidates and wants to know how to vote for one looks.
+ *
+ * The survey ask that used to sit in this band is now the explore grid's
+ * alignment card and the closing CTA — asking twice in the first screen was
+ * the old band's problem, not its strength.
*/
-function KeyDates({
+function Hero({
election,
- guideLinks,
+ content,
+ electionDay,
}: {
election: SupportedElection;
- guideLinks?: { label: string; href: string }[];
+ content: LandingContent;
+ /** poll hours for election day, where the region has published them */
+ electionDay?: ElectionDayPeriod;
+}) {
+ return (
+
+
+
{election.eyebrow}
+
+ {content.heroTitle}
+
+
+ {content.heroBlurb}
+
+
+ {/* The clock, under a hairline rather than in a band of its own. The
+ timer takes every pixel the grid can give it — eleven glyphs plus
+ four unit labels don't survive sharing a column — and the poll-hours
+ line shrink-wraps beside it. */}
+
+
+ {electionDay ? (
+ // The same live timer the /toronto hero band runs: the server
+ // reads the state and the remaining milliseconds once, and
+ // LiveCountdown ticks forward from there. It carries its own
+ // state label — "Until polls open" before the day, "Left to
+ // vote" during it — so the eyebrow above it names the election,
+ // not the count.
+ <>
+
+ {yearOf(election.electionDateIso)} Election day
+
+
+ >
+ ) : (
+ // Regions with no published poll hours get a whole-day count, in
+ // the same three-part shape so the hero reads the same either
+ // way.
+ <>
+
Until polls open
+
+
+
+ days
+
+
+ >
+ )}
+
+
+
+ Polls open{" "}
+
+ {election.voteDayLabel}, {yearOf(election.electionDateIso)}
+
+ , {election.pollHoursLabel}.
+
+
+
+
+ );
+}
+
+/**
+ * How to vote, and by when — the last thing on the page.
+ *
+ * These three cells opened the page, tucked under the hero: two deadline
+ * counters and a stack of guide links, read before the reader had met a single
+ * candidate. That is the wrong order for what they are. A deadline matters to
+ * someone who has decided to vote, and the page spends its whole length making
+ * that decision possible; asked at the top, "apply to vote by mail by Friday"
+ * is an errand in front of the thing they came for.
+ *
+ * So it closes rather than opens, immediately before the call to action, where
+ * a reader who has just read the field and wants to know how to act on it is
+ * already looking down the page.
+ *
+ * Cells a region can't fill are dropped rather than rendered empty, so a region
+ * with no published advance date and no guides gets no band at all.
+ */
+function VotingCalendar({
+ election,
+ content,
+}: {
+ election: SupportedElection;
+ content: LandingContent;
}) {
const { advanceVote, mailIn } = election;
- const hasMiddle = Boolean(advanceVote || mailIn);
+ const guideLinks = content.guideLinks ?? [];
+ const dateCells = [advanceVote, mailIn].filter(Boolean).length;
+ if (dateCells === 0 && guideLinks.length === 0) return null;
+
+ /* The columns, counted rather than auto-fit: the guides cell holds a stack
+ of links and wants the wider share, the counters are a number and a date
+ apiece. One cell takes the row on its own. */
+ const cells = dateCells + (guideLinks.length > 0 ? 1 : 0);
+ const cols =
+ cells === 3
+ ? "md:grid-cols-[1fr_1fr_1.15fr]"
+ : cells === 2
+ ? guideLinks.length > 0
+ ? "md:grid-cols-[1fr_1.15fr]"
+ : "md:grid-cols-2"
+ : "";
return (
-
-
-
-
-
- Days until
-
- polls open
-
-
+
+
+
Before you vote
+
+ Dates and how to vote
+
+
+ {advanceVote && (
+
+
+
+ )}
- {hasMiddle && (
-
- {advanceVote && (
-
-
-
-
- Days until
-
- advance polls
-
-
-
- {advanceVote.label}
-
-
- )}
- {mailIn && (
-
-
-
-
- Days to apply
-
- to vote by mail
-
-
-
- {/* Also spelled out, with the rest of Toronto's calendar, in
- src/app/toronto/vote/2026/key-dates.ts. This component is
- shared by four cities and can't import a Toronto route
- module, so the cutoff is written twice — change both. */}
- {mailIn.label}, 4:30 p.m.
-
-
- )}
-
- )}
+ {mailIn && (
+
+ {mailIn.label}, 4:30 p.m.>}
+ />
+
+ )}
-
-
Ready to vote?
-
- Put your name on the record. Pledging takes ten seconds — and it’s
- the first step to showing up on election day.
-
-
- Pledge to vote
-
-
-
- Polls open{" "}
-
- {election.voteDayLabel}, {yearOf(election.electionDateIso)}
-
- , {election.pollHoursLabel}.
-
- {guideLinks && guideLinks.length > 0 && (
-
- {guideLinks.map((link) => (
-
- {link.label}
-
-
- ))}
+ {guideLinks.length > 0 && (
+
0
+ ? "border-t md:border-t-0 md:border-l border-border-light"
+ : ""
+ }`}
+ >
+
How to vote
+
+ {guideLinks.map((link) => (
+
+ {link.label}
+
+
+ ))}
+
)}
@@ -320,6 +642,170 @@ function KeyDates({
);
}
+// ── Explore grid ───────────────────────────────────────────────────────────
+
+/**
+ * Everything we've made out of the questionnaire, in one grid.
+ *
+ * These pages exist either way — the field's answers issue by issue, the
+ * reader's own alignment, the mayoral grid — but until now the only way to
+ * find them was a link buried beside a section heading, and the alignment
+ * survey was the closing CTA at the bottom of a very long page. The grid puts
+ * them where someone who has just read the countdown can see them.
+ *
+ * Cards over a list, because each one needs a sentence to explain what it
+ * shows; the same border-collapse trick as the ward and mayoral grids, so the
+ * three sections read as one table rather than three treatments.
+ */
+function ExploreSection({
+ items,
+ anchorCandidates = false,
+}: {
+ items: ExploreItem[];
+ /** also answer to #candidates, which three other pages link to and which
+ * lands here whenever the mayoral cards do */
+ anchorCandidates?: boolean;
+}) {
+ return (
+
+ {anchorCandidates && (
+
+ )}
+
+
+ Explore the election
+
+
+ We put the same questions to every candidate on the ballot. See how
+ they answered — then answer them yourself and find out who lines up
+ with you.
+
+
+
+ {/* auto-FIT, not auto-fill. Filling laid out as many 288px tracks as the
+ row could hold and left the spare ones empty: three cards on a wide
+ screen were three narrow columns hard against the left edge, with
+ half the row a blank rectangle inside the same border. Fitting
+ collapses the empty tracks, so however many cards a region has, they
+ divide the row between them. */}
+
+ {items.map((item) => {
+ const invite = item.tone === "invite";
+ return (
+
+
+ {item.eyebrow}
+
+
+ {item.title}
+
+ {item.meta && (
+
+ {item.meta}
+
+ )}
+
+ {item.blurb}
+
+ {/* A button rather than a link with an arrow, on the one card
+ that is a control — the same distinction the survey card
+ makes everywhere else on the site. */}
+ {invite ? (
+
+
+ {item.cta ?? "Open"}
+
+
+
+ ) : (
+
+ Open
+
+
+ )}
+
+ );
+ })}
+
+
+ );
+}
+
+/**
+ * A row of cards that closes on all four sides, exactly once.
+ *
+ * THE PROBLEM THIS SOLVES
+ * Cards in a full-bleed grid have to be ruled off from each other without
+ * doubling the page frame that already surrounds them, and neither obvious
+ * arrangement manages it:
+ *
+ * · Rules on the container (`border-t border-l`) plus rules after each cell
+ * (`border-b border-r`) draws a second line on top of the frame's left and
+ * right borders, and leaves a hairline sitting a pixel above the section's
+ * 2px rule at the foot.
+ * · Rules before each cell (`border-t border-r`) fixes all three of those and
+ * breaks something worse: twenty-five wards in a four-column grid leave one
+ * card alone on the last row, and the rule above it spans one column of
+ * four. A line that stops a quarter of the way across the page is not a
+ * subtle defect.
+ *
+ * THE ARRANGEMENT
+ * Cells rule *after* themselves, so every row draws a full-width line under
+ * itself whether or not the row below it is complete. The two lines that
+ * would then land on the frame — the last row's and the last column's — are
+ * pushed a pixel past the edge with negative margins and clipped away by the
+ * wrapper. What is left is the frame's own border on the outside, the
+ * section's rule at the foot, and one line between any two cards.
+ */
+function CardGrid({
+ min,
+ fit = false,
+ children,
+}: {
+ /** the narrowest a column may get before the grid drops one */
+ min: string;
+ /** collapse empty tracks so a few cards fill the row, rather than lining up
+ * at their minimum width with the rest of the row left blank */
+ fit?: boolean;
+ children: ReactNode;
+}) {
+ return (
+
+ );
+}
+
// ── Candidate cards ────────────────────────────────────────────────────────
function MayoralCard({
@@ -419,9 +905,8 @@ export function CandidateRow({
}) {
return (
{candidate.name}
@@ -489,11 +973,11 @@ export function SiteLink({
ward?: string;
wardName?: string;
}) {
- if (!candidate.website) {
- return (
-
Profile to come
- );
- }
+ /* 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 (
("topic");
+ const [chosen, setChosen] = useState("all");
+ const filter: Filter = race ?? chosen;
+
+ const questions = useMemo(
+ () => groups.flatMap((group) => group.questions),
+ [groups],
+ );
+
+ /* Every question's split under the current filter, computed once. The
+ filter is the reason this is client-side at all: "how does the mayoral
+ field differ from the council field" is the question the page is for, and
+ it is a re-tally of the same picks rather than a new fetch. */
+ const splits = useMemo(() => {
+ const map = new Map();
+ for (const question of questions)
+ map.set(
+ question.questionId,
+ splitOf(question, forRace(question.picks, filter)),
+ );
+ return map;
+ }, [questions, filter]);
+
+ /* The picks behind each question, narrowed the same way the bars are.
+ The panel used to open on `question.picks` — the whole election — while
+ the chart above it drew the filtered field, so a mayoral page whose bars
+ counted nine listed all thirty-two underneath. One filter, applied once,
+ read by both. */
+ const picks = useMemo(() => {
+ const map = new Map();
+ for (const question of questions)
+ map.set(question.questionId, forRace(question.picks, filter));
+ return map;
+ }, [questions, filter]);
+
+ const field = useMemo(
+ () =>
+ filter === "all"
+ ? respondents
+ : respondents.filter((r) => r.race === filter),
+ [respondents, filter],
+ );
+
+ const ranked = useMemo(() => {
+ const answered = questions
+ .map((question) => ({
+ question,
+ split: splits.get(question.questionId)!,
+ }))
+ .filter((row) => row.split.answered > 0);
+ return [...answered].sort((a, b) =>
+ order === "division"
+ ? b.split.division - a.split.division
+ : a.split.division - b.split.division,
+ );
+ }, [questions, splits, order]);
+
+ /* The two ends, named up top. A reader who takes nothing else off this page
+ should still leave with the single most agreed-on statement and the single
+ most contested one. */
+ const consensus = ranked[0] ?? null;
+ const contested = ranked[ranked.length - 1] ?? null;
+ const [agreed, divided] =
+ order === "division" ? [contested, consensus] : [consensus, contested];
+
+ return (
+
+ {/* ── The two ends, as the page's headline numbers ───────── */}
+ {agreed && divided && agreed !== divided && (
+
+
+
+
+ )}
+
+ {/* ── Controls ───────────────────────────────────────────── */}
+
+
+ Order
+ {ORDERS.map(({ id, label }) => (
+ setOrder(id)}
+ label={label}
+ />
+ ))}
+
+
+ {!race && (
+
+ Field
+ {(
+ [
+ ["all", "All"],
+ ["mayor", "Mayoral"],
+ ["councillor", "Council"],
+ ] as const
+ ).map(([id, label]) => (
+ setChosen(id)}
+ label={label}
+ />
+ ))}
+
+ )}
+
+
+ {field.length === 0 ? (
+
+ No one in this part of the field has answered the questionnaire yet.
+
+ ) : (
+
+ {order === "topic" ? (
+ groups.map((group, i) => (
+
+
+
+ {group.questions.map((question) => (
+
+ ))}
+
+
+ ))
+ ) : (
+ <>
+
+
+ {ranked.map(({ question, split }, rank) => (
+
+ ))}
+
+ >
+ )}
+
+ )}
+
+ );
+}
+
+/* A section head that costs one line rather than a screen: the title set on
+ the rule that opens the section, with the optional note pushed to the far
+ end of the same line. Two dozen cards under eight headings is a page that
+ scrolls; the headings should not be why. */
+function SectionRule({ title, note }: { title: string; note?: string }) {
+ return (
+
+
+ {title}
+
+ {note &&
{note}
}
+
+ );
+}
+
+function Chip({
+ active,
+ onClick,
+ label,
+}: {
+ active: boolean;
+ onClick: () => void;
+ label: string;
+}) {
+ return (
+
+ {label}
+
+ );
+}
+
+/* The grid the cards tile into.
+ *
+ * Borders on the container's top and left and on each card's bottom and right,
+ * so the rules between cards are single-weight however the row wraps — the
+ * usual trick, and the reason the cards carry no border of their own.
+ *
+ * FOUR ROW TRACKS PER CARD, AND WHY
+ * Each card spans four of the grid's rows and adopts them with
+ * `grid-rows-subgrid` — question, chart, footer, and the answers it opens
+ * into — and the trigger nests a second subgrid to claim the first three.
+ * Every block is therefore sized by the tallest of its kind in the row, so
+ * every card in a row starts its bar on the same line as its neighbours.
+ * Which is the whole point of a bar that means the same thing on every card:
+ * aligned, the row can be read across.
+ *
+ * The obvious way to bottom-align — stretch the card to the row's height and
+ * push the footer down with `mt-auto` — cannot survive the disclosure. The
+ * slack a card is absorbing comes from whichever card in the row is tallest,
+ * and opening a card makes it the tallest, so the slack vanishes and
+ * everything the reader was looking at slides upward under the cursor. We
+ * shipped that once and it was the first thing anyone noticed.
+ *
+ * Shared tracks have no such coupling. Every closed card is sized by the
+ * first three tracks, so every footer sits on the same line. When one card
+ * opens, it is the fourth track that grows — for every card in the row at
+ * once, which costs the others nothing but empty space they do not draw —
+ * and the tracks above it never move. Nothing above the fold shifts. */
+function CardGrid({ children }: { children: React.ReactNode }) {
+ return (
+
+ {children}
+
+ );
+}
+
+/* ------------------------------------------------------------------ */
+/* Colour */
+/* ------------------------------------------------------------------ */
+
+/* Option colours, by position — see src/lib/elections/option-colors.ts, which
+ * this page shares with the ward cards so an option is the same colour on
+ * both. */
+function optionColors(question: FieldQuestion): string[] {
+ return rampFor(question.options.length, question.ordinal);
+}
+
+/* ------------------------------------------------------------------ */
+/* The bars */
+/* ------------------------------------------------------------------ */
+
+/**
+ * One question's field as a small horizontal bar chart, one bar per option,
+ * and every bar built out of one cell per candidate.
+ *
+ * A ROW PER OPTION, RATHER THAN ONE STACKED LINE
+ * The single line was one row of thirty-two cells, split into runs by
+ * option, with the option names printed underneath as a key. It reads the
+ * shape of a split well and everything else badly. Only the first run starts
+ * at the left edge, so comparing the second option to the third is comparing
+ * two floating runs with no shared baseline — the comparison bar charts
+ * exist to make trivial. And the names sat in a key, which means every
+ * reading costs a colour lookup: find the swatch, match the hue, come back.
+ *
+ * Broken into rows, every bar starts at the same left edge on the same
+ * scale, longest is longest at a glance, and each name sits directly over
+ * its own bar — so the key disappears, and colour goes back to being what it
+ * should be here, a second channel rather than the only one.
+ *
+ * THE SCALE IS THE FIELD, ON EVERY ROW AND EVERY CARD
+ * Each row is `field` cells wide — everyone who returned the questionnaire —
+ * with the unchosen remainder left as a faint track. So the rows within a
+ * card share an axis, and so do the cards: a bar half across is sixteen
+ * candidates on every question on the page, which is what makes a grid of
+ * two dozen of these scannable rather than merely present.
+ *
+ * Candidates who skipped the question get a row of their own at the bottom
+ * rather than a footnote, so a question the field ducked looks ducked.
+ */
+function UnitRows({
+ question,
+ split,
+ field,
+}: {
+ question: FieldQuestion;
+ split: Split;
+ /** how many candidates are in the filtered field — the cells in a row */
+ field: number;
+}) {
+ const colors = optionColors(question);
+ const total = Math.max(1, split.answered);
+
+ type Row = {
+ label: string;
+ count: number;
+ /** the option's colour, or null for the row of candidates who skipped it */
+ color: string | null;
+ share: number | null;
+ lead: boolean;
+ };
+
+ const rows: Row[] = question.options.map((option, i) => ({
+ label: option,
+ count: split.counts[i],
+ color: colors[i],
+ share: Math.round((split.counts[i] / total) * 100),
+ lead: split.counts[i] > 0 && i === split.lead,
+ }));
+
+ const skipped = Math.max(0, field - split.answered);
+ if (skipped > 0)
+ rows.push({
+ label: "Did not answer",
+ count: skipped,
+ color: null,
+ /* No share: every other row on the card is a share of the candidates who
+ answered, and this row is the ones who did not. A percentage here
+ would be a percentage of something else printed in the same column. */
+ share: null,
+ lead: false,
+ });
+
+ return (
+
+ {rows.map((row, i) => (
+
+
+
+ {row.label}
+
+ {/* The tally itself is the row of cells below — countable, and
+ the only place the number needs to be. What stays here is the
+ share, which the cells cannot show. The count survives for a
+ screen reader, which has no cells to count. */}
+
+ {row.count}
+ {row.share !== null && <>{row.share}%>}
+
+
+
+ {/* The cells carry the count for a sighted reader, but a screen
+ reader gets it as the number beside the label instead: walking
+ a grid of thirty-two blank cells is not counting them. */}
+
+ {Array.from({ length: field }, (_, cell) =>
+ cell < row.count ? (
+
+ ) : (
+
+ ),
+ )}
+
+
+ ))}
+
+ );
+}
+
+/* ------------------------------------------------------------------ */
+/* The card */
+/* ------------------------------------------------------------------ */
+
+function Card({
+ question,
+ split,
+ picks,
+ field,
+ rank,
+ topic,
+}: {
+ question: FieldQuestion;
+ split: Split;
+ /** the answers behind this card's bars — already narrowed to the field on
+ * screen, so the panel and the chart can never disagree */
+ picks: FieldPick[];
+ field: number;
+ /** the position in the ranking, where the cards are ranked rather than grouped */
+ rank?: number;
+ /** which part of the questionnaire this came from — printed only when the
+ * cards are ranked, since a topic section already says it otherwise */
+ topic?: string;
+}) {
+ const notes = picks.filter((pick) => pick.note?.trim()).length;
+
+ return (
+
+ {/* Claims the first three tracks, so the blocks inside it are laid on the
+ grid's own rows rather than on rows of its own. Horizontal padding
+ only: vertical padding here would inset the nested tracks from the
+ ones outside it, and the two would stop agreeing about where a row
+ begins. Each block carries its own vertical space instead. */}
+
+
+
+
+ {rank && (
+
+ {String(rank).padStart(2, "0")}
+
+ )}
+ {topic && (
+
+ {topic}
+
+ )}
+
+ {split.answered > 0 && (
+
+ {temperature(split.division)}
+
+ )}
+
+
+ {question.question}
+
+
+
+ {/* The chart, on a track of its own, so every card in the row starts
+ its bars on the same line as its neighbours.
+
+ No width cap here. A cap is the wrong instrument: it holds the
+ chart at one size while the card around it keeps growing, so a wide
+ screen buys nothing but a strip of empty card to the right of every
+ bar. The chart fills the card, and the card is kept to a sane width
+ by the column count instead — see CardGrid. */}
+
+
+
+
+ {/* THE FOOTER IS THE INVITATION
+ It used to be a caption — "9 of 9 answered · 8 comments" — set in
+ muted ink beside a chevron, which describes what is behind the card
+ without ever asking the reader to go there. A count is not a call
+ to action, and a card that hides a dozen candidates' own words
+ deserves one: the ask leads, in the accent, and the denominator
+ follows it as the supporting fact it always was. */}
+
+
+ {split.answered === 0 ? (
+
+ Nobody has answered this one
+
+ ) : (
+ <>
+
+
+ {notes > 0
+ ? `See comments from candidates (${notes})`
+ : "See who chose what"}
+
+
+ Hide
+
+
+ ·
+
+ {split.answered} of {field} answered
+
+ >
+ )}
+
+
+
+
+
+
+
+
+
+ );
+}
+
+/* The two ends of the ranking, given the top of the page and a headline number
+ each. Same object as the grid's cards — same bar, same key, same colours —
+ sized so the one thing a reader takes away is legible from across the room,
+ and so a screenshot of this block is a complete statement on its own: the
+ share, what it is a share of, and the field it came out of. */
+function Feature({
+ eyebrow,
+ row,
+ field,
+ className = "",
+}: {
+ eyebrow: string;
+ row: { question: FieldQuestion; split: Split };
+ field: number;
+ className?: string;
+}) {
+ const { question, split } = row;
+ const lead = split.lead >= 0 ? question.options[split.lead] : null;
+ const share = Math.round(split.leadShare * 100);
+
+ return (
+ /* THE CLAIM ON THE LEFT, THE EVIDENCE ON THE RIGHT
+ A feature gets half the page, which is far more width than a chart of
+ thirty-two cells should ever take. Stacked down that column — number,
+ then option, then question, then chart — every one of those lines ran
+ out well before the column did, and the block was mostly the empty
+ right-hand half of itself.
+
+ Set as two columns, the width is spent instead of left over: the claim
+ reads as a sentence at a comfortable measure, the chart sits beside it
+ at the same size the grid's cards draw, and the whole thing is shorter
+ than the stack it replaces. Below `lg` there is no width to divide and
+ it stacks, which is the arrangement the cards use anyway. */
+
+
+
+
{eyebrow}
+
+
+ {share}%
+
+ {lead && (
+
+ {lead}
+
+ )}
+
+
+ {question.question}
+
+
+
+
+
+
+ {split.answered} of {field} answered
+ {split.answered < field
+ ? ` · ${field - split.answered} skipped it`
+ : ""}
+ {" · "}
+ {temperature(split.division)}
+
+
+
+
+ );
+}
+
+/** How a split reads in a word. Thresholds on the entropy rather than on the
+ * leader's share, so a three-way question and a four-way one are described on
+ * the same scale — 50% of four options is a much stronger lead than 50% of
+ * two. */
+function temperature(division: number): string {
+ if (division < 0.45) return "Broad agreement";
+ if (division < 0.8) return "Leaning";
+ return "Split";
+}
+
+/** Who picked what, once a card is opened — the full wording each option was
+ * offered under, the candidates filed beneath it, and what each of them wrote
+ * about their own answer.
+ *
+ * That wording used to print under the chart on every card, and it was most
+ * of the page's height: two dozen cards each carrying three sentences nobody
+ * had asked for yet. It belongs here, next to the candidates who chose it,
+ * where a reader wanting to know exactly what "Public delivery" meant is
+ * already asking. */
+function Who({
+ question,
+ picks,
+}: {
+ question: FieldQuestion;
+ picks: FieldPick[];
+}) {
+ const colors = optionColors(question);
+ const unplaced = picks.filter((pick) => pick.choice === null);
+
+ return (
+
+ {question.options.map((option, i) => {
+ const chose = picks
+ .filter((pick) => pick.choice === i)
+ /* Ordered by surname — a reader checking on one candidate needs
+ somewhere to look them up — but printed in full, which is how the
+ ballot and every other page in the tracker names them. */
+ .sort(
+ (a, b) =>
+ a.surname.localeCompare(b.surname) ||
+ a.name.localeCompare(b.name),
+ );
+ return (
+
+
+ {option}
+
+ {question.details[i] && (
+
+ {question.details[i]}
+
+ )}
+ {chose.length === 0 ? (
+
Nobody
+ ) : (
+
+ {chose.map((pick) => (
+
+
+ {pick.name}
+
+ {pick.race === "mayor" ? "Mayor" : `Ward ${pick.ward}`}
+
+
+ {/* Their own words, verbatim, set against a rule so it is
+ never mistaken for ours. Most candidates who answered
+ also wrote something, and the writing is the part that
+ says why — a chart can show that nine picked the same
+ option and cannot show that they meant nine different
+ things by it. */}
+ {pick.note?.trim() && (
+
+ {pick.note.trim()}
+
+ )}
+
+ ))}
+
+ )}
+
+ );
+ })}
+ {unplaced.length > 0 && (
+
+
+ Answered in their own words
+
+
+ {unplaced.map((pick) => (
+
+
+ {pick.name}: “{pick.answer}”
+
+ {pick.note?.trim() && (
+
+ {pick.note.trim()}
+
+ )}
+
+ ))}
+
+
+ )}
+
+ );
+}
diff --git a/src/components/elections/QuestionRollCall.tsx b/src/components/elections/QuestionRollCall.tsx
new file mode 100644
index 00000000..56afa464
--- /dev/null
+++ b/src/components/elections/QuestionRollCall.tsx
@@ -0,0 +1,412 @@
+import { rollCall } from "@/lib/elections/candidate-answers";
+import { EMPTY, optionColors } from "@/lib/elections/option-colors";
+import type {
+ ComparedQuestion,
+ RollCallName,
+} from "@/lib/elections/candidate-answers";
+
+/* One question as a card, with the field sorted into the answers they gave.
+ *
+ * FORM
+ * The answer leads and the candidates sit inside it. That is the inversion
+ * the ward page needed: the grid it replaces gave every candidate a column
+ * and every question a row, which reads "what did this one person say" and
+ * makes the comparison — the thing the page is for — something a reader has
+ * to assemble across a sideways drag.
+ *
+ * Grouped, the comparison is the layout. Three panels is a three-way split
+ * and one panel is a field that agrees, without a number, a chart, or a
+ * click. Nothing is behind a disclosure here for the same reason: a question
+ * whose answers are collapsed is a question the reader has to open to
+ * compare, which is the failure being fixed.
+ *
+ * WHY A PANEL PER ANSWER, AND A NAME PLATE PER CANDIDATE
+ * The first pass drew the groups as a left rule against a flat list of
+ * surnames, and the two things a reader has to pick out — which answer, and
+ * who gave it — were both just runs of text at slightly different weights.
+ * So an answer is now an enclosed, tinted block that a reader can see the
+ * edges of, and a candidate is a plate with their own border inside it. Both
+ * become objects you can count at a glance rather than sentences to read.
+ *
+ * Names in full, never the surname. This page names the same handful of
+ * people thirty times over, which is the usual argument for cutting them
+ * down — but a surname is exactly what fails when the field is unfamiliar,
+ * and it fails worst on the names most likely to be misread ("Walied
+ * Khogali Ali" is not "Ali", and "Peter De Marco" is not "Marco").
+ *
+ * COLOUR
+ * Option position, from the ramps in lib/elections/option-colors — the same
+ * ones /issues and the mayoral page use, so an option is the same colour
+ * wherever a reader meets it. Yes/no scales take the diverging ramp rather
+ * than three unrelated hues, which is both the honest encoding of a scale
+ * and the thing that makes a direct question look different from a
+ * three-way one at a glance.
+ */
+
+export function QuestionRollCall({
+ question,
+ silent = [],
+ nameTheSilent = true,
+ seats,
+ notes = true,
+ yourKey,
+ headingId,
+}: {
+ question: ComparedQuestion;
+ /** on the ballot, but never returned the questionnaire */
+ silent?: { key: string; name: string }[];
+ /** false where nobody in the race answered anything — see below */
+ nameTheSilent?: boolean;
+ /** which seat each candidate is running for, keyed by candidate key. A ward
+ * page passes none: everyone named on it is running for the same seat, and
+ * printing it on every plate would be the page's own title, repeated a few
+ * hundred times. City-wide, it is the difference between a name and a
+ * ballot line the reader can act on — and it is also what splits a panel
+ * of thirty plates into the two races a voter actually holds. */
+ seats?: Record;
+ /** print what each candidate wrote about their own answer.
+ *
+ * A ward's four respondents leave four notes under a question and every
+ * one of them is worth the line. The city-wide page puts the same question
+ * to thirty-odd people, and a note under each turns a card a reader can
+ * take in at a glance into a page of prose they have to read to find the
+ * split — which is the comparison the form exists to make. So /issues
+ * files the names and leaves the writing to the ward and mayoral pages,
+ * where the field is small enough to read it.
+ *
+ * An answer given in the candidate's own words is not a note and prints
+ * either way: it is the whole of what they said, and dropping it would
+ * leave a plate under a heading with nothing behind it. */
+ notes?: boolean;
+ /** the reader's own answers, filed among the candidates' — the survey
+ * results page passes themselves through the same pivot as everyone else,
+ * so "who agreed with me" is a plate sitting in the same block rather than
+ * a column to compare against. Their plate is marked; nobody else's is. */
+ yourKey?: string;
+ /** the id the scroll rail scrolls to. A question is a level-3 entry under
+ * its section, which is what makes the rail nest rather than run thirty-odd
+ * headings down one list. */
+ headingId?: string;
+}) {
+ const { groups, verbatim, unanswered } = rollCall(
+ question,
+ nameTheSilent ? silent : [],
+ );
+
+ const colors = optionColors(question.options.length, question.ordinal);
+ const hue = (option: number) => colors[option] ?? EMPTY;
+
+ const empty = groups.length === 0 && verbatim.length === 0;
+
+ return (
+ /* A column rather than a grid, so the "did not answer" foot can take
+ `mt-auto` and sit on the bottom edge. Cards in a row stretch to the
+ tallest of them, and with the foot floating directly under whatever
+ content each card happened to have, the same line landed at a different
+ height in every card — the one piece of every card that says the same
+ thing was the piece a reader could never find twice in the same place. */
+
+
+ {question.question}
+
+
+ {empty ? (
+
+ No answers to this one yet.
+
+ ) : (
+
+ {groups.map((group) => (
+
+ ))}
+
+ {/* On no option, because none of them fit what they wrote. Their own
+ words are the whole of what they said here, so they are printed
+ rather than summarised away. */}
+ {verbatim.length > 0 && (
+
+ )}
+
+ )}
+
+ {/* One line, not a plate each. A ward can have ten registered candidates
+ and two respondents, and a plate per silent name per question is
+ three hundred cells of nothing — the grid's problem, restated. Named
+ all the same, on every question: a reader deciding how to vote is
+ owed the fact that their ballot line said nothing.
+
+ Unless nobody answered anything, which `nameTheSilent` turns off.
+ Then the line is the whole ballot, thirty times over, and it says
+ nothing the "No answers to this one yet." above it did not — the
+ roster at the top of the section is where those names belong. */}
+ {unanswered.length > 0 && (
+
+ Did not answer {" "}
+ {unanswered.map((candidate) => candidate.name).join(" · ")}
+
+ )}
+
+ );
+}
+
+/** A candidate's ballot line: which race, and the seat within it. */
+export type Seat = {
+ race: "mayor" | "councillor";
+ /** how the seat prints on a plate — "Ward 9". Mayoral candidates carry
+ * none: the run they sit in is already headed "For mayor". */
+ label?: string;
+};
+
+/* One answer, enclosed, with everyone who gave it inside it.
+ *
+ * The tint is the option's own hue at 7% — enough for the block to have an
+ * inside and an outside at a glance, light enough that the names on top of it
+ * are still the darkest thing in the card. */
+function AnswerPanel({
+ color,
+ label,
+ detail,
+ candidates,
+ seats,
+ notes = true,
+ yourKey,
+ muted = false,
+}: {
+ color: string;
+ label: string;
+ detail?: string | null;
+ candidates: (RollCallName & { answer?: string })[];
+ seats?: Record;
+ notes?: boolean;
+ yourKey?: string;
+ /** the "own words" panel, which is a caveat rather than an option */
+ muted?: boolean;
+}) {
+ /* The reader comes out of the run and sits above it.
+
+ Filed by surname among the candidates, "You" is one plate in a line of a
+ dozen, and the reader has to scan every panel on the card to find out
+ which one they are in — on a page whose entire question is "where am I",
+ that is the one thing that should never need looking for. Lifted to the
+ top of the block it is the first thing under the answer, in the same
+ place in every panel, so the panel a reader belongs to announces itself
+ before they read a single name. */
+ const you = yourKey
+ ? candidates.find((candidate) => candidate.key === yourKey)
+ : undefined;
+ const field = you
+ ? candidates.filter((candidate) => candidate.key !== yourKey)
+ : candidates;
+
+ /* Mayor first — one seat, and the race the whole city votes in. Both runs
+ keep the surname order they arrived in. */
+ const races = (["mayor", "councillor"] as const)
+ .map(
+ (race) =>
+ [
+ race,
+ field.filter((candidate) => seats?.[candidate.key]?.race === race),
+ ] as const,
+ )
+ .filter(([, named]) => named.length > 0);
+
+ return (
+
+ {/* The answer as it was put to the candidates, in full.
+
+ The questionnaire gives most options a short handle and the real
+ wording underneath — "Public delivery" over "Build or finance
+ substantially more affordable and supportive housing" — and this
+ panel used to title itself with the handle and print the wording as
+ a caption below. That is a summary of the answer standing where the
+ answer should be, and on a page whose whole job is what a candidate
+ said, the reader gets the sentence they actually endorsed. Options
+ with no expansion ("Yes") are already their own full wording.
+
+ Set like a title all the same: the biggest thing inside the block,
+ in serif rather than the question's sans, so the two read as heading
+ and sub-heading instead of competing at one size. */}
+
+ {detail || label}
+
+
+ {/* BY RACE, WHERE THERE IS MORE THAN ONE
+
+ A ward panel is one run of plates: everyone in it is running for the
+ same seat, so a heading over them would say what the page says. The
+ city-wide page puts thirty-odd plates in a panel drawn from two
+ ballot lines a voter holds separately — the mayor they get one vote
+ for, and the councillor they get one vote for — and undivided, the
+ two are a single wall of names in which the handful that matter to
+ any one reader are hidden. Split, a panel answers "did the mayoral
+ field agree with my councillor" without being read end to end.
+
+ Only where both races are actually present: a panel that happens to
+ be all councillors gets no heading, because a heading over the whole
+ of something is not a division. */}
+ {you && (
+
+ )}
+
+ {races.length > 1 ? (
+
+ {races.map(([race, named]) => (
+
+
+ {race === "mayor" ? "For mayor" : "For council"}
+
+
+
+ ))}
+
+ ) : (
+ field.length > 0 && (
+
+ )
+ )}
+
+ );
+}
+
+/* One run of candidates.
+ *
+ * Not a row of plates and a stack of notes under it: split in two, a candidate
+ * who explained their answer got their plate printed twice, which is the
+ * repetition the plates were meant to end.
+ *
+ * So each candidate appears once. The ones who only picked the option flow
+ * inline as plates; the ones who wrote something take a line of their own,
+ * with their words set underneath their plate — a plate is a label and a
+ * sentence is not, and running the two along one line makes the plate read as
+ * the first few words of the sentence. A candidate who took the trouble to
+ * explain has left the most useful thing on the page, so it prints in the
+ * open: a note behind a disclosure is a note nobody reads. */
+function Plates({
+ candidates,
+ seats,
+ notes,
+ color,
+ yourKey,
+}: {
+ candidates: (RollCallName & { answer?: string })[];
+ seats?: Record;
+ notes: boolean;
+ color: string;
+ yourKey?: string;
+}) {
+ return (
+
+ );
+}
+
+/* A candidate, as an object rather than a word: their own border on the card's
+ own background, so a name lifts off the tinted panel behind it. */
+function NamePlate({
+ name,
+ seat,
+ color,
+ you = false,
+}: {
+ name: string;
+ seat?: string;
+ color: string;
+ /** the reader's own plate — filled in the option's hue rather than outlined
+ * in it, so the one plate they are looking for is the one plate that is a
+ * solid block of colour in a panel of outlines. */
+ you?: boolean;
+}) {
+ return (
+
+
+ {name}
+ {seat && (
+ {seat}
+ )}
+
+ );
+}
diff --git a/src/components/elections/QuestionnaireCards.tsx b/src/components/elections/QuestionnaireCards.tsx
new file mode 100644
index 00000000..fdcf0fff
--- /dev/null
+++ b/src/components/elections/QuestionnaireCards.tsx
@@ -0,0 +1,186 @@
+import Link from "next/link";
+import { ArrowRight } from "lucide-react";
+
+import type { Heading } from "@/components/custom/signpost/config";
+import type { GridCandidate } from "./SurveyGrid";
+import { QuestionRollCall } from "./QuestionRollCall";
+import type { Seat } from "./QuestionRollCall";
+import type { ComparedGroup } from "@/lib/elections/candidate-answers";
+
+/* One race's questionnaire, read question by question — a card per question,
+ * the candidates filed under the answer they gave.
+ *
+ * WHAT THIS REPLACED
+ * A grid: candidates across, thirty-odd questions down, sticky heads, and a
+ * horizontally scrolling region with a chart behind every row. It answered
+ * "what did this candidate say" well and "how do these four differ" badly,
+ * and the second question is the one a voter arrives with. So the page is
+ * now a question at a time, with the candidates filed under the answer they
+ * gave — see QuestionRollCall for the form itself.
+ *
+ * THE ORDER IS THE QUESTIONNAIRE'S
+ * Sections in the order the questions were asked, everything open. Ranking
+ * them by how divided the field is would put the interesting ones on top,
+ * and it would also mean the page reshuffles between wards and between
+ * visits, so a reader who found something cannot find it again. The
+ * questionnaire's own topics are a table of contents that holds still.
+ *
+ * THE BALLOT IS NOT HERE
+ * The cards name candidates in full but do not link them: a campaign link
+ * per name per question is several hundred outbound links on one page. The
+ * links live once, in the CandidateRoster beside the section heading, where
+ * a reader gets the whole ballot before reading thirty cards about it.
+ *
+ * `silent` IS A JUDGEMENT THE PAGE MAKES
+ * A ward passes its non-respondents in, and every card names them: the
+ * field is a dozen people and a reader deciding how to vote is owed the
+ * fact that their ballot line said nothing. The mayoral page passes none,
+ * because forty-four names under each of thirty-four questions is fifteen
+ * hundred names saying one thing that the stats row and the roster page
+ * already say once.
+ */
+
+/** The id a section heading answers to, and the one the rail scrolls at. */
+export function sectionId(stepId: string, prefix?: string): string {
+ return prefix ? `${prefix}-${stepId}` : stepId;
+}
+
+/** The questionnaire's sections, as the scroll rail wants them.
+ *
+ * Sections only. Hanging every question off its section was tried and is
+ * wrong for this rail: a level-3 entry on a memo is three words of a
+ * sub-heading, where a question here is a full sentence — "Should Toronto
+ * permit substantially more housing as-of-right in every ward, including
+ * areas currently dominated by detached and semi-detached homes?" — and
+ * thirty-three of those turn a navigation aid into a second copy of the page
+ * you have to read to navigate.
+ *
+ * The questions still carry their ids, so a deep link into one works and the
+ * rail can be given them later if the case for it changes. */
+export function questionnaireHeadings(
+ groups: ComparedGroup[],
+ { prefix }: { prefix?: string } = {},
+): Heading[] {
+ return groups.map((group) => ({
+ id: sectionId(group.stepId, prefix),
+ text: group.stepTitle,
+ level: 2 as const,
+ }));
+}
+
+export function QuestionnaireCards({
+ groups,
+ respondents,
+ silent,
+ issuesHref,
+ seats,
+ notes = true,
+ yourKey,
+ idPrefix,
+}: {
+ groups: ComparedGroup[];
+ /** the candidates who returned the questionnaire */
+ respondents: GridCandidate[];
+ /** on the ballot, and yet to */
+ silent: GridCandidate[];
+ /** the whole city's answers, where this election has that page */
+ issuesHref?: string;
+ /** the seat each candidate is running for, keyed by candidate key — see
+ * QuestionRollCall. Only the city-wide page passes one. */
+ seats?: Record;
+ /** print each candidate's own words about their answer — see
+ * QuestionRollCall. The city-wide page turns them off. */
+ notes?: boolean;
+ /** the reader's own row, where they have answered the same questionnaire —
+ * see QuestionRollCall. */
+ yourKey?: string;
+ /** namespaces the section headings' ids. A page showing one questionnaire
+ * needs none; the survey results show two, a mayoral race and a ward one,
+ * built from the same question set — without a prefix both would put an
+ * element called "housing" in the document and the scroll rail would only
+ * ever find the first. */
+ idPrefix?: string;
+}) {
+ const silentNames = silent.map((candidate) => ({
+ key: candidate.key,
+ name: candidate.name,
+ }));
+
+ return (
+ /* Sections sit well apart. The cards inside one are a gap-4 grid, so a
+ section break that was only a little wider read as another row of the
+ same grid rather than a change of subject. */
+
+ {groups.map((group) => (
+
+ {/* The rule that opens a section, on one line. Five headings should
+ not be five screens — but they do have to sit above the question
+ headings inside the cards, which sit above the answer titles
+ inside those, so the three are set a step apart. */}
+
+ {group.stepTitle}
+
+
+ {group.questions.map((question) => (
+ 0}
+ headingId={sectionId(question.questionId, idPrefix)}
+ seats={seats}
+ notes={notes}
+ yourKey={yourKey}
+ />
+ ))}
+
+
+ ))}
+
+
+
+ );
+}
+
+/* How to read the blocks above. Short, because the form is nearly
+ * self-explanatory now — what it still has to say is what is NOT on the page:
+ * the options nobody picked, and the candidates who are not in any group. */
+function WardAnswerNote({
+ issuesHref,
+ notes,
+}: {
+ issuesHref?: string;
+ notes?: boolean;
+ /** the reader's own row, where they have answered the same questionnaire —
+ * see QuestionRollCall. */
+ yourKey?: string;
+ /** namespaces the section headings' ids. A page showing one questionnaire
+ * needs none; the survey results show two, a mayoral race and a ward one,
+ * built from the same question set — without a prefix both would put an
+ * element called "housing" in the document and the scroll rail would only
+ * ever find the first. */
+ idPrefix?: string;
+}) {
+ 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." : ""}
+
+ {issuesHref && (
+
+ How the whole city answered
+
+
+ )}
+
+ );
+}
diff --git a/src/components/elections/QuestionnaireRail.tsx b/src/components/elections/QuestionnaireRail.tsx
new file mode 100644
index 00000000..7eb13d14
--- /dev/null
+++ b/src/components/elections/QuestionnaireRail.tsx
@@ -0,0 +1,51 @@
+import type { ReactNode } from "react";
+
+import { Signpost } from "@/components/custom/signpost";
+import type { Heading } from "@/components/custom/signpost/config";
+
+/**
+ * The memo pages' scroll rail, around a questionnaire.
+ *
+ * WHY THESE PAGES WANT IT
+ * A questionnaire read is thirty-odd cards in eight or nine titled sections,
+ * which is memo-length without being a memo: a reader who wants housing has
+ * to scroll past transit to find out whether they have passed it. The rail
+ * is the same answer it is on a memo — where am I, what is left, and a way to
+ * jump — and using the same component means it behaves identically on both,
+ * rather than being a second thing that looks like the first.
+ *
+ * THE TWO CONTRACTS IT HAS TO HONOUR
+ * · Every heading in `headings` must exist in the document under that id, or
+ * the rail lists a section it cannot scroll to. QuestionnaireCards puts the
+ * ids on for us — see `questionnaireHeadings`, which is the only supported
+ * way to build the list.
+ * · Reading progress is measured off `[data-memo-content]`, and the hook
+ * takes the first one in the document. So this marks the content once, and
+ * a page must not nest two of these.
+ *
+ * THE COLUMN
+ * Only at `2xl-memo` (1200px), the same breakpoint the memos use, and below
+ * it the rail becomes the sticky bar and the content takes the full width.
+ * The tracker's pages are already padded by their section, so this adds none
+ * of its own — it divides the width it is handed.
+ */
+export function QuestionnaireRail({
+ headings,
+ children,
+}: {
+ headings: Heading[];
+ children: ReactNode;
+}) {
+ /* Nothing to point at — a rail listing no sections is a 220px hole down the
+ side of the page. */
+ if (headings.length === 0) return <>{children}>;
+
+ return (
+
+ );
+}
diff --git a/src/components/elections/SurveyCta.tsx b/src/components/elections/SurveyCta.tsx
new file mode 100644
index 00000000..ad8e336f
--- /dev/null
+++ b/src/components/elections/SurveyCta.tsx
@@ -0,0 +1,54 @@
+import Link from "next/link";
+import { ArrowRight } from "lucide-react";
+
+/* An invitation to answer the same questions the candidates did.
+ *
+ * It sits beside "Know Your Candidates" rather than under it, because it is
+ * the other half of the same idea: the grid says where the candidates stand,
+ * and the only thing that turns that into a decision is where the reader
+ * stands. A reader who has just read a column of answers is the likeliest
+ * person on the site to have an opinion about them.
+ *
+ * Loud on purpose. It was a bordered card in the page's own colours, at the
+ * page's own type size, which is the shape of a footnote: beside a heading set
+ * at three times its weight it read as something already dealt with. So on a
+ * desktop it takes the accent as a fill and the heading as a size — the only
+ * solid block of colour on the page, and the only thing on it that is not
+ * either a question or an answer. On a phone it keeps the same colours at a
+ * smaller size, stacked under the heading, where there is no room for a second
+ * column and nothing to compete with anyway.
+ */
+export function SurveyCta({
+ href,
+ className = "lg:min-w-[380px] lg:max-w-[440px]",
+}: {
+ href: string;
+ /** width and placement, which differ by slot — beside a heading on the
+ * candidate pages, filling a panel or centred on the landing page */
+ className?: string;
+}) {
+ return (
+
+ Voter survey
+
+
+ Where do you stand?
+
+
+
+ Answer the same questions we put to the candidates and see who lines up
+ with you.
+
+
+ {/* A button rather than a link with an arrow: the card is the control,
+ and this is what says so at a glance. */}
+
+ Take the survey
+
+
+
+ );
+}
diff --git a/src/components/elections/SurveyGrid.tsx b/src/components/elections/SurveyGrid.tsx
new file mode 100644
index 00000000..cbf4d3e0
--- /dev/null
+++ b/src/components/elections/SurveyGrid.tsx
@@ -0,0 +1,755 @@
+"use client";
+
+import { useEffect, useRef, useState } from "react";
+import type { CSSProperties } from "react";
+import { ArrowUpRight, ChevronDown } from "lucide-react";
+
+import { WedgeGlyph, palette } from "@/components/charts/trilemma";
+import { AnswerChart, isDial, sharedRadius } from "./AnswerChart";
+import { CandidateSiteLink } from "./CandidateSiteLink";
+import { lastName } from "@/lib/elections/names";
+import {
+ Collapsible,
+ CollapsibleContent,
+ CollapsibleTrigger,
+} from "@/components/ui/collapsible";
+import type {
+ AnswerCell,
+ ComparedGroup,
+ ComparedQuestion,
+} from "@/lib/elections/candidate-answers";
+
+/* A ward's questionnaire, read across: one row per question, one column per
+ * candidate.
+ *
+ * WHY NOT PER CANDIDATE
+ * The answers used to live on each candidate's card, in full — the question,
+ * the option they picked, every alternative in its own wording, a dial of the
+ * field's split, their note. That is the right shape for one candidate and it
+ * does not survive being repeated: one respondent ran a ward page to nineteen
+ * screens, and a ward with four would have run to sixty. Worse, the thing a
+ * voter is on the page to do — hold two candidates against each other on a
+ * question — meant scrolling between cards a screen apart and remembering
+ * what the first one said.
+ *
+ * Read across, the comparison is the layout. Two candidates who agree show
+ * as the same glyph in the same colour side by side; a row of three different
+ * colours is a question the ward is split on, visible without reading a word
+ * of it.
+ *
+ * WHAT A CLOSED ROW SHOWS
+ * The question, and each candidate's pick as its wedge glyph and the head of
+ * the option's wording. Not the full option — a row has a column's width for
+ * it, and "Permit substantially more housing as-of-right in every ward" does
+ * not go there. The head is enough to tell the options apart, and the full
+ * wording is one click away, which is where the option list, the field's
+ * chart and the candidates' own notes are too.
+ *
+ * Nothing is hidden by default: every question on the questionnaire is a row
+ * on the page, answered or not. An empty cell is a finding.
+ *
+ * ON A PHONE
+ * There is no width for a column per candidate, and a table that scrolls
+ * sideways puts the answers off screen behind the questions — the one thing
+ * the reader came for. So the row stops being a row: the question, then a
+ * line per candidate, each naming who it belongs to. The comparison survives
+ * because the answers are still gathered under their question; only the
+ * direction changes.
+ *
+ * COLOUR
+ * Option position, from the chart palette's corner hues — the first option is
+ * the same colour on all thirty-odd questions, and the same colour it has on
+ * the survey page's charts. That is what lets a column be scanned for
+ * "always the first option" without reading the labels.
+ */
+
+/** A column of the grid: one candidate on the ballot, answered or not. */
+export type GridCandidate = {
+ key: string;
+ name: string;
+ website?: string;
+ /** a line about who they are, where we have one — hand-maintained, and for
+ * most of a ballot we do not */
+ bio?: string;
+};
+
+export function SurveyGrid({
+ groups,
+ candidates,
+ election,
+ race = "councillor",
+ ward,
+ wardName,
+ yourKey,
+}: {
+ groups: ComparedGroup[];
+ /** every candidate on this ballot, in the order the clerk lists them —
+ * not only the ones who wrote back */
+ candidates: GridCandidate[];
+ /** York Factory election slug, for the outbound-link tracking */
+ election: string;
+ /** which ballot line these candidates are on, for the same tracking */
+ race?: "mayor" | "councillor" | "trustee";
+ /** the ward, where the race has one — a mayoral field does not */
+ ward?: string;
+ wardName?: string;
+ /**
+ * The column that is the reader's own, where the grid has one. It is a
+ * column like any other to look at, and no part of any count: the tallies
+ * under each question are how the ward's candidates answered, and folding
+ * the reader into them would have them agreeing with themselves.
+ */
+ yourKey?: string;
+}) {
+ /* The column heads are as tall as the longest name plus its link, which is
+ not a number this component can know: it depends on the names, the column
+ width and the reader's font size. The section titles park directly under
+ the heads, so the offset they stick at is measured rather than guessed —
+ a hardcoded height was fine when a head was a name on two short lines and
+ is not fine now that it carries a full name and a campaign link. */
+ const head = useRef(null);
+ const [headHeight, setHeadHeight] = useState(null);
+ useEffect(() => {
+ const el = head.current;
+ if (!el) return;
+ const measure = () => setHeadHeight(el.getBoundingClientRect().height);
+ measure();
+ const observer = new ResizeObserver(measure);
+ observer.observe(el);
+ return () => observer.disconnect();
+ }, []);
+
+ const questions = groups.flatMap((group) => group.questions);
+
+ /* The columns by key, so a stacked answer can name its candidate the way
+ the column heads do — in full, and linked where there is a campaign site.
+ A phone has no heads to carry that, and a surname on its own was the one
+ place the two layouts said different things about the same candidate. */
+ const roster = new Map(candidates.map((candidate) => [candidate.key, candidate]));
+
+ /* Who wrote back. Every question's cells carry it, and they all agree — the
+ first question is as good a witness as any. */
+ const responded = new Set(
+ questions[0]?.cells.filter((cell) => cell.responded).map((cell) => cell.key),
+ );
+ // One radius for every dial behind every row, so the expanded charts stay
+ // comparable with each other and with the ones on the survey page.
+ const radius = sharedRadius(questions);
+
+ /* Columns: the question takes the room it needs to stay readable and the
+ candidates split what is left evenly, down to a floor.
+
+ The floor is what makes this scroll sideways, and it is not optional now
+ that the columns are the whole ballot rather than the handful who wrote
+ back: a Toronto ward runs to a dozen registered candidates, and twelve
+ columns sharing a laptop's width are 80px each — a column too narrow to
+ print a surname, let alone an answer. Better to hold every column at a
+ readable width and let the reader drag.
+
+ 230px a candidate, which is about 30 characters of the answer type: a
+ column that breaks a two-word option over two lines is still squeezed,
+ and the reader is dragging either way — the only thing a tighter floor
+ buys is a couple of extra columns on screen, each of them harder to
+ read.
+
+ The ceiling stays too: it keeps a ward of one respondent from setting its
+ single column adrift at the right-hand edge of a very wide page — the grid
+ grows with the number of candidates rather than with the window. */
+ const columns = `minmax(0,1.6fr) repeat(${candidates.length}, minmax(0,1fr))`;
+
+ /* The widths and the pinning offset ride on custom properties so the
+ breakpoint can decide whether they apply at all: below `md` the grid is
+ not a grid — the answers stack and name their own candidate — so a floor
+ meant for columns would only push the page off the side of the screen. */
+ const vars = {
+ "--cols": columns,
+ "--grid-min": `${368 + candidates.length * 230}px`,
+ "--grid-max": `${640 + candidates.length * 260}px`,
+ /* Where the section titles park: the measured height of the head row, or
+ nothing at all before the first measurement and on a phone, where the
+ heads are not rendered and nothing sticks. */
+ "--head-h": headHeight === null ? "0px" : `${headHeight}px`,
+ } as CSSProperties;
+
+ return (
+ /* A scroll region of its own on wide screens. It is what carries the
+ sideways scroll the column floor forces, and it is also the only thing
+ the heads and section titles can pin to: `position: sticky` resolves
+ against the nearest scrolling ancestor, and an `overflow-x` on its own
+ would make this that ancestor anyway — the axes cannot be separated.
+ Capped just under the viewport so the pinned rows sit at the top of the
+ box while the questions run under them. Below `md` the grid is not a
+ grid, every answer names its own candidate, and the cap, the floor and
+ the scrolling all go away. */
+ /* `min-w-0` matters as much as the overflow does: as a grid or flex item —
+ which is how the survey page's results place it — this box would
+ otherwise be sized to its own content's minimum, which is the whole
+ twelve-thousand-pixel width of the columns. It would then push its
+ parent wide instead of scrolling inside it, and the scrollbar this box
+ exists for would never appear. */
+
+
+ {/* What a phone gets instead of column heads.
+
+ Stacked, there are no columns to head, and for a while that meant
+ the ballot appeared on a phone only as surnames scattered through
+ thirty questions: no full names, no campaign links, and no way to
+ see who is running without reading the whole questionnaire. So the
+ roster is printed once, at the top, in the order the columns take —
+ answered first, then the rest — with the same names and the same
+ links the heads carry. */}
+
+ {candidates.map((candidate) => (
+
+
+
+
+ {responded.has(candidate.key) ? "Answered" : "Did not respond"}
+
+
+ {/* The bio row's job on a phone, where there are no columns to
+ put a row across. */}
+ {candidate.bio && (
+
+ {candidate.bio}
+
+ )}
+
+ ))}
+
+
+ {/* The column heads, once, at the top of the grid and pinned there.
+ They were repeated per section on the reasoning that a reader
+ arriving in the middle of a page needs to know whose column is
+ whose — which is true, and is the job of a sticky header rather
+ than of five copies of one. */}
+
+ {/* The question column pins to the left edge as well as the top, so
+ the corner cell has to sit above its own row's other cells —
+ which come after it in the DOM and would otherwise paint over
+ it — while the row as a whole stays above the grid. It fills the
+ row's height and steps out into the column gutter for the same
+ reason the question cells below it do: the names are two lines
+ deep and were sliding past above a label one line tall. */}
+
+ Question
+
+ {candidates.map((candidate) => (
+
+
+
+ ))}
+
+
+ {/* Who they are, in their own column, once.
+
+ A row rather than a section of its own or a line inside the head:
+ under the column is where a bio belongs — it is about that
+ candidate and nothing else — and the head is a sticky bar whose
+ height every section title is measured against, so a sentence in it
+ would push the whole grid down for as long as the page is open.
+
+ The row appears only where somebody has a bio, and cells for the
+ candidates without one are left blank rather than filled with a
+ dash: we have nothing to say about them, which is not the same as
+ them having nothing to say. */}
+ {candidates.some((candidate) => candidate.bio) && (
+
+
+ About
+
+ {candidates.map((candidate) => (
+
+ {candidate.bio}
+
+ ))}
+
+ )}
+
+ {groups.map((group) => (
+
+ {/* The section rule needs air on both sides of the title: above it
+ so the heavy line reads as the start of a section rather than
+ as the bottom of the last row of the previous one, and below it
+ so the title is not sitting on the first question's hairline.
+
+ It sticks under the column heads, so a reader thirty rows into
+ a section can still see which part of the questionnaire they
+ are in — the same argument that pinned the names. The air above
+ it is margin rather than padding on purpose: the rule is the
+ element's own top edge, so when the title parks under the heads
+ there is no transparent band for rows to show through. */}
+
+ {/* The heading is as wide as the grid, so its background covers
+ the row however far it is scrolled; the words themselves ride
+ the left edge, where the question column is. */}
+
+ {group.stepTitle}
+
+
+
+ {group.questions.map((question) => (
+
+ ))}
+
+ ))}
+
+
+ );
+}
+
+/**
+ * A candidate's name, and where they have a campaign site the name is the way
+ * to it — a name is what a reader is looking for and what they will click, and
+ * "Campaign site" on a line of its own spent a second line of a narrow column
+ * saying so. The arrow is the whole tell that a name is a link, so it only
+ * appears where there is somewhere to go.
+ *
+ * Shared by the column heads, the roster a phone gets instead of them, and the
+ * list of candidates who never wrote back, so the three cannot drift apart.
+ */
+export function CandidateName({
+ candidate,
+ election,
+ race,
+ ward,
+ wardName,
+}: {
+ candidate: GridCandidate;
+ election: string;
+ race: "mayor" | "councillor" | "trustee";
+ ward?: string;
+ wardName?: string;
+}) {
+ const type =
+ "font-sans text-[1rem] font-medium leading-[1.15] tracking-[-0.015em] text-dark text-balance";
+
+ if (!candidate.website) return {candidate.name} ;
+
+ return (
+
+ {candidate.name}
+
+
+ );
+}
+
+function QuestionRow({
+ question,
+ radius,
+ roster,
+ yourKey,
+ election,
+ race,
+ ward,
+ wardName,
+}: {
+ question: ComparedQuestion;
+ radius?: number;
+ /** the columns by key, for the names a phone prints beside each answer */
+ roster: Map;
+ /** the reader's own column, kept out of the counts */
+ yourKey?: string;
+ election: string;
+ race: "mayor" | "councillor" | "trustee";
+ ward?: string;
+ wardName?: string;
+}) {
+ const full = palette(question.options.length);
+
+ return (
+
+ {/* The whole row is the control, so a reader does not have to find a
+ disclosure arrow in a grid of thirty-odd of them. */}
+
+ {/* Pinned to the left edge: a reader dragging sideways to reach the
+ twelfth candidate is doing it to read that candidate's answer to a
+ particular question, and an answer whose question has scrolled off
+ the screen is an answer to nothing. Opaque, so the answers pass
+ underneath it, and it carries the row's hover state itself — a
+ sticky cell has its own background and would otherwise stay
+ unhighlighted while the rest of its row lit up — which is why the
+ row no longer fills on hover at all: the mark of the pointer is on
+ the question itself, where the disclosure arrow already is.
+
+ Full height and a step out into the column gap, because the cover
+ has to be the size of what it covers: sized to its own text, the
+ pinned cell let the taller answers beside it slide past above and
+ below the question, and the 1rem gutter let a sliver through.
+
+ Stacked on a phone it pins to the top instead, under the site's
+ nav, and holds there for as long as its own answers run. A row is
+ one question and a dozen replies to it; scrolled a screen into
+ them, a reader was reading answers to a question that had left the
+ screen — the same failure the left-hand pin fixes on a desktop,
+ turned ninety degrees. It lets go at the end of its row, because
+ sticky only holds within its own box, so the next question takes
+ over rather than stacking up behind it. */}
+
+
+ {/* The question is set exactly as the answers beside it — same
+ family, size, weight and measure. A row is one sentence and the
+ replies to it, and typing the question in serif at a smaller size
+ made it read as a caption for its own answers rather than as the
+ first cell of the row. */}
+
+ {question.question}
+
+
+
+ {/* On a phone a candidate who never wrote back is dropped from every
+ question rather than repeating "Did not respond" down thirty rows
+ of a single column — the roster at the top of the grid has already
+ said so once, which is the right number of times. `contents` keeps
+ the wrapper out of the grid's way where the columns do exist. */}
+ {question.cells.map((cell) => (
+
+ |
+
+ ))}
+
+
+
+
+
+
+ );
+}
+
+/**
+ * One candidate's answer to one question, at row width: the option's wedge,
+ * the head of its wording, and whatever they wrote about it.
+ *
+ * The note goes in the column, under the answer it is about, rather than
+ * waiting behind the row's disclosure. It is the part of a questionnaire a
+ * candidate actually wrote, and a reader comparing three candidates on a
+ * question wants the three sentences side by side — which is exactly what a
+ * grid is for, and exactly what a disclosure per row prevents. Everything a
+ * candidate wrote prints in full — the option they picked, any answer in
+ * their own wording, and the note — because a truncated answer is a different
+ * answer. A long note makes its row taller; that is the cost of printing what
+ * was said, and it is the right one.
+ *
+ * The name rides along, hidden wherever the column heads carry it. Stacked on a
+ * phone there are no heads, and an answer that does not say whose it is on a
+ * page comparing several candidates is worse than no answer.
+ */
+function Cell({
+ cell,
+ colors,
+ candidate,
+ election,
+ race,
+ ward,
+ wardName,
+}: {
+ cell: AnswerCell;
+ colors: string[];
+ /** their column, for the name a phone prints above the answer */
+ candidate?: GridCandidate;
+ election: string;
+ race: "mayor" | "councillor" | "trustee";
+ ward?: string;
+ wardName?: string;
+}) {
+ const answer = cell.answer;
+
+ /* Stacked on a phone the name leads its own line rather than sharing a
+ baseline with the answer: an option runs to a sentence, and a name tucked
+ in front of one reads as the first words of it. It is the same name the
+ column heads print — in full, and linked to their campaign site where they
+ have one — because a reader on a phone is owed what a reader on a laptop
+ gets. Above `md` the heads carry it and this is not rendered at all. */
+ const name = (
+
+ {candidate ? (
+
+ ) : (
+
+ {lastName(cell.candidateName)}
+
+ )}
+
+ );
+
+ /* Answers sit under their question on a phone, indented and ruled, so a
+ screen of them reads as replies to the question above rather than as more
+ questions. In the grid the columns do that job and the rule would only be
+ a line down the middle of a row.
+
+ They also stand well apart from each other. Stacked, the only thing
+ separating one candidate's answer from the next is white space, and at a
+ tight gap four candidates read as one long paragraph with names in it —
+ the row's own gap does most of that work, and the block holds together
+ because its name, option and note are closer to each other than the block
+ is to its neighbours. */
+ const wrap =
+ "grid gap-1.5 border-l border-border-light pl-4 md:gap-1 md:border-0 md:pl-0";
+
+ /* The answer type steps down on a phone. In the grid it matches the
+ question exactly — one row, one voice — but stacked, a question and its
+ answers in the same size is a wall with no way in. */
+ const optionType =
+ "font-sans text-[1.05rem] md:text-[1.2rem] font-medium leading-[1.3] tracking-[-0.015em]";
+
+ /* Two different silences, and the difference matters to a voter: one
+ candidate answered our questionnaire and left this question alone, the
+ other never wrote back at all. The second is about them rather than about
+ the question, so it says so in as many words. */
+ if (!answer) {
+ return (
+
+ {name}
+
+ {cell.responded ? "Not answered" : "Did not respond"}
+
+
+ );
+ }
+
+ /* Their note, and — where their answer was prose of its own — the answer
+ itself, which is the only wording they gave and so belongs in the column
+ rather than being summarised as "in their own words". */
+ const said = (
+ <>
+ {answer.verbatim && answer.answer && (
+
+ “{answer.answer}”
+
+ )}
+ {answer.explanation && (
+
+ {answer.explanation}
+
+ )}
+ >
+ );
+
+ if (answer.choice === null) {
+ return (
+
+ {name}
+
+ In their own words
+
+ {said}
+
+ );
+ }
+
+ return (
+
+ {name}
+
+
+
+
+ {/* The option in the wording it was offered in. It used to take the
+ rim label's stub — the short form that exists so three options can
+ sit around a circle — and a stub is not an answer: "Decrease after
+ inflation…" and "Decrease after inflation, with some
+ responsibilities transferred to civilian services" are different
+ positions, and the ellipsis was standing where the difference was.
+ The column wraps instead. */}
+
+ {answer.options[answer.choice]}
+
+
+ {said}
+
+ );
+}
+
+/**
+ * The row opened up: the options in the wording the questionnaire offered
+ * them, what each one actually says, and how many of the ward's respondents
+ * picked it.
+ *
+ * Not a second copy of the row. It used to be one — the same candidates, the
+ * same notes, the same wording, pivoted from by-candidate to by-option — which
+ * was worth opening back when the row above showed a stub of the option and
+ * four lines of the note. The row prints all of it now, so what is left in
+ * here is the part a grid of answers cannot carry:
+ *
+ * · the fine print. Several options are a headline plus a sentence of
+ * detail, and the cells have room for the headline only.
+ * · the options nobody picked. A cell can only show a position someone
+ * took; that a whole ward declined one is a finding of its own, and it
+ * only exists as an empty row in a list of every option.
+ * · the count, as a shape. Twelve cells tallied by eye against a dial that
+ * does the tallying.
+ */
+function QuestionDetail({
+ question,
+ radius,
+ yourKey,
+}: {
+ question: ComparedQuestion;
+ radius?: number;
+ /** the reader's own column, which is nobody's candidate */
+ yourKey?: string;
+}) {
+ const full = palette(question.options.length);
+
+ /* The candidates, which on the survey page is every column but the reader's
+ own. Everything below counts these and only these. */
+ const field = question.cells.filter((cell) => cell.key !== yourKey);
+ const answered = field.filter((cell) => cell.answer);
+
+ /* Respondents, not candidates: nobody can pick an option on a form they did
+ not fill in, and counting the silent ones in the denominator would make
+ every option look less popular than the answers say it is. */
+ const respondents = field.filter((cell) => cell.responded).length;
+
+ /* The dial draws this ward on this question, not the election. It was the
+ whole field's split — thirty-odd respondents — sitting beside the ward's
+ four, which put two different populations a centimetre apart under one
+ question and left the reader to notice that the numbers could not be
+ compared. The page is a ward page: the candidates on this ballot are the
+ subject, and the chart should have the same subject as everything around
+ it. */
+ const wardCounts = question.options.map(
+ (_, i) => answered.filter((cell) => cell.answer?.choice === i).length,
+ );
+
+ return (
+ /* Held to a reading width and pinned to the left edge, rather than being
+ laid out across the grid.
+
+ An opened row is a child of the grid's inner wrapper, which is as wide
+ as every column put together — 12,000px on a mayoral field of fifty-odd.
+ Left to fill that, an option list and a dial were stretched across a
+ dozen screens of nothing, and a reader who had dragged sideways to reach
+ a column found the detail they opened somewhere off to the left. It is
+ one block about one question; it does not get wider because the ballot
+ did. */
+
+ {/* Every option in full, in the order they were offered — including the
+ ones nobody took. The rule down the left carries the option's hue on
+ the three-way questions, which is what ties this list to the dial
+ beside it and to the wedge glyphs in the cells above; the ordered
+ questions keep a neutral rule, since their options are points on a
+ scale rather than three rivals. */}
+
+
+
+ {/* The ward's split, all three thirds in their own colour: nobody's
+ position is being picked out here, so muting two of them would say
+ something the chart does not mean.
+
+ Counts rather than shares. A ward has two to six respondents, and
+ "50%" of four people is a statistic with nothing behind it — the
+ same reason the list beside it prints "2 of 4". */}
+
`${n}`}
+ ariaLabel={`${question.question} — how the ward's ${respondents} responding candidates answered: ${question.options
+ .map((option, i) => `${option} ${wardCounts[i]}`)
+ .join(", ")}`}
+ />
+
+ Candidates in this ward who picked each option, out of the{" "}
+ {respondents} who returned the questionnaire
+
+
+
+ );
+}
diff --git a/src/components/elections/WardDetail.tsx b/src/components/elections/WardDetail.tsx
index b9df38ad..cb61108d 100644
--- a/src/components/elections/WardDetail.tsx
+++ b/src/components/elections/WardDetail.tsx
@@ -3,7 +3,22 @@ import Image from "next/image";
import type { ReactNode } from "react";
import { ArrowLeft, ArrowRight } from "lucide-react";
import CountdownDays from "./CountdownDays";
+import { CandidateRoster } from "./CandidateRoster";
+import {
+ QuestionnaireCards,
+ questionnaireHeadings,
+} from "./QuestionnaireCards";
+import { QuestionnaireRail } from "./QuestionnaireRail";
+import { SurveyCta } from "./SurveyCta";
import { IncumbentBadge, SiteLink } from "./ElectionLanding";
+import {
+ comparedQuestions,
+ surveyRoster,
+} from "@/lib/elections/candidate-answers";
+import type {
+ CandidateAnswers,
+ ComparedGroup,
+} from "@/lib/elections/candidate-answers";
import { daysUntil } from "@/lib/elections/dates";
import type { SupportedElection } from "@/lib/elections/registry";
import type {
@@ -27,6 +42,8 @@ export function WardDetail({
nominationCloseLabel,
wardMapDefs,
wardMap,
+ surveyAnswers,
+ surveyShape,
}: {
election: SupportedElection;
data: WardDetailData;
@@ -36,6 +53,19 @@ export function WardDetail({
wardMapDefs?: ReactNode;
/** this region's locator map for this ward, when it has ward geometry */
wardMap?: ReactNode;
+ /**
+ * Published questionnaire answers for this ward's candidates, keyed by
+ * `nameKey`. A candidate with no entry simply shows no answers — for most of
+ * the campaign that is most of them.
+ */
+ surveyAnswers?: Record;
+ /**
+ * The questionnaire's questions with nobody's answers on them, used where a
+ * ward's whole field stayed quiet — there are no returned questionnaires to
+ * read the questions off, and a ward of non-respondents still deserves to
+ * show which questions they did not answer.
+ */
+ surveyShape?: ComparedGroup[];
}) {
const { ward, wards, councilRaces, trusteeRaces } = data;
const idx = wards.findIndex((w) => w.number === ward.number);
@@ -43,9 +73,27 @@ export function WardDetail({
const next = wards[(idx + 1) % wards.length];
// With one council race the heading would only repeat the page title, so the
- // candidates run straight down — which is how Toronto's page has always read.
+ // grid runs straight under it — which is how Toronto's page has always read.
const showRaceHeadings = councilRaces.length > 1;
+ /* The whole council ballot, and the part of it that wrote back.
+
+ The questionnaire grid is the ward's candidate list now — there is no
+ separate roster of cards above it to agree or disagree with. So its
+ columns are every candidate still standing, and one we never heard from is
+ a column that says exactly that, which is more use to a voter than a name
+ quietly left out of the comparison.
+
+ Withdrawn candidates are the exception, and are dropped: they cannot be
+ voted for, so a column of theirs is a column of a ballot line that does
+ not exist, and in a grid this wide every column costs the reader a drag. */
+ const councilCandidates = councilRaces
+ .flatMap((race) => race.candidates)
+ .filter((candidate) => !candidate.withdrawn);
+ const respondents = councilCandidates.filter(
+ (candidate) => surveyAnswers?.[candidate.key],
+ );
+
return (
@@ -108,48 +156,72 @@ export function WardDetail({
- {/* ── Council candidates ─────────────────────────────── */}
-
-
-
- Candidates
-
+ {/* ── Questionnaire ──────────────────────────────────── */}
+
+ {/* The heading, what it amounts to, and the ballot it is about —
+ one column, with the survey beside it. The ballot used to sit in
+ a band of its own under this one, which left the heading's column
+ as a line of type and a sentence against a survey card three
+ times its height: a rectangle of nothing exactly where the names
+ a reader came for should have been. */}
+
+
+
+ Know Your Candidates
+
+ {/* Only the empty states get a sentence. Where candidates have
+ answered, the roster underneath names both halves of the
+ ballot — "2 of 11 answered" was the same count, spelled out,
+ immediately above the list it was counting. */}
+ {respondents.length === 0 && (
+
+ {councilCandidates.length === 0
+ ? "No one has registered in this ward yet."
+ : "Nobody in this ward has answered yet. These are the questions we asked."}
+
+ )}
+ {councilCandidates.length > 0 && (
+
!surveyAnswers?.[candidate.key],
+ )}
+ election={election.slug}
+ race="councillor"
+ ward={ward.n}
+ wardName={ward.name}
+ />
+ )}
+
+
+
- {councilRaces.length === 0 && (
+ {councilCandidates.length === 0 ? (
+ ) : (
+ councilRaces.map((race) => (
+
+ ))
)}
- {councilRaces.map((race) => (
-
- {showRaceHeadings && }
- {race.candidates.length === 0 ? (
-
- ) : (
- race.candidates.map((cand) => (
-
- ))
- )}
-
- ))}
-
-
- Registered candidates from the City Clerk’s list. The field is
- not final until nominations close
- {nominationCloseLabel ? ` on ${nominationCloseLabel}` : ""}.
-
+
+
+ Registered candidates from the City Clerk’s list, less
+ anyone who has withdrawn. The field is not final until nominations
+ close{nominationCloseLabel ? ` on ${nominationCloseLabel}` : ""}.
+
+
{/* ── School board races ─────────────────────────────── */}
@@ -215,6 +287,83 @@ export function WardDetail({
);
}
+/**
+ * One race's questionnaire, question by question.
+ *
+ * One per race rather than one for the ward, because a ward can elect more
+ * than one councillor — Brampton's wards elect a city and a regional
+ * councillor — and two rival fields read together would compare candidates who
+ * are not running against each other.
+ *
+ * A race nobody answered has no questions to draw, since the questions come
+ * from the returned questionnaires. That case still names the candidates: they
+ * are on the ballot, and the page is now the only place that says so.
+ */
+function RaceQuestionnaire({
+ race,
+ surveyAnswers,
+ surveyShape,
+ showHeading,
+ issuesHref,
+}: {
+ race: RaceView;
+ surveyAnswers?: Record
;
+ surveyShape?: ComparedGroup[];
+ showHeading: boolean;
+ issuesHref?: string;
+}) {
+ /* Two lists, not one. The candidates who wrote back are the ones the
+ questions can group, and the rest are named beside them — a reader can
+ still see everyone on their ballot and go to their site, without a
+ ward's dozen registrants turning thirty questions into three hundred
+ cells of "did not respond". */
+ const roster = surveyRoster(
+ race.candidates.map((candidate) => ({
+ ...candidate,
+ // "" for most of the ballot; the grid only draws the row when something
+ // in it is non-empty, so pass through rather than filtering here.
+ bio: candidate.bio || undefined,
+ })),
+ surveyAnswers,
+ );
+ const answered = roster.filter((candidate) => candidate.answers);
+ const silent = roster.filter((candidate) => !candidate.answers);
+ const groups = comparedQuestions(
+ answered.map((candidate) => candidate.answers!),
+ answered,
+ surveyShape,
+ );
+
+ return (
+
+ {showHeading &&
}
+
+ {groups.length > 0 ? (
+
+
+
+ ) : (
+ /* Only two ways to get here now: nobody has filed for the seat, or
+ the questionnaire itself could not be fetched. Either way there is
+ no grid to draw, and the candidates are still worth naming. */
+
+ {roster.length === 0
+ ? "No one has filed for this seat yet."
+ : `On the ballot, and yet to respond to us: ${roster
+ .map((candidate) => candidate.name)
+ .join(", ")}.`}
+
+ )}
+
+
+ );
+}
+
function RaceHeading({ race }: { race: RaceView }) {
return (
@@ -251,6 +400,8 @@ function EmptyRace({
);
}
+/* A candidate card, which only the school-board races use now: the council
+ ballot is the questionnaire grid, and trustees have no questionnaire. */
function CouncilCandidate({
candidate,
election,
@@ -266,53 +417,55 @@ function CouncilCandidate({
}) {
return (
-
- {candidate.image ? (
-
- ) : (
- candidate.initials
- )}
-
-
-
-
- {candidate.name}
-
- {candidate.tag === "Incumbent" &&
}
- {candidate.withdrawn && (
-
- Withdrawn
-
+
+
+ {candidate.image ? (
+
+ ) : (
+ candidate.initials
)}
- {candidate.bio && (
-
- {candidate.bio}
-
- )}
-
-
-
+
+
+
+ {candidate.name}
+
+ {candidate.tag === "Incumbent" && }
+ {candidate.withdrawn && (
+
+ Withdrawn
+
+ )}
+
+ {candidate.bio && (
+
+ {candidate.bio}
+
+ )}
+
+
+
+
);
diff --git a/src/components/elections/WardLookup.tsx b/src/components/elections/WardLookup.tsx
index 3d6abc12..32ede208 100644
--- a/src/components/elections/WardLookup.tsx
+++ b/src/components/elections/WardLookup.tsx
@@ -1,6 +1,8 @@
"use client";
import { useState, type ReactNode } from "react";
+import { ArrowRight } from "lucide-react";
+
import type { WardView } from "@/lib/elections/election-data";
import type { WardLookupResponse } from "@/lib/elections/ward-lookup";
@@ -11,14 +13,25 @@ type State =
| { status: "failed" };
/**
- * Postal code → ward lookup for the wards section. The result is a best guess
- * — postal centroids sit off-line near ward boundaries — so it reads as "looks
- * like Ward 19", always offers the full ward list beside it, and never
- * navigates on its own. See docs/WARD_LOOKUP_API_SPEC.md.
+ * Postal code → ward, for the wards section.
+ *
+ * THE CONTROL IS ONE OBJECT
+ * It was a labelled field and a detached button, each with its own border
+ * and its own height, sitting on a baseline they only roughly shared. Two
+ * boxes with a gap between them read as two controls, and the gap is where
+ * the roughness was. They are one bordered box now, split by a single rule:
+ * type on the left, act on the right, one outline around the pair that takes
+ * the focus ring for both.
+ *
+ * IT IS A GUESS, AND SAYS SO
+ * A postal code's stored point is the centroid of its delivery area, so a
+ * code sitting on a ward line resolves to the neighbour. It reads as "looks
+ * like Ward 19", never navigates on its own, and always offers the full list
+ * beside itself. See docs/WARD_LOOKUP_API_SPEC.md.
*
* `cards` holds this region's ward tiles pre-rendered on the server, keyed by
* ward number, because the tile's locator map is server-rendered geometry that
- * can't be built here.
+ * cannot be built here.
*/
export default function WardLookup({
wards,
@@ -32,6 +45,7 @@ export default function WardLookup({
}) {
const [postalCode, setPostalCode] = useState("");
const [state, setState] = useState
({ status: "idle" });
+ const busy = state.status === "loading";
const handleSubmit = async (event: React.FormEvent) => {
event.preventDefault();
@@ -54,12 +68,20 @@ export default function WardLookup({
};
return (
-