Skip to content

Show the city-wide field as a band, not a wall of names - #85

Merged
mikaalnaik merged 5 commits into
mainfrom
mikaal/toronto-polish
Sep 11, 2026
Merged

Show the city-wide field as a band, not a wall of names#85
mikaalnaik merged 5 commits into
mainfrom
mikaal/toronto-polish

Conversation

@mikaalnaik

Copy link
Copy Markdown
Contributor

What

/toronto/vote/2026/issues put every respondent's name under every question — thirty-odd plates filed into three or four blocks, two dozen times over. Several hundred names on one page, none of them a ballot line any single reader votes on, and all of them standing between the reader and the thing the page exists to show: where the field agrees and where it splits.

The city-wide cards now lead with the split. Each question draws as OptionBar, the charts package's 100% band, with the options in full underneath and their counts and shares beside them.

The names are not gone — hovering a segment opens the candidates who gave that answer, each with the seat they are running for. The roll call behind a share is one gesture away instead of printed thirty-three times.

The ward and mayoral pages keep the roll call. Their fields are four or five people, where the names are the comparison the reader came for. QuestionnaireCards picks between the two forms on a chart flag.

Why a band and not a pie

This was first drafted as a donut. Two reasons it isn't one:

  • The work happens across thirty-odd cards at once. A share read as a length against a common left edge compares between cards; an angle does not.
  • A good many of these questions are a straight Yes/No, which is the case a pie serves worst — 72/28 is plain in a band and a judgement call in a circle.

Worth noting FieldSentiment.tsx already carries the same argument in a comment from an earlier round.

Also in here

  • OptionBar grows optional segment pointer handlers (onSegmentEnter, onSegmentLeave). Additive; nothing changes for callers that don't pass them. Leaving is reported from the bar as a whole rather than per segment — the segments sit a pixel apart to keep their joins visible, so per-segment handlers would fire leave-then-enter at every join and blink whatever the caller is driving.
  • The per-question denominator moved to the card foot and now prints on every card, not just ones where somebody skipped. Every share is against the people who answered that question, which is not the same number card to card, and two thirds of six is not two thirds of fifty-eight. It used to live in the middle of the donut; a band has no middle.
  • A hairline between candidates on the ward and mayoral cards. Where several have written notes, a plate and a paragraph and a plate and a paragraph run together into one column of prose with names in it.

Accessibility

The legend rows stay focusable buttons even though hover now lives on the band: a 7% segment is ~20px across on a phone, which is not a tap target, and no SVG segment is reachable by keyboard. Click latches a panel open, focus opens one, Escape and leaving the figure close it.

The name panel is pointer-events-none — it hangs over the legend rows, and catching the pointer would bury the row you'd click again to close it.

Testing

tsc --noEmit and npm run lint clean (4 pre-existing warnings in unrelated files). Issues, ward, mayoral and survey pages all render 200; verified 33 bands, 96 segments and the legend markup in the served HTML.

Not verified in a browser — the Chrome extension wasn't connected in this session, so the interaction was reasoned from the event flow rather than watched. Worth a click-through before merge, particularly the name panel's position on the right-hand column of the two-up grid.

One known rough edge: OptionBar's responsive measures with a ResizeObserver, which can't run server-side, so the band ships at a fixed width and snaps to the real one on hydration. I set the pre-measure width to 560 (roughly a card in the two-up grid) so it's a nudge rather than a jump from the library's 230px default. The proper fix is a viewBox on OptionBar, left alone here since it's shared.

🤖 Generated with Claude Code

/issues put every respondent's name under every question: thirty-odd
plates filed into three or four blocks, two dozen times over. That is
several hundred names on one page, none of them a ballot line any single
reader votes on, and all of them standing between the reader and the
thing the page exists to show — where the field agrees, and where it
splits.

So the city-wide cards now lead with the split. Each question draws as
OptionBar, the charts package's 100% band, with the options in full
underneath and their counts and shares beside them. The names are not
gone: hovering a segment opens the candidates who gave that answer, each
with the seat they are running for, so the roll call behind a share is
one gesture away rather than printed thirty-three times.

A band rather than the donut this was first drafted as. The work happens
across thirty-odd cards at once, and a share read as a length against a
common left edge compares between cards where an angle does not — and a
good many of these questions are a straight Yes/No, which is the case a
pie serves worst.

The ward and mayoral pages keep the roll call. Their fields are four or
five people, where the names ARE the comparison the reader came for.
`QuestionnaireCards` picks between the two on a `chart` flag.

Also here:

- OptionBar grows optional segment pointer handlers. Leaving is reported
  from the bar as a whole rather than per segment: the segments sit a
  pixel apart to keep their joins visible, so per-segment handlers would
  fire leave-then-enter at every join and blink whatever the caller is
  driving.

- The per-question denominator moves to the card foot and now prints on
  every card. Every share is against the people who answered THAT
  question, which is not the same number card to card, and two thirds of
  six is not two thirds of fifty-eight.

- A hairline between candidates on the ward and mayoral cards, where
  several have written notes. Stacked, a plate and a paragraph and a
  plate and a paragraph run together into one column of prose with names
  in it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@greptile-apps

greptile-apps Bot commented Sep 11, 2026

Copy link
Copy Markdown

Greptile Summary

The PR replaces city-wide questionnaire roll calls with proportional answer bands while retaining candidate details through an interactive disclosure. Follow-up changes make the SVG responsive without relying on client measurement and bound long candidate panels with scrolling.

  • Adds a chart-based questionnaire-card variant for the city-wide issues page.
  • Extends OptionBar with optional segment pointer handlers and responsive SVG scaling.
  • Adds candidate disclosures with answer counts and percentages.
  • Improves visual separation between candidates on ward and mayoral roll calls.
  • The previously reported fixed-width overflow and unbounded overlay issues are resolved.
  • The unanswered-versus-returned context raised in the remaining previous thread is still absent.

Confidence Score: 5/5

The PR appears safe to merge, with a non-blocking interaction issue in the scrollable candidate panel.

The responsive bar and bounded overlay fixes address the two resolved previous findings. The candidate panel now closes on any click despite being scrollable, which makes some long-list interactions awkward but does not block the primary chart experience. The existing unanswered-versus-returned context remains hidden, as the current card still derives and presents only the answered total.

Files Needing Attention: src/components/elections/QuestionSplitFigure.tsx

Important Files Changed

Filename Overview
src/app/toronto/vote/2026/issues/page.tsx Enables chart cards for the city-wide issues view and updates explanatory copy.
src/components/charts/trilemma/OptionBar.tsx Adds optional segment pointer callbacks and a responsive SVG view box.
src/components/elections/QuestionSplit.tsx Converts grouped questionnaire answers into proportional slices and candidate lists.
src/components/elections/QuestionSplitFigure.tsx Implements the interactive band, legend, and bounded names panel, but panel-wide click dismissal conflicts with its scrolling behavior.
src/components/elections/QuestionnaireCards.tsx Selects between roll-call and chart card presentations through an additive flag.
src/components/elections/QuestionRollCall.tsx Adds separators between candidate entries containing written responses.

Flowchart

%%{init: {'theme': 'neutral'}}%%
flowchart TD
  Page[City-wide issues page] --> Cards[QuestionnaireCards chart mode]
  Cards --> Split[QuestionSplit]
  Split --> Band[OptionBar answer-share band]
  Split --> Legend[Focusable answer legend]
  Band -->|Hover segment| Names[Candidate names panel]
  Legend -->|Focus or select| Names
  Names --> Seats[Candidate and ballot-seat details]
Loading

Fix all with Greploop Fix All in Codex Fix All in Claude Code

Prompt To Fix All With AI
### Issue 1
src/components/elections/QuestionSplitFigure.tsx:287
**Panel Clicks Close Scrolling**

The names panel is now scrollable for long candidate lists, but every click inside it calls `onDismiss`. Trying to drag the scrollbar or clicking within the list while reading closes the panel instead of allowing continued inspection. Limit dismissal to a dedicated control or backdrop rather than the scroll container itself.

---

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

Reviews (4): Last reviewed commit: "Make the band scale, and bound the names..." | Re-trigger Greptile

Comment thread src/components/elections/QuestionSplitFigure.tsx Outdated
Comment thread src/components/elections/QuestionSplitFigure.tsx
mikaalnaik and others added 3 commits September 11, 2026 11:55
The ward and mayoral cards title each block with the wording the
candidates were shown — serif, medium, the biggest thing inside the
block. The legend on /issues carried the same sentence at 1.02rem
regular, so the one piece of the card holding what somebody actually
endorsed read as a caption, level with the names in the hover panel and
the note at the foot.

Same treatment as those cards now, a step down in size because a legend
row is not a panel title. An answer reads as an answer wherever in the
tracker you meet it.

And ruled between rows rather than spaced. The wordings are full
sentences and the long ones wrap, so four of them separated by 8px read
as one block of prose with numbers down the side: where one answer
stopped and the next began was something the reader worked out from the
text rather than saw.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Every card carried a line at its foot saying how many candidates had
answered that particular question. It is a sentence of bookkeeping under
all thirty-three cards, read by a reader who came for the shape of the
split.

Not lost with it: the legend prints a count beside every option, so the
denominator is those added up. The method note at the foot of the page
says so once, in the one place a reader goes looking for how to read the
cards.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A name in the panel is scanned rather than read — the reader is looking
for one they know, or counting how many of a segment they recognise —
and forty of them at 0.92rem is a block that stops sitting under the
chart and starts being the card.

0.84rem, with a touch more leading to keep the columns readable at the
smaller size. It also buys the columns back some width, which is what
keeps a long name on one line.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
/** 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

Two things Greptile caught on the PR, both real.

OptionBar drew at a fixed pixel width. `responsive` measures with a
ResizeObserver, which does not run on a server and does not run at all
where scripting is off, so until it reported — or for good, without JS —
the bar was laid out at whatever width the caller asked for, overflowed
a narrower card and was clipped by the page's hidden overflow. The 560px
fallback I had picked was treating the symptom: it was a guess about the
reader's screen, and wrong on a phone.

It has a viewBox now and scales into its box, so the fallback width is a
unit system rather than a promise. `preserveAspectRatio="none"` keeps the
band's 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 and the scale
factor is 1, so segments and any type in them stay pixel-true.

The names panel had no height cap — I dropped it when the panel went
pointer-events-none, since a panel that cannot be scrolled gains nothing
from a scrollbar. But an overlay in nobody's layout will happily run over
the card below it, and a forty-name segment at two columns on a phone is
twenty rows. Capped at 14rem and scrolling.

Scrolling means taking the pointer again, which is safe now: the flicker
that forced pointer-events-none came from the panel covering the legend
rows back when THEY opened on hover. Hover lives on the band, which the
panel starts below and never covers. It does still bury the row you would
click a second time to release a latched panel, so clicking the panel
closes it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
/* The legend row that would let a latched panel go is underneath this,
so the panel itself is the way out. Harmless on a panel opened by
hover, which the pointer never reaches. */
onClick={onDismiss}

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 Panel Clicks Close Scrolling

The names panel is now scrollable for long candidate lists, but every click inside it calls onDismiss. Trying to drag the scrollbar or clicking within the list while reading closes the panel instead of allowing continued inspection. Limit dismissal to a dedicated control or backdrop rather than the scroll container itself.

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

Comment:
**Panel Clicks Close Scrolling**

The names panel is now scrollable for long candidate lists, but every click inside it calls `onDismiss`. Trying to drag the scrollbar or clicking within the list while reading closes the panel instead of allowing continued inspection. Limit dismissal to a dedicated control or backdrop rather than the scroll container itself.

---

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

Fix in Codex Fix in Claude Code

@mikaalnaik
mikaalnaik merged commit 52f7f31 into main Sep 11, 2026
2 checks 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