Give every Toronto candidate a page of their own - #81
Conversation
A candidate's name was a link to their campaign site, everywhere a name
appeared. So the one element that identified a person was also the exit,
and a reader who clicked it landed on a campaign's own account of the
candidate with the ward, the questionnaire answers and the ballot they
are on all left behind.
Names now lead to a page of ours at /toronto/vote/2026/candidates/:slug,
and the campaign site is a link on it — one fact among the race they are
standing in, the answers they gave us, and where the rest of their
ballot line is. 378 pages, slugged from the name, since the roster is
rebuilt from the City Clerk's feed daily and carries no candidate ids.
One shared CandidateNameLink replaced every outbound name link: the
questionnaire column heads and rosters, the mayoral cards and city-wide
race rows, the trustee cards, and the mayoral roster page. It falls back
to the old outbound link for regions with no candidate route, gated on a
new `candidateProfiles` registry flag, so a name never points at a 404.
The questionnaire reads through the ward pages' own cards — the same
QuestionnaireRail and QuestionnaireCards, given a roster of one — so the
two can never disagree about how a questionnaire reads. That retired
CandidateSurveyAnswers, which the profile page was the only consumer of,
and with it AnswerOptionList, which nothing imported any more.
Analytics keep the funnel whole: `candidate_website_clicked` still fires
from the profile page's outbound link, with `candidate_profile_clicked`
in front of it, same property names so a breakdown by `candidate_key`
reads across the pair.
Two things the roster forced, both verified against the live feed:
· Ten candidates registered in one race, withdrew, and registered in
another. Profiles take their identity from a race they are still
standing in — otherwise Dianne Saxe's page read "Withdrawn" while
she stands in Toronto-Danforth — and name the abandoned race
separately.
· Toronto's trustee races carry their district as a school-board ward
number that RaceView dropped, so those pages read "City-wide · Every
ward votes". RaceView now surfaces `districtNumber` and they read
"Trustee — Toronto Catholic District School Board, Ward 9".
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Greptile SummaryThe PR adds first-party profile pages for Toronto election candidates and changes candidate-name links to lead to those pages while retaining campaign links and analytics.
Confidence Score: 4/5The PR is not yet safe to merge because the previously reported outage fallback still renders council-profile links that lead to 404 pages. When York Factory is unavailable, ward fallback data still includes locally sourced councillors and links their names internally, while the profile fallback searches only mayoral candidates and consequently rejects those council slugs. Files Needing Attention: src/app/toronto/vote/2026/data.ts
|
| Filename | Overview |
|---|---|
| src/app/toronto/vote/2026/data.ts | Adds candidate-profile loading and fallback construction alongside trustee district metadata. |
| src/app/toronto/vote/2026/candidates/[candidate]/page.tsx | Adds the candidate profile route with race details, biography, campaign links, questionnaire answers, and ballot navigation. |
| src/components/elections/CandidateNameLink.tsx | Centralizes candidate-name links, selecting an internal profile or external campaign site according to election capabilities. |
| src/lib/elections/election-data.ts | Extends normalized election data with candidate profiles and race district information. |
Reviews (3): Last reviewed commit: "Let the bio prose fill the width" | Re-trigger Greptile
| const view = fallbackView(); | ||
| return view.mayoral.map((candidate) => ({ |
There was a problem hiding this comment.
Fallback profiles omit councillors
When York Factory is unavailable, ward pages still render locally enriched council candidates and link their names to internal profiles, but this fallback returns only mayoral profiles. getToronto2026Candidate therefore cannot resolve those council links and the candidate pages return 404.
Knowledge Base Used:
Prompt To Fix With AI
This is a comment left during a code review.
Path: src/app/toronto/vote/2026/data.ts
Line: 180-181
Comment:
**Fallback profiles omit councillors**
When York Factory is unavailable, ward pages still render locally enriched council candidates and link their names to internal profiles, but this fallback returns only mayoral profiles. `getToronto2026Candidate` therefore cannot resolve those council links and the candidate pages return 404.
**Knowledge Base Used:**
- [Municipal election engagement](https://app.greptile.com/buildcanada/-/custom-context/knowledge-base/buildcanada/tradingpost/-/docs/municipal-elections.md)
- [Ward lookup and candidate information](https://app.greptile.com/buildcanada/-/custom-context/knowledge-base/buildcanada/tradingpost/-/docs/ward-lookup.md)
---
For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.55 of the 58 candidates who returned the questionnaire wrote a bio in it, and the site showed none of them. The bio lives in the questionnaire's `about-you` step, which `comparableQuestions` excludes as non-policy — correctly, for a pivot that exists to compare choices against a field. But nothing ever picked the written half back up, so the only bio a candidate page could print was the hand-maintained one in candidates.ts, which is empty for all but a handful. We held 55 self-authored biographies and published zero. `writtenQuestions` is the complement of `comparableQuestions`, and `candidateWriting` pivots it per candidate the way `candidateAnswers` pivots the choices — with no counts, charts or comparison, because a paragraph cannot be counted against a field. `rosterSurvey` returns it alongside the answers, narrowed to the roster on the same rule: prose we cannot place on a candidate is prose we must not attribute. TEXTAREA ONLY, WHICH IS THE PRIVACY GUARD Not "everything that is not a choice". Every free-text field in the resident survey is identity — postal code, first name, last name, email — and all four are `text` or `email`. Selecting the long-form type is what keeps a contact field added in the CMS from ever arriving here as publishable prose. It is also the guard that does the work: postal_code sits in `about-you`, which the consent-step exclusion deliberately lets through so a candidate's bio can pass. ON THE PAGE A section of its own between the ballot facts and the questionnaire, in the order the page actually reads — who is this, then what do they think. Not the hero: these run to a median of 800 characters, half are several paragraphs, and the longest is 1,800, which in the hero pushed the ward and the answers off the screen. Attributed before it is read. A candidate's account of themselves set in the same type as the rest of the page would read as ours, so the section says who wrote it and that it is not our description of them. Blank lines are kept as paragraph breaks and single newlines as typed. `ward_commitment_target` — the numerical target and deadline, answered by 21 — comes through the same mechanism and prints under its question. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Set as one column it was 640px of serif — the width a paragraph can be read at — sitting in a 1300px band, so two-thirds of the section was air with a heading floating at the top of it. The heading and the attribution now take a rail of their own and the prose keeps its measure beside them, which spends the width on structure rather than on a line too long to read. The rail is sticky, so the line saying these are the candidate's words and not ours stays beside the prose it qualifies instead of being something the reader passes once and scrolls away from. Below lg it stacks as before. Also drops the section's "In their own words" eyebrow, which sat directly on top of a line already saying the candidate wrote this and we did not — the label and its own caption, stacked. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The measure cap was leaving a third of the band empty; filling the width is the call. So the prose runs the full width of its column, and the question headings on the follow-up answers lose their cap too. Leading goes up with the line length — 1.6 to 1.75 on the bio, 1.55 to 1.7 on the rest. A long line needs more space beneath it for the eye to find the start of the next one, which is the one thing available for readability that does not narrow the column again. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A candidate's name was a link to their campaign site, everywhere a name appeared — the mayoral cards, every ward roster, every questionnaire column head. So the one element that identified a person was also the exit, and a reader who clicked it landed on a campaign's own account of the candidate with the ward, the questionnaire answers and the ballot they're on all left behind.
Names now lead to a page of ours, and the campaign site is a link on it.
What's here
378 candidate pages at
/toronto/vote/2026/candidates/:slug, slugged from the name — the roster is rebuilt from the City Clerk's feed daily and carries no candidate ids, so the URL has to come from the name. A slug naming nobody 404s. Each page carries the portrait, incumbent/withdrawn badges, the race and ward, the campaign site, social links, the bio the candidate wrote us, and their questionnaire answers. Plus an OG image, rendered on demand rather than 378 satori renders in the build.One shared
CandidateNameLinkreplaced every outbound name link: the questionnaire column heads and rosters, the mayoral cards and city-wide race rows, the trustee cards, the mayoral roster page. The separate "Campaign site" line under each name is gone — the name is the link.SiteLinkbecame dead and was removed.Other regions degrade rather than break. The link falls back to the old outbound behaviour where a region has no candidate route, gated on a new
candidateProfilesregistry flag (Toronto only), so a name never points at a 404.The questionnaire reads through the ward pages' own cards — the same
QuestionnaireRailandQuestionnaireCards, given a roster of one — so the two can never disagree about how a questionnaire reads. Each question is a card, the candidate filed under the answer they gave, their own words printed in the open underneath. That retiredCandidateSurveyAnswers(this page was its only consumer) and with itAnswerOptionList, which nothing imported any more.Analytics keep the funnel whole.
candidate_website_clickedstill fires from the profile page's outbound link, with a newcandidate_profile_clickedin front of it — same property names, so a breakdown bycandidate_keyreads across the pair.The bios we were already holding
55 of the 58 candidates who returned the questionnaire wrote a bio in it, and the site showed none of them.
The bio lives in the questionnaire's
about-youstep, whichcomparableQuestionsexcludes as non-policy — correctly, for a pivot that exists to compare choices against a field. But nothing ever picked the written half back up, so the only bio a candidate page could print was the hand-maintained one incandidates.ts, which is empty for all but a handful.writtenQuestionsis the complement ofcomparableQuestions, andcandidateWritingpivots it per candidate the waycandidateAnswerspivots the choices — with no counts, charts or comparison, because a paragraph cannot be counted against a field.rosterSurveyreturns it alongside the answers, narrowed to the roster on the same rule: prose we cannot place on a candidate is prose we must not attribute.The filter is
textareaonly, and that is the privacy guard — worth a look in review. Not "everything that is not a choice". Every free-text field in the resident survey is identity: postal code, first name, last name, email, alltextoremail. Selecting the long-form type is what stops a contact field added in the CMS from ever arriving here as publishable prose. It is also the guard doing the work rather than the decorative one:postal_codesits inabout-you, which the consent-step exclusion deliberately lets through so a candidate's bio can pass.On the page it gets a section of its own between the ballot facts and the questionnaire — who is this, then what do they think. Not the hero: these run to a median of 800 characters, half are several paragraphs, and the longest is 1,800, which in the hero pushed the ward and the answers off the screen. It's attributed before it's read ("Written by X in answer to our questionnaire, and published as given. Not our description of them"), because a candidate's account of themselves set in our type would read as ours. Blank lines are kept as paragraph breaks, single newlines as typed.
ward_commitment_target— the numerical target and deadline, answered by 21 — comes through the same mechanism and prints under its question. Trim it if bios were all that was wanted; it's a couple of lines.Two things the real roster forced
Both found by checking the live feed rather than by reading the types:
RaceViewdropped, so those pages read "City-wide · Every ward votes" — wrong, since only part of the city elects a given trustee.RaceViewnow surfacesdistrictNumberand they read "Trustee — Toronto Catholic District School Board, Ward 9".Known issue, not fixed
The survey cards overflow on the x axis. Reported and not yet diagnosed. The container chain is identical to the ward pages' (
mx-[10px] … overflow-x-clip), so the cause is likely inside the card grid rather than the page wrapper, and it may well affect the ward pages too. Worth fixing before this goes in front of voters.Calls made along the way
lg:columns-2fills the band at a short measure — it costs the straight top-to-bottom reading order, which is why it isn't the default.Verification
Against the live York Factory API: 378 pages build (660 static pages total, up from ~272), bad slugs 404, OG images render, the multi-race and trustee cases read correctly, bios render as paragraphs with a non-English bio and a bare
"12000"target both publishing as given, non-respondents get no bio section, and the ward/mayor/issues/survey routes are unaffected. Typecheck and eslint clean.The build does still fail on
/tracker— its endpoints (/api/dashboard/1/at_a_glance,/api/v1/commitments.json) 404 on bothlocalhost:3000and production york_factory. Pre-existing and unrelated; every election page generates before it.🤖 Generated with Claude Code