From d5473390d1261ae977d748129a45a882e408056f Mon Sep 17 00:00:00 2001 From: Armin Samii Date: Fri, 25 Sep 2026 16:44:39 -0400 Subject: [PATCH 1/4] Make every visualization screenreader-friendly and keyboard-navigable Charts are exposed as described images: the bar chart's description follows the current round, the sankey describes how to read it, and the pie has a hidden description. Round players announce round changes with the plain-English narration from a new Describer.describe_narration(). Tables get captions and text for color-only elected/eliminated status. The tab strip follows the ARIA tabs pattern with arrow-key navigation, fixing two aria typos, and every page gets a skip link and main landmark. Embedded views get landmarks, a hidden title, and alt text. Adds testAccessibility.py, headless-browser tests for the tabs and chart descriptions, and docs/source/accessibility.rst. Co-Authored-By: Claude Fable 5.1 --- README.md | 3 + common/viewUtils.py | 3 + docs/source/accessibility.rst | 66 ++++++++ docs/source/index.rst | 1 + scripts/run-tests.sh | 3 +- static/bargraph/barchart.js | 34 +++- static/bargraph/style.css | 10 ++ static/sankey/sankey-wrapper.js | 23 ++- static/tabular/style.css | 17 ++ static/visualizer/round-player.js | 36 ++++ static/visualizer/tabs-nonblocking.js | 57 ++++++- static/visualizer/theme.css | 48 +++++- static/visualizer/visualize-ballotpedia.css | 2 + .../bargraph/barchart-fixed-nonblocking.html | 4 +- .../barchart-interactive-nonblocking.html | 11 +- templates/bargraph/barchart-interactive.html | 4 +- templates/pie/pie-nonblocking.html | 1 + templates/pie/pie.html | 7 +- templates/tabular/tabular-by-candidate.html | 3 +- ...ular-by-round-interactive-nonblocking.html | 1 + .../tabular/tabular-by-round-interactive.html | 3 +- templates/tabular/tabular-by-round.html | 1 + .../tabular/tabular-candidate-by-round.html | 10 +- templates/visualizer/base.html | 14 +- .../visualizer/common-visualizer-header.html | 2 + templates/visualizer/index.html | 18 +- .../visualizer/visualize-ballotpedia.html | 16 +- templates/visualizer/visualize-embedded.html | 15 +- templates/visualizer/visualize.html | 52 +++--- visualizer/descriptors/roundDescriber.py | 18 ++ visualizer/templatetags/tableHelpers.py | 20 ++- visualizer/tests/testAccessibility.py | 160 ++++++++++++++++++ visualizer/tests/testLiveBrowserHeadless.py | 69 ++++++++ 33 files changed, 647 insertions(+), 85 deletions(-) create mode 100644 docs/source/accessibility.rst create mode 100644 visualizer/tests/testAccessibility.py diff --git a/README.md b/README.md index 12b0ff1d..2b45fc8d 100644 --- a/README.md +++ b/README.md @@ -70,6 +70,9 @@ Django and NPM and then do a one time setup by running the script This is needed to create the new admin user interactively. Following this, you can kill the server and start it with the run configuration provided - 'RCVis', a Django server configuration. +## Accessibility +Every visualization is described for screenreaders and navigable by keyboard: pick a round, then read what the chart says. See [the accessibility docs](docs/source/accessibility.rst) for what is covered and how it fits together. + ## Test Data Test data, including real and mock elections, can be found in the `testData` directory. Example input formats can be found on [RCVFormats](https://github.com/artoonie/rcvformats/tree/main/testdata/inputs). diff --git a/common/viewUtils.py b/common/viewUtils.py index 95306d5a..7c11de42 100644 --- a/common/viewUtils.py +++ b/common/viewUtils.py @@ -84,11 +84,14 @@ def get_data_for_round_describer(graph, config): roundDescriber = Describer(graph, config, summarizeAsParagraph=False) humanFriendlyEventsPerRound = roundDescriber.describe_all_rounds() humanFriendlySummary = roundDescriber.describe_initial_summary(isForVideo=False) + narration = roundDescriber.describe_narration() faqsPerRound = json.dumps(FAQGenerator(graph, config).describe_all_rounds()) return { 'humanFriendlyEventsPerRound': json.dumps(humanFriendlyEventsPerRound), 'humanFriendlySummary': json.dumps(humanFriendlySummary), + # Plain-English narration, used for the charts' screenreader descriptions + 'narrationJson': json.dumps(narration), 'faqsPerRound': faqsPerRound } diff --git a/docs/source/accessibility.rst b/docs/source/accessibility.rst new file mode 100644 index 00000000..e35e746b --- /dev/null +++ b/docs/source/accessibility.rst @@ -0,0 +1,66 @@ +Accessibility +======================== + +Every RCVis visualization is built to be usable without sight and without a mouse. There +is no accessibility widget or menu: the visualizations themselves are described, and +screenreader users bring their own software. Everything below is invisible to sighted +users. + +Describing the charts +------------------------------------- + +The charts are drawings, so each one is exposed to assistive technology as a single +described image rather than hundreds of unlabelled shapes: + +* The **bar chart** SVG has ``role="img"`` with a ```` and a ``<desc>``. The + description follows the round being shown: "Showing round 2 of 3." followed by the + plain-English narration of that round. +* The **Sankey diagram** SVG has ``role="img"`` with a title and a description of how to + read it plus the election summary. Its top bar of round labels is hidden from assistive + technology. +* The **pie chart** has a visually hidden description. + +The plain-English narration comes from +``visualizer.descriptors.roundDescriber.Describer.describe_narration()``: a summary +paragraph plus one paragraph per round, e.g. *"In the second round, people who voted for +Banana had their votes transferred to their next choice. Banana had the fewest votes and +was eliminated."* It is the same text spoken in the generated movies. + +In all cases the description points to the table views, which carry the actual numbers. +The tables have captions, and the single-table summary adds visually hidden "Elected" and +"Eliminated" text to the cells whose status was previously shown by color only. + +Moving between rounds +------------------------------------- + +The intended screenreader workflow is: pick a round, then read what the chart says. + +* Every round player has a labelled ``<select>`` for the round, native buttons for + previous, next and play, and a polite live region that announces "Round N of M" and the + round's narration whenever the round changes. +* The bar chart's description box above the chart is also a polite live region, and the + "Read a detailed explanation" link is a real button. + +Keyboard support +------------------------------------- + +* A "Skip to main content" link is the first focusable element on every page. +* The visualization tabs use the ARIA tabs pattern: ``role="tablist"``, ``role="tab"`` + with ``aria-selected``, and ``role="tabpanel"``. Only the selected tab is in the tab + order; the arrow keys, Home and End move between tabs. + +Testing +------------------------------------- + +``visualizer/tests/testAccessibility.py`` checks the rendered markup, and +``visualizer/tests/testLiveBrowserHeadless.py`` exercises the tab keyboard navigation, +the chart descriptions and the round announcements in a headless browser. + +Known gaps +------------------------------------- + +* The Sankey diagram's nodes can be dragged with a mouse but not moved with the keyboard. + The table views carry the same data. +* The generated videos have spoken narration but no captions track yet. +* The pie chart is a third-party component; its animation-phase labels have low color + contrast. diff --git a/docs/source/index.rst b/docs/source/index.rst index d0fe27ce..ae049eed 100644 --- a/docs/source/index.rst +++ b/docs/source/index.rst @@ -11,6 +11,7 @@ Modules :caption: Contents: visualizer + accessibility movie graph api diff --git a/scripts/run-tests.sh b/scripts/run-tests.sh index a1937857..8691ed44 100755 --- a/scripts/run-tests.sh +++ b/scripts/run-tests.sh @@ -45,7 +45,8 @@ elif [ "$CI_NODE_TOTAL" -eq 3 ]; then ./scripts/test-code-quality.sh echo "Starting tests" - $RUN test visualizer.tests.testBallotpediaRestApi\ + $RUN test visualizer.tests.testAccessibility\ + visualizer.tests.testBallotpediaRestApi\ visualizer.tests.testDataTables\ visualizer.tests.testDataTablesRCTabFormat\ visualizer.tests.testDataTablesHeadlessBrowser\ diff --git a/static/bargraph/barchart.js b/static/bargraph/barchart.js index c2137b14..e5cb1d7f 100644 --- a/static/bargraph/barchart.js +++ b/static/bargraph/barchart.js @@ -15,6 +15,8 @@ function makeBarGraph(args) { const textForWinner = args.textForWinner; // Eliminated, elected, etc const doDimPrevRoundColors = args.doDimPrevRoundColors; // Desaturate previous rounds? No-op on noninteractive const candidateSidecarData = args.candidateSidecarData; // Additional metadata about each candidate + const roundDescriptions = args.roundDescriptions || null; // Optional plain-English text per round, for screenreaders + const chartSummary = args.chartSummary || null; // Optional plain-English summary of the election, for screenreaders const candidateVoteCounts = args.candidateVoteCounts; // List of dicts of candidate descriptions. // Each dict has two keys: // .candidate for the name, @@ -78,12 +80,41 @@ function makeBarGraph(args) { const viewboxWidth = width + margin.left + margin.right; const viewboxHeight = height + margin.top + margin.bottom + paddingForVertical; - const svg = d3.select('#'+idOfContainer) + // The chart is exposed to screenreaders as a single image with a title and a + // description that follows the current round. The tables provide the raw numbers. + const svgTitleId = idOfContainer + "-title"; + const svgDescId = idOfContainer + "-desc"; + const svgRoot = d3.select('#'+idOfContainer) .append("svg") .attr("viewBox", "0 0 " + viewboxWidth + " " + viewboxHeight) + .attr("role", "img") + .attr("aria-labelledby", svgTitleId + " " + svgDescId); + svgRoot.append("title") + .attr("id", svgTitleId) + .text(isInteractive + ? "Bar chart of each candidate's votes in the selected round" + : "Bar chart of each candidate's votes in every round"); + const svgDesc = svgRoot.append("desc") + .attr("id", svgDescId) + .text(describeChart(numRounds - 1)); + const svg = svgRoot .append("g") .attr("transform", "translate(" + margin.left + "," + margin.top + ")"); + function describeChart(round) { + let text = ""; + if (isInteractive) { + text += "Showing round " + (round + 1) + " of " + numRounds + ". "; + if (roundDescriptions && roundDescriptions[round]) { + text += roundDescriptions[round] + " "; + } + } else if (chartSummary) { + text += chartSummary + " "; + } + text += "The same numbers are available in the table views."; + return text; + } + const surplusPatternId = "diagonalHatch"+idOfContainer; const defs = svg.append("defs"); defs.append("pattern") @@ -849,6 +880,7 @@ function makeBarGraph(args) { transitionEachBarForRound(); transitionDataLabelsForRound(); transitionThresholdForRound(); + svgDesc.text(describeChart(round)); }; // Enable the bootstrap tooltip diff --git a/static/bargraph/style.css b/static/bargraph/style.css index 13dbb1af..f65f7400 100644 --- a/static/bargraph/style.css +++ b/static/bargraph/style.css @@ -104,6 +104,16 @@ path.domain { font-weight: bold; } +/* The "Read a detailed explanation" button, styled as inline text */ +.faq-link { + padding: 0; + border: 0; + font-size: inherit; + font-weight: bold; + line-height: inherit; + vertical-align: baseline; +} + .prev-next-button { color: white; } diff --git a/static/sankey/sankey-wrapper.js b/static/sankey/sankey-wrapper.js index d7e7edfd..f7ae6b0e 100644 --- a/static/sankey/sankey-wrapper.js +++ b/static/sankey/sankey-wrapper.js @@ -129,6 +129,16 @@ function makeSankey(graph, numRounds, numCandidates, numWinners, longestLabelApx labels.text(function(d) { return "Round " + (d.round+1) }); } + function describeSankey() { + let text = "Each column is a round of counting, and each flow shows votes moving from an " + + "eliminated candidate to those voters' next choice. "; + if (typeof narration !== 'undefined' && narration && narration.summary) { + text += narration.summary + " "; + } + text += "The same numbers are available in the table views."; + return text; + } + function makeGraph(graph) { // format variables const formatNumber = d3.format(",.2f"); // two decimal places @@ -145,9 +155,19 @@ function makeSankey(graph, numRounds, numCandidates, numWinners, longestLabelApx .style("width", getIdealWidth()); // append the svg object to the body of the page - const svg = d3.select("#sankey-body").append("svg") + // Exposed to screenreaders as a single described image; the tables have the numbers + const svgRoot = d3.select("#sankey-body").append("svg") .attr("id", "sankey-svg") .attr("viewBox", "0 0 " + makeViewboxSizeString(viewboxSize0, viewboxSize1)) + .attr("role", "img") + .attr("aria-labelledby", "sankey-svg-title sankey-svg-desc"); + svgRoot.append("title") + .attr("id", "sankey-svg-title") + .text("Sankey diagram of how votes moved between candidates in each round"); + svgRoot.append("desc") + .attr("id", "sankey-svg-desc") + .text(describeSankey()); + const svg = svgRoot .append("g") .attr("transform", "translate(" + cmargin.left + "," + cmargin.top + ")"); @@ -266,6 +286,7 @@ function makeSankey(graph, numRounds, numCandidates, numWinners, longestLabelApx function makeTopBar(graph) { const viewboxLeft = -tmargin.left const topbarG = d3.select('#topbar').append("svg") + .attr("aria-hidden", "true") .attr("viewBox", viewboxLeft + " 0 " + makeViewboxSizeString(tmarginLength, viewboxSize1)) .style("max-width", getIdealWidth()) .append("g") diff --git a/static/tabular/style.css b/static/tabular/style.css index 33c7ee41..0b229d58 100644 --- a/static/tabular/style.css +++ b/static/tabular/style.css @@ -35,6 +35,23 @@ h3 { padding-right: 1em; } +/* The big number in each single-table-summary cell. Styled like an h3, but not a heading. */ +.primaryLabel { + display: block; + font-size: 1.75rem; + font-weight: 500; + line-height: 1.2; + margin-bottom: 0.5rem; + padding-left: 1em; + padding-right: 1em; + color: var(--rcv-ink); +} + +/* Per-round and per-candidate table headings: h2 for document outline, sized like an h4 */ +.round-heading { + font-size: 1.5rem; +} + #single-table-summary-table { margin-bottom: 0; } diff --git a/static/visualizer/round-player.js b/static/visualizer/round-player.js index e705b7de..e615b39d 100644 --- a/static/visualizer/round-player.js +++ b/static/visualizer/round-player.js @@ -5,9 +5,12 @@ function RoundPlayer({ totalRounds, timeBetweenStepsMs, firstStepHoldTimeMs, + narration, // Optional {summary, rounds: [...]}: plain-English text announced to screenreaders + announceRounds, // Announce round changes to screen readers (default true) }) { let isPlaying = false; let currentStep = totalRounds - 1; + const shouldAnnounceRounds = announceRounds !== false; // Imported from visualize-common.js let stepTimeMs = timeBetweenStepsMs || getTimeBetweenAnimationStepsMs(totalRounds); @@ -32,8 +35,12 @@ function RoundPlayer({ navBtn.classList.add("round-player-hidden"); } + // The visible label is hidden on narrow screens, so name the button explicitly + navBtn.setAttribute("aria-label", isNext ? "Next round" : "Previous round"); + const labelEl = document.createElement("span"); labelEl.classList.add("round-player-nav-label"); + labelEl.setAttribute("aria-hidden", "true"); labelEl.innerText = isNext ? "Next" : "Back"; navBtn.appendChild(labelEl); @@ -59,6 +66,7 @@ function RoundPlayer({ const select = document.createElement("select"); select.classList.add("round-player-select"); + select.setAttribute("aria-label", "Round"); for (let round = 0; round < totalRounds; ++round) { const opt = document.createElement("option"); opt.value = round; @@ -87,9 +95,21 @@ function RoundPlayer({ return playBtn; } + function createLiveRegion() { + // Announces the current round to screen readers when it changes + const liveEl = document.createElement("div"); + liveEl.classList.add("round-player-live", "sr-only"); + liveEl.setAttribute("role", "status"); + liveEl.setAttribute("aria-live", "polite"); + liveEl.setAttribute("aria-atomic", "true"); + return liveEl; + } + function init() { const playerEl = document.createElement("div"); playerEl.classList.add("round-player-container"); + playerEl.setAttribute("role", "group"); + playerEl.setAttribute("aria-label", "Round controls"); const wrapperEl = document.createElement("div"); wrapperEl.classList.add("round-player-wrapper"); @@ -98,11 +118,21 @@ function RoundPlayer({ wrapperEl.appendChild(createNavButton(true)); playerEl.appendChild(wrapperEl); + playerEl.appendChild(createPlayButton()); + if (shouldAnnounceRounds) { + playerEl.appendChild(createLiveRegion()); + } + container.appendChild(playerEl); } + function announce(text) { + const liveEl = container.querySelector(".round-player-live"); + if (liveEl) liveEl.innerText = text; + } + function changeStep(step) { // This shouldn't get triggered, but short-circuit if out of bounds if (step >= totalRounds || step < 0) { @@ -127,6 +157,12 @@ function RoundPlayer({ } onChange(step); + + let announcement = `Round ${step + 1} of ${totalRounds}.`; + if (narration && narration.rounds[step]) { + announcement += " " + narration.rounds[step]; + } + announce(announcement); } function setStep(step) { diff --git a/static/visualizer/tabs-nonblocking.js b/static/visualizer/tabs-nonblocking.js index ca44626e..611bd266 100644 --- a/static/visualizer/tabs-nonblocking.js +++ b/static/visualizer/tabs-nonblocking.js @@ -20,14 +20,29 @@ function swapTabVisibility(oldTabName, newTabName) { document.getElementById(newTabId).style.display = 'block'; } +function tabLinkFor(tabName) { + return document.querySelector('a[data-toggle="changeviz"][href="#' + tabName + '"]'); +} + function goToTab(newTabName) { if (newTabName == currentTabName) return; + if (tabLinkFor(newTabName) == null) { + // Unknown #anchor: fall back to the default tab + newTabName = 'barchart'; + if (newTabName == currentTabName) return; + } // Update navigation if (currentTabName != null) { - $('a[data-toggle="changeviz"][href="#' + currentTabName + '"]').removeClass('selected-nav'); + const oldLink = tabLinkFor(currentTabName); + oldLink.classList.remove('selected-nav'); + oldLink.setAttribute('aria-selected', 'false'); + oldLink.setAttribute('tabindex', '-1'); } - $('a[data-toggle="changeviz"][href="#' + newTabName + '"]').addClass('selected-nav'); + const newLink = tabLinkFor(newTabName); + newLink.classList.add('selected-nav'); + newLink.setAttribute('aria-selected', 'true'); + newLink.removeAttribute('tabindex'); // Select tab via bootstrap swapTabVisibility(currentTabName, newTabName); @@ -43,6 +58,43 @@ function goToTab(newTabName) { } } +function visibleTabLinks() { + // Tabs hidden by the config (e.g. hidePie) are skipped when moving with the keyboard + return Array.from(document.querySelectorAll('a[data-toggle="changeviz"]')) + .filter(link => link.closest('li').offsetParent !== null); +} + +// Roving tabindex: only the selected tab is in the tab order, arrow keys move between tabs +function initializeTabKeyboardNavigation() { + const allLinks = document.querySelectorAll('a[data-toggle="changeviz"]'); + allLinks.forEach(link => { + if (!link.classList.contains('selected-nav')) { + link.setAttribute('tabindex', '-1'); + } + link.addEventListener('keydown', function(e) { + const links = visibleTabLinks(); + const index = links.indexOf(this); + let nextIndex = null; + if (e.key == 'ArrowRight' || e.key == 'ArrowDown') { + nextIndex = (index + 1) % links.length; + } else if (e.key == 'ArrowLeft' || e.key == 'ArrowUp') { + nextIndex = (index - 1 + links.length) % links.length; + } else if (e.key == 'Home') { + nextIndex = 0; + } else if (e.key == 'End') { + nextIndex = links.length - 1; + } + if (nextIndex === null) return; + e.preventDefault(); + const nextLink = links[nextIndex]; + const tabName = nextLink.getAttribute('href').substring(1); + goToTab(tabName); + history.pushState(null, null, '#' + tabName); + nextLink.focus(); + }); + }); +} + function loadTabFromTag() { // c/o https://stackoverflow.com/a/9393768/1057105 // Javascript to enable link to tab @@ -100,5 +152,6 @@ document.getElementById("make-interactive").addEventListener("click", function(e }); loadTabFromTag(); +initializeTabKeyboardNavigation(); hideTabsBasedOnConfig() window.addEventListener("hashchange", loadTabFromTag, false); diff --git a/static/visualizer/theme.css b/static/visualizer/theme.css index d8c0a793..7658bcb5 100644 --- a/static/visualizer/theme.css +++ b/static/visualizer/theme.css @@ -50,6 +50,44 @@ html { scroll-padding-top: calc(var(--rcv-header-height) + 12px); } +/* Visually hidden, but still read by screen readers */ +.sr-only { + position: absolute !important; + width: 1px !important; + height: 1px !important; + padding: 0 !important; + margin: -1px !important; + overflow: hidden !important; + clip: rect(0, 0, 0, 0) !important; + white-space: nowrap !important; + border: 0 !important; +} + +/* "Skip to main content" link: only visible when focused via keyboard */ +.skip-link { + position: absolute; + top: 8px; + left: 8px; + z-index: 2000; + padding: 0.5rem 1rem; + background: var(--rcv-brand); + color: #fff; + border-radius: var(--rcv-radius); + font-family: var(--rcv-font-ui); + transform: translateY(-200%); + transition: transform 0.15s ease; +} +.skip-link:focus { + transform: translateY(0); + color: #fff; + outline: 2px solid var(--rcv-ink); + outline-offset: 2px; +} + +#maincontent:focus { + outline: none; +} + body { -webkit-font-smoothing: antialiased; } @@ -420,6 +458,14 @@ h1, h2, h3, h4, h5, h6 { padding-left: 0.6rem; } +.masthead .masthead-details { + font-size: 0.85rem; +} +.masthead .masthead-details a { + /* Links inside running text must not rely on color alone */ + text-decoration: underline; +} + /* ------------------------------------------------------------------ */ /* Footer */ /* ------------------------------------------------------------------ */ @@ -458,7 +504,7 @@ h1, h2, h3, h4, h5, h6 { } } -.site-footer h5 { +.site-footer h2 { font-size: 0.8rem; font-weight: 700; letter-spacing: 0.06em; diff --git a/static/visualizer/visualize-ballotpedia.css b/static/visualizer/visualize-ballotpedia.css index 7d55b06b..2c3d34b2 100644 --- a/static/visualizer/visualize-ballotpedia.css +++ b/static/visualizer/visualize-ballotpedia.css @@ -1,4 +1,6 @@ #bp-header { + margin: 0; + line-height: 1.5; width: 100%; background-color: #4c4c4c; color: white; diff --git a/templates/bargraph/barchart-fixed-nonblocking.html b/templates/bargraph/barchart-fixed-nonblocking.html index f5f40a44..d0cbc592 100644 --- a/templates/bargraph/barchart-fixed-nonblocking.html +++ b/templates/bargraph/barchart-fixed-nonblocking.html @@ -21,7 +21,9 @@ isVertical: false, textForWinner: "{{ textForWinner }}", doDimPrevRoundColors: config.doDimPrevRoundColors, - candidateSidecarData: candidateSidecarData + candidateSidecarData: candidateSidecarData, + roundDescriptions: narration.rounds, + chartSummary: narration.summary }); } makeFixedGraph(); diff --git a/templates/bargraph/barchart-interactive-nonblocking.html b/templates/bargraph/barchart-interactive-nonblocking.html index 52c34bc1..edd122d3 100644 --- a/templates/bargraph/barchart-interactive-nonblocking.html +++ b/templates/bargraph/barchart-interactive-nonblocking.html @@ -80,7 +80,7 @@ .map(d => "<p class='faq-q'>" + d['question'] + "</p>" + "<p class='faq-a'>" + d['answer'] + "</p>") .reduce((accum, val) => accum + val); - document.getElementById(idOfFaqTextDiv).innerHTML = `<h3 class="faq-description-header">Round ${round + 1} Explanation</h3> ${text}`; + document.getElementById(idOfFaqTextDiv).innerHTML = `<h2 class="faq-description-header">Round ${round + 1} Explanation</h2> ${text}`; setTimeout(lockScrollbarIfNeeded, 0); // Needs to run after the DOM is updated } @@ -119,7 +119,9 @@ isVertical: false, textForWinner: "{{ textForWinner }}", doDimPrevRoundColors: config.doDimPrevRoundColors, - candidateSidecarData: candidateSidecarData + candidateSidecarData: candidateSidecarData, + roundDescriptions: narration.rounds, + chartSummary: narration.summary }); function sliderValueChangedCallback(round) { @@ -134,7 +136,10 @@ onChange: sliderValueChangedCallback, onPlay: function() { showTextOnRoundDescriber(humanFriendlySummary, false); - } + }, + narration: narration, + // The round description above the chart is already a live region + announceRounds: false }); // After creating the slider, make the default text the summary diff --git a/templates/bargraph/barchart-interactive.html b/templates/bargraph/barchart-interactive.html index da5af29b..d198debd 100644 --- a/templates/bargraph/barchart-interactive.html +++ b/templates/bargraph/barchart-interactive.html @@ -8,12 +8,12 @@ <div class="container-fluid"> <div class="row d-flex flex-column justify-content-left align-items-start"> <div class="round-description-container"> - <div id="round-description-wrapper" class="round-description-wrapper" role="alert"> + <div id="round-description-wrapper" class="round-description-wrapper" role="status" aria-live="polite" aria-atomic="true"> <div class="round-description-text"> <span id="bargraph-interactive-round-description"> </span> <span id="bargraph-interactive-why-button"> - <a role="button" tabindex="0" onclick="showFaqs()">Read a detailed explanation</a>. + <button type="button" class="btn btn-link faq-link" onclick="showFaqs()">Read a detailed explanation</button>. </span> </div> </div> diff --git a/templates/pie/pie-nonblocking.html b/templates/pie/pie-nonblocking.html index d34262ec..bd90eb67 100644 --- a/templates/pie/pie-nonblocking.html +++ b/templates/pie/pie-nonblocking.html @@ -41,6 +41,7 @@ totalRounds: {{ config.numRounds }}, onChange: updatePieChartRound, timeBetweenStepsMs: 3000, + narration: narration, }); function updatePieChartRound(sliderIndex) { diff --git a/templates/pie/pie.html b/templates/pie/pie.html index 8c740660..6ece89a2 100644 --- a/templates/pie/pie.html +++ b/templates/pie/pie.html @@ -5,5 +5,10 @@ <div id="pie-wrapper" class="shadow-wrapper"> <div id="pie-slider-container"></div> - <div id="pie-body" style="width: 100%; max-width: 800px; aspect-ratio: 1; margin: 0 auto;"></div> + <p id="pie-description" class="sr-only"> + Pie chart of each candidate's share of the votes in the selected round. + Use the round controls above to change rounds; the round is described as it changes. + The same numbers are available in the table views. + </p> + <div id="pie-body" style="width: 100%; max-width: 800px; aspect-ratio: 1; margin: 0 auto;" aria-describedby="pie-description"></div> </div> diff --git a/templates/tabular/tabular-by-candidate.html b/templates/tabular/tabular-by-candidate.html index ce94d4d0..99532e60 100644 --- a/templates/tabular/tabular-by-candidate.html +++ b/templates/tabular/tabular-by-candidate.html @@ -2,8 +2,9 @@ {% for candidateTabulation in tabularByCandidate.tabulation %} <div class="candidate-container" data-candidate-name="{{ candidateTabulation.name }}" data-is-winner="{{ candidateTabulation.isWinner|yesno:'true,false' }}"> - <h4 class="text-center"> {{ candidateTabulation.name }} </h4> + <h2 class="text-center round-heading"> {{ candidateTabulation.name }} </h2> <table class="shadow-wrapper table fixed-width-table"> + <caption class="sr-only">{{ candidateTabulation.name }}: what changed and total votes in each round</caption> <thead class="thead-light"> <tr> <th scope="col">Round</th> diff --git a/templates/tabular/tabular-by-round-interactive-nonblocking.html b/templates/tabular/tabular-by-round-interactive-nonblocking.html index 8fb7d161..9c516cea 100644 --- a/templates/tabular/tabular-by-round-interactive-nonblocking.html +++ b/templates/tabular/tabular-by-round-interactive-nonblocking.html @@ -19,6 +19,7 @@ container: document.getElementById("tabular-by-round-slider-container"), totalRounds: numRounds, onChange: showRound, + narration: narration, }); </script> diff --git a/templates/tabular/tabular-by-round-interactive.html b/templates/tabular/tabular-by-round-interactive.html index 4af15789..3891df9a 100644 --- a/templates/tabular/tabular-by-round-interactive.html +++ b/templates/tabular/tabular-by-round-interactive.html @@ -6,8 +6,9 @@ <div id="tabularByRoundInteractiveContainer"> {% for round in tabularByRoundInteractive.rounds %} <div id="tabular_round_container_{{ forloop.counter0 }}"> - <h4 class="mt-2 text-center">Round {{ forloop.counter }}</h4> + <h2 class="mt-2 text-center round-heading">Round {{ forloop.counter }}</h2> <table class="shadow-wrapper table fixed-width-table"> + <caption class="sr-only">Round {{ forloop.counter }}: what changed for each candidate, and their total votes</caption> <thead class="thead-light"> <tr> <th scope="col">Candidate</th> diff --git a/templates/tabular/tabular-by-round.html b/templates/tabular/tabular-by-round.html index 6db983b8..11b2ad69 100644 --- a/templates/tabular/tabular-by-round.html +++ b/templates/tabular/tabular-by-round.html @@ -6,6 +6,7 @@ <div class="responsive-table-wrapper"> <div id="tabularByRoundInteractiveContainer" class="responsive-table"> <table class="shadow-wrapper table fixed-width-table"> + <caption class="sr-only">Candidates eliminated and elected in each round</caption> <thead class="thead-light"> <tr> <th scope="col">Round</th> diff --git a/templates/tabular/tabular-candidate-by-round.html b/templates/tabular/tabular-candidate-by-round.html index 1747f2e3..b08c5977 100644 --- a/templates/tabular/tabular-candidate-by-round.html +++ b/templates/tabular/tabular-candidate-by-round.html @@ -5,6 +5,7 @@ <div class="shadow-wrapper responsive-table-wrapper" id="single-table-summary-wrapper"> <div class="responsive-table"> <table class="table" id="single-table-summary-table"> + <caption class="sr-only">Each candidate's votes in each round. Cells are shaded green once a candidate is elected and red once they are eliminated.</caption> <thead class="thead-light"> <tr> <th scope="col" class="col-auto">Candidate name </th> @@ -19,12 +20,15 @@ <tr> <th scope="row">{{ candidateTabulation.name }}</th> {% for round in candidateTabulation.eachRound %} - {% get_round_background_color candidateTabulation.name round as roundBg %} - <td style="background-color: {{ roundBg }}"> + {% get_round_cell_info candidateTabulation.name round as cellInfo %} + <td style="background-color: {{ cellInfo.color }}"> {% if round %} - <h3 class="primaryLabel">{{ round.primaryLabel }}</h3> + <span class="primaryLabel">{{ round.primaryLabel }}</span> {{ round.secondaryLabel }} {% endif %} + {% if cellInfo.status %} + <span class="sr-only">{{ cellInfo.status }}</span> + {% endif %} </td> {% endfor %} </tr> diff --git a/templates/visualizer/base.html b/templates/visualizer/base.html index 41c67e83..0ba4b7e2 100644 --- a/templates/visualizer/base.html +++ b/templates/visualizer/base.html @@ -12,6 +12,7 @@ </head> <body id="page-top" class="{% block bodyclass %}{% endblock %}"> + <a class="skip-link" href="#maincontent">Skip to main content</a> <!-- Site header / navigation --> <header class="site-header" id="mainNav"> <nav class="navbar container" aria-label="Main navigation"> @@ -21,23 +22,26 @@ </nav> </header> -<div id="maincontent"> +<main id="maincontent" tabindex="-1"> {% block maincontent %} {% endblock %} -</div> +</main> <!-- Footer --> <footer class="site-footer"> <div class="container"> <div class="site-footer-grid"> <div> - <h5>About RCVis</h5> + <h2>About RCVis</h2> <p>RCVis is open-source Ranked-Choice Voting Visualization software.</p> <p>Let's collaborate! To use these visualizations for your election, poll, or infographic, please get in touch: <a href="mailto:team@rcvis.com">team@rcvis.com</a>.</p> </div> <div> - <h5>Stay in touch</h5> + <h2>Stay in touch</h2> <ul class="site-footer-links"> + <li> + <a href="{% url 'updates' %}">Recent updates</a> + </li> <li> <a href="https://docs.google.com/forms/d/e/1FAIpQLSdwMw6xYNyxbZiEtuB0G9F7avQhkLY_jmlXjNOBPA01qR4y3g/viewform?usp=sf_link" target="_blank" rel="noopener">Send feedback</a> </li> @@ -53,7 +57,7 @@ <h5>Stay in touch</h5> </ul> </div> <div> - <h5>Newsletter</h5> + <h2>Newsletter</h2> <p>Occasional updates about new features and elections.</p> <!-- Begin Mailchimp Signup Form --> <div id="mc_embed_signup"> diff --git a/templates/visualizer/common-visualizer-header.html b/templates/visualizer/common-visualizer-header.html index 5bd9868f..59631392 100644 --- a/templates/visualizer/common-visualizer-header.html +++ b/templates/visualizer/common-visualizer-header.html @@ -54,6 +54,8 @@ // Data from python var humanFriendlyEventsPerRound = {{humanFriendlyEventsPerRound|safe}}; var humanFriendlySummary = {{humanFriendlySummary|safe}}; + // Plain-English narration of the whole election, for screenreaders: {summary, rounds: [...]} + var narration = {{narrationJson|safe}}; var candidateSidecarData = {{ candidateSidecarData|safe }}; </script> diff --git a/templates/visualizer/index.html b/templates/visualizer/index.html index cda10004..69f14233 100644 --- a/templates/visualizer/index.html +++ b/templates/visualizer/index.html @@ -280,26 +280,16 @@ <h2 class="page-section-heading text-center">Future Development</h2> </p> <ol> <li> - <strong>Accessibility</strong>: - RCVis is not screenreader-friendly, though we have an undocumented audio captions for each visualization. - We'd like to make these audio captions widely available, as well as making all RCVis visualizations screenreader-friendly. - </li> - <li> - <strong>ES&S Data</strong>: - We'd like to be able to upload and visualize data from Election Systems & Software voting machines. - </li> - <li> - <strong>Infrastructure Updates</strong>: - We'd like to use enable autoscaling to better support the influx of users and uploads on election night, as well as other infrastructure - updates for stability and speed. + <strong>User Dashboard</strong>: + We'd like to provide users a method for managing their own visualizations, including modifying them, deleting them, and marking them as private. </li> <li> <strong>"Blue Check" Verification</strong>: We'd like a system for displaying when a visualization hosted on RCVis has been vetted by an election administrator. </li> <li> - <strong>User Dashboard</strong>: - We'd like to provide users a method for managing their own visualizations, including modifying them, deleting them, and marking them as private. + <strong>ES&S Data</strong>: + We'd like to be able to upload and visualize data from Election Systems & Software voting machines. </li> </ol> </div> diff --git a/templates/visualizer/visualize-ballotpedia.html b/templates/visualizer/visualize-ballotpedia.html index a7c7d228..41358c4c 100644 --- a/templates/visualizer/visualize-ballotpedia.html +++ b/templates/visualizer/visualize-ballotpedia.html @@ -15,8 +15,8 @@ <body id="embedded-body"> <div id="embedded-wrapper"> - <div id="bp-header">{{title}}</div> - <div id="embedded-content"> + <header><h1 id="bp-header">{{title}}</h1></header> + <main id="embedded-content"> {% include "bargraph/barchart-interactive.html" %} <div id="bp-info" class="container"> <div id="bp-infobox-left"> @@ -35,18 +35,18 @@ </div> <div id="bp-infobox-right">Total Votes: {{ numVotesFirstRound }}</div> </div> - </div> - <div id="embedded-footer"> + </main> + <footer id="embedded-footer"> Visualizations by <a href="{% url 'visualize' config.slug %}" target="_blank"> - <img src="{% static 'visualizer/logo-white.png' %}" width="100px"/> + <img src="{% static 'visualizer/logo-white.png' %}" width="100px" alt="RCVis"/> </a> <div class="popout"> - <a href="{% url 'visualize' config.slug %}" target="_blank"> - <img src="{% static 'visualizer/icon_popout.png' %}" width="15px"/> + <a href="{% url 'visualize' config.slug %}" target="_blank" aria-label="Open the full visualization in a new tab"> + <img src="{% static 'visualizer/icon_popout.png' %}" width="15px" alt=""/> </a> </div> - </div> + </footer> </div> {% include "visualizer/common-visualizer-nonblocking.html" %} diff --git a/templates/visualizer/visualize-embedded.html b/templates/visualizer/visualize-embedded.html index 84c9260b..4c899334 100644 --- a/templates/visualizer/visualize-embedded.html +++ b/templates/visualizer/visualize-embedded.html @@ -10,7 +10,8 @@ <body id="embedded-body"> <div id="embedded-wrapper"> - <div id="embedded-content"> + <main id="embedded-content"> + <h1 class="sr-only">{{ title }}</h1> {% if vistype == "sankey" %} {% include "sankey/sankey.html" %} {% elif vistype == "pie" %} @@ -30,18 +31,18 @@ {% else %} <p id="no-such-vistype-message">Invalid visualization type requested. Don't expect this error? Contact team@rcvis.com for help.</p> {% endif %} - </div> - <div id="embedded-footer"> + </main> + <footer id="embedded-footer"> Visualizations by <a href="{% url 'visualize' config.slug %}" target="_blank"> - <img src="{% static 'visualizer/logo-white.png' %}" width="100px"/> + <img src="{% static 'visualizer/logo-white.png' %}" width="100px" alt="RCVis"/> </a> <div class="popout"> - <a href="{% url 'visualize' config.slug %}" target="_blank"> - <img src="{% static 'visualizer/icon_popout.png' %}" width="15px"/> + <a href="{% url 'visualize' config.slug %}" target="_blank" aria-label="Open the full visualization in a new tab"> + <img src="{% static 'visualizer/icon_popout.png' %}" width="15px" alt=""/> </a> </div> - </div> + </footer> </div> {% include "visualizer/common-visualizer-nonblocking.html" %} diff --git a/templates/visualizer/visualize.html b/templates/visualizer/visualize.html index a1f4c844..1df53281 100644 --- a/templates/visualizer/visualize.html +++ b/templates/visualizer/visualize.html @@ -33,51 +33,51 @@ {% block bodyclass %}vis-page{% endblock %} {% block navlinks %} - <ul class="navbar-nav ml-auto tabswitchnavbar"> - <li class="nav-item"> - <a class="nav-link btn" id="barchart-tab" data-toggle="changeviz" href="#barchart" role="tab" aria-controls="barchart" aria-selected="true"> + <ul class="navbar-nav ml-auto tabswitchnavbar" role="tablist" aria-label="Visualizations"> + <li class="nav-item" role="presentation"> + <a class="nav-link btn" id="barchart-tab" data-toggle="changeviz" href="#barchart" role="tab" aria-controls="id-barchart" aria-selected="true"> <img class="tabicon" src="{% static 'visualizer/tabicons/barchart.png' %}" width="18" height="18" alt="Barchart icon"/> <span class="tabtext">Bar</span> </a> </li> - <li class="nav-item" name="li_hideable_tabular"> - <a class="nav-link btn" id="single-table-summary-tab" data-toggle="changeviz" href="#single-table-summary" role="tab" aria-controls="single-table-summary" aria-selected="false"> + <li class="nav-item" role="presentation" name="li_hideable_tabular"> + <a class="nav-link btn" id="single-table-summary-tab" data-toggle="changeviz" href="#single-table-summary" role="tab" aria-controls="id-single-table-summary" aria-selected="false"> <img class="tabicon" src="{% static 'visualizer/tabicons/singletable.png' %}" width="18" height="18" alt="Single Table Summary icon. 'formulae table with row and column cells' by icon 54 from the Noun Project."/> <span class="tabtext">Table</span> </a> </li> - <li class="nav-item" name="li_hideable_pie"> - <a class="nav-link btn" id="pie-tab" data-toggle="changeviz" href="#pie" role="tab" aria-controls="pie" aria-selected="false"> + <li class="nav-item" role="presentation" name="li_hideable_pie"> + <a class="nav-link btn" id="pie-tab" data-toggle="changeviz" href="#pie" role="tab" aria-controls="id-pie" aria-selected="false"> <img class="tabicon" src="{% static 'visualizer/tabicons/pie.png' %}" width="18" height="18" alt="Pie icon"/> <span class="tabtext">Pie</span> </a> </li> - <li class="nav-item" name="li_hideable_sankey"> - <a class="nav-link btn" id="sankey-tab" data-toggle="changeviz" href="#sankey" role="tab" aria-controls="sankey" aria-selected="false"> + <li class="nav-item" role="presentation" name="li_hideable_sankey"> + <a class="nav-link btn" id="sankey-tab" data-toggle="changeviz" href="#sankey" role="tab" aria-controls="id-sankey" aria-selected="false"> <img class="tabicon" src="{% static 'visualizer/tabicons/sankey.png' %}" width="18" height="18" alt="Sankey icon. 'sankey graph' by Product Pencil from the Noun Project."/> <span class="tabtext">Sankey</span> </a> </li> - <li class="nav-item" name="li_hideable_tabular"> - <a class="nav-link btn" id="round-by-round-tab" data-toggle="changeviz" href="#round-by-round" role="tab" aria-controls=="round-by-round" aria-selected="false"> + <li class="nav-item" role="presentation" name="li_hideable_tabular"> + <a class="nav-link btn" id="round-by-round-tab" data-toggle="changeviz" href="#round-by-round" role="tab" aria-controls="id-round-by-round" aria-selected="false"> <img class="tabicon" src="{% static 'visualizer/tabicons/roundbyround.png' %}" width="18" height="18" alt="Round-By-Round icon. 'move cells to another location for editing' by icon 54 from the Noun Project."/> <span class="tabtext">By round</span> </a> </li> - <li class="nav-item" name="li_hideable_tabular"> - <a class="nav-link btn" id="candidate-summary-tab" data-toggle="changeviz" href="#candidate-summary" role="tab" aria-controls="candidate-summary" aria-selected="false"> + <li class="nav-item" role="presentation" name="li_hideable_tabular"> + <a class="nav-link btn" id="candidate-summary-tab" data-toggle="changeviz" href="#candidate-summary" role="tab" aria-controls="id-candidate-summary" aria-selected="false"> <img class="tabicon" src="{% static 'visualizer/tabicons/candidatesummary.png' %}" width="18" height="18" alt="Candidate summary icon. 'data set' by H Alberto Gongora from the Noun Project."/> <span class="tabtext">By candidate</span> </a> </li> - <li class="nav-item"> - <a class="nav-link btn" id="settings-tab" data-toggle="changeviz" href="#settings" role="tab" aria-controls="settings" aria-selected="false"> + <li class="nav-item" role="presentation"> + <a class="nav-link btn" id="settings-tab" data-toggle="changeviz" href="#settings" role="tab" aria-controls="id-settings" aria-selected="false"> <img class="tabicon" src="{% static 'visualizer/tabicons/settings.png' %}" width="18" height="18" alt="Settings icon"/> <span class="tabtext">Settings</span> </a> </li> - <li class="nav-item"> - <a class="nav-link btn" id="share-tab" data-toggle="changeviz" href="#share" role="tab" aria-controls="share" aria-selected="false"> + <li class="nav-item" role="presentation"> + <a class="nav-link btn" id="share-tab" data-toggle="changeviz" href="#share" role="tab" aria-controls="id-share" aria-selected="false"> <img class="tabicon" src="{% static 'visualizer/tabicons/share.png' %}" width="18" height="18" alt="Share icon"/> <span class="tabtext">Share</span> </a> @@ -92,7 +92,7 @@ <div class="container px-0"> <h1 class="masthead-subheading font-weight-light electiontitleheading mt-1 mb-0 ml-3">{{ title }}</h1> <p class="font-weight-light mb-0 ml-3">{{ date }}</p> - <h6 class="font-weight-light mb-1 ml-3"> + <p class="font-weight-light mb-1 ml-3 masthead-details"> {% if config.customText %} {{ config.customText }} {% else %} @@ -110,12 +110,12 @@ <h6 class="font-weight-light mb-1 ml-3"> {% if config.dataSourceURL %} | <a href="{{ config.dataSourceURL }}">Source [{{ sourceDomain }}]</a> {% endif %} - </h6> + </p> </div> </header> <div class="px-0"> - <div class="container vis-wrapper" id="id-barchart" data-anchor="barchart" aria-labeledby="barchart-tab"> + <div class="container vis-wrapper" id="id-barchart" data-anchor="barchart" role="tabpanel" aria-labelledby="barchart-tab"> <div class="vis-fixed"> {% include "bargraph/barchart-fixed.html" %} </div> @@ -124,11 +124,11 @@ <h6 class="font-weight-light mb-1 ml-3"> </div> </div> - <div class="vis-wrapper" id="id-sankey" data-anchor="sankey" aria-labeledby="sankey-tab"> + <div class="vis-wrapper" id="id-sankey" data-anchor="sankey" role="tabpanel" aria-labelledby="sankey-tab"> {% include "sankey/sankey.html" %} </div> - <div class="vis-wrapper" id="id-round-by-round" data-anchor="round-by-round" aria-labeledby="round-by-round-tab"> + <div class="vis-wrapper" id="id-round-by-round" data-anchor="round-by-round" role="tabpanel" aria-labelledby="round-by-round-tab"> <div class="vis-fixed"> {% include "tabular/tabular-by-round.html" %} </div> @@ -137,19 +137,19 @@ <h6 class="font-weight-light mb-1 ml-3"> </div> </div> - <div class="vis-wrapper" id="id-pie" data-anchor="pie" aria-labeledby="pie-tab"> + <div class="vis-wrapper" id="id-pie" data-anchor="pie" role="tabpanel" aria-labelledby="pie-tab"> {% include "pie/pie.html" %} </div> - <div class="vis-wrapper" id="id-candidate-summary" data-anchor="candidate-summary" aria-labeledby="candidate-summary-tab"> + <div class="vis-wrapper" id="id-candidate-summary" data-anchor="candidate-summary" role="tabpanel" aria-labelledby="candidate-summary-tab"> {% include "tabular/tabular-by-candidate.html" %} </div> - <div class="vis-wrapper" id="id-single-table-summary" data-anchor="single-table-summary" aria-labeledby="single-table-summary-tab"> + <div class="vis-wrapper" id="id-single-table-summary" data-anchor="single-table-summary" role="tabpanel" aria-labelledby="single-table-summary-tab"> {% include "tabular/tabular-candidate-by-round.html" %} </div> - <div class="vis-wrapper" id="id-settings" data-anchor="settings" aria-labeledby="settings-tab"> + <div class="vis-wrapper" id="id-settings" data-anchor="settings" role="tabpanel" aria-labelledby="settings-tab"> {% include "settings/settings-update.html" %} </div> diff --git a/visualizer/descriptors/roundDescriber.py b/visualizer/descriptors/roundDescriber.py index 769650db..184c1df5 100644 --- a/visualizer/descriptors/roundDescriber.py +++ b/visualizer/descriptors/roundDescriber.py @@ -200,6 +200,24 @@ def describe_all_rounds(self): """ Returns an array corresponding to the description of each round """ return [self.describe_round(i) for i in range(self.graph.numRounds)] + def describe_narration(self): + """ + The plain-English narration of the whole election, as spoken in the generated movie + and used to describe the charts to screenreaders. + + Returns a dict with a 'summary' paragraph and a 'rounds' list with one paragraph per round. + Always uses paragraphs, regardless of summarizeAsParagraph. + """ + originalSummarizeAsParagraph = self.summarizeAsParagraph + try: + self.summarizeAsParagraph = True + return { + 'summary': self.describe_initial_summary(isForVideo=True).strip(), + 'rounds': [self.describe_round(i).strip() for i in range(self.graph.numRounds)] + } + finally: + self.summarizeAsParagraph = originalSummarizeAsParagraph + def describe_initial_summary(self, isForVideo): """ Summarizes the entire election. """ summary = self.graph.summarize() diff --git a/visualizer/templatetags/tableHelpers.py b/visualizer/templatetags/tableHelpers.py index 580a3971..bca61609 100644 --- a/visualizer/templatetags/tableHelpers.py +++ b/visualizer/templatetags/tableHelpers.py @@ -5,8 +5,13 @@ @register.simple_tag(takes_context=True) -def get_round_background_color(context, candidateName, candidate): - """Track candidate status through iterations and return cell background color""" +def get_round_cell_info(context, candidateName, candidate): + """ + Track candidate status through iterations of the single table summary. + Returns a dict with the cell background color and a plain-text status + (for screenreaders, since the color alone conveys the status visually). + Must be called exactly once per cell, in round order. + """ candidateKey = f"round_state_{candidateName}" if candidateKey not in context: context[candidateKey] = { @@ -15,13 +20,14 @@ def get_round_background_color(context, candidateName, candidate): } if candidate is None and not context[candidateKey]["wasEliminated"]: context[candidateKey]["wasEliminated"] = True - return "#FFBEBE" + return {"color": "#FFBEBE", "status": "Eliminated"} if context[candidateKey]["wasEliminated"]: - return "#FAD7D7" + return {"color": "#FAD7D7", "status": "Previously eliminated"} if candidate and candidate.isWinner: + wasWinner = context[candidateKey]["wasWinner"] context[candidateKey]["wasWinner"] = True - return "#A0FFB5" + return {"color": "#A0FFB5", "status": "Previously elected" if wasWinner else "Elected"} if context[candidateKey]["wasWinner"]: - return "#DCFFE1" + return {"color": "#DCFFE1", "status": "Previously elected"} - return "rgba(0,0,0,0)" + return {"color": "rgba(0,0,0,0)", "status": ""} diff --git a/visualizer/tests/testAccessibility.py b/visualizer/tests/testAccessibility.py new file mode 100644 index 00000000..528ed354 --- /dev/null +++ b/visualizer/tests/testAccessibility.py @@ -0,0 +1,160 @@ +""" +Tests for the accessibility features: the plain-English narration, +and the screenreader markup of the visualizations. +""" + +from django.test import TestCase +from django.urls import reverse +from mock import Mock + +from common.testUtils import TestHelpers +from common.viewUtils import DefaultConfig +from visualizer.descriptors.roundDescriber import Describer +from visualizer.graph.graphCreator import make_graph_with_file +from visualizer.templatetags.tableHelpers import get_round_cell_info +from visualizer.tests import filenames + + +class NarrationTests(TestCase): + """ Tests the narration produced by the Describer """ + + def setUp(self): + TestHelpers.setup_host_mocks(self) + self.config = DefaultConfig() + + def test_narration_has_summary_and_one_paragraph_per_round(self): + """ The narration is a summary plus a paragraph per round, in order """ + with open(filenames.THREE_ROUND, 'r', encoding='utf-8') as f: + graph = make_graph_with_file(f, False) + narration = Describer(graph, self.config, summarizeAsParagraph=False).describe_narration() + + self.assertIn('there were 3 rounds', narration['summary']) + self.assertIn("Here's what happened in each round.", narration['summary']) + self.assertEqual(len(narration['rounds']), 3) + self.assertTrue(narration['rounds'][0].startswith('In the first round, ')) + self.assertTrue(narration['rounds'][1].startswith('In the second round, ')) + self.assertTrue(narration['rounds'][2].startswith('In the third round, ')) + self.assertIn('was elected', narration['rounds'][2]) + + def test_narration_does_not_change_describer_mode(self): + """ describe_narration always uses paragraphs, but leaves the describer as it found it """ + with open(filenames.ONE_ROUND, 'r', encoding='utf-8') as f: + graph = make_graph_with_file(f, False) + describer = Describer(graph, self.config, summarizeAsParagraph=False) + narration = describer.describe_narration() + + self.assertIsInstance(narration['rounds'][0], str) + self.assertFalse(describer.summarizeAsParagraph) + # The list-of-events form still works afterwards + self.assertIsInstance(describer.describe_round(0), list) + + +class TableHelpersTests(TestCase): + """ Tests the single-table-summary cell helper """ + + @classmethod + def _statuses(cls, roundsForCandidate): + """ Runs the helper over each round, in order, with a fresh template context """ + context = {} + return [get_round_cell_info(context, 'Candidate', roundData)['status'] + for roundData in roundsForCandidate] + + def test_winner_is_marked_once_then_previously(self): + """ The color-only "elected" status has text: Elected, then Previously elected """ + active = Mock(isWinner=False) + winner = Mock(isWinner=True) + self.assertEqual(self._statuses([active, winner, winner]), + ['', 'Elected', 'Previously elected']) + + def test_eliminated_is_marked_once_then_previously(self): + """ Eliminated candidates have no data for the round: Eliminated, then Previously """ + active = Mock(isWinner=False) + self.assertEqual(self._statuses([active, None, None]), + ['', 'Eliminated', 'Previously eliminated']) + + +class VisualizationMarkupTests(TestCase): + """ Tests the screenreader-facing markup of the rendered pages """ + + def setUp(self): + TestHelpers.login(self.client) + TestHelpers.setup_host_mocks(self) + TestHelpers.get_multiwinner_upload_response(self.client) + self.slug = TestHelpers.get_latest_upload().slug + + def tearDown(self): + TestHelpers.logout(self.client) + + def _get(self, url): + response = self.client.get(url) + self.assertEqual(response.status_code, 200) + return response.content.decode('utf-8') + + def test_visualize_page_landmarks_and_tabs(self): + """ Skip link, main landmark, and the ARIA tabs pattern """ + content = self._get(reverse('visualize', args=(self.slug,))) + + self.assertIn('class="skip-link" href="#maincontent"', content) + self.assertIn('<main id="maincontent" tabindex="-1">', content) + + self.assertIn('role="tablist"', content) + self.assertEqual(content.count('role="presentation"'), 8) + self.assertEqual(content.count('role="tab"'), 8) + self.assertEqual(content.count('role="tabpanel"'), 8) + self.assertIn('aria-controls="id-barchart"', content) + self.assertIn('aria-labelledby="barchart-tab"', content) + # The typo that used to break the label association + self.assertNotIn('aria-labeledby', content) + self.assertNotIn('aria-controls==', content) + + def test_narration_is_passed_to_the_charts(self): + """ The narration is passed to JS for the charts' screenreader descriptions """ + content = self._get(reverse('visualize', args=(self.slug,))) + self.assertNotIn('id="summary-tab"', content) + self.assertIn('var narration = {"summary": "In this Multi-Winner Ranked Choice Voting ' + 'election, there were', content) + + def test_round_description_is_a_polite_live_region(self): + """ The bar chart's description announces round changes without interrupting """ + content = self._get(reverse('visualize', args=(self.slug,))) + self.assertIn('id="round-description-wrapper" class="round-description-wrapper" ' + 'role="status" aria-live="polite" aria-atomic="true"', content) + self.assertIn('<button type="button" class="btn btn-link faq-link" onclick="showFaqs()">', + content) + + def test_tables_have_captions_and_status_text(self): + """ Every table has a caption, and color-only statuses have hidden text """ + content = self._get(reverse('visualize', args=(self.slug,))) + + self.assertIn('<caption class="sr-only">Candidates eliminated and elected in each round', + content) + self.assertIn('<caption class="sr-only">Round 1: what changed for each candidate', + content) + self.assertIn("<caption class=\"sr-only\">Each candidate's votes in each round.", content) + self.assertIn('<span class="sr-only">Elected</span>', content) + self.assertIn('<span class="sr-only">Eliminated</span>', content) + # The big number in each cell is no longer a heading + self.assertNotIn('<h3 class="primaryLabel">', content) + self.assertIn('<span class="primaryLabel">', content) + + def test_embedded_pages_have_alt_text_and_descriptions(self): + """ The embedded views get the same treatment as the full page """ + base = reverse('visualizeEmbedded', args=(self.slug,)) + + content = self._get(base + '?vistype=barchart-interactive') + self.assertIn('alt="RCVis"', content) + self.assertIn('aria-label="Open the full visualization in a new tab"', content) + self.assertIn('var narration = ', content) + + content = self._get(base + '?vistype=pie') + self.assertIn('id="pie-description" class="sr-only"', content) + self.assertIn('aria-describedby="pie-description"', content) + + content = self._get(base + '?vistype=tabular-candidate-by-round') + self.assertIn('<span class="sr-only">Elected</span>', content) + + def test_homepage_roadmap_reflects_accessibility_work(self): + """ The roadmap no longer claims RCVis is not screenreader-friendly """ + content = self._get('/') + self.assertNotIn('RCVis is not screenreader-friendly', content) + self.assertIn('described for screenreaders', content) diff --git a/visualizer/tests/testLiveBrowserHeadless.py b/visualizer/tests/testLiveBrowserHeadless.py index 2699536e..bc178180 100644 --- a/visualizer/tests/testLiveBrowserHeadless.py +++ b/visualizer/tests/testLiveBrowserHeadless.py @@ -18,6 +18,7 @@ from mock import patch from selenium.common.exceptions import NoSuchElementException from selenium.webdriver.common.by import By +from selenium.webdriver.common.keys import Keys from selenium.webdriver.support import expected_conditions as EC from selenium.webdriver.support.ui import Select from selenium.webdriver.support.ui import WebDriverWait @@ -742,3 +743,71 @@ def test_pie_chart_round_change(self): # The SVG text lives inside the pie-chart custom element's shadow DOM self._ensure_eventually_asserts( lambda: self.assertIn('Banana', self._get_pie_svg_text())) + + def test_tab_keyboard_navigation(self): + """ Arrow keys move between tabs, and aria-selected follows the selected tab """ + self._upload(filenames.THREE_ROUND) + + barchartTab = self.browser.find_element(By.ID, 'barchart-tab') + tableTab = self.browser.find_element(By.ID, 'single-table-summary-tab') + shareTab = self.browser.find_element(By.ID, 'share-tab') + self.assertEqual(barchartTab.get_attribute('aria-selected'), 'true') + self.assertEqual(tableTab.get_attribute('aria-selected'), 'false') + # Only the selected tab is in the tab order + self.assertIsNone(barchartTab.get_attribute('tabindex')) + self.assertEqual(tableTab.get_attribute('tabindex'), '-1') + + barchartTab.send_keys(Keys.ARROW_RIGHT) + self.assertEqual(tableTab.get_attribute('aria-selected'), 'true') + self.assertEqual(barchartTab.get_attribute('aria-selected'), 'false') + self.assertEqual(barchartTab.get_attribute('tabindex'), '-1') + self.assertTrue(self.browser.current_url.endswith('#single-table-summary')) + self.assertTrue(self.browser.find_element(By.ID, 'id-single-table-summary').is_displayed()) + self.assertFalse(self.browser.find_element(By.ID, 'id-barchart').is_displayed()) + self.assertEqual(self.browser.switch_to.active_element.get_attribute('id'), + 'single-table-summary-tab') + + tableTab.send_keys(Keys.END) + self.assertEqual(shareTab.get_attribute('aria-selected'), 'true') + shareTab.send_keys(Keys.HOME) + self.assertEqual(barchartTab.get_attribute('aria-selected'), 'true') + + def test_chart_descriptions_follow_the_round(self): + """ The bar chart and sankey are described images; the bar chart follows the round """ + self._upload(filenames.THREE_ROUND) + + def _bargraph_desc(): + return self.browser.execute_script( + "const svg = document.querySelector('#bargraph-interactive-body > svg');" + "return [svg.getAttribute('role'), svg.querySelector('title').textContent," + " svg.querySelector('desc').textContent];") + + role, title, desc = _bargraph_desc() + self.assertEqual(role, 'img') + self.assertIn('Bar chart', title) + self.assertIn('Showing round 3 of 3.', desc) + self.assertIn('In the third round,', desc) + + self._go_to_round_by_clicking(0) + self.assertIn('Showing round 1 of 3.', _bargraph_desc()[2]) + self.assertIn('In the first round,', _bargraph_desc()[2]) + + # Changing rounds is announced to screenreaders by each player's live region + def _pie_live_text(): + return self.browser.execute_script( + "return document.querySelector(" + "'#pie-slider-container .round-player-live').innerText;") + self.assertEqual(_pie_live_text(), '') # only the bar chart's player has been used + self._go_to_tab('pie-tab') + container = self.browser.find_element(By.ID, 'pie-slider-container') + Select(container.find_element(By.CSS_SELECTOR, '.round-player-select')).select_by_value('1') + self.assertIn('Round 2 of 3.', _pie_live_text()) + self.assertIn('In the second round,', _pie_live_text()) + + self._go_to_tab('sankey-tab') + role, desc = self.browser.execute_script( + "const svg = document.getElementById('sankey-svg');" + "return [svg.getAttribute('role'), svg.querySelector('desc').textContent];") + self.assertEqual(role, 'img') + self.assertIn('Each column is a round of counting', desc) + self.assertIn('there were 3 rounds', desc) From c2612fe8bbb7e589284651709d824f9afc824fa2 Mon Sep 17 00:00:00 2001 From: Armin Samii <armin.samii@gmail.com> Date: Fri, 25 Sep 2026 16:44:39 -0400 Subject: [PATCH 2/4] Add a Recent Updates page A short changelog at /updates.html, grouped by month, linked from the homepage navigation and the footer, and listed in the sitemap. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> --- static/visualizer/updates.css | 53 +++++++++++++++++++++++++++++++ templates/visualizer/index.html | 3 ++ templates/visualizer/updates.html | 43 +++++++++++++++++++++++++ visualizer/sitemaps.py | 4 +-- visualizer/tests/testSimple.py | 10 ++++++ visualizer/urls.py | 1 + visualizer/views.py | 5 +++ 7 files changed, 117 insertions(+), 2 deletions(-) create mode 100644 static/visualizer/updates.css create mode 100644 templates/visualizer/updates.html diff --git a/static/visualizer/updates.css b/static/visualizer/updates.css new file mode 100644 index 00000000..893aa041 --- /dev/null +++ b/static/visualizer/updates.css @@ -0,0 +1,53 @@ +/* Recent updates page: a short, dated list of changes */ +.updates-page { + max-width: 720px; + margin: 0 auto; + padding: 3rem 1rem 4rem 1rem; + font-family: var(--rcv-font-ui); + line-height: 1.6; +} + +.updates-title { + font-size: 2rem; + font-weight: 700; + letter-spacing: -0.01em; + margin-bottom: 0.5rem; +} + +.updates-intro { + color: var(--rcv-muted); + margin-bottom: 2.5rem; +} + +.updates-month { + font-size: 0.85rem; + font-weight: 700; + letter-spacing: 0.06em; + text-transform: uppercase; + color: var(--rcv-brand); + border-bottom: 1px solid var(--rcv-border); + padding-bottom: 0.5rem; + margin: 2.5rem 0 1.25rem 0; +} + +.updates-month:first-of-type { + margin-top: 0; +} + +.updates-post { + margin-bottom: 2.25rem; +} + +.updates-post h3 { + font-size: 1.25rem; + font-weight: 700; + margin-bottom: 0.5rem; +} + +.updates-post ul { + padding-left: 1.25rem; +} + +.updates-post li { + margin-bottom: 0.35rem; +} diff --git a/templates/visualizer/index.html b/templates/visualizer/index.html index 69f14233..8fdf3ea9 100644 --- a/templates/visualizer/index.html +++ b/templates/visualizer/index.html @@ -28,6 +28,9 @@ <li class="nav-item"> <a class="nav-link" href="/upload.html">Create A Visualization</a> </li> + <li class="nav-item"> + <a class="nav-link" href="{% url 'updates' %}">Recent Updates</a> + </li> </ul> </div> {% endblock %} diff --git a/templates/visualizer/updates.html b/templates/visualizer/updates.html new file mode 100644 index 00000000..20d02dcc --- /dev/null +++ b/templates/visualizer/updates.html @@ -0,0 +1,43 @@ +{% extends "visualizer/base.html" %} +{% load static %} +{% load compress %} + +{% block header %} + {% compress css file %} + <link rel="stylesheet" href="{% static "visualizer/updates.css" %}"> + {% endcompress %} + + <title>Recent Updates: RCVis + +{% endblock %} + +{% block maincontent %} +
+

Recent Updates

+

+ RCVis changelog +

+ +

September 2026

+ +
+

Screenreader and keyboard support

+

+ Every visualization now works with screenreaders and without a mouse. This applies to + the full RCVis pages and to visualizations embedded on other sites. +

+
    +
  • Each chart has a text description. The bar chart's description changes with the round, so a screenreader user can pick a round and then read what the chart shows.
  • +
  • Tables have captions, and cells that only used color to show "elected" or "eliminated" now say so for screenreaders.
  • +
  • The tabs at the top of a visualization can be moved between with the arrow keys, and there is a "Skip to main content" link for keyboard users.
  • +
+
+ +
+

A new look for RCVis

+

+ RCVis has a refreshed visual design. The charts and tables themselves have not changed, just cleaner formatting throughout. +

+
+
+{% endblock %} diff --git a/visualizer/sitemaps.py b/visualizer/sitemaps.py index a488b777..ccdb2edb 100644 --- a/visualizer/sitemaps.py +++ b/visualizer/sitemaps.py @@ -7,13 +7,13 @@ class StaticViewSitemap(sitemaps.Sitemap): - """ Home and upload pages """ + """ Home, updates and upload pages """ priority = 0.8 changefreq = 'weekly' protocol = 'https' def items(self): - return ['index', 'upload'] + return ['index', 'updates', 'upload'] def location(self, item): return reverse(item) diff --git a/visualizer/tests/testSimple.py b/visualizer/tests/testSimple.py index 910e35a7..64e75bce 100644 --- a/visualizer/tests/testSimple.py +++ b/visualizer/tests/testSimple.py @@ -171,6 +171,16 @@ def test_home_page(self): response = self.client.get('/') self.assertEqual(response.status_code, 200) + def test_updates_page(self): + """ Tests that the recent updates page loads and is linked from the home page """ + response = self.client.get(reverse('updates')) + self.assertEqual(response.status_code, 200) + self.assertIn(b'Recent Updates', response.content) + self.assertIn(b'

September 2026

', response.content) + + response = self.client.get('/') + self.assertIn(b'href="/updates.html"', response.content) + def test_upload_file(self): """ Tests uploading a random file """ response = TestHelpers.get_multiwinner_upload_response(self.client) diff --git a/visualizer/urls.py b/visualizer/urls.py index 4d9142f9..9d9456f6 100644 --- a/visualizer/urls.py +++ b/visualizer/urls.py @@ -30,6 +30,7 @@ path('vb/', views.VisualizeBallotpedia.as_view(), name='visualizeBallotpedia'), path('raw/', views.DownloadRawData.as_view(), name='downloadRawData'), + path('updates.html', views.Updates.as_view(), name='updates'), path('upload.html', views.Upload.as_view(), name='upload'), path('uploadByDataTable.html', views.UploadByDataTable.as_view(), name='uploadByDataTable'), path('oembed', views.Oembed.as_view(), name='oembed'), diff --git a/visualizer/views.py b/visualizer/views.py index d1857f78..e73dac35 100644 --- a/visualizer/views.py +++ b/visualizer/views.py @@ -81,6 +81,11 @@ def get_context_data(self, **kwargs): return context +class Updates(TemplateView): + """ Recent updates: a short changelog for people who use RCVis """ + template_name = 'visualizer/updates.html' + + # pylint: disable=too-many-ancestors class Upload(LoginRequiredMixin, CreateView): """ The upload page """ From 0a6cef605db161de9d3c58c0f279ecd5e05d47ae Mon Sep 17 00:00:00 2001 From: Armin Samii Date: Sun, 27 Sep 2026 11:44:51 -0400 Subject: [PATCH 3/4] fix tests --- static/tabular/style.css | 7 ++++--- visualizer/tests/testLiveBrowserWithHead.py | 2 +- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/static/tabular/style.css b/static/tabular/style.css index 0b229d58..d4c43a5a 100644 --- a/static/tabular/style.css +++ b/static/tabular/style.css @@ -35,11 +35,12 @@ h3 { padding-right: 1em; } -/* The big number in each single-table-summary cell. Styled like an h3, but not a heading. */ +/* The big number in each single-table-summary cell. Styled like the h3 it replaced + (1.2em bold, from common.css overriding bootstrap), but not a heading. */ .primaryLabel { display: block; - font-size: 1.75rem; - font-weight: 500; + font-size: 1.2em; + font-weight: 700; line-height: 1.2; margin-bottom: 0.5rem; padding-left: 1em; diff --git a/visualizer/tests/testLiveBrowserWithHead.py b/visualizer/tests/testLiveBrowserWithHead.py index 2b94c300..121adfb9 100644 --- a/visualizer/tests/testLiveBrowserWithHead.py +++ b/visualizer/tests/testLiveBrowserWithHead.py @@ -314,5 +314,5 @@ def test_faq_visibility_wrt_iframes(self): self.assertEqual(faq.value_of_css_property("display"), "none") # After clicking "Read a detailed explanation" it becomes visible - self.browser.find_element(By.LINK_TEXT, "Read a detailed explanation").click() + self.browser.find_element(By.CSS_SELECTOR, "button.faq-link").click() self.assertEqual(faq.value_of_css_property("display"), "block") From 6e414edd8173f28fb3412d8a33e22b59174eb326 Mon Sep 17 00:00:00 2001 From: Armin Samii Date: Mon, 28 Sep 2026 14:54:26 -0400 Subject: [PATCH 4/4] fix tests --- visualizer/tests/testAccessibility.py | 6 ------ visualizer/tests/testLiveBrowserHeadless.py | 6 +++--- visualizer/tests/testSidecar.py | 4 ++-- 3 files changed, 5 insertions(+), 11 deletions(-) diff --git a/visualizer/tests/testAccessibility.py b/visualizer/tests/testAccessibility.py index 528ed354..7975c476 100644 --- a/visualizer/tests/testAccessibility.py +++ b/visualizer/tests/testAccessibility.py @@ -152,9 +152,3 @@ def test_embedded_pages_have_alt_text_and_descriptions(self): content = self._get(base + '?vistype=tabular-candidate-by-round') self.assertIn('Elected', content) - - def test_homepage_roadmap_reflects_accessibility_work(self): - """ The roadmap no longer claims RCVis is not screenreader-friendly """ - content = self._get('/') - self.assertNotIn('RCVis is not screenreader-friendly', content) - self.assertIn('described for screenreaders', content) diff --git a/visualizer/tests/testLiveBrowserHeadless.py b/visualizer/tests/testLiveBrowserHeadless.py index bc178180..615763e7 100644 --- a/visualizer/tests/testLiveBrowserHeadless.py +++ b/visualizer/tests/testLiveBrowserHeadless.py @@ -494,7 +494,7 @@ def register(): self.browser.find_element(By.ID, "id_password2").send_keys(password) self.browser.find_element(By.ID, "id_email").send_keys("test@example.com") submitButton.click() - WebDriverWait(self.browser, 1).until(EC.staleness_of(submitButton)) + WebDriverWait(self.browser, 5).until(EC.staleness_of(submitButton)) def login_via_upload_redirect(): self.open(reverse('upload')) @@ -502,14 +502,14 @@ def login_via_upload_redirect(): self.browser.find_element(By.ID, "id_username").send_keys(username) self.browser.find_element(By.ID, "id_password").send_keys(password) submitButton.click() - WebDriverWait(self.browser, 1).until(EC.staleness_of(submitButton)) + WebDriverWait(self.browser, 5).until(EC.staleness_of(submitButton)) def click_activation_link(): emailBodyRelativeLink = TestHelpers.get_email_reg_link(test_mailbox.outbox) self.open(emailBodyRelativeLink) submitButton = self.browser.find_element(By.XPATH, "//button[@type='submit']") submitButton.click() - WebDriverWait(self.browser, 1).until(EC.staleness_of(submitButton)) + WebDriverWait(self.browser, 5).until(EC.staleness_of(submitButton)) # Make sure we won't sign up for mailchimp self.assertEqual(settings.MAILCHIMP_API_KEY, None) diff --git a/visualizer/tests/testSidecar.py b/visualizer/tests/testSidecar.py index 8e773e10..19f6c77e 100644 --- a/visualizer/tests/testSidecar.py +++ b/visualizer/tests/testSidecar.py @@ -85,7 +85,7 @@ def _upload_and_go_to_ballotpedia(self, uploadConfig): def _get_bp_body(self, visResponse): """ Get the main body of /vb/, just to make the search string a little smaller """ bodyStart = visResponse.content.find(b'') + bodyEnd = visResponse.content.find(b'