Skip to content

Publish the candidates' answers again, and rebuild how a question reads - #88

Merged
mikaalnaik merged 8 commits into
mainfrom
mikaal/revamp-toronto-survey
Sep 16, 2026
Merged

mikaalnaik merged 8 commits into
mainfrom
mikaal/revamp-toronto-survey

Conversation

@mikaalnaik

@mikaalnaik mikaalnaik commented Sep 14, 2026

Copy link
Copy Markdown
Contributor

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

questionnaireHidden and ANSWERS_WITHHELD are deleted, so rosterSurvey and the candidate-responses read proxy pass answers through again. /issues and /mayor lose their temporary 307s in next.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:

respondents wards
0 2, 3, 5, 6, 16, 18, 20, 21 — 8 of 25
1–2 10 wards
3–4 6 wards
6 ward 13 only

Median ward: 2 respondents over 33 questions. /mayor is the opposite — 14 respondents, ~27 name plates per card. One design was serving both and was built for the dense end.

  • Empty wards no longer draw 34 cards reading "No answers to this one yet." They get QuestionnaireOutline — the questions in two columns under their section headings, keeping every id a card carried. ~9,000px becomes ~900px.
  • The repeated footer is gone. Ward 1 was printing the same 140-character list of non-respondents at the foot of all 33 cards. The roster above already names and links them. Cards still name respondents who skipped that question, which is per-question and worth saying.
  • A question is now a table of candidates — a row each in surname order, answer beside them, so names and answers are both columns you can run down and a candidate sits in the same place on every card. The answer prints in full (outside yes/no these run to ~90 characters, so it wraps rather than being a pill). What a candidate wrote opens in a <details> — no script, works with JS off, card stays a Server Component.
  • Only respondents get a row; a line under the question says how much of the ballot that accounts for. Toronto's council races are non-partisan, so the line under a name is the incumbent badge only — "Challenger" under every other name would have been ~200 printings of a word that separates nobody.

Layout notes for review

  • Two cards per row from 1166px. Rows inside size against the card via a container query, not the window — at half width a viewport-keyed template would lay out three columns for 1000px inside a 460px card.
  • content-start on the card and its nested grids is load-bearing: two-up means both cards stretch to the taller, and grid's align-content defaults to normalstretch, so opening one answer was sliding the neighbouring card's heading, table and foot apart. The card was flex flex-col before, which is why this never showed.
  • Rows animate via ::details-content. interpolate-size is set on the element, not :root, so keyword interpolation doesn't quietly change every other transition in the document. It carries its own prefers-reduced-motion guard because the global rule reaches *, ::before and ::after — not this pseudo-element.

3. The voter survey reopens

One line off the registry entry, which is how surveyClosed was designed to be undone. surveyHref is 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 /mayor and /issues, and the SurveyCta on all 25 ward pages.

⚠️ The submissions endpoint is live again, not just the links. POST /api/elections/survey stops 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-election

Carried 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-t of the proxy-voting paragraph beneath, which carried max-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 /issues overflowed sideways

A 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-3 was firing off a 640px viewport.

Worth a reviewer's eye: container-type: inline-size carries contain: layout, which makes the figure a stacking context where plain position: relative was not. The panel's z-20 had 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 takes z-30 while a panel is open, and only while open.

5. /issues presentation

Three 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 OptionPie beside OptionBar in 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).

⚠️ This reverses an earlier decision. The figure was a donut once and was deliberately replaced by the band. That argument still holds: the page's work is comparing splits across 33 cards, which is aligned lengths on bars versus angles across separate circles — and 26 of the 33 questions are the ordered yes / yes-with-conditions / no scale (they already use the diverging ramp for exactly that reason), with five more having two options, where a pie is two slices. The reasoning is recorded in both files rather than argued away, and OptionBar is still exported with the same props, so reverting is an import and a figure element.

The survey CTA moved up beside the title, in addition to the closing band. Same SurveyCta as 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_target is a textarea authored directly beneath the choice question it follows up on, so writtenQuestions swept 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: followUpQuestions takes, 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 from explanation, which is reasoning nobody asked for.

Against live data: writtenQuestions now yields bio alone, and 26 answers carry a follow-up under performance_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.

CandidatePortrait is that plate, extracted rather than copied a third time. The copies had drifted in the one way that matters: the sizes hint, which decides how large a file the browser fetches. It is now derived from the size.

We hold a photograph for 44 of Toronto's 388 registrants, so most rows are a monogram rather than a face. That is deliberate — a plate appearing only where there is a photo leaves names starting in two different places down one column, which reads as a fault in the page rather than a fact about the ballot. Measured: /mayor renders 256 photos against 192 monograms.

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.

printWriting prints it outright: no details, no summary, no icon. The icon's trailing 1rem column 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.

page <details> icons grid
candidates/darrell-brown 0 0 2-column
wards/25 16 32 3-column
mayor 322 644 3-column
issues 0 0 n/a (bands)

Deliberately not done

  • /issues gets 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 existing notes={false}.
  • The resident survey results page (AlignmentResults) takes its roster over a JSON API route at runtime; portraits there would mean changing that payload. It renders unchanged.
  • MayoralCard and CouncilCandidate were refactored onto CandidatePortrait but do not render on any route I could reach — the ward pages use the questionnaire grid now, and CouncilCandidate is school-board-only. The markup is identical apart from the initials gaining aria-hidden (the name is always printed beside them), but that pair is unverified in a browser.

Verification

npx tsc --noEmit and npm run lint clean on every commit (4 pre-existing warnings elsewhere). Against the dev server: all 25 wards plus /, /survey, /issues, /mayor, /mayor/candidates and 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, /issues is unchanged.

npm run build fails at /tracker, which prerenders against localhost: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/17 is the richest page for the card, and /toronto/vote/2026/candidates/darrell-brown for section 6 (he is one of the ten respondents we hold a photograph for).

🤖 Generated with Claude Code

mikaalnaik and others added 2 commits September 14, 2026 14:45
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>

@greptile-apps greptile-apps Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

mikaalnaik and others added 5 commits September 14, 2026 15:30
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>
@mikaalnaik mikaalnaik self-assigned this Sep 15, 2026
@mikaalnaik
mikaalnaik requested a review from xrendan September 15, 2026 16:27
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>
@mikaalnaik
mikaalnaik merged commit e8de0fb into main Sep 16, 2026
1 check passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant