diff --git a/next.config.ts b/next.config.ts index d5314e3..e21945e 100644 --- a/next.config.ts +++ b/next.config.ts @@ -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 diff --git a/src/app/api/elections/candidate-responses/route.ts b/src/app/api/elections/candidate-responses/route.ts index 9b98cb7..72cb1e4 100644 --- a/src/app/api/elections/candidate-responses/route.ts +++ b/src/app/api/elections/candidate-responses/route.ts @@ -6,7 +6,6 @@ import { } from "@/lib/elections/candidate-responses"; import { DEFAULT_ELECTION_SLUG, - getElection, isSupportedElection, } from "@/lib/elections/registry"; import { @@ -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"); diff --git a/src/app/globals.css b/src/app/globals.css index fa23adb..459b5e7 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -575,3 +575,40 @@ body { .memo-print-logo { filter: invert(1); } + +/* ─── Expandable candidate answers ─── + The questionnaire rows on the ward and mayoral pages are
, so what + a candidate wrote opens in place. Native
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; + } +} diff --git a/src/app/toronto/vote/2026/candidates/[candidate]/page.tsx b/src/app/toronto/vote/2026/candidates/[candidate]/page.tsx index 5b19d14..fd2d741 100644 --- a/src/app/toronto/vote/2026/candidates/[candidate]/page.tsx +++ b/src/app/toronto/vote/2026/candidates/[candidate]/page.tsx @@ -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, @@ -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`, @@ -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, @@ -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 @@ -422,6 +452,60 @@ export default async function CandidatePage({ )} + {/* ── Questionnaire ──────────────────────────────────── */} +
+

Our questionnaire

+

+ {surveyAnswers + ? `Where ${candidate.name} stands` + : "Yet to answer"} +

+ + {surveyAnswers ? ( + <> +

+ {possessive(candidate.name)} own answers to the questions we put + to every candidate, published as given — including, where they + wrote one, their reasoning in their own words. +

+ + + + ) : ( +

+ {candidate.name} has not returned our questionnaire. We publish + answers as they arrive, so check back — and{" "} + + see where the rest of the field stands + {" "} + in the meantime. +

+ )} +
+ {/* ── Source note ────────────────────────────────────── */}

@@ -432,10 +516,7 @@ export default async function CandidatePage({

{/* ── Elsewhere ──────────────────────────────────────── */} - {/* The rest of this candidate's ballot line, and nothing else. The - second way out of here was the field read question by question, - which is switched off with the rest of the questionnaire. */} -
+
+ + + Where the whole field stands + + +
diff --git a/src/app/toronto/vote/2026/issues/page.tsx b/src/app/toronto/vote/2026/issues/page.tsx index 033b40d..beb9e9c 100644 --- a/src/app/toronto/vote/2026/issues/page.tsx +++ b/src/app/toronto/vote/2026/issues/page.tsx @@ -8,7 +8,7 @@ import { } from "@/components/elections/QuestionnaireCards"; import { QuestionnaireRail } from "@/components/elections/QuestionnaireRail"; import { SurveyCta } from "@/components/elections/SurveyCta"; -import { ANSWERS_WITHHELD, surveyHref } from "@/lib/elections/registry"; +import { surveyHref } from "@/lib/elections/registry"; import CountdownDays from "@/components/elections/CountdownDays"; import { fieldSentiment } from "@/lib/elections/field-sentiment"; import { @@ -64,18 +64,11 @@ 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, published] = await Promise.all([ + const [survey, responses] = await Promise.all([ fetchSurvey(ELECTION.slug, CANDIDATE_QUESTIONNAIRE_SLUG).catch(() => null), fetchCandidateResponses(ELECTION.slug), ]); - /* Held back at the top of the page rather than at each place that draws - them — see `questionnaireHidden` in the registry. This page is nothing but - the answers, so emptying the array empties the page; what is left is the - masthead saying so. */ - const withheld = ELECTION.questionnaireHidden ?? false; - const responses = withheld ? [] : published; - /* `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 @@ -139,44 +132,45 @@ export default async function IssuesPage() { {/* ── Hero ───────────────────────────────────────────── */}
-

The whole field

-

- Where the candidates stand -

-

- {withheld ? ( - <> - This page reads the whole field’s answers across every - issue we asked about. - - ) : ( - <> - 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. - - )} -

+ {/* The ask beside the title rather than only at the foot of the + page. This page is thirty-three cards long and a reader who + stops halfway never reaches the band at the bottom — and the + question it asks, where do you stand, is the one the whole page + is trying to provoke. Beside the heading is the slot SurveyCta + was drawn for, which is how it sits on the ward pages too. */} +
+
+

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. +

+
+ + {/* Nothing here while the survey is closed — `surveyHref` returns + no path to link, so the column collapses and the title keeps + the full width. */} + {surveyInvite && } +
{/* ── Key stats ──────────────────────────────────────── */} - {/* Every one of these four counts the answers, so with them withheld - the row is four zeros — a page-wide claim that nobody answered - anything. It goes rather than lies. */} - {!withheld && ( -
- - - - -
- )} +
+ + + + +
{/* ── The field, question by question ────────────────── */} {groups.length > 0 && respondents.length > 0 ? ( @@ -210,9 +204,8 @@ export default async function IssuesPage() { ) : (

- {withheld - ? ANSWERS_WITHHELD - : "No candidate answers have been published yet. Responses appear here as they are reviewed and released."} + No candidate answers have been published yet. Responses appear + here as they are reviewed and released.

)} @@ -253,34 +246,29 @@ export default async function IssuesPage() { )} {/* ── Method ─────────────────────────────────────────── */} - {/* How to read cards that are not on the page is not method, it is - noise — and the second paragraph explains where the notes went, - which is a distinction with nothing to draw it between. */} - {!withheld && ( -
-

- Each card is one question, drawn as the share of the field that - gave each answer. The options are listed in full under the band, - in the wording the candidates were shown, with the number who - chose each. Options nobody picked are not shown, and a candidate - who answered in their own words is counted in the unshaded segment - rather than on any option. Shares are of the candidates who - answered that particular question, not of the whole field — a - questionnaire can come back half filled in, so the number behind a - card is the counts in its own legend added up. Hover or select any - answer to see the candidates who gave it, with the seat each is - running for. -

-

- The note most candidates wrote to explain their answer lives on - the ward and mayoral pages — thirty notes under every question is - more reading than this page can carry, and it is on those pages - that a reader has a ballot to weigh them against. Answers appear - as candidates return the questionnaire and staff review them, so - the field shown here grows through the campaign. -

-
- )} +
+

+ Each card is one question, drawn as the share of the field that + gave each answer. The options are listed in full under the band, + in the wording the candidates were shown, with the number who chose + each. Options nobody picked are not shown, and a candidate who + answered in their own words is counted in the unshaded segment + rather than on any option. Shares are of the candidates who + answered that particular question, not of the whole field — a + questionnaire can come back half filled in, so the number behind a + card is the counts in its own legend added up. Hover or select any + answer to see the candidates who gave it, with the seat each is + running for. +

+

+ The note most candidates wrote to explain their answer lives on + the ward and mayoral pages — thirty notes under every question is + more reading than this page can carry, and it is on those pages + that a reader has a ballot to weigh them against. Answers appear as + candidates return the questionnaire and staff review them, so the + field shown here grows through the campaign. +

+
{/* ── Elsewhere ──────────────────────────────────────── */}
diff --git a/src/app/toronto/vote/2026/mayor/candidates/page.tsx b/src/app/toronto/vote/2026/mayor/candidates/page.tsx index 1075617..d21a78e 100644 --- a/src/app/toronto/vote/2026/mayor/candidates/page.tsx +++ b/src/app/toronto/vote/2026/mayor/candidates/page.tsx @@ -8,6 +8,7 @@ import { CandidateNameLink } from "@/components/elections/CandidateNameLink"; import CountdownDays from "@/components/elections/CountdownDays"; import { surveyRoster } from "@/lib/elections/candidate-answers"; import { daysUntil } from "@/lib/elections/dates"; +import { rosterSurvey } from "@/lib/elections/survey-answers"; import type { CandidateView } from "@/lib/elections/election-data"; import { ELECTION, getToronto2026 } from "../../data"; @@ -25,14 +26,14 @@ import { ELECTION, getToronto2026 } from "../../data"; * questions by every column — which answers "what did they say" and never * answers "who is running". A reader who wants the ballot got a grid. * - * ONE LIST, IN SURNAME ORDER - * The field used to split in two — the candidates who returned our - * questionnaire and the candidates who had not — which is the most useful - * sort available while those answers are published. They are not, until the - * questionnaire launches, so the split would be a scoreboard reading nil-all - * and every word of it our doing. Flat, the page is what it says it is: - * everyone running, in surname order, because the alternative is a ranking - * nobody asked us to make. + * 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 @@ -42,12 +43,12 @@ import { ELECTION, getToronto2026 } from "../../data"; export const metadata: Metadata = { title: "Every candidate for Mayor of Toronto", description: - "The full field for Mayor of Toronto in the October 26, 2026 election: every registered candidate, in surname order, with their campaign site.", + "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: everyone registered to run for the city's top job.", + "The full field for Mayor of Toronto: who is running, and who has told us where they stand.", type: "website", }, }; @@ -55,10 +56,18 @@ export const metadata: Metadata = { export default async function MayoralCandidatesPage() { const view = await getToronto2026(); - /* The ballot, in surname order. `surveyRoster` with no answers to sort by is - exactly that — it is the same call the ward pages make, and the ordering - is the part of it this page still needs. */ - const roster = surveyRoster(view.mayoral); + /* 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; @@ -85,13 +94,17 @@ export default async function MayoralCandidatesPage() {

The one race every Toronto voter votes in, and the longest ballot in - the city. {roster.length} candidates have registered. + 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 ──────────────────────────────────────── */} -
+
+
- {/* ── The ballot ────────────────────────────────────── */} - {roster.length > 0 && ( + {/* ── Answered ───────────────────────────────────────── */} + {answered.length > 0 && ( +
+ + +
+ )} + + {/* ── Yet to respond ─────────────────────────────────── */} + {quiet.length > 0 && (
- +
)} @@ -142,13 +171,19 @@ export default async function MayoralCandidatesPage() {
{/* ── Elsewhere ──────────────────────────────────────── */} - {/* One way on, not two: the other was the field read question by - question, which is switched off with the rest of the - questionnaire. */} -
+
+ + + Where the whole field stands + + + diff --git a/src/app/toronto/vote/2026/mayor/page.tsx b/src/app/toronto/vote/2026/mayor/page.tsx index a917591..8ec4ad8 100644 --- a/src/app/toronto/vote/2026/mayor/page.tsx +++ b/src/app/toronto/vote/2026/mayor/page.tsx @@ -9,7 +9,7 @@ import { } from "@/components/elections/QuestionnaireCards"; import { QuestionnaireRail } from "@/components/elections/QuestionnaireRail"; import { SurveyCta } from "@/components/elections/SurveyCta"; -import { ANSWERS_WITHHELD, surveyHref } from "@/lib/elections/registry"; +import { surveyHref } from "@/lib/elections/registry"; import CountdownDays from "@/components/elections/CountdownDays"; import { byCandidateKey, @@ -68,19 +68,12 @@ export const metadata: Metadata = { }; export default async function MayorPage() { - const [view, survey, published] = await Promise.all([ + const [view, survey, responses] = await Promise.all([ getToronto2026(), fetchSurvey(ELECTION.slug, CANDIDATE_QUESTIONNAIRE_SLUG).catch(() => null), fetchCandidateResponses(ELECTION.slug), ]); - /* Held back at the top of the page rather than at each place that draws - them — see `questionnaireHidden` in the registry. Everything downstream is - derived from this array, so emptying it here is what guarantees no answer - reaches the markup by a route nobody remembered to check. */ - const withheld = ELECTION.questionnaireHidden ?? false; - const responses = withheld ? [] : published; - /* The ballot line, and the part of it that wrote back. The whole election's responses come back from one fetch — the counts a @@ -135,11 +128,9 @@ export default async function MayorPage() { How the mayoral field answered

- {withheld - ? `${registered} candidates have registered for the race. The ballot is below.` - : 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.`} + {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.`}

{/* ── Key stats ──────────────────────────────────────── */} - {/* Two of these four count the answers, and while those are withheld - both would read zero — which is not a smaller version of the truth, - it is a different claim: that nobody answered. So the row drops to - what it can still say honestly, the ballot and the clock. */} -
- {!withheld && } +
+ - {!withheld && } +
{/* ── The field, question by question ────────────────── */} - {/* The ballot outlives the answers. A page about the mayoral race that - names nobody in it is no use to a reader who came with a name in - mind, and who is running is a fact about the election rather than - anything a candidate told us. */} - {withheld ? ( -
-

- {ANSWERS_WITHHELD} -

- -
- ) : groups.length > 0 && mayoral.length > 0 ? ( + {groups.length > 0 && mayoral.length > 0 ? (
{/* THE WHOLE BALLOT, ONCE @@ -221,6 +187,17 @@ export default async function MayorPage() { groups={groups} respondents={mayoral} silent={[]} + /* Only the sitting mayor gets a line under their name. The + other mayoral tags — "Declared", "Exploratory" — say where + a campaign is in its own life rather than what the reader + is choosing between, and printed under fourteen names on + thirty-three cards they would be noise. */ + roles={Object.fromEntries( + ballot + .filter((candidate) => candidate.tag === "Incumbent") + .map((candidate) => [candidate.key, "Incumbent"]), + )} + ballotSize={registered} issuesHref={`${ELECTION.basePath}/issues`} /> @@ -255,18 +232,14 @@ export default async function MayorPage() { {/* ── Method ─────────────────────────────────────────── */}
- {/* How to read cards that are not on the page is not method, it - is noise. */} - {!withheld && ( -

- 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. -

- )} +

+ 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 diff --git a/src/app/toronto/vote/2026/page.tsx b/src/app/toronto/vote/2026/page.tsx index 35791bf..1ecde69 100644 --- a/src/app/toronto/vote/2026/page.tsx +++ b/src/app/toronto/vote/2026/page.tsx @@ -32,6 +32,7 @@ export default async function Toronto2026ElectionPage() { - Toronto votes Monday, October 26. Put your name on the record, then - find out who is running in your ward. + 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. Until the - questionnaire is published that is the mayoral ballot: fifty-odd - names, on a page of their own because the field is too long to - print here. ElectionLanding supplies that card off - `mayorRosterPath`, so this list is empty rather than carrying the - two questionnaire reads it used to — the field read question by - question, and the mayoral field's own answers — which are both - switched off (see next.config.ts). + 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 pledge from the closing - band, and the wards from the ward grid two hundred pixels below. */ + 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/wards/[ward]/page.tsx b/src/app/toronto/vote/2026/wards/[ward]/page.tsx index 15586ce..9eea248 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"; import { wardProfile } from "../../wardProfiles"; @@ -42,11 +43,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 ( - There is a fourth way. If you can’t get to a voting place - yourself, you can appoint another eligible Toronto voter as your - proxy — the form is certified by the City Clerk up to 4:30 - p.m. on election day, and on election day itself only at Toronto - City Hall.{" "} - - How voting by proxy works - - . -

+ {/* The rule and the gutters ride on the wrapper, the measure on the + paragraph. Together on one element, `max-w-[62ch]` bounded the + box the border belongs to, so the line closing the three-way grid + above stopped two thirds of the way across the page. */} +
+

+ There is a fourth way. If you can’t get to a voting place + yourself, you can appoint another eligible Toronto voter as your + proxy — the form is certified by the City Clerk up to 4:30 + p.m. on election day, and on election day itself only at Toronto + City Hall.{" "} + + How voting by proxy works + + . +

+
{/* ── Who's on the ballot ──────────────────────────────── */} diff --git a/src/components/charts/trilemma/OptionPie.tsx b/src/components/charts/trilemma/OptionPie.tsx new file mode 100644 index 0000000..abb617c --- /dev/null +++ b/src/components/charts/trilemma/OptionPie.tsx @@ -0,0 +1,144 @@ +'use client' + +/* One question's answers as a pie. + * + * The sibling of OptionBar, drawing the same data in the same hues: a slice an + * option, sized by how many candidates gave it. Same handlers, so whatever the + * bar was wired into a pie drops into. + * + * WHAT A PIE COSTS HERE, SO THE NEXT READER KNOWS + * A hundred-per-cent bar and a pie encode the same numbers, and the bar is + * the better of the two for this page: the questions run thirty-three cards + * deep and the reader's work is comparing one split against another, which + * is a comparison of aligned lengths on a bar and a comparison of angles + * across separate circles on a pie. Most of these questions are also an + * ordered scale — yes, yes-with-conditions, no — which a bar keeps in order + * along its length and a pie only keeps by convention. + * + * It is here because it was asked for, and because a pie does one thing + * better: a single card read on its own says "most of the field" without + * the reader measuring anything. + * + * SLICES ARE SEPARATED BY THE SURFACE, NOT BY A LINE + * Each slice is stroked in the page's own background at two pixels, so the + * gap between two fills is the card showing through rather than a border + * drawn over them. A hairline in some third colour would read as a fifth + * thing on a chart that has at most four. + */ + +export interface OptionPieProps { + /** The options exactly as they were offered — used for the description. */ + options: string[] + /** How many gave each option. Zero-count options are not drawn. */ + counts: number[] + /** One colour an option, by position. */ + colors: string[] + /** The option being shown, which keeps its fill while the rest recede. */ + highlight?: number | null + onSegmentEnter?: (index: number) => void + onSegmentLeave?: () => void + /** The question, for the accessible description. */ + label?: string + /** Drawn size in pixels. The viewBox is unit-square, so this only sets how + * much room it takes. */ + size?: number + className?: string +} + +/* Unit circle in a 100-box, inset by the stroke so the outer edge is not + shaved by its own two pixels. */ +const R = 49 +const CX = 50 +const CY = 50 + +/** A slice from `start` to `end`, in turns clockwise from twelve o'clock. */ +function slicePath(start: number, end: number): string { + const point = (turn: number) => { + const angle = (turn - 0.25) * 2 * Math.PI + return [CX + R * Math.cos(angle), CY + R * Math.sin(angle)] as const + } + const [x1, y1] = point(start) + const [x2, y2] = point(end) + const large = end - start > 0.5 ? 1 : 0 + return `M ${CX} ${CY} L ${x1} ${y1} A ${R} ${R} 0 ${large} 1 ${x2} ${y2} Z` +} + +export function OptionPie({ + options, + counts, + colors, + highlight = null, + onSegmentEnter, + onSegmentLeave, + label, + size = 148, + className, +}: OptionPieProps) { + const total = counts.reduce((sum, n) => sum + n, 0) + if (total <= 0) return null + + /* Each slice starts where everything before it ended, so they sit in the + options' own order — which for the ordered questions is the scale's order. + Summed per slice rather than carried in a running variable: there are + never more than four, and a value reassigned during a render is the one + thing the compiler will not have. */ + const slices = counts.map((count, index) => { + const before = counts + .slice(0, index) + .reduce((sum, n) => sum + n, 0) + const start = before / total + return { index, count, start, end: start + count / total } + }) + + const drawn = slices.filter((slice) => slice.count > 0) + /* One option took everything: an arc of a full turn has the same two end + points and renders as nothing, so it is a circle instead. */ + const whole = drawn.length === 1 ? drawn[0] : null + + const described = options + .map((option, i) => (counts[i] > 0 ? `${option}: ${counts[i]}` : null)) + .filter(Boolean) + .join('; ') + + return ( + + {whole ? ( + onSegmentEnter?.(whole.index)} + /> + ) : ( + drawn.map((slice) => ( + onSegmentEnter?.(slice.index)} + /> + )) + )} + + ) +} diff --git a/src/components/charts/trilemma/index.ts b/src/components/charts/trilemma/index.ts index 3e6ada5..7f325eb 100644 --- a/src/components/charts/trilemma/index.ts +++ b/src/components/charts/trilemma/index.ts @@ -3,9 +3,11 @@ export type { DialValues, TrilemmaDialProps } from './TrilemmaDial' export { TrilemmaDialGroup } from './TrilemmaDialGroup' export type { DialDatum, TrilemmaDialGroupProps } from './TrilemmaDialGroup' export { OptionBar } from './OptionBar' +export { OptionPie } from './OptionPie' export { WedgeGlyph } from './WedgeGlyph' export { percentOf } from './format' export type { OptionBarProps } from './OptionBar' +export type { OptionPieProps } from './OptionPie' export { bisectorPoint, kitePath, layoutDial, wedgeAngles, wedgePath } from './dial' export type { DialLayout, DialLayoutInput } from './dial' export { blendCorners } from './colour' diff --git a/src/components/elections/CandidatePortrait.tsx b/src/components/elections/CandidatePortrait.tsx new file mode 100644 index 0000000..83a4959 --- /dev/null +++ b/src/components/elections/CandidatePortrait.tsx @@ -0,0 +1,63 @@ +import Image from "next/image"; + +/* A candidate's face, or their initials where we have no picture. + * + * WHY A COMPONENT + * The mayoral card, the ward card and now the questionnaire's rows all print + * the same plate: a dark square, the photo cropped to fill it, the initials + * centred in it when there is no photo. Three copies of it had drifted only + * in the one way that matters — the `sizes` hint, which is what decides how + * large a file the browser actually fetches — so it is one component with the + * hint derived from the size rather than typed out beside it. + * + * WHY INITIALS AND NOT A BLANK + * Toronto's ballot is 388 names and we hold a picture for 44 of them, so on + * most lists most rows have nothing. A plate that appears only where there is + * a photo leaves the names starting in two different places down one column, + * which reads as a fault in the page rather than as a fact about the ballot. + * The monogram fills the slot, keeps the column straight, and says the same + * thing the empty plate would: we have no picture of this person. + */ + +/** The three plates this site prints, from the questionnaire's rows up to a + * ward card. Each carries its own type size and its own `sizes` hint. */ +const SIZES = { + sm: { box: "size-7", type: "text-[0.7rem]", hint: "28px" }, + md: { box: "size-12", type: "text-[1rem]", hint: "48px" }, + lg: { box: "size-16", type: "text-[1.35rem]", hint: "64px" }, +} as const; + +export function CandidatePortrait({ + candidate, + size = "md", + className = "", +}: { + candidate: { name: string; image?: string; initials?: string }; + size?: keyof typeof SIZES; + className?: string; +}) { + const { box, type, hint } = SIZES[size]; + + return ( +
+ {candidate.image ? ( + {candidate.name} + ) : ( + /* `aria-hidden`, because the name is always printed beside this. Read + out, "DB" before "Darrell Brown" is the name twice, the first time + spelled. */ + + )} +
+ ); +} diff --git a/src/components/elections/ElectionLanding.tsx b/src/components/elections/ElectionLanding.tsx index 0d70dfd..dd196a0 100644 --- a/src/components/elections/ElectionLanding.tsx +++ b/src/components/elections/ElectionLanding.tsx @@ -1,10 +1,10 @@ -import Image from "next/image"; import Link from "next/link"; import { Suspense, type ReactNode } from "react"; import { ArrowRight } from "lucide-react"; import CountdownDays from "./CountdownDays"; import LiveCountdown from "./LiveCountdown"; import { CandidateNameLink } from "./CandidateNameLink"; +import { CandidatePortrait } from "./CandidatePortrait"; import { PledgeButton } from "./PledgeButton"; import { SurveyCta } from "./SurveyCta"; import { ResidencyModal } from "./ResidencyModal"; @@ -99,6 +99,7 @@ export function ElectionLanding({ content, wardMapDefs, renderWardMap, + mayorSurveyPath, mayorRosterPath, surveyPath, electionDay, @@ -109,11 +110,14 @@ export function ElectionLanding({ /** 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 hands the list off to a card in the explore - * grid; 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. */ + * 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 @@ -128,29 +132,31 @@ export function ElectionLanding({ }) { /* 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 another page, which is + 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: the ballot for mayor. A field of fifty-odd names is a page of - its own rather than half the city's front page, and who is running is the - thing a reader arriving on this page wants from this race. + 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 - ? [ - { - eyebrow: "Mayor", - title: "Everyone running for mayor", - blurb: - "The one race every voter in the city votes in, and the longest ballot on the ledger.", - href: mayorRosterPath, - }, - ] - : []; + 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 @@ -236,6 +242,15 @@ export function ElectionLanding({ Candidates for Mayor
+ {mayorSurveyPath && ( + + How they answered our questionnaire + + + )} @@ -651,6 +666,11 @@ function ExploreSection({ * lands here whenever the mayoral cards do */ anchorCandidates?: boolean; }) { + /* Read off the cards rather than taken as a prop: the survey card IS the + invite, so the blurb and the grid cannot disagree about whether there is + one. */ + const invitesSurvey = items.some((item) => item.tone === "invite"); + return (
{anchorCandidates && ( @@ -664,13 +684,16 @@ function ExploreSection({

Explore the election

- {/* What the cards below actually go to, and nothing more. This used to - open "We put the same questions to every candidate on the ballot" — - a promise about answers, which is what the reader then went looking - for in a grid that has none of them until the questionnaire is - published. */} + {/* The second half of this is a promise about the survey, so it is + only made where there is a survey to make it about. Read under a + grid with no survey card in it, "then answer them yourself" sends + a reader hunting the page for something that is not on it. */}

- Every candidate on the ballot, race by race and ward by ward. + We put the same questions to every candidate on the ballot. See how + they answered + {invitesSurvey + ? " — then answer them yourself and find out who lines up with you." + : ", question by question and ward by ward."}

@@ -805,19 +828,7 @@ function MayoralCard({ }) { return (
-
- {candidate.image ? ( - {candidate.name} - ) : ( - candidate.initials - )} -
+ {/* The name is the link. It used to be plain text with "Campaign site" on the line beneath it, which spent a second line saying that the thing above it led somewhere — and led off the site. */} diff --git a/src/components/elections/QuestionRollCall.tsx b/src/components/elections/QuestionRollCall.tsx index 021a2bf..f29e32f 100644 --- a/src/components/elections/QuestionRollCall.tsx +++ b/src/components/elections/QuestionRollCall.tsx @@ -1,38 +1,52 @@ +import { MessageSquareText } from "lucide-react"; + +import { CandidatePortrait } from "./CandidatePortrait"; + import { rollCall } from "@/lib/elections/candidate-answers"; +import { lastName } from "@/lib/elections/names"; 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. +/* One question as a card: the candidates down it, and what each of them said. * * 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. + * A row a candidate, in surname order, and the answer beside them. The two + * things a reader does here are run down the names looking for one person, + * and run down the answers looking for the split — so both are columns, and + * a candidate sits in the same place on all thirty-three cards. + * + * This replaced a panel per answer with the people who gave it inside it. + * That read the split well and a single candidate badly: finding one + * person's position meant scanning every panel for their name, and the name + * landed somewhere different on every card. The split is still readable — it + * is the answer column, in the option's own colour, read downwards — and the + * whole-field version of it is what /issues draws. * - * 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. + * THE ANSWER IN FULL + * The column prints the answer as it was put to the candidates, not a + * handle for it. Most of these run to a phrase and some to ninety + * characters, so it is a block that wraps rather than a pill that cannot. + * On a page whose whole job is what a candidate said, the reader gets the + * sentence they actually endorsed. * - * 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. + * THE WRITING OPENS + * What a candidate wrote about their answer is behind the row rather than + * under it. It is the one thing on this card that is not simply printed, + * and it is what makes a table of thirty-three questions readable at all: a + * ward's respondents write a paragraph each. `
`, so it opens with + * JavaScript off and the card stays a server component. A candidate who + * wrote nothing gets no control, because an arrow onto nothing is worse + * than no arrow. * - * 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"). + * ONLY THE PEOPLE WHO ANSWERED + * A candidate who never returned the questionnaire has no row. A ward of + * ten with one respondent would otherwise be nine identical "did not + * respond" rows on each of thirty-three cards, and they are already named + * and linked once in the roster over the section. The line under the + * question says how many of the ballot the rows account for. * * COLOUR * Option position, from the ramps in lib/elections/option-colors — the same @@ -43,12 +57,29 @@ import type { * three-way one at a glance. */ +/* The row template, shared by the column heads and every row under them so + the two line up. + + The trailing `1rem` is the disclosure icon's column. A card that prints its + writing has no icon, so it drops the column rather than keeping a gutter + that nothing will ever sit in — which is also what puts the answer against + the right edge of the card instead of a rem short of it. */ +const COLUMNS = { + open: "grid-cols-[minmax(0,1fr)_1rem] @sm:grid-cols-[minmax(0,1fr)_minmax(0,1.5fr)_1rem] @2xl:grid-cols-[minmax(0,1fr)_minmax(0,22rem)_1rem]", + printed: + "grid-cols-[minmax(0,1fr)] @sm:grid-cols-[minmax(0,1fr)_minmax(0,1.5fr)] @2xl:grid-cols-[minmax(0,1fr)_minmax(0,22rem)]", +} as const; + export function QuestionRollCall({ question, silent = [], nameTheSilent = true, seats, + roles, + portraits, + ballotSize, notes = true, + printWriting = false, yourKey, headingId, }: { @@ -64,6 +95,28 @@ export function QuestionRollCall({ * 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; + /** what each candidate is on this ballot — "Incumbent", "Challenger" — + * keyed by candidate key. Toronto's council races are non-partisan, so + * there is no party to print under a name and this is the only standing a + * candidate has. Optional: a caller with nothing to say leaves the second + * line off the row rather than filling it. */ + roles?: Record; + /** each candidate's photograph and monogram, keyed by candidate key. + * + * A face is the fastest way to find one person in a list of thirty, and + * this table is the one place on the site that named candidates without + * showing them — the ward cards, the mayoral field and a candidate's own + * page all print the plate already. + * + * We hold a photograph for 44 of Toronto's 388 registrants, so on most + * rows this is the monogram; see CandidatePortrait for why that is a plate + * and not a blank. A caller that passes nothing gets the rows unchanged. */ + portraits?: Record; + /** how many candidates are on the ballot this table is drawn from, for the + * line that says how much of it answered. Only respondents get a row, so + * without it a reader cannot tell a ward where everyone answered from one + * where two people did. Omitted, the line is not printed. */ + ballotSize?: number; /** print what each candidate wrote about their own answer. * * A ward's four respondents leave four notes under a question and every @@ -78,6 +131,20 @@ export function QuestionRollCall({ * 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; + /** print each row's writing outright instead of putting it behind a + * disclosure. + * + * The disclosure earns its place on a page with a field in it: a ward's + * four respondents write a paragraph each, and thirty-three cards of + * paragraphs is the page this table replaced. A candidate's own page has a + * field of one — there is no split to read down and nothing to compare, so + * the writing is the whole of what the card has to say. + * + * So it is printed, not opened: no `
`, no summary, no icon. An + * open disclosure on every row is a control whose only remaining use is to + * hide the thing the reader came for, and thirty-three of them are + * thirty-three ways to make the page worse. */ + printWriting?: 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 @@ -98,69 +165,136 @@ export function QuestionRollCall({ const empty = groups.length === 0 && verbatim.length === 0; + /* One row a candidate, in surname order, whatever they answered. + + The card used to be the other way up: a panel per answer with the people + who gave it inside it. That reads the split at a glance, and it reads a + single candidate badly — to find out what one person said you scanned + every panel until you found their name, and the name you were looking for + sat in a different place on all thirty-three cards. A row apiece puts + every candidate in the same place on every card, and the answer beside + them in the same column, so a reader can run down either. */ + const rows: AnswerRow[] = [ + ...groups.flatMap((group) => + group.candidates.map((candidate) => ({ + ...candidate, + option: group.option, + answerLabel: group.detail || group.label, + })), + ), + /* On no option, because none of them fit what they wrote. The column says + so and their words are under it, rather than a choice they did not + make. */ + ...verbatim.map((candidate) => ({ + ...candidate, + option: null, + answerLabel: "In their own words", + })), + ].sort( + (a, b) => + lastName(a.name).localeCompare(lastName(b.name)) || + a.name.localeCompare(b.name), + ); + 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} -

+ /* `@container`, because the rows have to size against the card and not + against the window. The cards sit two to a row on a wide screen, so a + 1440px viewport gives a card about 460 pixels of inside — and a row + template keyed to the viewport would lay out three columns for a + thousand pixels in a card that has half that, leaving the name a + sliver. Asking the card how wide it is gets it right at both widths. */ + /* `content-start` is load-bearing, not tidiness. + + Two cards to a row means both are stretched to the height of the taller + one, and a grid container's `align-content` defaults to `normal`, which + behaves as `stretch`: its auto-sized rows grow to absorb whatever extra + height they are given. So opening a row in one card stretched its + neighbour, and the neighbour's heading, table and foot slid apart to + fill the space — a reader opening one answer watched an unrelated card + rearrange itself. Pinned to the start, the rows keep their own heights + and the slack collects at the bottom of the card where nobody sees it. + + The card was `flex flex-col` before it was a table, which is why this + never showed: a column flex container leaves its children alone. */ +
+
+

+ {question.question} +

+ + {/* How much of the ballot is in the table under this. + + Only the candidates who answered get a row, so without this line a + reader has no way to tell a ward where everyone answered from one + where two people did — the table looks the same, just shorter. It + is the one denominator on the card and it earns its place by + saying what is missing from the rows below it. */} + {ballotSize !== undefined && ballotSize > 0 && ( +

+ {rows.length} of {ballotSize}{" "} + {ballotSize === 1 ? "candidate" : "candidates"} responded +

+ )} +
{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 && ( - - )} -
+
+ {/* The column heads, once per card. They are what makes the two + runs read as columns rather than as a name with something after + it — and the third says the rows open, which an arrow alone + leaves a reader to discover. */} +
+ Candidate + Answer + {!printWriting && } +
+ +
    + {rows.map((row) => ( + + ))} +
+
)} - {/* 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. + {/* One line, not a row each. A row per absent name per question is + three hundred cells of nothing — the grid's problem, restated. - 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. */} + Who is on this line depends on what the caller passed as `silent`. + The race pages pass none, so it is the respondents who skipped this + particular question: people who did write back, and did not answer + this. That is per-question and worth a line. The ones who never + wrote back at all are named once by the roster over the section, + which links them too. + + `nameTheSilent` turns the line off entirely where nobody answered + anything, for callers that do pass a `silent` list. */} {unanswered.length > 0 && ( -

+

Did not answer{" "} {unanswered.map((candidate) => candidate.name).join(" · ")}

@@ -172,258 +306,216 @@ export function QuestionRollCall({ /** 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". */ + /** how the seat prints on a row — "Ward 9". Mayoral candidates carry none: + * the race they sit in is already the page. */ label?: string; }; -/* One answer, enclosed, with everyone who gave it inside it. +/** One candidate's answer to one question, as the table wants it. */ +type AnswerRow = RollCallName & { + /** their own words, where they answered in them rather than on an option */ + answer?: string; + /** which option they picked, or null for an answer in their own words */ + option: number | null; + /** the answer as it was put to them, which is what the column prints */ + answerLabel: string; +}; + +/* + * One row: who, what they answered, and what they wrote about 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({ + * The writing opens rather than printing, which is the one thing here that + * hides anything, and it is worth it: a ward's respondents write a paragraph + * each, and thirty-three cards of paragraphs is the page this table replaced. + * `
` and not a script — the row opens with JavaScript off, it is + * keyboard-operable for free, and the card stays a server component. + * + * A candidate who wrote nothing gets no control. An arrow that opens onto + * nothing is worse than no arrow. + */ +function AnswerRow({ + row, color, - label, - detail, - candidates, - seats, - notes = true, - yourKey, - muted = false, + muted, + seat, + role, + portrait, + notes, + printWriting, + you, }: { + row: AnswerRow; 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 */ + /** an answer on no option — set in the empty hue, and named rather than + * coloured in as a choice */ muted?: boolean; + seat?: string; + /** Incumbent, Challenger — what they are on this ballot. Toronto's council + * races carry no party, so this is the only standing a name has. */ + role?: string; + /** their face, or the monogram standing in for it — see CandidatePortrait */ + portrait?: { name: string; image?: string; initials?: string }; + notes: boolean; + /** print the writing rather than hiding it — see `printWriting` above */ + printWriting?: boolean; + /** the reader's own row, on the survey results */ + you?: 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); + /* What opens behind the row. The follow-up counts as much as a note: it is + an answer the questionnaire asked for, under the question that asked it. + `notes` gates both — the city-wide page files names and leaves the writing + to the pages whose field is small enough to read it. */ + const followUp = notes ? row.followUp : null; + const words = row.answer || (notes ? row.note : null) || followUp; - 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. */} -

    + {/* The plate and the name are one cell, so the name column stays a + column: the plate is a fixed 28px and the name takes what is left, + whatever the row's second line says. `items-start` rather than + centred — a row with a seat under the name is two lines tall, and + centring floated the plate between them instead of keeping it level + with the name it belongs to. */} + + {portrait && } + + + {row.name} + + {(seat || role) && ( + + {[seat, role].filter(Boolean).join(" · ")} + + )} + + + + {/* The answer as it was put to the candidates, in full. Most of these + are a phrase and not a word — "Concentrate growth on major streets + and near rapid transit" — so it is a block that wraps rather than a + pill that cannot, set in the option's own hue so the column can be + read down as a split. */} + - {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 && ( - + {row.answerLabel} + + + ); + + /* Stacked on a narrow screen and in columns from `cards` (612px) up: the + answer runs to ninety characters on some questions, and beside a name in + four hundred pixels that is a column of two or three words a line. */ + const grid = `grid ${ + printWriting ? COLUMNS.printed : COLUMNS.open + } gap-x-3 gap-y-1 @sm:items-start @sm:gap-x-4`; + + /* What the row has to say, once — printed under an open row and behind a + closed one, so the two arrangements can never drift apart. */ + const writing = words ? ( + <> + {(row.answer || (notes && row.note)) && ( +

    + {row.answer && <>“{row.answer}”} + {row.answer && notes && row.note && " "} + {notes && row.note} +

    )} - {races.length > 1 ? ( -
    - {races.map(([race, named]) => ( -
    -

    - {race === "mayor" ? "For mayor" : "For council"} -

    - -
    - ))} + {/* The follow-up, under its own prompt. Without the prompt the answer + to it is a number with nothing to measure — "12000" — and the prompt + is the question the candidate was actually answering, so it is + printed rather than paraphrased. */} + {followUp && ( +
    + + {followUp.question} + + + {followUp.text} +
    - ) : ( - field.length > 0 && ( - - ) )} -
  • - ); -} + + ) : null; -/* 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. - * - * Those written lines are ruled off from each other. Stacked, a plate, a - * paragraph, a plate and a paragraph run together into one column of prose - * with names in it, and the reader has to work out where one candidate stops - * and the next starts from the shape of the text. A hairline above each one - * after the first says it instead — faint enough to stay out of the way of - * the tinted panel it sits in, present enough that the block reads as a list - * of people rather than a passage. */ -function Plates({ - candidates, - seats, - notes, - color, - yourKey, -}: { - candidates: (RollCallName & { answer?: string })[]; - seats?: Record; - notes: boolean; - color: string; - yourKey?: string; -}) { - return ( -
      - {candidates.map((candidate, index) => { - const note = notes ? candidate.note : null; - const words = candidate.answer || note; - /* Not on the first entry in the run: a rule above the opening line - divides the names from the answer they are filed under, which is - the one break the panel already makes with its own heading. */ - const ruled = Boolean(words) && index > 0; - return ( -
    • - - {words && ( -

      - {candidate.answer && <>“{candidate.answer}”} - {candidate.answer && note && " "} - {note} -

      - )} -
    • - ); - })} -
    - ); -} + /* Printed. No `
    `, no summary, no icon — the row is a name, an + answer and what they wrote about it, in that order. + + The plate keeps the same grid as a disclosure row so it lines up under + the card's column heads, and the writing sits below it at full width. */ + if (writing && printWriting) { + return ( +
  • +
    {body}
    + {writing} +
  • + ); + } -/* 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 ( - - + ); } diff --git a/src/components/elections/QuestionSplitFigure.tsx b/src/components/elections/QuestionSplitFigure.tsx index c19f07e..3fc8ba2 100644 --- a/src/components/elections/QuestionSplitFigure.tsx +++ b/src/components/elections/QuestionSplitFigure.tsx @@ -2,30 +2,32 @@ import { useState } from "react"; -import { OptionBar, percentOf } from "@/components/charts/trilemma"; +import { OptionPie, percentOf } from "@/components/charts/trilemma"; -/* The interactive half of a QuestionSplit card: the band, the legend, and the +/* The interactive half of a QuestionSplit card: the pie, the legend, and the * panel of names behind each of them. * - * THE CHART IS OptionBar, FROM THE CHARTS PACKAGE - * This was a hand-drawn donut first, and a donut is the wrong chart for this - * page twice over. The page's work is done across thirty-odd cards at once — - * where does the field agree, where does it split — and a share read as a - * length against a common left edge can be compared between cards, where an - * angle cannot. And a good many of the questions are a straight Yes/No, - * which is the case a pie serves worst: 72/28 is plain in a band and a - * judgement call in a circle. + * THE CHART IS OptionPie, AND THIS IS THE SECOND TIME ROUND + * It was a hand-drawn donut first. That was replaced by OptionBar, a single + * 100% band, on an argument this file used to make at length and which has + * not stopped being true: the page's work is done across thirty-odd cards at + * once — where does the field agree, where does it split — and a share read + * as a length against a common left edge can be compared between cards, + * where an angle cannot. Twenty-six of the thirty-three questions are also + * an ordered scale, yes / yes-with-conditions / no, which a band keeps in + * order along its length. Five more have two options, where a pie is two + * slices and a number would have done. * - * OptionBar already draws exactly this — "one question's answers as a single - * 100% band" — with the fade-all-but-one behaviour the legend needs, so the - * only thing here is the legend and the panel behind it. + * It is a pie again because that was the call. Recorded rather than argued + * so that whoever weighs it next has the reasoning in front of them instead + * of rediscovering it: OptionBar is still exported and still takes these + * exact props, so going back is this component's import and its figure. * - * THE BAND CARRIES NO NUMBERS - * `showCounts` and `showLabels` are both off. The legend sits directly under - * the bar with every option's wording, count and share on it, so a segment - * printing its own share is the same figure twice, a centimetre apart — and - * the option names cannot fit under a narrow segment anyway, which is why - * OptionBar drops them. The bar is the shape; the legend is the key. + * THE PIE CARRIES NO NUMBERS + * The legend sits directly under it with every option's wording, count and + * share on it, so a slice printing its own share is the same figure twice, a + * centimetre apart — and an option wording here runs to ninety characters, + * which no slice can hold. The pie is the shape; the legend is the key. * * WHY THE NAMES ARE BEHIND SOMETHING * This page put every name under every question and the names were the @@ -70,7 +72,9 @@ import { OptionBar, percentOf } from "@/components/charts/trilemma"; */ /** The band's height, and so where the panel of names hangs from. */ -const BAR = 32; +/* The pie's drawn size. The names panel is positioned off it, so the two are + one constant rather than two that can drift. */ +const PIE = 148; export type SplitSlice = { key: string; @@ -122,7 +126,21 @@ export function QuestionSplitFigure({ return (
    { if (event.key === "Escape") close(); }} @@ -132,19 +150,22 @@ export function QuestionSplitFigure({ be. */ onMouseLeave={close} > - slice.label)} - counts={slices.map((slice) => slice.names.length)} - colors={slices.map((slice) => slice.color)} - onSegmentEnter={(i) => graze(slices[i].key)} - onSegmentLeave={() => graze(null)} - /* -1 from findIndex is "no option", which OptionBar spells null. */ - highlight={highlight < 0 ? null : highlight} - height={BAR} - responsive - showCounts={false} - label={question} - /> + {/* Centred, because a pie has no left edge to align to the way the bar + did — set flush left in a card this wide it read as an ornament + beside the legend rather than the figure the legend keys. */} +
    + slice.label)} + counts={slices.map((slice) => slice.names.length)} + colors={slices.map((slice) => slice.color)} + onSegmentEnter={(i) => graze(slices[i].key)} + onSegmentLeave={() => graze(null)} + /* -1 from findIndex is "no option", which the pie spells null. */ + highlight={highlight < 0 ? null : highlight} + size={PIE} + label={question} + /> +
    void return (
    void ? "1 candidate" : `${slice.names.length} candidates`}

    - {/* Columns, because a segment can hold forty people: in one run they are - a list taller than anything this panel can be allowed to be. Three - columns puts forty names in fourteen rows. + {/* A grid, not CSS columns. A segment can hold forty people and the + panel is capped at fourteen rem, and multi-column laid out inside a + capped box does not grow downwards — it fragments sideways, opening a + fourth and fifth column past the panel's right edge. So the overflow + ran horizontally while the scrolling was vertical, and the names in + those columns could not be reached at all. A grid fills rows + downwards, which is the direction this box scrolls. + + How many columns is a question about the panel's width and not the + window's: these cards sit two to a row on a wide screen, so the panel + is about four hundred and sixty pixels there and a viewport-keyed + third column would have squeezed every name onto two lines. Hence the + container query on the figure. Set small. A name here is a thing the reader scans for rather than - reads — they are looking for one they know, or counting how many of - a slice they recognise — and forty of them is a block that has to sit - under the chart without becoming the card. Smaller also buys the - columns their width back, which is what keeps a long name on one - line. */} -
      + reads — they are looking for one they know, or counting how many of a + slice they recognise — and forty of them is a block that has to sit + under the chart without becoming the card. */} +
        {slice.names.map((candidate) => (
      • {candidate.name} {candidate.seat && ( diff --git a/src/components/elections/QuestionnaireCards.tsx b/src/components/elections/QuestionnaireCards.tsx index 835de30..1b57e8b 100644 --- a/src/components/elections/QuestionnaireCards.tsx +++ b/src/components/elections/QuestionnaireCards.tsx @@ -43,12 +43,22 @@ import type { ComparedGroup } from "@/lib/elections/candidate-answers"; * as a share of the field. * * `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. + * Nobody who reads a card should have to wonder what the rest of the ballot + * said — but a name repeated under all thirty-odd questions says it thirty + * times and tells a reader once. A ward of one respondent and nine silent + * was printing the same nine names on every card: a hundred and forty + * characters, identical, thirty-three times, and the longest thing in most + * of those cards. + * + * So the race pages pass none, and the CandidateRoster over the section + * carries the fact instead — it names the same people, links each to their + * own page, and does it before the reader starts on the questions rather + * than at the foot of each one. What the cards still name is the other + * half of `unanswered`: a candidate who did write back and skipped this + * question. That one is per-question and genuinely news. + * + * The survey's own results pass their silent in, having no roster to hand + * the job to. */ /** The id a section heading answers to, and the one the rail scrolls at. */ @@ -85,7 +95,10 @@ export function QuestionnaireCards({ silent, issuesHref, seats, + roles, + ballotSize, notes = true, + printWriting = false, yourKey, idPrefix, answerNote, @@ -102,9 +115,20 @@ export function QuestionnaireCards({ * QuestionRollCall, and QuestionSplit, which prints it beside the names * behind a segment. Only the city-wide page passes one. */ seats?: Record; + /** what each candidate is on the ballot — "Incumbent", "Challenger" — keyed + * by candidate key. See QuestionRollCall. */ + roles?: Record; + /** how many candidates are on the ballot these cards are drawn from, for + * the line saying how much of it answered each question. */ + ballotSize?: number; /** print each candidate's own words about their answer — see * QuestionRollCall. The city-wide page turns them off. */ notes?: boolean; + /** print each row's writing outright rather than putting it behind a + * disclosure. A candidate's own page passes it: with a field of one there + * is no split to read down, and the writing is the whole of what a card + * says. See `printWriting` in QuestionRollCall. */ + printWriting?: boolean; /** the reader's own row, where they have answered the same questionnaire — * see QuestionRollCall. */ yourKey?: string; @@ -129,6 +153,25 @@ export function QuestionnaireCards({ name: candidate.name, })); + /* Faces for the rows, keyed the way `seats` and `roles` are. Built here + rather than asked of the caller: every page already hands this component + its whole roster, and the portrait is two fields of it. Where a roster + carries neither a photograph nor a monogram — the city-wide page, whose + names come from the responses and not from the ballot — the map is empty + and the rows print as they always did. */ + const portraits = Object.fromEntries( + [...respondents, ...silent] + .filter((candidate) => candidate.image || candidate.initials) + .map((candidate) => [ + candidate.key, + { + name: candidate.name, + image: candidate.image, + initials: candidate.initials, + }, + ]), + ); + 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 @@ -146,7 +189,30 @@ export function QuestionnaireCards({ > {group.stepTitle} -
        + {/* Two to a row from 1166px, both kinds of card. + + The roll call went to one card a row when it was panels of + quotes, which needed the width. It is a table of rows now with + the writing folded away, so a card is a handful of short lines + and two of them sit side by side without crowding — and thirty- + three full-width cards was a great deal of scrolling for a page + a reader is meant to compare across. The rows inside size + themselves against the card rather than the window, so they lay + out correctly at half width. */} +
        {group.questions.map((question) => chart ? ( 0} headingId={sectionId(question.questionId, idPrefix)} seats={seats} + roles={roles} + portraits={portraits} + ballotSize={ballotSize} notes={notes} + printWriting={printWriting} yourKey={yourKey} /> ), @@ -176,11 +246,93 @@ export function QuestionnaireCards({ issuesHref={issuesHref} notes={notes} note={answerNote} + silentNamedElsewhere={silentNames.length === 0} />
        ); } +/** + * The questionnaire with nobody's answers on it — the questions alone. + * + * For a ward where not one candidate wrote back, which is eight of Toronto's + * twenty-five. The cards above still draw in that case, because the questions + * survive without answers (`comparedQuestions` falls back to the shape), and + * what a reader got was thirty-four bordered articles each containing one + * sentence: "No answers to this one yet." Nine screens of chrome to say once, + * thirty-four times over, what the heading above them had already said. + * + * So the cards come off and the questions stay. Nothing is withheld by this: + * an unanswered question has no candidate's words in it to withhold, and every + * question still prints in full, in the questionnaire's own order, under its + * own section heading. What goes is the card around each one. + * + * Two columns, which the cards could never be: these are one- and two-line + * sentences that `break-inside-avoid` keeps whole, and reading a plain list + * down one column and back up the next is what a list of questions is for. + * The cards carry answers a reader compares across, and column order would + * have shuffled the questionnaire. + */ +export function QuestionnaireOutline({ + groups, + issuesHref, + idPrefix, +}: { + groups: ComparedGroup[]; + /** the city-wide read. On a ward where nobody answered it is the only thing + * on the page a reader can go on, so it is worth reaching in a screen + * rather than past thirty-four blanks. */ + issuesHref?: string; + idPrefix?: string; +}) { + return ( +
        + {groups.map((group) => ( +
        + {/* The same heading as a section of cards, because it is the same + section — a reader moving between a ward that answered and one + that did not should not have to learn a second page. */} +

        + {group.stepTitle} +

        +
          + {group.questions.map((question) => ( + /* The id a card would have carried, so a link written when this + ward had answers — or to a ward that has them — still lands on + its question here. */ +
        • + {question.question} +
        • + ))} +
        +
        + ))} + + {/* No note on how to read the answers: there are none. The sentence the + cards carry — which options are not shown, who sits on no option — + is about a comparison this page is not making. */} + {issuesHref && ( +
        + + How the whole city answered + + +
        + )} +
        + ); +} + /* 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. */ @@ -188,9 +340,19 @@ function WardAnswerNote({ issuesHref, notes, note, + silentNamedElsewhere, }: { issuesHref?: string; notes?: boolean; + /** + * The cards are not naming the candidates who never wrote back, because a + * roster above them already has. True where the caller passes no `silent` + * and stands a CandidateRoster over the section — the ward pages and the + * mayoral one. The survey's own results have neither, so they keep naming + * the silent on each card and this sentence would be a direction to + * somewhere that is not there. + */ + silentNamedElsewhere?: boolean; /** an override for the sentence — see `answerNote` */ note?: ReactNode; /** the reader's own row, where they have answered the same questionnaire — @@ -212,6 +374,9 @@ function WardAnswerNote({ 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." : ""} + {silentNamedElsewhere + ? " Candidates who have not returned the questionnaire are named at the top of this section." + : ""} )}

        diff --git a/src/components/elections/SurveyGrid.tsx b/src/components/elections/SurveyGrid.tsx index 40769a3..b731a4e 100644 --- a/src/components/elections/SurveyGrid.tsx +++ b/src/components/elections/SurveyGrid.tsx @@ -71,6 +71,12 @@ export type GridCandidate = { /** a line about who they are, where we have one — hand-maintained, and for * most of a ballot we do not */ bio?: string; + /** their photograph, where we hold one — 44 of Toronto's 388 registrants. + * The questionnaire's rows print it beside the name; the grid does not. */ + image?: string; + /** the monogram that stands in for a missing photograph. Optional because a + * caller that prints no portrait has no use for it. */ + initials?: string; }; export function SurveyGrid({ diff --git a/src/components/elections/WardDetail.tsx b/src/components/elections/WardDetail.tsx index 550aed4..7742bfc 100644 --- a/src/components/elections/WardDetail.tsx +++ b/src/components/elections/WardDetail.tsx @@ -1,11 +1,28 @@ import Link from "next/link"; -import Image from "next/image"; import type { ReactNode } from "react"; import { ArrowLeft, ArrowRight } from "lucide-react"; +import { CandidatePortrait } from "./CandidatePortrait"; import CountdownDays from "./CountdownDays"; +import { CandidateRoster } from "./CandidateRoster"; +import { + QuestionnaireCards, + QuestionnaireOutline, + questionnaireHeadings, +} from "./QuestionnaireCards"; +import { QuestionnaireRail } from "./QuestionnaireRail"; +import { SurveyCta } from "./SurveyCta"; +import { surveyHref } from "@/lib/elections/registry"; import { IncumbentBadge } from "./ElectionLanding"; import { CandidateNameLink } from "./CandidateNameLink"; import { WardProfileSection, type WardProfile } from "./WardProfile"; +import { + comparedQuestions, + surveyRoster, +} from "@/lib/elections/candidate-answers"; +import type { + CandidateAnswers, + ComparedGroup, +} from "@/lib/elections/candidate-answers"; import { daysUntil } from "@/lib/elections/dates"; import type { SupportedElection } from "@/lib/elections/registry"; import type { @@ -29,6 +46,8 @@ export function WardDetail({ nominationCloseLabel, wardMapDefs, wardMap, + surveyAnswers, + surveyShape, profile, }: { election: SupportedElection; @@ -39,6 +58,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[]; /** * What this ward is, above the race to represent it — a short brief and the * Census statistics behind it. Only regions that maintain ward profiles @@ -55,6 +87,24 @@ export function WardDetail({ // grid runs straight under it — which is how Toronto's page has always read. const showRaceHeadings = councilRaces.length > 1; + /* The whole council ballot, and the part of it that wrote back. + + The questionnaire grid is the ward's candidate list now — there is no + separate roster of cards above it to agree or disagree with. So its + columns are every candidate still standing, and one we never heard from is + a column that says exactly that, which is more use to a voter than a name + quietly left out of the comparison. + + Withdrawn candidates are the exception, and are dropped: they cannot be + voted for, so a column of theirs is a column of a ballot line that does + not exist, and in a grid this wide every column costs the reader a drag. */ + const councilCandidates = councilRaces + .flatMap((race) => race.candidates) + .filter((candidate) => !candidate.withdrawn); + const respondents = councilCandidates.filter( + (candidate) => surveyAnswers?.[candidate.key], + ); + return (
        @@ -117,53 +167,75 @@ export function WardDetail({
    - {/* ── Council candidates ─────────────────────────────── */} - {/* The ward's ballot, one card a candidate: who is standing, whether - they hold the seat, and the way to their own campaign. That is a - fact about the election rather than anything a candidate told us, - so it is what the page is made of until the questionnaire is - published. */} -
    -
    -

    - Candidates -

    + {/* ── 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} + /> + )} +
    + + {/* Absent entirely while the survey is closed. The column beside + it is the heading and the ballot, which stand on their own — + this was always the ask, not part of the ward's own facts. */} + {surveyHref(election) && ( + + )}
    - {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}` : ""}.

    @@ -234,6 +306,110 @@ 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; +}) { + /* Only the candidates who wrote back, because they are the only ones the + questions can group. The rest are named once, in the roster over this + section, which links each of them as well — a ward's dozen registrants + carried down thirty questions is three hundred cells of "did not + respond", and the reader learns it from the first. */ + 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 groups = comparedQuestions( + answered.map((candidate) => candidate.answers!), + answered, + surveyShape, + ); + + return ( +
    + {showHeading && } +
    + {groups.length > 0 && answered.length > 0 ? ( + + candidate.tag === "Incumbent") + .map((candidate) => [candidate.key, "Incumbent"]), + )} + ballotSize={roster.length} + issuesHref={issuesHref} + /> + + ) : groups.length > 0 ? ( + /* Not one candidate in this ward wrote back — eight of Toronto's + twenty-five. The questions survive without them, and are worth + showing: the heading above has just said these are the questions + we asked, and this is them. As a list, though. Thirty-four cards + each holding "No answers to this one yet." is the same sentence + thirty-four times inside thirty-four borders. */ + + + + ) : ( + /* 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 (
    @@ -270,8 +446,8 @@ function EmptyRace({ ); } -/* A candidate card — one per candidate, in both the council and the - school-board races. */ +/* 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, @@ -292,19 +468,7 @@ function CouncilCandidate({ }`} >
    -
    - {candidate.image ? ( - {candidate.name} - ) : ( - candidate.initials - )} -
    +

    question.id), + ); + return survey.steps.flatMap((step) => CONSENT_STEPS.has(step.id) ? [] @@ -215,7 +228,8 @@ export function writtenQuestions( .filter( (question) => question.type === "textarea" && - !NON_POLICY_QUESTIONS.has(question.id), + !NON_POLICY_QUESTIONS.has(question.id) && + !followUps.has(question.id), ) .map((question) => ({ question, @@ -225,6 +239,47 @@ export function writtenQuestions( ); } +/** + * The textareas that ask a respondent to expand on the choice they just made, + * keyed by the id of the question they expand on. + * + * Read off position rather than off a field in the schema, because there is no + * such field: the CMS has one flat list of questions per step, and a follow-up + * is a textarea authored directly beneath the question it follows. So the rule + * is exactly that — a textarea takes the nearest choice question above it in + * its own step, and a textarea with no choice question above it (a step of + * pure prose, like `about-you`) is standalone and belongs to nobody. + * + * Only the last textarea under a question wins, which is the only sane reading + * of two in a row and has never happened. + */ +export function followUpQuestions(survey: Survey): Map { + const followUps = new Map(); + + for (const step of survey.steps) { + if (NON_POLICY_STEPS.has(step.id)) continue; + + let previous: SurveyQuestion | null = null; + for (const question of step.questions) { + if ( + CHOICE_TYPES.has(question.type) && + !NON_POLICY_QUESTIONS.has(question.id) && + (question.options?.length ?? 0) > 0 + ) { + previous = question; + } else if ( + question.type === "textarea" && + !NON_POLICY_QUESTIONS.has(question.id) && + previous + ) { + followUps.set(previous.id, question); + } + } + } + + return followUps; +} + /** * Compares one set of resident answers against several candidate responses. * diff --git a/src/lib/elections/candidate-answers.ts b/src/lib/elections/candidate-answers.ts index ecd2bf5..d02295b 100644 --- a/src/lib/elections/candidate-answers.ts +++ b/src/lib/elections/candidate-answers.ts @@ -15,11 +15,16 @@ // carry it: most wards have one or two respondents, where a per-ward count // says only that the candidate agrees with themselves. -import { comparableQuestions, isYesNoScale, writtenQuestions } from "./alignment"; +import { + comparableQuestions, + followUpQuestions, + isYesNoScale, + writtenQuestions, +} from "./alignment"; import type { CandidateSurveyResponse } from "./alignment"; import { nameKey } from "./election-data"; import { lastName } from "./names"; -import type { Survey } from "./survey"; +import type { Survey, SurveyQuestion } from "./survey"; export type CandidateAnswer = { questionId: string; @@ -50,6 +55,16 @@ export type CandidateAnswer = { /** true when `answer` is the candidate's own words rather than an option */ verbatim: boolean; explanation?: string; + /** + * The questionnaire's own follow-up to this question, where it had one and + * the candidate filled it in — "If you selected “Ward commitment,” state one + * numerical target and a deadline", and the target they stated. + * + * Kept apart from `explanation`, which is unprompted reasoning. This was + * asked for, so it prints under the question that asked — which is the only + * place it means anything. + */ + followUp?: { question: string; text: string }; }; export type AnswerGroup = { @@ -94,9 +109,12 @@ export function candidateAnswers( responses.map((response) => [response.candidateName, []]), ); + const followUps = followUpQuestions(survey); + for (const { question, stepId, stepTitle } of comparableQuestions(survey)) { const options = question.options ?? []; const counts = options.map(() => 0); + const followUp = followUps.get(question.id); // First pass: the field's split, so every candidate's chart is drawn // against the same denominator. @@ -125,6 +143,7 @@ export function candidateAnswers( pick.index === -1 ? pick.raw : (options[pick.index]?.label ?? pick.raw), verbatim: pick.index === -1, explanation: response.explanations?.[question.id], + followUp: followUpAnswer(followUp, response), }; const last = groups.at(-1); @@ -147,6 +166,23 @@ export function candidateAnswers( .filter((entry) => entry.answered > 0); } +/** What a candidate wrote in a question's follow-up box, where there is one + * and they wrote in it. The text is stored under the follow-up's own id, in + * `answers` — it is an answer like any other — and mirrored into + * `explanations` by the CMS, so either will do and `answers` is the original. */ +function followUpAnswer( + question: SurveyQuestion | undefined, + response: CandidateSurveyResponse, +): { question: string; text: string } | undefined { + if (!question) return undefined; + const text = ( + response.answers[question.id] ?? + response.explanations?.[question.id] ?? + "" + ).trim(); + return text ? { question: question.label, text } : undefined; +} + /** Keyed by `nameKey`, ready to look up against a roster `CandidateView.key`. */ export function byCandidateKey( entries: CandidateAnswers[], @@ -373,6 +409,9 @@ export type RollCallName = { name: string; /** their own words about why, where they wrote any */ note: string | null; + /** the questionnaire's follow-up to this question and their answer to it, + * where it asked one and they filled it in */ + followUp: { question: string; text: string } | null; }; /** One answer, and everyone who gave it. */ @@ -420,6 +459,7 @@ export function rollCall( key: cell.key, name: cell.candidateName, note: cell.answer?.explanation?.trim() || null, + followUp: cell.answer?.followUp ?? null, }); /* Surname order inside every group, so a candidate sits in the same relative @@ -454,6 +494,7 @@ export function rollCall( key: candidate.key, name: candidate.name, note: null, + followUp: null, })), ].sort( (a, b) => diff --git a/src/lib/elections/registry.ts b/src/lib/elections/registry.ts index 4026f05..829414f 100644 --- a/src/lib/elections/registry.ts +++ b/src/lib/elections/registry.ts @@ -99,60 +99,8 @@ export type SupportedElection = { * ask, so the rule lives in one place and no call site can forget it. */ surveyClosed?: boolean; - /** - * The candidates' questionnaire answers are off for now. - * - * The sibling of `surveyClosed` and the same bargain: temporary, one line, - * and nothing deleted. It shuts the answers off at the source — the read - * proxy that serves them to the browser, and `rosterSurvey`, which every - * page reading them goes through. Turning them back on is deleting this - * flag. - * - * A candidate's own bio stays, though it arrives in the same response. It is - * a self-description rather than a position, and it is the only thing - * standing between most candidate pages and an empty one. - * - * WHAT THE PAGES SHOW INSTEAD - * - * The pre-questionnaire ballot, which is what they showed before there were - * answers to publish: who is running, in which ward, with their campaign - * site — every one of which is a fact about the election rather than - * anything a candidate told us. The ward pages list their candidates, - * /mayor/candidates lists the mayoral field flat, and a candidate's own page - * keeps their bio and their links. - * - * None of them says anything about the answers. An empty state written for - * the weeks before anybody had written back — "Nobody in this ward has - * answered yet" — is a claim about candidates who answered months ago, and - * a notice that the answers are coming is a promise on a page that is - * complete without one. - * - * The two pages that exist only to publish answers, /issues and /mayor, have - * nothing left when those are gone, so they are switched off at the router - * instead and land on the nearest ballot (see next.config.ts). They stay in - * the repo exactly as built, which is why `ANSWERS_WITHHELD` below is still - * here for them. - */ - questionnaireHidden?: boolean; }; -/** - * What a page says where the candidates' answers would be. - * - * Nothing a reader can reach says it today: the pages that publish the ballot - * are complete without the answers, and are left to say what they do know - * rather than what they are not saying yet — see `questionnaireHidden` above. - * It is still here because /issues and /mayor, the two pages that were nothing - * but the answers, are kept in the repo as built and switched off at the - * router; this is the line they print if either is ever served again. - * - * One phrase, in one place, because four hand-written versions of it would be - * four different accounts of the same fact. Once per page: it names what is - * missing rather than only promising a return, so on a page that says it twice - * it reads as a stutter rather than as a fuller explanation. - */ -export const ANSWERS_WITHHELD = "Candidate survey coming soon."; - /** * Where this region's voter survey lives, or nothing while it is closed. * @@ -187,8 +135,6 @@ const TORONTO_2026: SupportedElection = { wardLookup: true, candidateProfiles: true, themeClass: "theme-election", - surveyClosed: true, - questionnaireHidden: true, }; const BRAMPTON_2026: SupportedElection = { diff --git a/src/lib/elections/survey-answers.ts b/src/lib/elections/survey-answers.ts index 5fe5957..4b1057b 100644 --- a/src/lib/elections/survey-answers.ts +++ b/src/lib/elections/survey-answers.ts @@ -13,7 +13,6 @@ // is nothing to show at all. import { - BIO_QUESTION_ID, byCandidateKey, candidateAnswers, candidateWriting, @@ -26,7 +25,6 @@ import { CANDIDATE_QUESTIONNAIRE_SLUG, fetchCandidateResponses, } from "./candidate-responses"; -import { getElection } from "./registry"; import { fetchSurvey } from "./survey"; export type RosterSurvey = { @@ -67,32 +65,6 @@ export async function rosterSurvey( ); const written = candidateWriting(survey, responses); - /* Answers withheld, prose kept — see `questionnaireHidden` in the - registry. The responses are still fetched because the bio rides in on - them, and a candidate's account of themselves is not one of the answers - being held back. Every page reading this is left with the empty state it - already had for a field that has not written back yet. - - `written` is narrowed to the bio alone rather than passed through. The - rest of it is prose answering a policy question — the questionnaire's - ward-commitment target is one — and letting that through under the - heading "About" would publish an answer by another door. */ - if (getElection(electionSlug).questionnaireHidden) { - return { - answers: {}, - shape: [], - written: Object.fromEntries( - Object.entries(written) - .filter(([key]) => candidateKeys.has(key)) - .map(([key, entries]) => [ - key, - entries.filter((entry) => entry.questionId === BIO_QUESTION_ID), - ]) - .filter(([, entries]) => (entries as WrittenAnswer[]).length > 0), - ), - }; - } - return { answers: byCandidateKey(entries), shape: questionnaireShape(survey, responses),