Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
61 changes: 41 additions & 20 deletions src/app/toronto/vote/2026/issues/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -31,11 +31,14 @@ import { ELECTION } from "../data";
* is not "what did this candidate say" anyway. It is where the people running
* to govern Toronto converge, and where they split.
*
* A card per question, with the candidates filed under the answer they gave —
* the same roll-call form the ward and mayoral pages use, so a reader who has
* learned to read one page of the tracker can read all of them. The only
* difference here is scale: the field is the whole city, so a name plate also
* carries the seat that candidate is running for.
* A card per question, and on this page the card is a band: how the
* field divided, with the options in full under it. The ward and mayoral pages name
* names under each answer, because four or five candidates under a question is
* the comparison their reader came for. Here the same form put thirty-odd
* names under each of two dozen questions — several hundred plates, none of
* them a ballot line any one reader votes on, and all of them standing between
* the reader and the split the page exists to show. The names stay on the
* pages where a reader can act on them; this one shows the shape of the field.
*
* The shell around them is deliberately short. Two dozen questions is the
* page; every band of prose above them is a band the reader scrolls past to
Expand Down Expand Up @@ -77,9 +80,11 @@ export default async function IssuesPage() {
const wards = new Set(respondents.filter((r) => r.ward).map((r) => r.ward))
.size;

/* The roster the cards name, in the order `fieldSentiment` sorted it
(surname), and the seat each of them is running for — which is both what
prints on a plate and what splits each answer into its two races. */
/* The field the bands are drawn over, in the order `fieldSentiment` sorted it
(surname), and the seat each of them is running for. The cards lead with
shares rather than names, but the names are behind every slice, and a name
on a city-wide page is only useful to a reader once they can see whether
it is on their ballot. */
const roster = respondents.map((r) => ({ key: r.key, name: r.name }));
const seats = Object.fromEntries(
respondents.map((r) => [
Expand Down Expand Up @@ -153,15 +158,25 @@ export default async function IssuesPage() {
Theirs names one ballot line and links each candidate to their
campaign; this page's field is thirty-odd people across a
mayoral race and two dozen wards, and a flat list of them is a
list with no ballot behind it. The seat on each plate is the
pointer instead. */}
list with no ballot behind it. The links out at the foot of the
page are the pointer instead. */}
<QuestionnaireRail headings={questionnaireHeadings(groups)}>
<QuestionnaireCards
groups={groups}
respondents={roster}
silent={[]}
chart
seats={seats}
notes={false}
answerNote={
<>
Each segment is the share of the candidates who answered
that particular question who gave that answer. Options
nobody picked are not shown. Select an answer to see which
candidates gave it; what they wrote about it is on the{" "}
<span className="text-dark">mayoral</span> and{" "}
<span className="text-dark">ward</span> pages.
</>
}
/>
</QuestionnaireRail>
</section>
Expand Down Expand Up @@ -202,17 +217,23 @@ export default async function IssuesPage() {
{/* ── Method ─────────────────────────────────────────── */}
<section className="px-6 md:px-14 py-4 border-t border-border-light grid gap-2">
<p className="type-label-sm text-text-muted max-w-[80ch] text-pretty">
Each card is one question, and each block inside it is one of the
answers offered, printed in the wording the candidates were shown.
Under it are the candidates who gave that answer, with the seat
they are running for. Options nobody picked are not shown, and a
candidate who answered in their own words sits on no option.
Each card is one question, drawn as the share of the field that
gave each answer. The options are listed in full under the band,
in the wording the candidates were shown, with the number who chose
each. Options nobody picked are not shown, and a candidate who
answered in their own words is counted in the unshaded segment
rather than on any option. Shares are of the candidates who
answered that particular question, not of the whole field — a
questionnaire can come back half filled in, so the number behind a
card is the counts in its own legend added up. Hover or select any
answer to see the candidates who gave it, with the seat each is
running for.
</p>
<p className="type-label-sm text-text-muted max-w-[80ch] text-pretty">
Most candidates also wrote a note explaining their answer. Thirty
of them under every question is more reading than this page can
carry, so the notes live on the ward and mayoral pages, where the
field is small enough to read them in full. Answers appear as
The note most candidates wrote to explain their answer lives on
the ward and mayoral pages — thirty notes under every question is
more reading than this page can carry, and it is on those pages
that a reader has a ballot to weigh them against. Answers appear as
candidates return the questionnaire and staff review them, so the
field shown here grows through the campaign.
</p>
Expand Down
46 changes: 43 additions & 3 deletions src/components/charts/trilemma/OptionBar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,19 @@ export interface OptionBarProps {
*/
showLabels?: boolean

/* ---- pointer ---- */
/**
* The pointer entering a segment, by option index — for a caller whose bar
* drives something beside it, a key or a panel of names.
*
* Leaving is reported from the bar as a whole rather than per segment,
* because the segments are drawn a pixel apart to keep their joins visible:
* per-segment, dragging across the bar would report a leave and an enter at
* every join, and whatever the caller is driving would blink on each one.
*/
onSegmentEnter?: (index: number) => void
onSegmentLeave?: () => void

/* ---- chrome ---- */
colors?: string[]
theme?: ThemeName
Expand All @@ -67,6 +80,8 @@ export function OptionBar({
showCounts = true,
valueFormat = String,
showLabels = false,
onSegmentEnter,
onSegmentLeave,
colors: colorsProp,
theme = 'light',
fontFamily,
Expand Down Expand Up @@ -101,17 +116,42 @@ export function OptionBar({
const labelRows = labelLines.reduce((n, lines) => Math.max(n, lines.length), 0)
const labelBlock = labelRows > 0 ? 6 + labelRows * 13 : 0

/* A viewBox, so the band fits whatever it is put in rather than whatever it
was drawn at.
*
* `responsive` measures its container with a ResizeObserver, which does not
* run on a server and does not run at all where scripting is off. Until it
* has (or for good, without JS), the bar is laid out at whatever `width` the
* caller asked for — and drawn at a fixed pixel width it simply overflowed a
* narrower parent and was clipped by whatever had the hidden overflow.
*
* Scaled into the box instead, the fallback width stops being a promise
* about the reader's screen and becomes what it should be: a unit system.
* `none` for the aspect ratio, so the band keeps its stated height while the
* width takes up the slack — a bar that shortened itself on a phone to hold
* its ratio would be a different chart. Once the observer reports, the
* viewBox and the box agree exactly and the scale factor is 1, so the
* segments and any type in them are pixel-true from then on. */
const boxHeight = height + labelBlock

return (
<div ref={ref} className={className} style={{ width: responsive ? '100%' : width, maxWidth: '100%' }}>
<svg
width={width}
height={height + labelBlock}
width={responsive ? '100%' : width}
height={boxHeight}
viewBox={`0 0 ${width} ${boxHeight}`}
preserveAspectRatio={responsive ? 'none' : undefined}
style={{ display: 'block', overflow: 'visible' }}
role="img"
aria-label={aria}
onMouseLeave={onSegmentLeave}
>
{segments.map(({ i, x: sx, w, count, color }) => (
<g key={i}>
<g
key={i}
onMouseEnter={onSegmentEnter && (() => onSegmentEnter(i))}
style={onSegmentEnter ? { cursor: 'pointer' } : undefined}
>
{/* A 1px bite out of each segment keeps the joins visible without a stroke. */}
<rect x={sx} y={0} width={Math.max(0, w - 1)} height={height} fill={color} />
{showCounts && w > 26 && (
Expand Down
23 changes: 20 additions & 3 deletions src/components/elections/QuestionRollCall.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -330,7 +330,15 @@ function AnswerPanel({
* sentence is not, and running the two along one line makes the plate read as
* the first few words of the sentence. A candidate who took the trouble to
* explain has left the most useful thing on the page, so it prints in the
* open: a note behind a disclosure is a note nobody reads. */
* open: a note behind a disclosure is a note nobody reads.
*
* Those written lines are ruled off from each other. Stacked, a plate, a
* paragraph, a plate and a paragraph run together into one column of prose
* with names in it, and the reader has to work out where one candidate stops
* and the next starts from the shape of the text. A hairline above each one
* after the first says it instead — faint enough to stay out of the way of
* the tinted panel it sits in, present enough that the block reads as a list
* of people rather than a passage. */
function Plates({
candidates,
seats,
Expand All @@ -346,11 +354,20 @@ function Plates({
}) {
return (
<ul className="flex list-none flex-wrap items-baseline gap-1.5 m-0 p-0">
{candidates.map((candidate) => {
{candidates.map((candidate, index) => {
const note = notes ? candidate.note : null;
const words = candidate.answer || note;
/* Not on the first entry in the run: a rule above the opening line
divides the names from the answer they are filed under, which is
the one break the panel already makes with its own heading. */
const ruled = Boolean(words) && index > 0;
return (
<li key={candidate.key} className={words ? "basis-full" : ""}>
<li
key={candidate.key}
className={`${words ? "basis-full" : ""} ${
ruled ? "mt-1 border-t border-border-light pt-2.5" : ""
}`}
>
<NamePlate
name={candidate.name}
seat={seats?.[candidate.key]?.label}
Expand Down
128 changes: 128 additions & 0 deletions src/components/elections/QuestionSplit.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,128 @@
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.
*
* WHY THIS EXISTS ALONGSIDE QuestionRollCall
* The two cards answer different questions, and which one a page wants is
* decided by the size of its field. A ward runs four or five candidates: the
* names ARE the answer, because the reader is choosing between those people
* and there is room to print what each of them said. /issues puts the same
* questionnaire to the whole city — thirty-odd names under every one of two
* dozen questions, filed into three or four blocks. Printed as plates that
* is several hundred names on one page, and the reader cannot see the thing
* the page exists to show (where the field agrees, and where it splits)
* through them.
*
* So this card prints the split and not the field: a single 100% band of who
* picked what — the charts package's OptionBar — with the options in full
* under it. A question the field agrees on is one colour, a question it is
* torn on is a divided band, and both read without a word.
*
* NO DENOMINATOR PRINTED
* The card used to carry a line at its foot saying how many candidates had
* answered this particular question — the figure every percentage above is
* taken against, which is not the same from card to card. It is off the
* card now, and not lost with it: the legend prints a count beside every
* option, so a reader who wants the denominator can add them, and one who
* only wants the shape of the split is not made to read a sentence of
* bookkeeping under all thirty-three cards to get it.
*
* THE NAMES ARE STILL HERE
* 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.
*
* COLOUR
* The same ramps as everywhere else in the tracker (lib/elections/
* option-colors), handed to OptionBar rather than left to the charts
* package's own palette — so an option sits at the same hue here as on the
* ward page a reader came from. Nothing else in this card carries meaning by
* colour, which is what lets the band use all of it.
*/

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<string, Seat>;
/** the id the scroll rail scrolls to — see QuestionRollCall */
headingId?: string;
}) {
const { groups, verbatim } = rollCall(question);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Nonresponse Context Is Hidden

Removing the card footer also discards rollCall's unanswered data. When candidates skip a question, the legend shows only per-option counts and shares. Readers can add those counts to recover how many candidates answered, but they cannot tell how many respondents omitted the question. This makes the practical difference between broad participation and heavy nonresponse invisible when interpreting similar shares. Please preserve the answered-versus-returned count or provide equivalent nonresponse context.

Knowledge Base Used: Municipal election engagement

Prompt To Fix With AI
This is a comment left during a code review.
Path: src/components/elections/QuestionSplit.tsx
Line: 65

Comment:
**Nonresponse Context Is Hidden**

Removing the card footer also discards `rollCall`'s `unanswered` data. When candidates skip a question, the legend shows only per-option counts and shares. Readers can add those counts to recover how many candidates answered, but they cannot tell how many respondents omitted the question. This makes the practical difference between broad participation and heavy nonresponse invisible when interpreting similar shares. Please preserve the answered-versus-returned count or provide equivalent nonresponse context.

**Knowledge Base Used:** [Municipal election engagement](https://app.greptile.com/buildcanada/-/custom-context/knowledge-base/buildcanada/tradingpost/-/docs/municipal-elections.md)

---

For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.

Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!

Fix in Codex Fix in Claude Code


const colors = optionColors(question.options.length, question.ordinal);

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) => ({
key: String(group.option),
/* The wording the candidates were shown, where the option has one. The
handle over it ("Public delivery") is a summary standing where the
answer should be. */
label: group.detail || group.label,
color: colors[group.option] ?? EMPTY,
names: group.candidates.map(named),
}));

/* Answers that matched no option are part of the field and so part of the
band — left out, every share on the card would be computed against a
denominator the card does not show. Drawn in the neutral, because they are
not a position the field took, they are the absence of one. */
if (verbatim.length > 0) {
slices.push({
key: "verbatim",
label: "Answered in their own words",
color: EMPTY,
names: verbatim.map(named),
});
}

const answered = slices.reduce((n, slice) => n + slice.names.length, 0);

return (
<article className="flex flex-col gap-4 border border-border-light p-6 md:p-7">
<h3
id={headingId}
className="scroll-mt-24 font-sans font-medium leading-[1.2] tracking-[-0.025em] text-[1.45rem] text-dark text-pretty"
>
{question.question}
</h3>

{answered === 0 ? (
<p className="type-caption text-text-muted">
No answers to this one yet.
</p>
) : (
<QuestionSplitFigure
slices={slices}
total={answered}
question={question.question}
/>
)}

</article>
);
}
Loading
Loading