From 9b07adf1de27dc9a663ac46b28077469ed63cb66 Mon Sep 17 00:00:00 2001
From: Mikaal Naik
Date: Fri, 11 Sep 2026 11:32:05 -0400
Subject: [PATCH 1/5] Show the city-wide field as a band, not a wall of names
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
/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)
---
src/app/toronto/vote/2026/issues/page.tsx | 60 ++--
src/components/charts/trilemma/OptionBar.tsx | 22 +-
src/components/elections/QuestionRollCall.tsx | 23 +-
src/components/elections/QuestionSplit.tsx | 148 +++++++++
.../elections/QuestionSplitFigure.tsx | 290 ++++++++++++++++++
.../elections/QuestionnaireCards.tsx | 51 ++-
6 files changed, 557 insertions(+), 37 deletions(-)
create mode 100644 src/components/elections/QuestionSplit.tsx
create mode 100644 src/components/elections/QuestionSplitFigure.tsx
diff --git a/src/app/toronto/vote/2026/issues/page.tsx b/src/app/toronto/vote/2026/issues/page.tsx
index 9de5209..3d7d5bc 100644
--- a/src/app/toronto/vote/2026/issues/page.tsx
+++ b/src/app/toronto/vote/2026/issues/page.tsx
@@ -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
@@ -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) => [
@@ -153,15 +158,26 @@ 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. */}
+ Each segment is the share of the candidates who answered
+ that question who gave that answer, counted against the
+ figure at the foot of the card. Options nobody picked are
+ not shown. Select an answer to see which candidates gave
+ it; what they wrote about it is on the{" "}
+ mayoral and{" "}
+ ward pages.
+ >
+ }
/>
@@ -202,17 +218,21 @@ export default async function IssuesPage() {
{/* ── Method ─────────────────────────────────────────── */}
- 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, which is the figure at the foot
+ of each card. Hover or select any answer to see the candidates who
+ gave it, with the seat each is running for.
- 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.
diff --git a/src/components/charts/trilemma/OptionBar.tsx b/src/components/charts/trilemma/OptionBar.tsx
index 9e5c475..3a37423 100644
--- a/src/components/charts/trilemma/OptionBar.tsx
+++ b/src/components/charts/trilemma/OptionBar.tsx
@@ -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
@@ -67,6 +80,8 @@ export function OptionBar({
showCounts = true,
valueFormat = String,
showLabels = false,
+ onSegmentEnter,
+ onSegmentLeave,
colors: colorsProp,
theme = 'light',
fontFamily,
@@ -109,9 +124,14 @@ export function OptionBar({
style={{ display: 'block', overflow: 'visible' }}
role="img"
aria-label={aria}
+ onMouseLeave={onSegmentLeave}
>
{segments.map(({ i, x: sx, w, count, color }) => (
-
+ onSegmentEnter(i))}
+ style={onSegmentEnter ? { cursor: 'pointer' } : undefined}
+ >
{/* A 1px bite out of each segment keeps the joins visible without a stroke. */}
{showCounts && w > 26 && (
diff --git a/src/components/elections/QuestionRollCall.tsx b/src/components/elections/QuestionRollCall.tsx
index 56afa46..021a2bf 100644
--- a/src/components/elections/QuestionRollCall.tsx
+++ b/src/components/elections/QuestionRollCall.tsx
@@ -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,
@@ -346,11 +354,20 @@ function Plates({
}) {
return (
- {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 (
-
+
;
+ /** the id the scroll rail scrolls to — see QuestionRollCall */
+ headingId?: string;
+}) {
+ const { groups, verbatim, unanswered } = rollCall(question);
+
+ 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 (
+
+
+ {question.question}
+
+
+ {answered === 0 ? (
+
+ No answers to this one yet.
+
+ ) : (
+
+ )}
+
+ {/* THE DENOMINATOR, ON EVERY CARD
+
+ Every percentage above is a share of the people who answered THIS
+ question, which is not the same number from card to card — a
+ questionnaire can come back with half of it filled in, and the field
+ grows all through the campaign. Two thirds of six and two thirds of
+ fifty-eight are not the same finding, so the figure they are taken
+ against is printed rather than left for the reader to total up from
+ the legend. It lived in the middle of the donut this card used to
+ draw; a band has no middle, so it comes down here.
+
+ Where somebody skipped, this is also the line that says so. Counted,
+ not named: the names are the clutter this card was drawn to get out
+ of the way, and unlike the segments there is nothing behind them
+ worth opening — a list of people who said nothing tells a reader
+ nothing they can weigh. */}
+
+ Answered by{" "}
+ {unanswered.length > 0 ? (
+ <>
+ {answered} of the {answered + unanswered.length} candidates who
+ returned the questionnaire
+ >
+ ) : (
+ <>
+ all {answered} candidates who returned the questionnaire
+ >
+ )}
+
+
+ );
+}
diff --git a/src/components/elections/QuestionSplitFigure.tsx b/src/components/elections/QuestionSplitFigure.tsx
new file mode 100644
index 0000000..ddb6a49
--- /dev/null
+++ b/src/components/elections/QuestionSplitFigure.tsx
@@ -0,0 +1,290 @@
+"use client";
+
+import { useState } from "react";
+
+import { OptionBar, percentOf } from "@/components/charts/trilemma";
+
+/* The interactive half of a QuestionSplit card: the band, the legend, and the
+ * panel of names behind each of them.
+ *
+ * THE CHART IS OptionBar, FROM THE CHARTS PACKAGE
+ * This was a hand-drawn donut first, and a donut is the wrong chart for this
+ * page twice over. The page's work is done across thirty-odd cards at once —
+ * where does the field agree, where does it split — and a share read as a
+ * length against a common left edge can be compared between cards, where an
+ * angle cannot. And a good many of the 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.
+ *
+ * OptionBar already draws exactly this — "one question's answers as a single
+ * 100% band" — with the fade-all-but-one behaviour the legend needs, so the
+ * only thing here is the legend and the panel behind it.
+ *
+ * THE BAND CARRIES NO NUMBERS
+ * `showCounts` and `showLabels` are both off. The legend sits directly under
+ * the bar with every option's wording, count and share on it, so a segment
+ * printing its own share is the same figure twice, a centimetre apart — and
+ * the option names cannot fit under a narrow segment anyway, which is why
+ * OptionBar drops them. The bar is the shape; the legend is the key.
+ *
+ * WHY THE NAMES ARE BEHIND SOMETHING
+ * This page put every name under every question and the names were the
+ * clutter — thirty-odd plates, two dozen times, standing between the reader
+ * and the split. But they are still the evidence for the share, and a chart
+ * whose underlying roll call cannot be reached is a chart a reader has to
+ * take on trust. So the shares are the page and the names are one gesture
+ * away: the reader asks per question, on the one or two questions they care
+ * enough to ask about, instead of being handed all of them at once.
+ *
+ * THE BAND IS WHAT YOU HOVER
+ * A reader with a question about a share is pointing at the share — at the
+ * block of colour, not at the line of text under it. So the segments carry
+ * the hover, and hovering one both opens its names and picks its row out of
+ * the legend below. The rows themselves no longer answer to the pointer;
+ * they did first, and it put the tooltip somewhere the reader was not
+ * looking.
+ *
+ * BUT THE ROWS ARE STILL BUTTONS, FOR THE READERS WITH NO POINTER
+ * A 7% segment of a 32px band is about twenty pixels across on a phone,
+ * which is not a tap target, and no segment is reachable by keyboard at
+ * all. So the rows stay focusable and clickable: click latches a panel
+ * open, focus opens one, Escape and leaving the figure close it. What they
+ * no longer do is open on hover.
+ *
+ * THE PANEL TAKES NO POINTER
+ * It is `pointer-events-none`, and that is load-bearing rather than tidy.
+ * The panel hangs under the band, which puts it over the legend rows. Were
+ * it to catch the pointer, a row latched open by a click would be buried
+ * under the thing it opened, with no way to click it again to close — and
+ * back when the rows opened on hover it was worse: the panel took the hover
+ * off the row the moment it appeared, so the row fired mouseleave, the
+ * panel closed, the row fired mouseenter, and it strobed.
+ *
+ * Transparent to the pointer, the rows stay clickable through it. Nothing in
+ * here is interactive, so nothing is lost — and nothing in here scrolls
+ * either, which is why the names are set in columns that fit rather than in
+ * a scrolling box.
+ *
+ */
+
+/** The band's height, and so where the panel of names hangs from. */
+const BAR = 32;
+
+/* The width the band is drawn at until the browser has measured its container.
+ *
+ * `responsive` fits the bar to the card, but it fits it with a ResizeObserver,
+ * which cannot run on the server — so the markup that ships carries whatever
+ * width was asked for and snaps to the real one on hydration. OptionBar's own
+ * default is 230px, which on a page of thirty-three cards is thirty-three
+ * stubs that all jump at once. This is roughly a card in the two-up grid, so
+ * the snap is a nudge rather than a jump. */
+const BAR_WIDTH_BEFORE_MEASURE = 560;
+
+export type SplitSlice = {
+ key: string;
+ /** the option as it was put to the candidates */
+ label: string;
+ color: string;
+ /** who gave this answer, surname order, with their ballot line where the
+ * page tracks one */
+ names: { key: string; name: string; seat?: string }[];
+};
+
+export function QuestionSplitFigure({
+ slices,
+ total,
+ question,
+}: {
+ slices: SplitSlice[];
+ /** everyone who answered this question — the denominator */
+ total: number;
+ /** the question itself, which is the band's accessible name */
+ question: string;
+}) {
+ const [open, setOpen] = useState(null);
+ /* Opened by a click rather than by the pointer passing over, and so not the
+ pointer's to close — which is the only thing a touch screen can do, since
+ it has no hover to open one with. Leaving the figure, or Escape, lets it
+ go. */
+ const [pinned, setPinned] = useState(false);
+
+ const close = () => {
+ setOpen(null);
+ setPinned(false);
+ };
+ /* A click on the row already showing latches it; a second one lets go. */
+ const toggle = (key: string) => {
+ if (pinned && open === key) close();
+ else {
+ setOpen(key);
+ setPinned(true);
+ }
+ };
+ const graze = (key: string | null) => {
+ if (!pinned) setOpen(key);
+ };
+
+ const shown = slices.find((slice) => slice.key === open);
+ /* OptionBar picks its one option out by position, not by key. */
+ const highlight = slices.findIndex((slice) => slice.key === open);
+
+ return (
+
{
+ if (event.key === "Escape") close();
+ }}
+ /* The outer edge of the latch. Without it a pinned panel outlives the
+ reader's interest in it — they read on down the page and it is still
+ open behind them, and on a page of thirty-three cards several could
+ be. */
+ onMouseLeave={close}
+ >
+ slice.label)}
+ counts={slices.map((slice) => slice.names.length)}
+ colors={slices.map((slice) => slice.color)}
+ onSegmentEnter={(i) => graze(slices[i].key)}
+ onSegmentLeave={() => graze(null)}
+ /* -1 from findIndex is "no option", which OptionBar spells null. */
+ highlight={highlight < 0 ? null : highlight}
+ height={BAR}
+ width={BAR_WIDTH_BEFORE_MEASURE}
+ responsive
+ showCounts={false}
+ label={question}
+ />
+
+
+
+ {/* ONE PLACE, UNDER THE BAND — NOT UNDER THE ROW THAT OPENED IT
+
+ Hung off its own legend row, the panel opened wherever that row
+ happened to sit, so the fourth option on a four-option question put
+ the names well below the chart they belong to — and the same gesture
+ put the panel somewhere different on every option.
+
+ Anchored to the band, it is a fixed distance from the thing it
+ explains and lands in the same spot every time, with the segment it
+ came from picked out directly above it. It overlaps the legend rows,
+ which is the right trade: they are still there when it closes, and
+ the alternative was distance. */}
+ {shown && }
+
+ );
+}
+
+/* The options in full, in the order they were put to the candidates — never
+ * sorted by size. A reader coming from a ward page has learned that the first
+ * option is the first colour; resorting every card by its own result takes
+ * that away and puts the legend in a different order on every question. */
+function Legend({
+ slices,
+ total,
+ open,
+ onGraze,
+ onToggle,
+}: {
+ slices: SplitSlice[];
+ total: number;
+ open: string | null;
+ /** keyboard focus arriving on a row, which opens only while nothing is
+ * latched. The pointer is the band's business, not the legend's. */
+ onGraze: (key: string | null) => void;
+ /** a click, which latches — see `pinned` */
+ onToggle: (key: string) => void;
+}) {
+ const share = percentOf(total);
+
+ return (
+
+ );
+}
+
+/* Who gave this answer, set just below the band.
+ *
+ * Over the card rather than in the flow of it: a panel that pushed the legend
+ * down would move the rows out from under the reader's cursor every time they
+ * opened one, and shift every card beneath it on the page.
+ *
+ * The top offset is the band's own height and the gap under it, so the panel
+ * meets the bottom of the bar whichever option opened it.
+ *
+ * Names as plain text, not as the bordered plates the ward pages use. A plate
+ * is an object a reader counts in a field of four or five; forty of them in a
+ * panel is a mosaic, and the count is already on the row that opened it. */
+function Names({ slice }: { slice: SplitSlice }) {
+ return (
+
+ {/* Columns, because a segment can hold forty people. In one run they are
+ a list taller than the card, and this panel cannot be scrolled — it
+ takes no pointer, by design. Three columns puts forty names in
+ fourteen rows. */}
+
+ );
+}
diff --git a/src/components/elections/QuestionnaireCards.tsx b/src/components/elections/QuestionnaireCards.tsx
index 880d563..835de30 100644
--- a/src/components/elections/QuestionnaireCards.tsx
+++ b/src/components/elections/QuestionnaireCards.tsx
@@ -5,6 +5,7 @@ import { ArrowRight } from "lucide-react";
import type { Heading } from "@/components/custom/signpost/config";
import type { GridCandidate } from "./SurveyGrid";
import { QuestionRollCall } from "./QuestionRollCall";
+import { QuestionSplit } from "./QuestionSplit";
import type { Seat } from "./QuestionRollCall";
import type { ComparedGroup } from "@/lib/elections/candidate-answers";
@@ -32,6 +33,15 @@ import type { ComparedGroup } from "@/lib/elections/candidate-answers";
* links live once, in the CandidateRoster beside the section heading, where
* a reader gets the whole ballot before reading thirty cards about it.
*
+ * TWO CARDS, CHOSEN BY THE SIZE OF THE FIELD
+ * A race's own page names names: four candidates under a question is the
+ * comparison the reader came for. The city-wide page puts the same question
+ * to thirty-odd people across a mayoral race and two dozen wards, and the
+ * names there are neither a comparison nor a ballot the reader can act on —
+ * they are several hundred plates standing between the reader and the split.
+ * `chart` swaps the roll call for QuestionSplit, which draws the same data
+ * as a share of the field.
+ *
* `silent` IS A JUDGEMENT THE PAGE MAKES
* A ward passes its non-respondents in, and every card names them: the
* field is a dozen people and a reader deciding how to vote is owed the
@@ -79,6 +89,7 @@ export function QuestionnaireCards({
yourKey,
idPrefix,
answerNote,
+ chart = false,
}: {
groups: ComparedGroup[];
/** the candidates who returned the questionnaire */
@@ -88,7 +99,8 @@ export function QuestionnaireCards({
/** the whole city's answers, where this election has that page */
issuesHref?: string;
/** the seat each candidate is running for, keyed by candidate key — see
- * QuestionRollCall. Only the city-wide page passes one. */
+ * QuestionRollCall, and QuestionSplit, which prints it beside the names
+ * behind a segment. Only the city-wide page passes one. */
seats?: Record;
/** print each candidate's own words about their answer — see
* QuestionRollCall. The city-wide page turns them off. */
@@ -107,6 +119,10 @@ export function QuestionnaireCards({
* not shown" — and a page whose cards hold a single candidate has a
* different thing to say about what is missing from them. */
answerNote?: ReactNode;
+ /** draw each question as a band of the field's split rather than a roll
+ * call of names — see QuestionSplit. The city-wide page turns it on; a
+ * race's own page never should. */
+ chart?: boolean;
}) {
const silentNames = silent.map((candidate) => ({
key: candidate.key,
@@ -131,18 +147,27 @@ export function QuestionnaireCards({
{group.stepTitle}
))}
From ef68a9115294a218de16f25217fa560b41089bda Mon Sep 17 00:00:00 2001
From: Mikaal Naik
Date: Fri, 11 Sep 2026 11:55:14 -0400
Subject: [PATCH 2/5] Set an answer as an answer in the /issues legend
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
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)
---
.../elections/QuestionSplitFigure.tsx | 34 +++++++++++++++----
1 file changed, 28 insertions(+), 6 deletions(-)
diff --git a/src/components/elections/QuestionSplitFigure.tsx b/src/components/elections/QuestionSplitFigure.tsx
index ddb6a49..c9722b9 100644
--- a/src/components/elections/QuestionSplitFigure.tsx
+++ b/src/components/elections/QuestionSplitFigure.tsx
@@ -203,28 +203,50 @@ function Legend({
const share = percentOf(total);
return (
-
- {slices.map((slice) => {
+ /* Ruled, not spaced. A gap alone left four answers reading as one block of
+ prose with numbers down the side — the wordings are full sentences and
+ the long ones wrap, so where one answer stopped and the next began was
+ something the reader worked out from the text rather than saw. A
+ hairline between them says it outright, and costs a line of grey. */
+
The note most candidates wrote to explain their answer lives on
diff --git a/src/components/elections/QuestionSplit.tsx b/src/components/elections/QuestionSplit.tsx
index df0ca9d..9ef68d2 100644
--- a/src/components/elections/QuestionSplit.tsx
+++ b/src/components/elections/QuestionSplit.tsx
@@ -23,6 +23,15 @@ import type { ComparedQuestion } from "@/lib/elections/candidate-answers";
* 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
@@ -53,7 +62,7 @@ export function QuestionSplit({
/** the id the scroll rail scrolls to — see QuestionRollCall */
headingId?: string;
}) {
- const { groups, verbatim, unanswered } = rollCall(question);
+ const { groups, verbatim } = rollCall(question);
const colors = optionColors(question.options.length, question.ordinal);
@@ -114,35 +123,6 @@ export function QuestionSplit({
/>
)}
- {/* THE DENOMINATOR, ON EVERY CARD
-
- Every percentage above is a share of the people who answered THIS
- question, which is not the same number from card to card — a
- questionnaire can come back with half of it filled in, and the field
- grows all through the campaign. Two thirds of six and two thirds of
- fifty-eight are not the same finding, so the figure they are taken
- against is printed rather than left for the reader to total up from
- the legend. It lived in the middle of the donut this card used to
- draw; a band has no middle, so it comes down here.
-
- Where somebody skipped, this is also the line that says so. Counted,
- not named: the names are the clutter this card was drawn to get out
- of the way, and unlike the segments there is nothing behind them
- worth opening — a list of people who said nothing tells a reader
- nothing they can weigh. */}
-
- Answered by{" "}
- {unanswered.length > 0 ? (
- <>
- {answered} of the {answered + unanswered.length} candidates who
- returned the questionnaire
- >
- ) : (
- <>
- all {answered} candidates who returned the questionnaire
- >
- )}
-
);
}
From a733b2f4fb574fe17323f0a54c5ed0ae1cfc89e7 Mon Sep 17 00:00:00 2001
From: Mikaal Naik
Date: Fri, 11 Sep 2026 12:10:22 -0400
Subject: [PATCH 4/5] Set the /issues hover-panel names smaller
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
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)
---
src/components/elections/QuestionSplitFigure.tsx | 11 +++++++++--
1 file changed, 9 insertions(+), 2 deletions(-)
diff --git a/src/components/elections/QuestionSplitFigure.tsx b/src/components/elections/QuestionSplitFigure.tsx
index c9722b9..ff3a441 100644
--- a/src/components/elections/QuestionSplitFigure.tsx
+++ b/src/components/elections/QuestionSplitFigure.tsx
@@ -293,12 +293,19 @@ function Names({ slice }: { slice: SplitSlice }) {
{/* Columns, because a segment can hold forty people. In one run they are
a list taller than the card, and this panel cannot be scrolled — it
takes no pointer, by design. Three columns puts forty names in
- fourteen rows. */}
+ fourteen rows.
+
+ Set small. A name here is a thing the reader scans for rather than
+ reads — they are looking for one they know, or counting how many of
+ a slice they recognise — and forty of them is a block that has to sit
+ under the chart without becoming the card. Smaller also buys the
+ columns their width back, which is what keeps a long name on one
+ line. */}
{slice.names.map((candidate) => (
{candidate.name}
{candidate.seat && (
From 573df179b7e0d1339029e2496015e41e17e7fc09 Mon Sep 17 00:00:00 2001
From: Mikaal Naik
Date: Fri, 11 Sep 2026 12:53:39 -0400
Subject: [PATCH 5/5] Make the band scale, and bound the names panel
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
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)
---
src/components/charts/trilemma/OptionBar.tsx | 24 ++++++-
.../elections/QuestionSplitFigure.tsx | 66 +++++++++----------
2 files changed, 55 insertions(+), 35 deletions(-)
diff --git a/src/components/charts/trilemma/OptionBar.tsx b/src/components/charts/trilemma/OptionBar.tsx
index 3a37423..950156e 100644
--- a/src/components/charts/trilemma/OptionBar.tsx
+++ b/src/components/charts/trilemma/OptionBar.tsx
@@ -116,11 +116,31 @@ 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 (
}
+ {shown && }
);
}
@@ -270,30 +261,39 @@ function Legend({
/* Who gave this answer, set just below the band.
*
* Over the card rather than in the flow of it: a panel that pushed the legend
- * down would move the rows out from under the reader's cursor every time they
- * opened one, and shift every card beneath it on the page.
+ * down would move the rows out from under the reader as it opened, and shift
+ * every card beneath it on the page.
*
* The top offset is the band's own height and the gap under it, so the panel
* meets the bottom of the bar whichever option opened it.
*
+ * And capped, because an overlay in nobody's layout is an overlay that will
+ * happily run over the card below it: a segment can hold forty people, which
+ * at two columns on a phone is twenty rows and taller than the card it
+ * belongs to. Fourteen rem holds the common cases outright and scrolls the
+ * rest.
+ *
* Names as plain text, not as the bordered plates the ward pages use. A plate
* is an object a reader counts in a field of four or five; forty of them in a
- * panel is a mosaic, and the count is already on the row that opened it. */
-function Names({ slice }: { slice: SplitSlice }) {
+ * panel is a mosaic, and the count is already at the top of the panel. */
+function Names({ slice, onDismiss }: { slice: SplitSlice; onDismiss: () => void }) {
return (
- {/* Columns, because a segment can hold forty people. In one run they are
- a list taller than the card, and this panel cannot be scrolled — it
- takes no pointer, by design. Three columns puts forty names in
- fourteen rows.
+ {/* Columns, because a segment can hold forty people: in one run they are
+ a list taller than anything this panel can be allowed to be. Three
+ columns puts forty names in fourteen rows.
Set small. A name here is a thing the reader scans for rather than
reads — they are looking for one they know, or counting how many of