Publish the candidates' answers again, and rebuild how a question reads - #88
Merged
Merged
Conversation
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 `<details>` — 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) <noreply@anthropic.com>
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) <noreply@anthropic.com>
There was a problem hiding this comment.
This review was skipped because it would exceed your organization's monthly flex usage limit. Raise the limit in billing settings or wait until the next billing period resets limits.
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) <noreply@anthropic.com>
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) <noreply@anthropic.com>
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) <noreply@anthropic.com>
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) <noreply@anthropic.com>
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) <noreply@anthropic.com>
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 `<details>` 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) <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Brings the candidates' questionnaire answers back, rebuilds the card they are read in, and reopens the voter survey.
Two commits, each standing on its own.
1. The answers come back
questionnaireHiddenandANSWERS_WITHHELDare deleted, sorosterSurveyand the candidate-responses read proxy pass answers through again./issuesand/mayorlose their temporary 307s innext.config.ts. The ward pages,/mayor/candidates, the candidate pages and the landing grid return to the questionnaire presentation.2. The card is rebuilt, because the field is sparser than it assumed
Measured across all 25 wards against the dev server:
Median ward: 2 respondents over 33 questions.
/mayoris the opposite — 14 respondents, ~27 name plates per card. One design was serving both and was built for the dense end.QuestionnaireOutline— the questions in two columns under their section headings, keeping every id a card carried. ~9,000px becomes ~900px.<details>— no script, works with JS off, card stays a Server Component.Layout notes for review
content-starton the card and its nested grids is load-bearing: two-up means both cards stretch to the taller, and grid'salign-contentdefaults tonormal→stretch, so opening one answer was sliding the neighbouring card's heading, table and foot apart. The card wasflex flex-colbefore, which is why this never showed.::details-content.interpolate-sizeis set on the element, not:root, so keyword interpolation doesn't quietly change every other transition in the document. It carries its ownprefers-reduced-motionguard because the global rule reaches*,::beforeand::after— not this pseudo-element.3. The voter survey reopens
One line off the registry entry, which is how
surveyClosedwas designed to be undone.surveyHrefis the single rule all five invitations ask, so they return together: the landing card (between the mayoral read and the whole-field one, as that page's own comment describes), its closing CTA, the bands on/mayorand/issues, and theSurveyCtaon all 25 ward pages.POST /api/elections/surveystops returning 404 and starts recording real responses. I did not test-submit, since that would write an actual survey response — worth a deliberate check of the write path before this ships.4. Two unrelated fixes
A truncated rule on
/when-is-the-electionCarried here rather than split out, since it touches one file nothing else on this branch goes near.
The line closing the three-way ways-to-vote grid stopped about two thirds across. It is the
border-tof the proxy-voting paragraph beneath, which carriedmax-w-[62ch]on the same element — a max-width bounds the box the border is drawn on. The gutters and the rule move to a wrapper; the measure stays on the paragraph.It showed there and not elsewhere because that rule closes a full-bleed grid between two full-width ones. Three other spots pair a border with a
max-w(AlignmentResults.tsx:203,SurveyClient.tsx:779,ElectionLanding.tsx:372) and are left alone — each is a short rule above a footnote, where matching the note's measure is a plausible choice.The names panel on
/issuesoverflowed sidewaysA segment's panel lists everyone who gave that answer, capped at
max-h-[14rem]and scrolling past it — but the names were in CSS columns, and multi-column inside a height-capped box fragments sideways rather than growing down, opening extra columns past the panel's right edge. The overflow ran horizontally while the scrolling ran vertically, so those names were unreachable. It's a grid now, which fills rows downward.Column count is keyed to the panel rather than the window: these cards are two-up on a wide screen, so the panel is ~460px there while
sm:columns-3was firing off a 640px viewport.Worth a reviewer's eye:
container-type: inline-sizecarriescontain: layout, which makes the figure a stacking context where plainposition: relativewas not. The panel'sz-20had lifted it clear of the page; scoped to its own figure, the card below — later in the tree, and exactly what the panel opens over — would have painted on top. The figure now takesz-30while a panel is open, and only while open.5.
/issuespresentationThree separate commits, each revertible on its own.
Three cards to a row from 1280. Charts only — roll calls are tables whose answer column already runs to ninety characters. Not from 1166, because the scroll rail arrives at 1200 and takes 268px with it, so splitting three ways there would drop a card from ~510px straight to 283px. Measured cost: the option wordings are full sentences (median 71 chars, max 108), so a legend row goes from two lines to three or four. Taller cards, a third fewer rows, much the same total height.
The split is drawn as a pie. New
OptionPiebesideOptionBarin the charts package — same props, same hues, same hover wiring, so the legend and names panel are untouched. Slices are separated by a 2px stroke in the page background rather than a border; a 100% option is drawn as a<circle>, since a full-turn arc has identical endpoints and renders as nothing. Geometry verified numerically (starts at 12 o'clock, contiguous boundaries, closes the circle, large-arc flag correct at the half turn).The survey CTA moved up beside the title, in addition to the closing band. Same
SurveyCtaas the landing page, at its default "beside a heading" width rather than the landing's 560px band override. The page now shows that block twice — there's a fair argument the top one should replace the closing band rather than join it.6. A candidate's own page reads as a candidate, not as a field of one
The card in sections 2 and 5 was designed against a ward. A profile page runs the same card over a roster of one, and three things it does for a field are wrong for a person.
A follow-up was printing under the bio.
ward_commitment_targetis atextareaauthored directly beneath the choice question it follows up on, sowrittenQuestionsswept it in with the bio and every profile printed "If you selected "Ward commitment," state one numerical target and a deadline" under the 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 available:
followUpQuestionstakes, for each choice question, the textarea authored beneath it in the same step. Those leave the prose and travel on the answer, 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 fromexplanation, which is reasoning nobody asked for.Against live data:
writtenQuestionsnow yieldsbioalone, and 26 answers carry a follow-up underperformance_metric.Faces on the questionnaire rows. The ward cards, the mayoral field and a candidate's own hero all print a portrait; the rows — the one list where a reader hunts for a single person among thirty — printed names alone. They now carry the plate, built from the roster the component is already handed, so no caller changed.
CandidatePortraitis that plate, extracted rather than copied a third time. The copies had drifted in the one way that matters: thesizeshint, which decides how large a file the browser fetches. It is now derived from the size.The disclosure is gone from profile pages.
<details>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 profile 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 hiding it.printWritingprints it outright: no details, no summary, no icon. The icon's trailing1remcolumn goes with it, which is what puts the answer against the card's right edge instead of a rem short of it, and the answer badge stops spanning a column that no longer exists when the row stacks on a narrow screen. The two grid templates are now named constants shared by the column heads and the rows beneath them, so they cannot disagree.<details>candidates/darrell-brownwards/25mayorissuesDeliberately not done
/issuesgets no portraits. Its names come from the responses rather than the ballot and carry no image, so the map is empty and the page is untouched — the same reasoning as its existingnotes={false}.AlignmentResults) takes its roster over a JSON API route at runtime; portraits there would mean changing that payload. It renders unchanged.MayoralCardandCouncilCandidatewere refactored ontoCandidatePortraitbut do not render on any route I could reach — the ward pages use the questionnaire grid now, andCouncilCandidateis school-board-only. The markup is identical apart from the initials gainingaria-hidden(the name is always printed beside them), but that pair is unverified in a browser.Verification
npx tsc --noEmitandnpm run lintclean on every commit (4 pre-existing warnings elsewhere). Against the dev server: all 25 wards plus/,/survey,/issues,/mayor,/mayor/candidatesand a candidate page return 200. Ward 5 (empty) shows the outline, ward 1 has no repeated footer, ward 17 shows the incumbent label on the 23 questions Shelley Carroll answered,/issuesis unchanged.npm run buildfails at/tracker, which prerenders againstlocalhost:3000/api/dashboard/.... Verified pre-existing on a clean tree — unrelated to this branch.Not verified visually. No browser was available in any session on this branch, so every layout decision here is reasoned from the CSS and measured from rendered markup rather than seen. The container-query breakpoints, the answer-block tint, the icon alignment, the 220ms reveal, the 28px portrait in a compact row and the printed card's right edge all deserve a real look —
/toronto/vote/2026/wards/17is the richest page for the card, and/toronto/vote/2026/candidates/darrell-brownfor section 6 (he is one of the ten respondents we hold a photograph for).🤖 Generated with Claude Code