From 4e1fa4b406eeea3ab4291cd1a55e92150d0203a0 Mon Sep 17 00:00:00 2001 From: Mikaal Naik Date: Thu, 17 Sep 2026 09:26:48 -0400 Subject: [PATCH 1/4] Say it plainly when a ward's field stayed quiet MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A ward where nobody answered printed the questionnaire's thirty-odd questions with no answers on them, on the argument that a reader deserved to see what it was the candidates did not answer. Eight of Toronto's twenty-five wards are in that state, and what it gave them was nine screens of questions burying the one thing the section had to report. The questions are not the ward's news; the silence is. So the outline comes off and the finding stands on its own — "No candidate has responded", with the city-wide read a click away for anyone who wants the questions with answers on them. The ballot is still named in full in the roster above, now labelled "On the ballot": "also on the ballot" needs something to be also to. QuestionnaireOutline was rendered from this one branch and goes with it, as does the questionnaire-shape plumbing that fed it — rosterSurvey no longer computes a shape no page reads. questionnaireShape itself stays; /issues, /mayor and the survey results still call it. Co-Authored-By: Claude Opus 5 (1M context) --- .../toronto/vote/2026/wards/[ward]/page.tsx | 3 +- .../elections/QuestionnaireCards.tsx | 81 -------------- src/components/elections/WardDetail.tsx | 100 +++++++++--------- src/lib/elections/survey-answers.ts | 16 +-- 4 files changed, 56 insertions(+), 144 deletions(-) diff --git a/src/app/toronto/vote/2026/wards/[ward]/page.tsx b/src/app/toronto/vote/2026/wards/[ward]/page.tsx index 9eea248..a043dab 100644 --- a/src/app/toronto/vote/2026/wards/[ward]/page.tsx +++ b/src/app/toronto/vote/2026/wards/[ward]/page.tsx @@ -46,7 +46,7 @@ export default async function WardDetailPage({ const candidateKeys = new Set( data.councilRaces.flatMap((race) => race.candidates.map((c) => c.key)), ); - const { answers: surveyAnswers, shape: surveyShape } = await rosterSurvey( + const { answers: surveyAnswers } = await rosterSurvey( ELECTION.slug, candidateKeys, ); @@ -57,7 +57,6 @@ export default async function WardDetailPage({ data={data} nominationCloseLabel={view.nominationCloseLabel} surveyAnswers={surveyAnswers} - surveyShape={surveyShape} profile={ wardProfile( data.ward.n, diff --git a/src/components/elections/QuestionnaireCards.tsx b/src/components/elections/QuestionnaireCards.tsx index 1b57e8b..e0e16ef 100644 --- a/src/components/elections/QuestionnaireCards.tsx +++ b/src/components/elections/QuestionnaireCards.tsx @@ -252,87 +252,6 @@ export function QuestionnaireCards({ ); } -/** - * 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. */ diff --git a/src/components/elections/WardDetail.tsx b/src/components/elections/WardDetail.tsx index 7742bfc..60851ba 100644 --- a/src/components/elections/WardDetail.tsx +++ b/src/components/elections/WardDetail.tsx @@ -6,7 +6,6 @@ import CountdownDays from "./CountdownDays"; import { CandidateRoster } from "./CandidateRoster"; import { QuestionnaireCards, - QuestionnaireOutline, questionnaireHeadings, } from "./QuestionnaireCards"; import { QuestionnaireRail } from "./QuestionnaireRail"; @@ -19,10 +18,7 @@ import { comparedQuestions, surveyRoster, } from "@/lib/elections/candidate-answers"; -import type { - CandidateAnswers, - ComparedGroup, -} from "@/lib/elections/candidate-answers"; +import type { CandidateAnswers } from "@/lib/elections/candidate-answers"; import { daysUntil } from "@/lib/elections/dates"; import type { SupportedElection } from "@/lib/elections/registry"; import type { @@ -47,7 +43,6 @@ export function WardDetail({ wardMapDefs, wardMap, surveyAnswers, - surveyShape, profile, }: { election: SupportedElection; @@ -64,13 +59,6 @@ export function WardDetail({ * 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 @@ -180,15 +168,14 @@ export function WardDetail({

Know Your Candidates

- {/* Only the empty states get a sentence. Where candidates have - answered, the roster underneath names both halves of the + {/* Only an empty ballot gets a sentence here. 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 && ( + immediately above the list it was counting. Where none have, + the questionnaire below says so in its own voice. */} + {councilCandidates.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."} + No one has registered in this ward yet.

)} {councilCandidates.length > 0 && ( @@ -201,6 +188,11 @@ export function WardDetail({ race="councillor" ward={ward.n} wardName={ward.name} + /* "Also on the ballot" needs something to be also to. With + nobody answering, this list is the ballot. */ + silentLabel={ + respondents.length === 0 ? "On the ballot" : undefined + } /> )} @@ -224,7 +216,6 @@ export function WardDetail({ key={race.id} race={race} surveyAnswers={surveyAnswers} - surveyShape={surveyShape} showHeading={showRaceHeadings} issuesHref={`${election.basePath}/issues`} /> @@ -314,20 +305,18 @@ export function WardDetail({ * 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. + * A race nobody answered draws no questions at all — see `NoResponses`. The + * candidates are still named, in the roster above this section: 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; }) { @@ -349,14 +338,19 @@ function RaceQuestionnaire({ const groups = comparedQuestions( answered.map((candidate) => candidate.answers!), answered, - surveyShape, ); return (
{showHeading && }
- {groups.length > 0 && answered.length > 0 ? ( + {roster.length === 0 ? ( +

+ No one has filed for this seat yet. +

+ ) : answered.length === 0 || groups.length === 0 ? ( + + ) : ( - ) : 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(", ")}.`} -

)}
); } +/* A race whose whole field stayed quiet — eight of Toronto's twenty-five + wards on the day this was written. + + This used to print the questionnaire's thirty-odd questions with nobody's + answers on them, on the argument that a ward of non-respondents still + deserved to see what it was they did not answer. What it produced was nine + screens of questions burying the one thing the section had to report. The + questions are not the ward's news; the silence is. So the finding stands on + its own, and the reader who wants the questions can have them from the + city-wide read, which also has answers on them. */ +function NoResponses({ issuesHref }: { issuesHref?: string }) { + return ( +
+

+ No candidate has responded +

+ {issuesHref && ( + + How the whole city answered + + + )} +
+ ); +} + function RaceHeading({ race }: { race: RaceView }) { return (
diff --git a/src/lib/elections/survey-answers.ts b/src/lib/elections/survey-answers.ts index 4b1057b..b04b1a2 100644 --- a/src/lib/elections/survey-answers.ts +++ b/src/lib/elections/survey-answers.ts @@ -1,9 +1,8 @@ // The questionnaire, fetched once and cut to a roster. // // Both the ward pages and the mayoral page draw the same grid from the same -// two York Factory resources, and both need the same two things out of them: -// the answers belonging to the candidates on the page, and the shape of the -// questionnaire itself for the case where none of them wrote back. +// two York Factory resources, and both need the same thing out of them: the +// answers belonging to the candidates on the page. // // Both halves are publish-gated, so an empty result is the normal case for // most of the campaign — not a failure. The questionnaire is a nice-to-have on @@ -16,9 +15,7 @@ import { byCandidateKey, candidateAnswers, candidateWriting, - questionnaireShape, type CandidateAnswers, - type ComparedGroup, type WrittenAnswer, } from "./candidate-answers"; import { @@ -30,17 +27,13 @@ import { fetchSurvey } from "./survey"; export type RosterSurvey = { /** the roster's own answers, keyed by `nameKey` */ answers: Record; - /** every question, with nobody attached — the grid's shape when the whole - * roster stayed quiet */ - shape: ComparedGroup[]; /** the roster's free-text answers, keyed by `nameKey`. The choices are what * a grid can compare; this is what the candidates wrote. */ written: Record; }; /** - * Every published answer belonging to `candidateKeys`, plus the questionnaire's - * shape. + * Every published answer belonging to `candidateKeys`. * * Answers we cannot match to a candidate on the roster are dropped. The join is * on name, and a response we cannot place is one we must not attribute. @@ -67,7 +60,6 @@ export async function rosterSurvey( return { answers: byCandidateKey(entries), - shape: questionnaireShape(survey, responses), /* Narrowed to the roster on the same rule the answers are: prose we cannot place on a candidate is prose we must not attribute. */ written: Object.fromEntries( @@ -75,6 +67,6 @@ export async function rosterSurvey( ), }; } catch { - return { answers: {}, shape: [], written: {} }; + return { answers: {}, written: {} }; } } From add9e2170f61140bccbd0da53c8396093302b9f9 Mon Sep 17 00:00:00 2001 From: Mikaal Naik Date: Thu, 17 Sep 2026 11:23:09 -0400 Subject: [PATCH 2/4] Open the split panel on the answer, and at a size that holds the names MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two faults in the panel behind a slice on /issues. It opened on a bare count — "38 candidates" — and nothing else. Which thirty-eight, of the three or four splits on the card, was something the reader had to carry from the row they clicked, and on a pie they may not have come from a row at all: a wedge of colour carries no wording. The answer the candidates were shown now heads the panel, dotted in the slice's own colour and set the way the legend sets it, with the count under it as the caption. And it was capped at fourteen rem, about ten names. The largest slice in the Toronto field is fifty-five, so the rest sat behind a scrollbar inside an overlay — a place readers do not look. The cap is now forty-six rem, what fifty-five names come to in two columns, or three-quarters of the window, whichever is smaller, so the panel shows everything it has and still cannot run off the screen. The heading sits outside the scroller so it stays put while the names move under it. Co-Authored-By: Claude Opus 5 (1M context) --- .../elections/QuestionSplitFigure.tsx | 95 +++++++++++++------ 1 file changed, 64 insertions(+), 31 deletions(-) diff --git a/src/components/elections/QuestionSplitFigure.tsx b/src/components/elections/QuestionSplitFigure.tsx index 3fc8ba2..08d06f9 100644 --- a/src/components/elections/QuestionSplitFigure.tsx +++ b/src/components/elections/QuestionSplitFigure.tsx @@ -53,11 +53,13 @@ import { OptionPie, percentOf } from "@/components/charts/trilemma"; * open, focus opens one, Escape and leaving the figure close it. What they * no longer do is open on hover. * - * THE PANEL IS BOUNDED, AND IT DOES TAKE THE POINTER - * It hangs under the band and lies across the legend rows, so left - * unbounded a forty-name segment ran past the foot of its own card and over - * the cards below — which stayed laid out as though nothing were there. It - * is capped and scrolls. + * THE PANEL IS A POPOVER, AND IT DOES TAKE THE POINTER + * It hangs under the pie and lies across the legend rows, and a slice on + * the city-wide page can hold fifty-five names — so it runs past the foot of + * its own card and over the cards below, which stay laid out as though + * nothing were there. That is what an overlay is; it is bounded by the + * window rather than by a guess at how many names is too many, and it leads + * with the answer it is the evidence for. See `Names`. * * Scrolling means it has to take the pointer, and there was a spell when it * could not: back when the legend rows opened on hover, the panel covering @@ -279,58 +281,89 @@ function Legend({ ); } -/* Who gave this answer, set just below the band. +/* Who gave this answer, set just below the pie. * * Over the card rather than in the flow of it: a panel that pushed the legend * down would move the rows out from under the reader as it opened, and shift * every card beneath it on the page. * - * The top offset is the band's own height and the gap under it, so the panel - * meets the bottom of the bar whichever option opened it. + * The top offset is the pie's own height and the gap under it, so the panel + * meets the bottom of the chart whichever option opened it. * - * And capped, because an overlay in nobody's layout is an overlay that will - * happily run over the card below it: a segment can hold forty people, which - * at two columns on a phone is twenty rows and taller than the card it - * belongs to. Fourteen rem holds the common cases outright and scrolls the - * rest. + * IT LEADS WITH THE ANSWER + * The panel used to open on a count — "38 candidates" — and nothing else. + * Which thirty-eight, of the three or four splits on the card, was a fact + * the reader had to hold from the row they clicked, and on a pie they may + * have come from a slice rather than a row: a wedge of colour carries no + * wording at all. So the wording the candidates were shown is the first + * thing in the panel, set the way the legend sets it and dotted in the + * slice's own colour, and the count sits under it as the caption it is. + * + * IT IS AS TALL AS IT CAN BE + * The cap was fourteen rem, which held about ten names of a slice that on + * the city-wide page runs to fifty-five: the rest were behind a scrollbar + * inside an overlay, which is a place readers do not look. The panel now + * takes the height it needs up to forty-six rem — what fifty-five names + * come to in two columns, with the heading over them — and stops there or + * at three-quarters of the window, whichever comes first, so it can never + * run off the screen it opened on. + * + * It can and does run over the cards below, which stay laid out as though + * nothing were there. That is what an overlay is, and it is why the figure + * lifts to `z-30` while a panel is open: what the reader sees is a popover + * above the page, held open only as long as the pointer stays in the figure. + * + * The heading is outside the scroller rather than sticky inside it, so the + * answer stays put while fifty names move under it. * * Names as plain text, not as the bordered plates the ward pages use. A plate - * is an object a reader counts in a field of four or five; forty of them in a + * is an object a reader counts in a field of four or five; fifty of them in a * panel is a mosaic, and the count is already at the top of the panel. */ function Names({ slice, onDismiss }: { slice: SplitSlice; onDismiss: () => void }) { return (
-

- {slice.names.length === 1 - ? "1 candidate" - : `${slice.names.length} candidates`} -

- {/* 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. +
+

+

+

+ {slice.names.length === 1 + ? "1 candidate" + : `${slice.names.length} candidates`} +

+
+ + {/* A grid, not CSS columns. A segment can hold fifty people and the + panel is capped, 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 + window's: these cards sit two and three to a row on a wide screen, so + the panel is around four hundred 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 + slice they recognise — and fifty of them is a block that has to sit under the chart without becoming the card. */} -
    +
      {slice.names.map((candidate) => (
    • Date: Thu, 17 Sep 2026 12:36:24 -0400 Subject: [PATCH 3/4] Keep the split panel inside the card, and buy its height with columns MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Follow-on to the panel opening on its answer. Three changes, all about the shape of it. The ballot line comes off the names. "· Ward 17" or "· For mayor" after every name was longer than most of the names it trailed, and it set the width of every column in the panel — for a fact the reader is not in the panel for. They opened it to see who took a position; the ward pages are where a name is matched to a ballot. With short names the panel buys height with columns instead of a taller box: around a hundred and ten pixels a column, two or three to a card, so a slice of fifty-five is nineteen rows rather than twenty-eight. The breakpoints are the panel's own width, since these cards run one, two and three to a row and the same window gives the panel three widths. And the panel is exactly the card's width — the figure sits inside the card's padding, so the panel gives that padding back and lands flush inside the card's border. A version that broke out past the card was shorter still and looked wrong: wider than the card it belongs to, it reads as something arriving over the page rather than coming out of the card, and it cannot be caught in a screenshot of that card. The question heading drops to 1.25rem with it. Ninety characters over a card three to a row took three and four lines at 1.45rem, and the page read as a stack of headlines with charts attached. Co-Authored-By: Claude Opus 5 (1M context) --- src/components/elections/QuestionSplit.tsx | 37 ++-- .../elections/QuestionSplitFigure.tsx | 181 ++++++++++++------ .../elections/QuestionnaireCards.tsx | 7 +- 3 files changed, 142 insertions(+), 83 deletions(-) diff --git a/src/components/elections/QuestionSplit.tsx b/src/components/elections/QuestionSplit.tsx index 9ef68d2..eb42bb8 100644 --- a/src/components/elections/QuestionSplit.tsx +++ b/src/components/elections/QuestionSplit.tsx @@ -2,7 +2,6 @@ import { rollCall } from "@/lib/elections/candidate-answers"; import { EMPTY, optionColors } from "@/lib/elections/option-colors"; import { QuestionSplitFigure } from "./QuestionSplitFigure"; import type { SplitSlice } from "./QuestionSplitFigure"; -import type { Seat } from "./QuestionRollCall"; import type { ComparedQuestion } from "@/lib/elections/candidate-answers"; /* One question as a card, read as a share of the field rather than a roll call. @@ -36,11 +35,11 @@ import type { ComparedQuestion } from "@/lib/elections/candidate-answers"; * Behind the legend rows, one option at a time — see QuestionSplitFigure. * Hidden, they stop crowding out the split; reachable, the reader can still * check who is in a segment, which is the difference between a chart and a - * chart you have to take on trust. Each name carries the seat it is running - * for, because a name on a city-wide page is only useful once the reader - * knows whether it is on their ballot. What they WROTE stays on the ward and - * mayoral pages: a note is a paragraph, and a panel of thirty paragraphs is - * the page this card was drawn to get away from. + * chart you have to take on trust. Names alone — the seat each is running + * for rode beside them once and cost more width than it paid for; see + * `named`. What they WROTE stays on the ward and mayoral pages: a note is a + * paragraph, and a panel of thirty paragraphs is the page this card was + * drawn to get away from. * * COLOUR * The same ramps as everywhere else in the tracker (lib/elections/ @@ -52,13 +51,9 @@ import type { ComparedQuestion } from "@/lib/elections/candidate-answers"; export function QuestionSplit({ question, - seats, headingId, }: { question: ComparedQuestion; - /** the seat each candidate is running for, keyed by candidate key — printed - * beside their name in the panel behind a segment. */ - seats?: Record; /** the id the scroll rail scrolls to — see QuestionRollCall */ headingId?: string; }) { @@ -66,15 +61,16 @@ export function QuestionSplit({ const colors = optionColors(question.options.length, question.ordinal); + /* Name only. This card took a `seats` map once and printed "· Ward 17" or + "· For mayor" after every name in the panel, on the argument that a name + on a city-wide page is only useful once the reader knows whether it is on + their ballot. True, and still the wrong place for it: the suffix is + longer than most of the names it trails, it set the width of every column + in the panel, and the panel is a list of fifty the reader scans for one + they recognise. The ward pages are where a name is matched to a ballot. */ const named = (candidate: { key: string; name: string }) => ({ key: candidate.key, name: candidate.name, - /* Mayoral candidates carry no label of their own — a ward number is the - thing that tells a reader whether a name is on their ballot, and "For - mayor" has to be spelled out rather than left blank beside it. */ - seat: seats?.[candidate.key] - ? (seats[candidate.key].label ?? "For mayor") - : undefined, }); const slices: SplitSlice[] = groups.map((group) => ({ @@ -104,9 +100,16 @@ export function QuestionSplit({ return (
      + {/* Smaller than a card heading usually runs, and smaller than it was. + A question here is a line of up to ninety characters over a card + three to a row, so at 1.45rem it took three and four lines and the + page read as a stack of headlines with charts attached. The chart is + what the reader is scanning; the question is what tells them which + chart it is. Close to the roll-call card's own base size, so the two + kinds of card sit at one scale where a reader meets both. */}

      {question.question}

      diff --git a/src/components/elections/QuestionSplitFigure.tsx b/src/components/elections/QuestionSplitFigure.tsx index 08d06f9..db99534 100644 --- a/src/components/elections/QuestionSplitFigure.tsx +++ b/src/components/elections/QuestionSplitFigure.tsx @@ -61,6 +61,13 @@ import { OptionPie, percentOf } from "@/components/charts/trilemma"; * window rather than by a guess at how many names is too many, and it leads * with the answer it is the evidence for. See `Names`. * + * It is exactly the card's width, though, and there was a version that was + * not: it broke out past the card on both sides so a big slice could be + * short, which worked and still looked wrong. A panel wider than the card it + * belongs to reads as a different object arriving over the page, and it + * cannot be screenshotted with that card — which is how these get shared. + * Edges are worth more here than rows. See `Names`. + * * Scrolling means it has to take the pointer, and there was a spell when it * could not: back when the legend rows opened on hover, the panel covering * them stole the hover the instant it appeared, so the row fired mouseleave, @@ -83,9 +90,11 @@ export type SplitSlice = { /** the option as it was put to the candidates */ label: string; color: string; - /** who gave this answer, surname order, with their ballot line where the - * page tracks one */ - names: { key: string; name: string; seat?: string }[]; + /** who gave this answer, surname order. Names only: the ballot line used to + * ride along beside each one and it doubled the width of a column for a + * fact the reader is not in the panel for — they opened it to see who took + * a position, and the ward pages are where a name is matched to a ballot. */ + names: { key: string; name: string }[]; }; export function QuestionSplitFigure({ @@ -128,21 +137,17 @@ export function QuestionSplitFigure({ return (
      { if (event.key === "Escape") close(); }} @@ -299,22 +304,36 @@ function Legend({ * thing in the panel, set the way the legend sets it and dotted in the * slice's own colour, and the count sits under it as the caption it is. * - * IT IS AS TALL AS IT CAN BE - * The cap was fourteen rem, which held about ten names of a slice that on - * the city-wide page runs to fifty-five: the rest were behind a scrollbar - * inside an overlay, which is a place readers do not look. The panel now - * takes the height it needs up to forty-six rem — what fifty-five names - * come to in two columns, with the heading over them — and stops there or - * at three-quarters of the window, whichever comes first, so it can never - * run off the screen it opened on. + * IT IS THE CARD'S WIDTH, AND SO IT GETS ITS HEIGHT FROM THE COLUMNS + * The panel runs to the card's own edges — the figure sits inside the card's + * padding, so the panel gives that padding back with a negative inset and + * lands flush inside the card's border. + * + * There was a version that broke out past the card entirely: fifty-five + * names went to five columns over eleven rows and the thing was twenty rem + * tall, the shortest it has ever been and still the wrong answer. A panel + * wider than the card it belongs to reads as something that has arrived over + * the page rather than come out of the card, and it cannot be caught in a + * screenshot of that card. + * + * So height is bought with columns instead, inside that width. The names are + * short now that none of them trails a ward, so the column is around a + * hundred and ten pixels and a card fits two or three of them — and a slice + * of fifty-five, twenty-eight rows at the old size, is nineteen. * - * It can and does run over the cards below, which stay laid out as though - * nothing were there. That is what an overlay is, and it is why the figure - * lifts to `z-30` while a panel is open: what the reader sees is a popover - * above the page, held open only as long as the pointer stays in the figure. + * The breakpoints are the panel's own width, not the window's: these cards + * run one, two and three to a row, so the same window gives the panel three + * different widths. Hence the container query on the panel, and the + * arbitrary values — the stock `@xs` lands at twenty rem, just above the + * panel a phone gives, which would drop that whole case to one column. + * + * It still runs over the cards below, which stay laid out as though nothing + * were there. That is what an overlay is, and it is why the figure lifts to + * `z-30` while a panel is open: what the reader sees is a popover above the + * page, held open only as long as the pointer stays in the figure. * * The heading is outside the scroller rather than sticky inside it, so the - * answer stays put while fifty names move under it. + * answer stays put while names move under it. * * Names as plain text, not as the bordered plates the ward pages use. A plate * is an object a reader counts in a field of four or five; fifty of them in a @@ -322,57 +341,93 @@ function Legend({ function Names({ slice, onDismiss }: { slice: SplitSlice; onDismiss: () => void }) { return (
      -
      -

      -

      +

      -

      + + {slice.names.length === 1 ? "1 candidate" : `${slice.names.length} candidates`} -

      -
      + +

      + + {/* A grid, not CSS columns. Multi-column laid out inside a box with a + ceiling on it does not grow downwards — it fragments sideways, + opening a further 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 would scroll if it ever + had to. + + The breakpoints are the panel's own width, and they are arbitrary + values rather than the stock ones because of where the stock ones + fall: `@xs` is twenty rem, and the panel on a phone or a two-up + tablet card is a shade under that, so the whole of that case would + drop to a single column. These are set at the widths that actually + hold a column of names — two from fifteen rem, three from twenty-two. - {/* A grid, not CSS columns. A segment can hold fifty people and the - panel is capped, 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. + Three, not more. The three-up cards give a column of 105 to 125 + pixels, where about an eighth of this field's names wrap to a second + line; a fourth column would put it under ninety, where more than a + third of them do. The cap is what keeps the panel a list rather than + a paragraph per name. - How many columns is a question about the panel's width and not the - window's: these cards sit two and three to a row on a wide screen, so - the panel is around four hundred pixels there and a viewport-keyed - third column would have squeezed every name onto two lines. Hence the - container query on the figure. + `overflow-y-auto` is the valve, not the plan: at three columns a + fifty-five-name slice is nineteen rows and well inside the panel's + ceiling, at two it is twenty-eight and still inside it, and only a + phone on a short window actually scrolls. - 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 fifty of them is a block that has to sit - under the chart without becoming the card. */} -
        + Set small, and smaller than the page's other lists of names. 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 fifty of them is a block that has to sit under the + chart without becoming the card. The size is also what puts a name in + a column this narrow on one line, so the two are set together. */} +
          {slice.names.map((candidate) => (
        • {candidate.name} - {candidate.seat && ( - · {candidate.seat} - )}
        • ))}
        diff --git a/src/components/elections/QuestionnaireCards.tsx b/src/components/elections/QuestionnaireCards.tsx index e0e16ef..23f9f6e 100644 --- a/src/components/elections/QuestionnaireCards.tsx +++ b/src/components/elections/QuestionnaireCards.tsx @@ -112,8 +112,10 @@ export function QuestionnaireCards({ /** the whole city's answers, where this election has that page */ issuesHref?: string; /** the seat each candidate is running for, keyed by candidate key — see - * QuestionRollCall, and QuestionSplit, which prints it beside the names - * behind a segment. Only the city-wide page passes one. */ + * QuestionRollCall. The split cards took this too and printed it beside + * every name in the panel behind a segment; they no longer do, so on the + * city-wide page this now reaches nothing. Kept because the same component + * draws the roll-call pages, which do print it. */ seats?: Record; /** what each candidate is on the ballot — "Incumbent", "Challenger" — keyed * by candidate key. See QuestionRollCall. */ @@ -218,7 +220,6 @@ export function QuestionnaireCards({ ) : ( From 4aa6c4eccf4e44fb87d98142cbec9f5085b567a3 Mon Sep 17 00:00:00 2001 From: Mikaal Naik Date: Thu, 17 Sep 2026 12:36:35 -0400 Subject: [PATCH 4/4] Point the Toronto compare card at the survey The card sold a signup because the questions were written and the answers were not in. They are now, so it links the survey itself and the modal copy that stood in for it comes off. Co-Authored-By: Claude Opus 5 (1M context) --- src/app/toronto/page.tsx | 14 ++++---------- 1 file changed, 4 insertions(+), 10 deletions(-) diff --git a/src/app/toronto/page.tsx b/src/app/toronto/page.tsx index 413b13c..608710c 100644 --- a/src/app/toronto/page.tsx +++ b/src/app/toronto/page.tsx @@ -11,6 +11,7 @@ import { breakdown, msUntil, periodTiming } from "@/lib/elections/dates"; import { isDaylight } from "@/lib/daylight"; import { ELECTION } from "./vote/2026/data"; import { STAGE_ONE } from "./vote/survey-questions/questions"; +import { SURVEY_PATH } from "./vote/survey-questions/path"; import { WARD_GEO, WARD_SHAPES } from "./vote/2026/wardGeo"; import { ELECTION_DAY, @@ -304,17 +305,10 @@ function ElectionCardsSection() {