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 ( +
      + {slices.map((slice) => { + const shown = open === slice.key; + 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 ( +
    +

    + {slice.names.length === 1 + ? "1 candidate" + : `${slice.names.length} candidates`} +

    + {/* 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. */} +
      + {slice.names.map((candidate) => ( +
    • + {candidate.name} + {candidate.seat && ( + · {candidate.seat} + )} +
    • + ))} +
    +
    + ); +} 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}
    - {group.questions.map((question) => ( - 0} - headingId={sectionId(question.questionId, idPrefix)} - seats={seats} - notes={notes} - yourKey={yourKey} - /> - ))} + {group.questions.map((question) => + chart ? ( + + ) : ( + 0} + headingId={sectionId(question.questionId, idPrefix)} + seats={seats} + notes={notes} + yourKey={yourKey} + /> + ), + )}
))} 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. */ +
      + {slices.map((slice, i) => { const shown = open === slice.key; return ( -
    • +
    • 0 ? "border-t border-border-light" : ""} + >