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() { 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/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 3fc8ba2..db99534 100644 --- a/src/components/elections/QuestionSplitFigure.tsx +++ b/src/components/elections/QuestionSplitFigure.tsx @@ -53,11 +53,20 @@ 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`. + * + * 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 @@ -81,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({ @@ -126,21 +137,17 @@ export function QuestionSplitFigure({ return (
{ if (event.key === "Escape") close(); }} @@ -279,67 +286,148 @@ 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. + * + * 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 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. + * + * 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. * - * 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. + * The heading is outside the scroller rather than sticky inside it, so the + * 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; 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`} + {/* One line, not a masthead. This was a bordered block with the answer + on one row and the count under it, which meant a rule across the + whole panel and two lines of near-empty space over a list that is + the point of opening it. The answer, its colour + and its count are one sentence — "Yes, with conditions · 38 + candidates" — so they are set as one, and the names start directly + under it. + + Still outside the scroller, and still `flex-none`: on the narrow + windows where the list does scroll, the answer is what the names + scrolling past are an answer to. */} +

+

- {/* 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 + + {/* 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 scrolls. + 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. + + 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 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. + `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 forty 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 1b57e8b..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({ ) : ( @@ -252,87 +253,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: {} }; } }