Skip to content
Merged
15 changes: 0 additions & 15 deletions next.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -124,21 +124,6 @@ const nextConfig: NextConfig = {
destination: "/vote",
permanent: false,
},
// /issues and /mayor are nothing but the candidates' answers, which are
// hidden until the questionnaire launches (`questionnaireHidden` in the
// election registry). Both pages stay in the repo as built; each lands
// on the nearest ballot instead. Temporary, so a 307: a 308 would sit in
// a reader's browser and skip the server after launch.
{
source: "/toronto/vote/2026/issues",
destination: "/toronto/vote/2026",
permanent: false,
},
{
source: "/toronto/vote/2026/mayor",
destination: "/toronto/vote/2026/mayor/candidates",
permanent: false,
},
// Toronto's get-involved page is switched off. It stays in the repo but
// sends people to the election landing instead. The legacy /elections
// shape gets its own rule so it lands there directly rather than
Expand Down
10 changes: 0 additions & 10 deletions src/app/api/elections/candidate-responses/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,6 @@ import {
} from "@/lib/elections/candidate-responses";
import {
DEFAULT_ELECTION_SLUG,
getElection,
isSupportedElection,
} from "@/lib/elections/registry";
import {
Expand Down Expand Up @@ -54,15 +53,6 @@ export async function GET(req: NextRequest) {
return NextResponse.json({ error: "Invalid ward" }, { status: 400 });
}

/* The answers are held back everywhere, and "everywhere" has to include the
door the pages do not come through. This is a read proxy for exactly the
answers the ward and mayoral pages have stopped drawing, and left open it
would serve the whole comparison as JSON to anyone who asked. Its only
caller is the survey page, which is closed too. */
if (getElection(election).questionnaireHidden) {
return NextResponse.json({ error: "Not found" }, { status: 404 });
}

const toronto = election === TORONTO_2026_SLUG;
const wardToken = ward.padStart(2, "0");

Expand Down
37 changes: 37 additions & 0 deletions src/app/globals.css
Original file line number Diff line number Diff line change
Expand Up @@ -575,3 +575,40 @@ body {
.memo-print-logo {
filter: invert(1);
}

/* ─── Expandable candidate answers ───
The questionnaire rows on the ward and mayoral pages are <details>, so what
a candidate wrote opens in place. Native <details> snaps, which on a card of
fourteen rows gives no sense of which row grew — the page just jumps.

Animated with `::details-content` rather than a script, so the rows stay
server-rendered and still work with JavaScript off. `interpolate-size` is
what makes a transition to `block-size: auto` possible at all; it is set
here rather than on :root because it inherits, and enabling keyword
interpolation for the whole document would quietly change every other
transition that lands on an intrinsic size.

Browsers without `::details-content` ignore all of this and open the way
they always did. */
.answer-reveal {
interpolate-size: allow-keywords;
}

.answer-reveal::details-content {
block-size: 0;
overflow: hidden;
}

.answer-reveal[open]::details-content {
block-size: auto;
}

/* The global reduced-motion rule reaches `*`, `::before` and `::after`, which
does not include `::details-content` — so this one guards itself. */
@media (prefers-reduced-motion: no-preference) {
.answer-reveal::details-content {
transition:
block-size 220ms ease,
content-visibility 220ms allow-discrete;
}
}
122 changes: 106 additions & 16 deletions src/app/toronto/vote/2026/candidates/[candidate]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,15 +2,23 @@ import type { Metadata } from "next";
import Image from "next/image";
import Link from "next/link";
import { notFound } from "next/navigation";
import { ArrowLeft, ArrowUpRight } from "lucide-react";
import { ArrowLeft, ArrowRight, ArrowUpRight } from "lucide-react";

import { CandidateSiteLink } from "@/components/elections/CandidateSiteLink";
import {
QuestionnaireCards,
questionnaireHeadings,
} from "@/components/elections/QuestionnaireCards";
import { QuestionnaireRail } from "@/components/elections/QuestionnaireRail";
import CountdownDays from "@/components/elections/CountdownDays";
import { IncumbentBadge } from "@/components/elections/ElectionLanding";
import { BIO_QUESTION_ID } from "@/lib/elections/candidate-answers";
import {
BIO_QUESTION_ID,
comparedQuestions,
} from "@/lib/elections/candidate-answers";
import { rosterSurvey } from "@/lib/elections/survey-answers";
import { daysUntil } from "@/lib/elections/dates";
import { firstName } from "@/lib/elections/names";
import { firstName, possessive } from "@/lib/elections/names";
import type { CandidateProfile, RaceView } from "@/lib/elections/election-data";
import {
ELECTION,
Expand Down Expand Up @@ -95,7 +103,7 @@ export async function generateMetadata({

return {
title: `${name} — ${race}`,
description: `${name} is a registered candidate for ${race} in Toronto's October 26, 2026 municipal election — the ward they are standing in, and how to reach their campaign.`,
description: `${name} is a registered candidate for ${race} in Toronto's October 26, 2026 municipal election. Their campaign site, and how they answered our questionnaire.`,
alternates: { canonical: `${ELECTION.basePath}/candidates/${slug}` },
openGraph: {
title: `${name} — Toronto 2026 Election`,
Expand All @@ -119,14 +127,14 @@ export default async function CandidatePage({
const race = races[0];

/* The one-candidate case of what every roster page does: the questionnaire
is fetched for the whole election and narrowed to this candidate by key. A
missing questionnaire costs the prose, not the page.

Only the prose is read off it. The answers are not published yet, so the
page has no cards to draw — what it still wants is the bio a candidate
wrote in the questionnaire's own words, which `rosterSurvey` is left
holding while `questionnaireHidden` is set. */
const { written } = await rosterSurvey(ELECTION.slug, new Set([candidate.key]));
is fetched for the whole election — the counts beside each answer are the
field's split — and narrowed to this candidate by key. A missing
questionnaire costs the answers, not the page. */
const { answers, written } = await rosterSurvey(
ELECTION.slug,
new Set([candidate.key]),
);
const surveyAnswers = answers[candidate.key];

/* What the candidate wrote, as against what they picked.
55 of the 58 candidates who returned the questionnaire wrote a bio in it,
Expand All @@ -144,6 +152,28 @@ export default async function CandidatePage({
(entry) => entry.questionId !== BIO_QUESTION_ID,
);

/* The ward pages' cards, given a roster of one.
`comparedQuestions` is the same pivot a ward runs — question first, the
candidates filed under the answer they gave — so this page's cards are
literally the ward's cards with a field of one person in them. A card
therefore shows the one option this candidate picked, in the option's own
colour, with their note printed in the open underneath their name plate.
What it cannot show is the split, since the other candidates are not in
the roster; "How the whole city answered" at the foot is the way to it. */
const roster = [
{
key: candidate.key,
name: candidate.name,
website: candidate.website,
bio: candidate.bio || undefined,
image: candidate.image,
initials: candidate.initials,
},
];
const groups = surveyAnswers
? comparedQuestions([surveyAnswers], roster)
: [];

const raceKind = profile.officeTypes[0] === "mayor" ? "mayor" : profile.officeTypes[0] === "trustee" ? "trustee" : "councillor";

/* Where the rest of this candidate's ballot line is — the ward page for a
Expand Down Expand Up @@ -422,6 +452,60 @@ export default async function CandidatePage({
</section>
)}

{/* ── Questionnaire ──────────────────────────────────── */}
<section className="px-6 md:px-14 py-9">
<p className="type-label text-accent mb-3">Our questionnaire</p>
<h2 className="font-sans font-medium leading-[1.05] tracking-[-0.03em] text-[clamp(1.6rem,2.6vw,2.1rem)] mb-3">
{surveyAnswers
? `Where ${candidate.name} stands`
: "Yet to answer"}
</h2>

{surveyAnswers ? (
<>
<p className="font-serif text-[1.05rem] leading-[1.5] text-dark/85 max-w-[62ch] text-pretty mb-8">
{possessive(candidate.name)} own answers to the questions we put
to every candidate, published as given — including, where they
wrote one, their reasoning in their own words.
</p>
<QuestionnaireRail headings={questionnaireHeadings(groups)}>
<QuestionnaireCards
groups={groups}
respondents={roster}
silent={[]}
issuesHref={`${ELECTION.basePath}/issues`}
/* Printed, not hidden behind a disclosure. That control
is there to keep a field of four legible across
thirty-three cards; here the field is one person, so
every card holds a single row and there is no split to
read down it. What the card has to say IS the writing,
and a control whose only use is to hide the thing this
URL was opened for is not one worth printing. */
printWriting
answerNote={
`Each card is one question, with ${candidate.name} filed ` +
"under the answer they gave and their own words underneath " +
"it. The other options offered are not shown, nor how the " +
"rest of the field answered."
}
/>
</QuestionnaireRail>
</>
) : (
<p className="font-serif text-[1.05rem] leading-[1.5] text-dark/85 max-w-[62ch] text-pretty">
{candidate.name} has not returned our questionnaire. We publish
answers as they arrive, so check back — and{" "}
<Link
href={`${ELECTION.basePath}/issues`}
className="text-accent hover:underline"
>
see where the rest of the field stands
</Link>{" "}
in the meantime.
</p>
)}
</section>

{/* ── Source note ────────────────────────────────────── */}
<section className="px-6 md:px-14 py-4 border-t-2 border-dark">
<p className="type-label-sm text-text-muted max-w-[80ch] text-pretty">
Expand All @@ -432,10 +516,7 @@ export default async function CandidatePage({
</section>

{/* ── Elsewhere ──────────────────────────────────────── */}
{/* The rest of this candidate's ballot line, and nothing else. The
second way out of here was the field read question by question,
which is switched off with the rest of the questionnaire. */}
<section className="border-t border-dark">
<section className="border-t border-dark grid md:grid-cols-2">
<Link
href={ballotHref}
className="group px-6 md:px-14 py-6 flex items-center gap-2.5 transition-colors hover:bg-linen-50"
Expand All @@ -445,6 +526,15 @@ export default async function CandidatePage({
{ballotLabel}
</span>
</Link>
<Link
href={`${ELECTION.basePath}/issues`}
className="group px-6 md:px-14 py-6 flex items-center justify-between gap-4 border-t md:border-t-0 md:border-l border-border-light transition-colors hover:bg-linen-50"
>
<span className="font-sans font-medium text-[1.15rem] tracking-[-0.015em]">
Where the whole field stands
</span>
<ArrowRight className="size-4 flex-none text-text-secondary transition-transform group-hover:translate-x-0.5" />
</Link>
</section>
</div>
</div>
Expand Down
Loading
Loading