From e83efcbf2a4784118af9aca0bccdc267ebc42a53 Mon Sep 17 00:00:00 2001 From: Mikaal Naik Date: Mon, 14 Sep 2026 14:45:07 -0400 Subject: [PATCH 1/8] Publish the candidates' answers again, a question at a time MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The questionnaire answers went behind `questionnaireHidden` in September and the pages that published them were rewritten to the pre-questionnaire ballot. This takes the flag out and brings the answers back, and rebuilds the form they are read in, because the old one did not survive contact with the real spread of the data. WHAT THE FLAG COMING OUT RESTORES `questionnaireHidden` and `ANSWERS_WITHHELD` are gone, so `rosterSurvey` and the candidate-responses read proxy pass answers through again. /issues and /mayor lose the temporary 307s in next.config.ts and serve as built. The ward pages, /mayor/candidates, the candidate pages and the landing grid go back to the questionnaire presentation. `surveyClosed` is untouched: the voter survey is a separate flag and stays shut here. THE FIELD IS SPARSER THAN THE CARD ASSUMED Measured across all twenty-five wards: eight of them have no respondents at all, ten have one or two, and the median ward is two respondents spread over thirty-three questions. /mayor is the opposite — fourteen respondents, about twenty-seven name plates a card. One card design was serving both, and it was built for the dense end. So the empty wards no longer draw thirty-four cards whose whole body is "No answers to this one yet." They get `QuestionnaireOutline` instead: the questions themselves, in two columns under their own section headings, keeping every id a card would have carried. Nine screens becomes one. The race pages also stop passing `silent`. A ward of one respondent and nine who never wrote back was printing the same nine names at the foot of all thirty-three cards — a hundred and forty characters, identical, thirty-three times, and the longest thing in most of those cards. The roster over the section already names them, and links them. What the cards still name is the other half of `unanswered`, a candidate who did write back and skipped this question, which is per-question and worth saying. A QUESTION IS NOW A TABLE OF CANDIDATES `QuestionRollCall` was a panel per answer with the people who gave it inside. That reads 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 each of thirty-three cards. It is a row a candidate now, in surname order, with the answer beside them — so both the names and the answers are columns a reader can run down, and a candidate sits in the same place on every card. The split is still readable as the answer column, and the whole-field version of it is what /issues draws. The answer prints in full rather than as a handle. Outside yes/no these run to phrases, up to ninety-odd characters, so it is a tinted block that wraps and not a pill that cannot. What a candidate wrote is behind the row rather than under it, in a `
` — the one thing here that is not simply printed, and what makes thirty-three questions readable at all when a ward's respondents write a paragraph each. No script: it opens with JavaScript off and the card stays a server component. A candidate who wrote nothing gets no control. Only respondents get a row. A line under the question says how much of the ballot that accounts for, which is the one denominator on the card and the only thing distinguishing a ward where everyone answered from one where two people did. Toronto's council races carry no party, so the line under a name is the incumbent badge and nothing else — "Challenger" under every other name would have been two hundred printings of a word that separates nobody. LAYOUT Two cards to a row from 1166px. The rows inside size against the card through a container query rather than against the window, because at half width a viewport-keyed template would lay out three columns for a thousand pixels in a card that has four hundred and sixty. `content-start` on the card and its nested grids is load-bearing. Two cards to a row means both stretch to the taller, and a grid container's `align-content` defaults to `normal`, which behaves as `stretch` — so opening one answer stretched its neighbour and slid that card's heading, table and foot apart. The card was `flex flex-col` before, which is why this never showed. The rows animate open through `::details-content`, with `interpolate-size` set on the element rather than the root so keyword interpolation does not quietly change every other transition in the document. Guarded on `prefers-reduced-motion` itself, since the global rule reaches `*`, `::before` and `::after` and not this pseudo-element. Co-Authored-By: Claude Opus 5 (1M context) --- next.config.ts | 15 - .../elections/candidate-responses/route.ts | 10 - src/app/globals.css | 37 ++ .../vote/2026/candidates/[candidate]/page.tsx | 112 +++- src/app/toronto/vote/2026/issues/page.tsx | 111 ++-- .../vote/2026/mayor/candidates/page.tsx | 87 ++- src/app/toronto/vote/2026/mayor/page.tsx | 83 +-- src/app/toronto/vote/2026/page.tsx | 32 +- .../toronto/vote/2026/wards/[ward]/page.tsx | 11 + src/components/elections/ElectionLanding.tsx | 73 ++- src/components/elections/QuestionRollCall.tsx | 587 +++++++++--------- .../elections/QuestionnaireCards.tsx | 137 +++- src/components/elections/WardDetail.tsx | 254 ++++++-- src/lib/elections/registry.ts | 53 -- src/lib/elections/survey-answers.ts | 28 - 15 files changed, 970 insertions(+), 660 deletions(-) diff --git a/next.config.ts b/next.config.ts index d5314e30..e21945e9 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 9b98cb71..72cb1e4b 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 fa23adbc..459b5e79 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 5b19d141..0bdb4fb0 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,26 @@ 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, + }, + ]; + 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 +450,52 @@ 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 +506,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 033b40d8..12242236 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 @@ -144,39 +137,25 @@ export default async function IssuesPage() { 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 same {questionCount} questions, put to everyone + running for mayor and for council. Read across the whole field, the + answers show what no single ballot can: what Toronto’s next + council already agrees on, and what it will spend four years + fighting over.

{/* ── Key stats ──────────────────────────────────────── */} - {/* 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 +189,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 +231,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 10756175..d21a78e3 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 a9175914..8ec4ad82 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 35791bf4..1ecde692 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 15586ce1..9eea2486 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 (

+ {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."}

diff --git a/src/components/elections/QuestionRollCall.tsx b/src/components/elections/QuestionRollCall.tsx index 021a2bfe..f8041d08 100644 --- a/src/components/elections/QuestionRollCall.tsx +++ b/src/components/elections/QuestionRollCall.tsx @@ -1,38 +1,50 @@ +import { MessageSquareText } from "lucide-react"; + 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 @@ -48,6 +60,8 @@ export function QuestionRollCall({ silent = [], nameTheSilent = true, seats, + roles, + ballotSize, notes = true, yourKey, headingId, @@ -64,6 +78,17 @@ 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; + /** 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 @@ -98,69 +123,130 @@ 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) => ( - - ))} +
    + {/* 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 + +
    - {/* 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 && ( - - )} -
+
    + {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 +258,147 @@ 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 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. * - * 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({ + * 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, + notes, + 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; + notes: boolean; + /** the reader's own row, on the survey results */ + you?: boolean; }) { - /* The reader comes out of the run and sits above it. + const words = row.answer || (notes ? row.note : null); + + const body = ( + <> + + + {row.name} + + {(seat || role) && ( + + {[seat, role].filter(Boolean).join(" · ")} + + )} + - 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; + {/* 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. */} + + {row.answerLabel} + + + ); - /* 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); + /* 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 grid-cols-[minmax(0,1fr)_1rem] gap-x-3 gap-y-1 @sm:grid-cols-[minmax(0,1fr)_minmax(0,1.5fr)_1rem] @2xl:grid-cols-[minmax(0,1fr)_minmax(0,22rem)_1rem] @sm:items-start @sm:gap-x-4"; return (
  • - {/* The answer as it was put to the candidates, in full. - - The questionnaire gives most options a short handle and the real - wording underneath — "Public delivery" over "Build or finance - substantially more affordable and supportive housing" — and this - panel used to title itself with the handle and print the wording as - a caption below. That is a summary of the answer standing where the - answer should be, and on a page whose whole job is what a candidate - said, the reader gets the sentence they actually endorsed. Options - with no expansion ("Yes") are already their own full wording. - - Set like a title all the same: the biggest thing inside the block, - in serif rather than the question's sans, so the two read as heading - and sub-heading instead of competing at one size. */} -

    - {detail || label} -

    - - {/* BY RACE, WHERE THERE IS MORE THAN ONE + {words ? ( +
    + + {body} + {/* Not a chevron. The icon only appears on rows that have + something behind them, so it can say what that something is — + the candidate wrote about this answer — instead of only that + the row opens. - 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. + Lines of text in a bubble — a picture of the thing it opens + onto. All three inner strokes are straight rules, so it holds + together at sixteen pixels where a glyph built from small + curled quote marks would not. - 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 && ( - - )} + It darkens rather than turns: this is not a chevron and + rotating it would only make it unrecognisable. */} + 1 ? ( -
    - {races.map(([race, named]) => ( -
    -

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

    - -
    - ))} -
    + The row is as tall as the answer, and an answer can wrap to + three lines, so centring dropped the icon down the row away + from its own candidate. But the answer's first line starts + four pixels down inside the badge's own padding, so matching + the name instead left the icon sitting high. Six pixels puts + the middle of the icon on the middle of that first line, and + on a single-line answer — which is most of them — that is + the middle of the badge as well. */ + className="col-start-2 row-start-1 mt-[2px] size-4 self-start justify-self-end text-text-muted transition-colors duration-150 group-open:text-dark @sm:col-start-3 @sm:mt-1.5" + aria-hidden="true" + /> +
    +

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

    +
    ) : ( - field.length > 0 && ( - - ) +
    {body}
    )}
  • ); } - -/* 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} -

      - )} -
    • - ); - })} -
    - ); -} - -/* 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/QuestionnaireCards.tsx b/src/components/elections/QuestionnaireCards.tsx index 835de30f..5648b760 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,6 +95,8 @@ export function QuestionnaireCards({ silent, issuesHref, seats, + roles, + ballotSize, notes = true, yourKey, idPrefix, @@ -102,6 +114,12 @@ 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; @@ -146,6 +164,16 @@ 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 ? ( @@ -163,6 +191,8 @@ export function QuestionnaireCards({ nameTheSilent={respondents.length > 0} headingId={sectionId(question.questionId, idPrefix)} seats={seats} + roles={roles} + ballotSize={ballotSize} notes={notes} yourKey={yourKey} /> @@ -176,11 +206,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 +300,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 +334,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/WardDetail.tsx b/src/components/elections/WardDetail.tsx index 550aed41..052e903c 100644 --- a/src/components/elections/WardDetail.tsx +++ b/src/components/elections/WardDetail.tsx @@ -3,9 +3,26 @@ import Image from "next/image"; import type { ReactNode } from "react"; import { ArrowLeft, ArrowRight } from "lucide-react"; import CountdownDays from "./CountdownDays"; +import { CandidateRoster } from "./CandidateRoster"; +import { + QuestionnaireCards, + 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, diff --git a/src/lib/elections/registry.ts b/src/lib/elections/registry.ts index 4026f057..2bded811 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. * @@ -188,7 +136,6 @@ const TORONTO_2026: SupportedElection = { 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 5fe59578..4b1057b6 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), From e346ede26d41da6907176495b065b706448cf9ad Mon Sep 17 00:00:00 2001 From: Mikaal Naik Date: Mon, 14 Sep 2026 14:45:17 -0400 Subject: [PATCH 2/8] Reopen the Toronto voter survey MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit One line off the registry entry, which is how `surveyClosed` was built to be undone: the questions, the submissions already taken and every piece of code that reads them never moved. `surveyHref` is the single rule all five invitations ask, so they come back together rather than one at a time — the landing page's explore card, which sits between the mayoral read and the whole-field one exactly as that page's own comment describes, its closing call to action, the bands on /mayor and /issues, and the SurveyCta on all twenty-five ward pages. The route and the submissions endpoint answer again too, not only the links. That is the part worth knowing: /api/elections/survey stops returning 404 and starts recording real responses from here. The flag and the `surveyHref` check stay where they are, so closing it again is putting this line back. Co-Authored-By: Claude Opus 5 (1M context) --- src/lib/elections/registry.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/src/lib/elections/registry.ts b/src/lib/elections/registry.ts index 2bded811..829414fb 100644 --- a/src/lib/elections/registry.ts +++ b/src/lib/elections/registry.ts @@ -135,7 +135,6 @@ const TORONTO_2026: SupportedElection = { wardLookup: true, candidateProfiles: true, themeClass: "theme-election", - surveyClosed: true, }; const BRAMPTON_2026: SupportedElection = { From cb17f72d8a40b7232939c5f1b9f889d4d7303d6d Mon Sep 17 00:00:00 2001 From: Mikaal Naik Date: Mon, 14 Sep 2026 15:30:16 -0400 Subject: [PATCH 3/8] Run the rule under the ways-to-vote row the width of the page MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The line closing the three-way grid on /when-is-the-election stopped about two thirds of the way across. It is the top border of the proxy-voting paragraph underneath, and that paragraph carried `max-w-[62ch]` on the same element — a max-width bounds the box the border is drawn on, so the rule ended where the text measure did. It showed there and not elsewhere because this rule closes a full-bleed grid and sits between two full-width ones: the grid's own border above it and the section's `border-b-2 border-dark` below. A short rule between those two reads as a broken line rather than as a choice. The gutters and the rule move to a wrapper and the measure stays on the paragraph, so the line runs edge to edge and the prose still sets to 62 characters. Co-Authored-By: Claude Opus 5 (1M context) --- .../vote/2026/when-is-the-election/page.tsx | 28 +++++++++++-------- 1 file changed, 17 insertions(+), 11 deletions(-) diff --git a/src/app/toronto/vote/2026/when-is-the-election/page.tsx b/src/app/toronto/vote/2026/when-is-the-election/page.tsx index 5655f644..40858888 100644 --- a/src/app/toronto/vote/2026/when-is-the-election/page.tsx +++ b/src/app/toronto/vote/2026/when-is-the-election/page.tsx @@ -295,17 +295,23 @@ export default async function WhenIsTheTorontoElectionPage() { people to — but leaving it out of a section that compares methods would tell a reader who cannot get to a voting place that they have run out of options, which is false. */} -

    - 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 ──────────────────────────────── */} From 8ed53832b76188a263e5a0a954c90a1785e72ada Mon Sep 17 00:00:00 2001 From: Mikaal Naik Date: Tue, 15 Sep 2026 09:42:37 -0400 Subject: [PATCH 4/8] Let the /issues names panel grow downwards, not sideways MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A segment's panel lists everyone who gave that answer, capped at fourteen rem and scrolling past it. The names were set in CSS columns, and multi-column inside a height-capped box does not grow downwards — it fragments sideways, opening a fourth and a fifth column past the panel's right edge. So the overflow ran horizontally while the scrolling ran vertically, and the names in those columns could not be reached at all. A grid fills rows downwards, which is the direction the box scrolls, so the cap now does what it was written to do. How many columns is a question about the panel and not the window. These cards sit two to a row on a wide screen, where the panel is about four hundred and sixty pixels, and `sm:columns-3` was firing off a six-hundred-and-forty-pixel viewport — three columns in that width puts most names on two lines. The figure is a container now and the panel asks it: one column, two from twenty rem, three from forty-two. THE STACKING THAT CAME WITH IT `container-type: inline-size` carries `contain: layout`, which makes the figure a stacking context where a bare `position: relative` was not. The panel's `z-20` had been lifting it clear of everything on the page; scoped to its own figure it only orders against siblings there, and the card below — later in the tree, and precisely what a panel opening downwards runs over — would have painted on top of it. Untreated, this trades an overflow the reader can at least see for an occlusion they cannot. So the figure lifts while a panel is open, and only while: left permanently raised, thirty-three of them would stack in tree order to no purpose. Co-Authored-By: Claude Opus 5 (1M context) --- .../elections/QuestionSplitFigure.tsx | 44 ++++++++++++++----- 1 file changed, 33 insertions(+), 11 deletions(-) diff --git a/src/components/elections/QuestionSplitFigure.tsx b/src/components/elections/QuestionSplitFigure.tsx index c19f07ec..01de4c84 100644 --- a/src/components/elections/QuestionSplitFigure.tsx +++ b/src/components/elections/QuestionSplitFigure.tsx @@ -122,7 +122,21 @@ export function QuestionSplitFigure({ return (
    { if (event.key === "Escape") close(); }} @@ -291,21 +305,29 @@ function Names({ slice, onDismiss }: { slice: SplitSlice; onDismiss: () => 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 && ( From 1674dc6dc2c55870641b4bf72878573cdc9ddf58 Mon Sep 17 00:00:00 2001 From: Mikaal Naik Date: Tue, 15 Sep 2026 11:21:07 -0400 Subject: [PATCH 5/8] Three chart cards to a row on /issues from 1280 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The charts get a third column and the roll calls do not. A band and a legend read fine narrow; a roll call is a table whose answer column already runs to ninety characters, and it has only just stopped being cramped at half width. From 1280 rather than from 1166, because the scroll rail arrives at 1200 and takes 268 pixels with it — splitting three ways at 1166 would have handed the cards their narrowest width at the moment they multiplied, dropping from about 510 pixels straight to 283. At 1280 a card is about 280, at 1440 about 336. What it costs, measured off the page: the option wordings are full sentences, median 71 characters and up to 108, so a legend row goes from two lines at half width to three or four at a third. Taller cards, a third fewer rows of them, and much the same total height — the gain is density on a screen rather than less scrolling. Co-Authored-By: Claude Opus 5 (1M context) --- src/components/elections/QuestionnaireCards.tsx | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) diff --git a/src/components/elections/QuestionnaireCards.tsx b/src/components/elections/QuestionnaireCards.tsx index 5648b760..26a4cf71 100644 --- a/src/components/elections/QuestionnaireCards.tsx +++ b/src/components/elections/QuestionnaireCards.tsx @@ -174,7 +174,20 @@ export function QuestionnaireCards({ 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 ? ( Date: Tue, 15 Sep 2026 11:21:07 -0400 Subject: [PATCH 6/8] Draw a question's split as a pie on /issues MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A new OptionPie beside OptionBar in the charts package, taking the same props and the same hues, and swapped in on the /issues cards. The legend, the hover and the panel of names behind each option are untouched: the pie hands them the same option index the band did. Slices are separated by a two-pixel stroke in the page's own background rather than a border, so the gap between two fills is the card showing through. A slice holding the whole field is drawn as a circle, since an arc of a full turn has identical endpoints and renders as nothing. THIS REVERSES AN EARLIER CALL, ON PURPOSE The figure was a hand-drawn donut once and was replaced by the band, and the argument for replacing it has not stopped being true: this page's work is done across thirty-three cards at once, and a share read as a length against a common left edge can be compared between cards where an angle cannot. Of the thirty-three questions, twenty-six are the ordered yes / yes-with-conditions / no scale, which a band keeps in order along its length and a pie keeps only by convention; five more have two options, where a pie is two slices. It is a pie because that was the call. The reasoning is written into both files rather than argued away, so whoever weighs it next has it in front of them — and OptionBar is still exported and still takes these exact props, so going back is an import and a figure element. Co-Authored-By: Claude Opus 5 (1M context) --- src/components/charts/trilemma/OptionPie.tsx | 144 ++++++++++++++++++ src/components/charts/trilemma/index.ts | 2 + .../elections/QuestionSplitFigure.tsx | 75 ++++----- 3 files changed, 187 insertions(+), 34 deletions(-) create mode 100644 src/components/charts/trilemma/OptionPie.tsx diff --git a/src/components/charts/trilemma/OptionPie.tsx b/src/components/charts/trilemma/OptionPie.tsx new file mode 100644 index 00000000..abb617c0 --- /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 3e6ada5b..7f325eba 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/QuestionSplitFigure.tsx b/src/components/elections/QuestionSplitFigure.tsx index 01de4c84..3fc8ba2a 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; @@ -146,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 (
        Date: Tue, 15 Sep 2026 11:21:07 -0400 Subject: [PATCH 7/8] Put the survey CTA beside the /issues title MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The ask was only at the foot of the page. /issues is thirty-three cards long and a reader who stops halfway never reaches it — and "where do you stand" is the question the whole page exists to provoke, so it belongs where the reader arrives as well as where they leave. The same SurveyCta the landing page's closing band uses, in the slot it was drawn for: beside a heading at its own default width, which is how it sits on the ward pages. The hero becomes two columns and stacks below 1166. Guarded on `surveyHref`, so if the survey closes again the column collapses and the title takes the full width back. Co-Authored-By: Claude Opus 5 (1M context) --- src/app/toronto/vote/2026/issues/page.tsx | 37 ++++++++++++++++------- 1 file changed, 26 insertions(+), 11 deletions(-) diff --git a/src/app/toronto/vote/2026/issues/page.tsx b/src/app/toronto/vote/2026/issues/page.tsx index 12242236..beb9e9cc 100644 --- a/src/app/toronto/vote/2026/issues/page.tsx +++ b/src/app/toronto/vote/2026/issues/page.tsx @@ -132,17 +132,32 @@ export default async function IssuesPage() { {/* ── Hero ───────────────────────────────────────────── */}
        -

        The whole field

        -

        - Where the candidates stand -

        -

        - The same {questionCount} questions, put to everyone - running for mayor and for council. Read across the whole field, the - answers show what no single ballot can: what Toronto’s next - council already agrees on, and what it will spend four years - fighting over. -

        + {/* 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 ──────────────────────────────────────── */} From 37af0aa7c3eaf3394aba850047fd03c1c9bbe277 Mon Sep 17 00:00:00 2001 From: Mikaal Naik Date: Wed, 16 Sep 2026 12:28:18 -0400 Subject: [PATCH 8/8] Read a candidate's own page as a candidate, not as a field of one MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three things the questionnaire card got wrong when the field in it is one person, found on the candidate profile pages. A FOLLOW-UP PRINTED UNDER THE BIO `ward_commitment_target` is a textarea authored directly beneath the choice question it follows up on, so `writtenQuestions` swept it up with the bio and the profile printed "If you selected "Ward commitment," state one numerical target and a deadline" under a candidate's biography — a prompt with no question attached, reading as a stray instruction to the candidate. The CMS has no field marking a follow-up, so position is the only signal there is: `followUpQuestions` takes, for each choice question, the textarea authored beneath it in the same step. Those leave the prose and travel on the answer instead, printing under the question that asked them, with the prompt above the text — several of these answers are bare numbers ("12000") and mean nothing without it. Kept apart from `explanation`, which is reasoning nobody asked for. NO FACES ON THE ONE LIST OF CANDIDATES THAT NAMES THEM ONE BY ONE The ward cards, the mayoral field and a candidate's own hero all print a portrait; the questionnaire's rows, which is where a reader actually hunts for one person among thirty, printed names alone. They now carry the plate, built from the roster the component is already handed. `CandidatePortrait` is that plate, extracted rather than copied a third time — the three copies had drifted in the one way that matters, the `sizes` hint that decides how big a file the browser fetches, so it is now derived from the size instead of typed beside it. We hold a photograph for 44 of Toronto's 388 registrants, so most rows are a monogram. That is deliberate: a plate that appeared only where there is a photo would leave names starting in two different places down one column, which reads as a fault in the page rather than a fact about the ballot. A DISCLOSURE OVER A FIELD OF ONE The `
        ` 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 the table replaced. A candidate's own page has one row per card and no split to read down, so the writing is the whole of what a card says and the control's only remaining use is to hide it. `printWriting` prints it: no details, no summary, no icon. The icon's 1rem column goes with it, which is what puts the answer against the card's right edge rather than a rem short of it, and the answer badge stops spanning a column that is no longer there when the row stacks on a narrow screen. Verified against the dev server: 33 rows on a profile, none a disclosure, two-column grid; ward 25 and /mayor unchanged at 16 and 322 disclosures on the three-column grid; /issues untouched. tsc and lint clean. Co-Authored-By: Claude Opus 5 (1M context) --- .../vote/2026/candidates/[candidate]/page.tsx | 10 ++ .../elections/CandidatePortrait.tsx | 63 +++++++ src/components/elections/ElectionLanding.tsx | 16 +- src/components/elections/QuestionRollCall.tsx | 157 +++++++++++++++--- .../elections/QuestionnaireCards.tsx | 27 +++ src/components/elections/SurveyGrid.tsx | 6 + src/components/elections/WardDetail.tsx | 16 +- src/lib/elections/alignment.ts | 57 ++++++- src/lib/elections/candidate-answers.ts | 45 ++++- 9 files changed, 346 insertions(+), 51 deletions(-) create mode 100644 src/components/elections/CandidatePortrait.tsx diff --git a/src/app/toronto/vote/2026/candidates/[candidate]/page.tsx b/src/app/toronto/vote/2026/candidates/[candidate]/page.tsx index 0bdb4fb0..fd2d741b 100644 --- a/src/app/toronto/vote/2026/candidates/[candidate]/page.tsx +++ b/src/app/toronto/vote/2026/candidates/[candidate]/page.tsx @@ -166,6 +166,8 @@ export default async function CandidatePage({ name: candidate.name, website: candidate.website, bio: candidate.bio || undefined, + image: candidate.image, + initials: candidate.initials, }, ]; const groups = surveyAnswers @@ -472,6 +474,14 @@ export default async function CandidatePage({ respondents={roster} silent={[]} issuesHref={`${ELECTION.basePath}/issues`} + /* Printed, not hidden behind a disclosure. That control + is there to keep a field of four legible across + thirty-three cards; here the field is one person, so + every card holds a single row and there is no split to + read down it. What the card has to say IS the writing, + and a control whose only use is to hide the thing this + URL was opened for is not one worth printing. */ + printWriting answerNote={ `Each card is one question, with ${candidate.name} filed ` + "under the answer they gave and their own words underneath " + diff --git a/src/components/elections/CandidatePortrait.tsx b/src/components/elections/CandidatePortrait.tsx new file mode 100644 index 00000000..83a4959d --- /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 de2d8f0c..dd196a07 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"; @@ -828,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 f8041d08..f29e32f1 100644 --- a/src/components/elections/QuestionRollCall.tsx +++ b/src/components/elections/QuestionRollCall.tsx @@ -1,5 +1,7 @@ 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"; @@ -55,14 +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, }: { @@ -84,6 +101,17 @@ export function QuestionRollCall({ * 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 @@ -103,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 @@ -210,10 +252,14 @@ export function QuestionRollCall({ 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 && }
          @@ -225,7 +271,9 @@ export function QuestionRollCall({ muted={row.option === null} seat={seats?.[row.key]?.label} role={roles?.[row.key]} + portrait={portraits?.[row.key]} notes={notes} + printWriting={printWriting} you={row.key === yourKey} /> ))} @@ -291,7 +339,9 @@ function AnswerRow({ muted, seat, role, + portrait, notes, + printWriting, you, }: { row: AnswerRow; @@ -303,23 +353,41 @@ function AnswerRow({ /** 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; }) { - const words = row.answer || (notes ? row.note : null); + /* 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; const body = ( <> - - - {row.name} - - {(seat || role) && ( - - {[seat, role].filter(Boolean).join(" · ")} + {/* 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 @@ -328,7 +396,12 @@ function AnswerRow({ pill that cannot, set in the option's own hue so the column can be read down as a split. */} + {(row.answer || (notes && row.note)) && ( +

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

          + )} + + {/* 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} + +
          + )} + + ) : null; + + /* 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} +
        • + ); + } return (
        • - {words ? ( + {writing ? (
          -

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

          + {writing}
          ) : (
          {body}
          diff --git a/src/components/elections/QuestionnaireCards.tsx b/src/components/elections/QuestionnaireCards.tsx index 26a4cf71..1b57e8b9 100644 --- a/src/components/elections/QuestionnaireCards.tsx +++ b/src/components/elections/QuestionnaireCards.tsx @@ -98,6 +98,7 @@ export function QuestionnaireCards({ roles, ballotSize, notes = true, + printWriting = false, yourKey, idPrefix, answerNote, @@ -123,6 +124,11 @@ export function QuestionnaireCards({ /** 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; @@ -147,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 @@ -205,8 +230,10 @@ export function QuestionnaireCards({ headingId={sectionId(question.questionId, idPrefix)} seats={seats} roles={roles} + portraits={portraits} ballotSize={ballotSize} notes={notes} + printWriting={printWriting} yourKey={yourKey} /> ), diff --git a/src/components/elections/SurveyGrid.tsx b/src/components/elections/SurveyGrid.tsx index 40769a3f..b731a4e4 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 052e903c..7742bfc0 100644 --- a/src/components/elections/WardDetail.tsx +++ b/src/components/elections/WardDetail.tsx @@ -1,7 +1,7 @@ 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 { @@ -468,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 ecd2bf55..d02295b5 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) =>