From 98571ea95b03499aea0be4a63b26de4c77120d10 Mon Sep 17 00:00:00 2001 From: Lukas Wallrich Date: Wed, 21 Oct 2026 19:35:37 +0100 Subject: [PATCH 1/3] Improve dashboard keyboard and screen reader accessibility Keep paging focus, expose sort and filter state, name landmarks and chart controls, respect accessibility preferences, and distinguish dark outcome colours. Co-Authored-By: GPT 6.1 Sol --- assets/app.js | 19 ++++-- assets/chart-accessibility.js | 9 ++- assets/citation-impact.js | 28 +++++--- assets/ds-tokens.css | 13 ++-- assets/styles.css | 56 +++++++++++++--- assets/view-controls.js | 20 +++++- index.html | 69 +++++++++++--------- tests/browser.test.cjs | 118 ++++++++++++++++++++++++++++++++++ 8 files changed, 268 insertions(+), 64 deletions(-) diff --git a/assets/app.js b/assets/app.js index e5006b13..bbc5954d 100644 --- a/assets/app.js +++ b/assets/app.js @@ -873,13 +873,13 @@ function bmRender() { const total = bmFiltered.length; if (total === 0) { list.innerHTML = '
No matching reference pairs.
'; - meta.textContent = '0 reference pairs'; pager.style.display = 'none'; return; + meta.textContent = (browseKind === 'all' ? 'All studies' : studyTypeLabel(browseKind)) + ': 0 reference pairs'; pager.style.display = 'none'; return; } const totalPages = Math.max(1, Math.ceil(total / BM_PAGE_SIZE)); if (bmPage >= totalPages) bmPage = totalPages - 1; const start = bmPage * BM_PAGE_SIZE; const end = Math.min(total, start + BM_PAGE_SIZE); list.innerHTML = bmFiltered.slice(start, end).map(bmCardHtml).join(''); - meta.innerHTML = `Showing ${(start + 1).toLocaleString('en-GB')}–${end.toLocaleString('en-GB')} of ${total.toLocaleString()} reference ${total === 1 ? "pair" : "pairs"}`; + meta.innerHTML = `${browseKind === 'all' ? 'All studies' : studyTypeLabel(browseKind)}: Showing ${(start + 1).toLocaleString('en-GB')}–${end.toLocaleString('en-GB')} of ${total.toLocaleString()} reference ${total === 1 ? "pair" : "pairs"}`; info.textContent = `Page ${bmPage + 1} of ${totalPages}`; prev.disabled = bmPage === 0; next.disabled = bmPage >= totalPages - 1; pager.style.display = totalPages > 1 ? 'flex' : 'none'; @@ -892,8 +892,14 @@ function setupBrowseMobile(data) { const next = document.getElementById('bm-next'); let debounceTimer; input.addEventListener('input', () => { clearTimeout(debounceTimer); debounceTimer = setTimeout(() => bmApplySearch(input.value), 200); }); - prev.addEventListener('click', () => { if (bmPage > 0) { bmPage--; bmRender(); syncBrowseUrl(); document.querySelector('.app-main').scrollTo({ top: 0, behavior: 'smooth' }); } }); - next.addEventListener('click', () => { bmPage++; bmRender(); syncBrowseUrl(); document.querySelector('.app-main').scrollTo({ top: 0, behavior: 'smooth' }); }); + function showPage(delta) { + bmPage += delta; bmRender(); syncBrowseUrl(); + const heading = document.getElementById('browse-results-heading'); + heading.focus({preventScroll:true}); + heading.scrollIntoView({block:'start', behavior:matchMedia('(prefers-reduced-motion: reduce)').matches ? 'instant' : 'smooth'}); + } + prev.addEventListener('click', () => { if (bmPage > 0) showPage(-1); }); + next.addEventListener('click', () => { if (!next.disabled) showPage(1); }); input.value = browseQuery; bmApplySearch(browseQuery); } @@ -1056,7 +1062,8 @@ function trendsFilteredData() { return filterByKind(fullRowData, trendsKind); } function updateTrendsCount() { const el = document.getElementById('trend-filter-count'); if (!el) return; const n = trendsFilteredData().length; const total = fullRowData.length; - el.textContent = trendsKind === 'all' ? `${n.toLocaleString()} reference ${n === 1 ? "pair" : "pairs"}` : `${n.toLocaleString()} of ${total.toLocaleString()} reference ${total === 1 ? "pair" : "pairs"}`; + const count = trendsKind === 'all' ? `${n.toLocaleString()} reference ${n === 1 ? "pair" : "pairs"}` : `${n.toLocaleString()} of ${total.toLocaleString()} reference ${total === 1 ? "pair" : "pairs"}`; + el.innerHTML = `${BROWSE_KIND_LABELS[trendsKind]}: ${count}`; } // Plain per-category counts (year/journal/field) - no outcome breakdown. A prior version @@ -1346,7 +1353,7 @@ function renderMcCharts() { const statCards = buckets.filter(b => ov[b.overviewKey] > 0).map(b => { const n = ov[b.overviewKey] || 0; const pct = FloraCharts.percent(n, ov.n_total); - return `
${n.toLocaleString()}${b.label} (${escapeHtml(pct)}%)
`; + return `
${n.toLocaleString()}${b.label} (${escapeHtml(pct)}%)
`; }).join(''); document.getElementById('mc-overview').innerHTML = `
${ov.n_total.toLocaleString()}Reference pairs with OMC
diff --git a/assets/chart-accessibility.js b/assets/chart-accessibility.js index e5eb3078..a2ede154 100644 --- a/assets/chart-accessibility.js +++ b/assets/chart-accessibility.js @@ -59,7 +59,7 @@ window.FloraCharts = (() => { || el.closest('.trend-block')?.querySelector('.trend-desc h3') || el.closest('.mc-chart-card, .plot-card')?.querySelector('.mc-chart-title, h3') || el.closest('.modal-body')?.querySelector('h3'); - const prefix = el.id.startsWith('browse-') ? 'Browse results: ' : ''; + const prefix = el.id.startsWith('browse-') ? 'Browse results: ' : /^plot-(cit|cocit)/.test(el.id) ? (el.id.endsWith('-model') ? 'Adjusted association: ' : 'Raw trajectory: ') : ''; return heading ? prefix + plain(heading.textContent) : el.id.replace(/-/g, ' '); } // Removes a chart together with everything that describes or exports it, so an @@ -84,7 +84,7 @@ window.FloraCharts = (() => { anchor.insertAdjacentElement('afterend', details); } const wasOpen = details.open; - details.innerHTML = `View chart data

${escape(summary)}

${escape(context(el))}

${headers.map(h=>``).join('')}${rows.map(row=>`${row.map((v,i)=>i ? `` : ``).join('')}`).join('')}
${escape(visibleTitle(el))}
${escape(h)}
${escape(fmt(v, headers[i]))}${escape(fmt(v, headers[i]))}
`; + details.innerHTML = `View chart data

${escape(summary)}

${escape(context(el))}

${headers.map(h=>``).join('')}${rows.map(row=>`${row.map((v,i)=>i ? `` : ``).join('')}`).join('')}
${escape(visibleTitle(el))}
${escape(h)}
${escape(fmt(v, headers[i]))}${escape(fmt(v, headers[i]))}
`; details.open = wasOpen; const label = el.closest('.outcome-chart-row')?.querySelector('.outcome-chart-label') || el.closest('.trend-block')?.querySelector('.trend-title'); let action = document.getElementById(id + '-copy'); @@ -108,7 +108,7 @@ window.FloraCharts = (() => { try { await navigator.clipboard.writeText(csvText(headers, rows, summary + '\n' + context(el))); action.title = 'Chart CSV copied'; - action.setAttribute('aria-label', 'Chart CSV copied'); + action.setAttribute('aria-label', 'Chart CSV copied for ' + visibleTitle(el)); status.textContent = 'Chart CSV copied'; setTimeout(() => { action.title = 'Copy chart CSV'; action.setAttribute('aria-label', 'Copy chart CSV for ' + visibleTitle(el)); }, 2000); } catch { @@ -130,6 +130,8 @@ window.FloraCharts = (() => { table(el,rows,['Category',...datasets.map(d=>d.label)],sample); if (config.options.scales) for (const axis of Object.values(config.options.scales)) if (axis.ticks) axis.ticks.autoSkip = !config.options.indexAxis || config.options.indexAxis !== 'y'; if (config.options.plugins?.legend) config.options.plugins.legend.reverse = false; + Chart.defaults.font.family = theme().fontFamily; + if (matchMedia('(prefers-reduced-motion: reduce)').matches) config.options.animation = false; return new Chart(ctx, config); } // options.table = {headers, rows, summary} replaces the generic per-trace table, for @@ -179,6 +181,7 @@ window.FloraCharts = (() => { toggle.onclick=()=>{modes.set(id,modes.get(id)==='count'?'percent':'count');const args=originals.get(id);plot(el,...args);}; } toggle.textContent=counts?'Show percentages':'Show counts'; + toggle.setAttribute('aria-label', toggle.textContent + ' for ' + visibleTitle(el)); } if (window.innerWidth < 600) { layout.margin={...layout.margin,l:60,r:layout.yaxis2 ? 65 : 20,b:100}; diff --git a/assets/citation-impact.js b/assets/citation-impact.js index e3753bad..2bfb8d1a 100644 --- a/assets/citation-impact.js +++ b/assets/citation-impact.js @@ -368,7 +368,7 @@ `; }).join(''); return `
-

${title}

+

${title}

${rows} @@ -398,6 +398,11 @@ } function renderTable() { + const focused = document.activeElement; + const pageAction = focused.closest('#pagination button')?.dataset.action; + const pageNumber = focused.closest('#pagination button')?.dataset.p; + const timelineRow = focused.closest('#originals-table tbody tr'); + const rowIndex = timelineRow ? [...timelineRow.parentElement.children].indexOf(timelineRow) : -1; const q = document.getElementById('search-input').value || ''; const tokens = searchTokens(q); const outFilter = document.getElementById('filter-outcome').value; @@ -420,7 +425,13 @@ th.classList.remove('sort-asc', 'sort-desc'); if (th.dataset.sort === col) th.classList.add(dir === 'asc' ? 'sort-asc' : 'sort-desc'); }); + document.querySelectorAll('#originals-table th').forEach(th => { + const active = th.querySelector(`[data-sort="${col}"]`); + if (active) th.setAttribute('aria-sort', dir === 'asc' ? 'ascending' : 'descending'); + else th.removeAttribute('aria-sort'); + }); document.querySelectorAll('.cocit-sort-btn').forEach(btn => { + btn.setAttribute('aria-pressed', String(btn.dataset.sort === col)); btn.classList.remove('sort-asc', 'sort-desc'); if (btn.dataset.sort === col) btn.classList.add(dir === 'asc' ? 'sort-asc' : 'sort-desc'); }); @@ -457,10 +468,12 @@ const winStart = Math.max(1, CI.page - Math.floor(winSize / 2)); const winEnd = Math.min(pages, winStart + winSize - 1); let pHtml = ''; - if (CI.page > 1) pHtml += ``; - for (let i = winStart; i <= winEnd; i++) pHtml += ``; - if (CI.page < pages) pHtml += ``; + if (CI.page > 1) pHtml += ``; + for (let i = winStart; i <= winEnd; i++) pHtml += ``; + if (CI.page < pages) pHtml += ``; pag.innerHTML = pHtml; + if (pageNumber) (pag.querySelector(pageAction ? `[data-action="${pageAction}"]` : `[data-p="${pageNumber}"]`) || pag.querySelector('[aria-current]'))?.focus({preventScroll:true}); + else if (rowIndex >= 0) (tbody.rows[Math.min(rowIndex, tbody.rows.length - 1)]?.querySelector('.timeline-open') || document.getElementById('originals-table')).focus(); } // Fetch one original's full record (citation timeline and replication list), @@ -656,12 +669,7 @@ CI.page = 1; renderTable(); } - document.querySelectorAll('#originals-table thead th[data-sort]').forEach(th => { - th.tabIndex = 0; th.setAttribute('role','button'); - th.addEventListener('click', () => sortBy(th.dataset.sort)); - th.addEventListener('keydown', e => { if(e.key === 'Enter' || e.key === ' ') {e.preventDefault();sortBy(th.dataset.sort);} }); - }); - document.querySelectorAll('.cocit-sort-btn').forEach(btn => { + document.querySelectorAll('#originals-table button[data-sort]').forEach(btn => { btn.addEventListener('click', () => sortBy(btn.dataset.sort)); }); document.querySelector('#originals-table tbody').addEventListener('click', e => { diff --git a/assets/ds-tokens.css b/assets/ds-tokens.css index f536d5e4..e43e4173 100644 --- a/assets/ds-tokens.css +++ b/assets/ds-tokens.css @@ -88,12 +88,14 @@ --color-plot-band-original: rgba(37, 99, 235, .15); /* study-original band */ --color-plot-point: rgba(80, 80, 80, .15); /* jittered observations */ --color-plot-rule: rgba(100, 100, 100, .4); /* reference lines */ - --color-plot-rule-label: rgba(100, 100, 100, .6); + --color-plot-rule-label: #6b7280; /* Chart series colours (FLoRA explorer). Light values keep the established outcome palette; dark values below keep series marks at >= 3:1 against --color-surface-raised, except --color-chart-not-coded, which stays deliberately faint (2.6:1) for unassessed rows. */ + --color-badge-successful: #246f3d; + --color-badge-on-outcome: #fff; --color-chart-successful: #2f8f4f; --color-chart-failed: #b3331e; --color-chart-mixed: #d49b1d; @@ -291,10 +293,13 @@ --color-plot-band-original: rgba(147, 180, 253, .18); --color-plot-point: rgba(210, 210, 220, .2); --color-plot-rule: rgba(200, 200, 210, .45); - --color-plot-rule-label: rgba(200, 200, 210, .6); + --color-plot-rule-label: #c3bac0; - --color-chart-successful: #4ade80; - --color-chart-failed: #f08a7c; + /* Lightness separates success and failure under red/green colour-vision loss. */ + --color-badge-successful: var(--color-chart-successful); + --color-badge-on-outcome: #17131a; + --color-chart-successful: #86efac; + --color-chart-failed: #ed7465; --color-chart-mixed: #e0b040; --color-chart-inconclusive: #a3a9b6; --color-chart-qualified: #c0a8fa; diff --git a/assets/styles.css b/assets/styles.css index d86acbf7..38cc0447 100644 --- a/assets/styles.css +++ b/assets/styles.css @@ -172,8 +172,8 @@ h1, .serif, .card-header, .stats-card .number, /* Badges */ .badge { font-weight: 500; padding: 0.4em 0.7em; border-radius: 4px; font-size: 0.78rem; letter-spacing: 0.02em; } -.badge-successful { background-color: #246f3d; color: #fff; } -.badge-failed { background-color: #b3331e; color: #fff; } +.badge-successful { background-color: var(--color-badge-successful); color: var(--color-badge-on-outcome); } +.badge-failed { background-color: var(--color-chart-failed); color: var(--color-badge-on-outcome); } .badge-mixed { background-color: #d49b1d; color: #201800; } .badge-inconclusive, .badge-unknown { background-color: var(--color-muted-fg); color: white; } @@ -450,6 +450,7 @@ tr.shown td.details-control .study-details::before { transform: rotate(90deg); } .table-wrap { background: var(--color-surface-raised); border: 1px solid var(--color-border); border-radius: 8px; overflow-x: auto; } #originals-table { width: 100%; border-collapse: collapse; font-size: 0.92rem; } #originals-table thead { background: var(--color-surface-subtle); } +#originals-table th { position: relative; } #originals-table th, #originals-table td { padding: 0.75rem 1rem; text-align: left; border-bottom: 1px solid var(--color-border); } #originals-table tbody tr { cursor: pointer; transition: background 0.1s; } #originals-table tbody tr:hover { background: var(--color-surface-alt); } @@ -457,8 +458,8 @@ tr.shown td.details-control .study-details::before { transform: rotate(90deg); } .title-cell { font-weight: 600; color: var(--color-text); font-family: var(--font-display); } .author-cell { font-size: 0.78rem; color: var(--color-muted-fg); margin-top: 0.15rem; } .outcome-badge { display: inline-block; padding: 0.15em 0.55em; border-radius: 4px; font-size: 0.7rem; font-weight: 600; text-transform: uppercase; letter-spacing: 0.05em; margin-right: 0.25rem; } -.outcome-badge.failed { background: rgba(179,51,30,0.15); color: var(--color-error); } -.outcome-badge.successful { background: rgba(47,143,79,0.15); color: var(--color-success-strong); } +.outcome-badge.failed { background: var(--color-chart-failed); color: var(--color-badge-on-outcome); } +.outcome-badge.successful { background: var(--color-badge-successful); color: var(--color-badge-on-outcome); } .outcome-badge.mixed { background: rgba(212,155,29,0.18); color: var(--color-warning); } #originals-table th.sortable { cursor: pointer; user-select: none; white-space: nowrap; } @@ -476,7 +477,7 @@ tr.shown td.details-control .study-details::before { transform: rotate(90deg); } .cocit-sort-btn.sort-desc::after { content: ' ▼'; } .cocit-breakdown-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(320px, 1fr)); gap: 1.25rem; margin-top: 0.75rem; } -.cocit-breakdown-table h4 { margin: 0 0 0.5rem; font-size: 0.88rem; color: var(--color-primary); font-family: var(--font-display); font-weight: 600; } +.cocit-breakdown-table h3 { margin: 0 0 0.5rem; font-size: 0.88rem; color: var(--color-primary); font-family: var(--font-display); font-weight: 600; } .cocit-breakdown-table table { width: 100%; border-collapse: collapse; font-size: 0.86rem; } .cocit-breakdown-table th, .cocit-breakdown-table td { padding: 0.5rem 0.6rem; text-align: left; border-bottom: 1px solid var(--color-border); } .cocit-breakdown-table th:not(:first-child), .cocit-breakdown-table td:not(:first-child) { text-align: right; } @@ -487,9 +488,9 @@ tr.shown td.details-control .study-details::before { transform: rotate(90deg); } .rep-bar-wrap { display: flex; align-items: center; gap: 0.45rem; } .rep-bar { display: flex; height: 10px; border-radius: 4px; overflow: hidden; min-width: 80px; flex: 1; background: var(--color-border); } .rep-seg { height: 100%; min-width: 3px; transition: width 0.2s; } -.rep-seg.failed { background: #b3331e; } -.rep-seg.mixed { background: #d49b1d; } -.rep-seg.successful { background: #2f8f4f; } +.rep-seg.failed { background: var(--color-chart-failed); } +.rep-seg.mixed { background: var(--color-chart-mixed); } +.rep-seg.successful { background: var(--color-chart-successful); } .rep-count { font-size: 0.78rem; color: var(--color-muted-fg); white-space: nowrap; } .cocit-pct { font-size: 0.78rem; color: var(--color-muted-fg); } @@ -633,7 +634,7 @@ h1,.card-header,.mc-chart-title {font-family:var(--font-display);} .table-scroll caption {caption-side:top;color:var(--color-text);} .chart-data .table-scroll th {text-transform:none;} .badge {white-space:normal;line-height:1.4;text-transform:none;} -.badge-successful,.outcome-badge.successful {background:#246f3d!important;color:#fff!important;} +.badge-successful,.outcome-badge.successful {background:var(--color-badge-successful)!important;color:var(--color-badge-on-outcome)!important;} .badge-mixed,.outcome-badge.mixed {background:#d49b1d!important;color:#201800!important;} .badge-qualified {background:#684491!important;color:#fff!important;} .badge-other,.badge-unknown,.badge-inconclusive {background:#626875!important;color:#fff!important;} @@ -716,3 +717,40 @@ h1,.card-header,.mc-chart-title {font-family:var(--font-display);} /* Citation timeline: numbered event markers match the numbered replication list. */ .rep-marker { display: inline-flex; align-items: center; justify-content: center; min-width: 1.4rem; height: 1.4rem; margin-right: 0.4rem; border-radius: 50%; font-size: 0.75rem; font-weight: 700; border: 1.5px solid currentColor; } .kpi-note { grid-column: 1 / -1; margin: 0; font-size: 0.85rem; } + +/* Keyboard access uses opaque rings against the surface and the dark chrome. */ +:root { --color-focus: var(--color-link); --color-focus-on-chrome: #fff; } +:focus-visible, input:focus-visible, select:focus-visible, .form-control:focus-visible, +#search-input:focus-visible, .dataTables_filter input:focus-visible { + outline: 3px solid var(--color-focus); outline-offset: 3px; box-shadow: none; +} +.top-bar :focus-visible, .footer-bar :focus-visible { + outline: 3px solid var(--color-focus-on-chrome); outline-offset: -3px; box-shadow: none; +} +.skip-link { position: fixed; top: .5rem; left: .5rem; z-index: 1300; padding: .5rem 1rem; background: var(--color-surface-raised); color: var(--color-text); transform: translateY(-200%); } +.skip-link:focus { transform: none; } +.info-icon { width: 24px; height: 24px; flex-shrink: 0; } +.gloss { display: inline-block; min-width: 24px; min-height: 24px; } +.footer-bar a { display: inline-flex; align-items: center; min-height: 24px; } +.study-details, .btn-close, .bm-evidence summary { min-width: 24px; min-height: 24px; } +.data-stamp a { text-decoration: underline; } +.study-type-note { color: var(--color-text-secondary); } +.table-sort { border: 0; background: none; color: inherit; font: inherit; padding: .15rem; min-width: 24px; min-height: 24px; text-align: left; } +#originals-table th.sortable::after { content: none; } +.table-sort::after { content: ' ⇅'; } +.sort-asc .table-sort::after { content: ' ▲'; } +.sort-desc .table-sort::after { content: ' ▼'; } +.cocit-sort-btn { min-width: 24px; min-height: 24px; opacity: 1; } + +@media (prefers-reduced-motion: reduce) { + *, *::before, *::after { scroll-behavior: auto !important; animation: none !important; transition: none !important; } +} +@media (forced-colors: active) { + .chip.active, .trend-filter-btn.active { outline: 3px solid Highlight; outline-offset: -4px; border-color: Highlight; } + .nav-tabs .nav-link.active { border-bottom: 5px double Highlight; } + .rep-bar { border: 1px solid CanvasText; } + .rep-seg { forced-color-adjust: none; background: Canvas !important; border-right: 1px solid CanvasText; } + .rep-seg.failed { background: CanvasText !important; } + .rep-seg.mixed { background: repeating-linear-gradient(45deg, Canvas 0 2px, CanvasText 2px 4px) !important; } + :focus-visible { outline-color: Highlight; } +} diff --git a/assets/view-controls.js b/assets/view-controls.js index 66db441b..0fb4fd8d 100644 --- a/assets/view-controls.js +++ b/assets/view-controls.js @@ -81,7 +81,14 @@ } document.addEventListener('flora-ready',()=>restore(initial),{once:true}); document.addEventListener('click',e=>{ - if(e.target.closest('.chip, .trend-filter-btn')) {pressed();recordState();} + const chip = e.target.closest('.chip, .trend-filter-btn'); + if(chip) { + pressed();recordState(); + if (chip.closest('#ci-study-type, #outcome-chips, #mc-study-type, #ao-study-type, #pubtype-study-type')) { + const pane = chip.closest('.tab-pane'); + document.getElementById('analysis-feedback').textContent = pane.querySelector('h2')?.textContent.trim() + ': ' + chip.textContent.trim() + ' selected.'; + } + } }); document.addEventListener('change',e=>{if(Object.keys(controls).includes(e.target.id))recordState();}); document.getElementById('search-input')?.addEventListener('input',recordState); @@ -135,7 +142,7 @@ `Unit: reference pair\n${stamp}\nStudy kind: ${browseKind}\nQuery: ${browseQuery}\nIncluded: ${rows.length.toLocaleString('en-GB')}; excluded by filters: ${(fullRowData.length-rows.length).toLocaleString('en-GB')}\nQualified success is separate from unqualified success; reproduction dimensions overlap.`); }; // Associate static and dynamically inserted glossary definitions with their triggers. - let tipNumber = 0; + let tipNumber = 0, openingTip = null; function describeTips() { document.querySelectorAll('.info-icon, .gloss').forEach(trigger => { const tip = trigger.querySelector('.info-tip, .gloss-tip'); @@ -143,6 +150,11 @@ tip.id ||= 'flora-tip-' + (++tipNumber); tip.setAttribute('role', 'tooltip'); trigger.setAttribute('aria-describedby', tip.id); + trigger.addEventListener('focus', () => { + // Focusing an off-screen definition scrolls it into view before paint. + openingTip = trigger; + requestAnimationFrame(() => requestAnimationFrame(() => { if (openingTip === trigger) openingTip = null; })); + }); trigger.addEventListener('mouseleave', () => trigger.classList.remove('tip-dismissed')); trigger.addEventListener('blur', () => trigger.classList.remove('tip-dismissed')); }); @@ -151,6 +163,10 @@ document.addEventListener('keydown', event => { if (event.key === 'Escape') document.querySelectorAll('.info-icon:hover, .info-icon:focus, .gloss:hover, .gloss:focus').forEach(el => el.classList.add('tip-dismissed')); }); + document.addEventListener('scroll', event => { + if (!matchMedia('(max-width: 600px)').matches || event.target.closest?.('.info-tip, .gloss-tip')) return; + document.querySelectorAll('.info-icon:hover, .info-icon:focus, .gloss:hover, .gloss:focus').forEach(el => { if (el !== openingTip) el.classList.add('tip-dismissed'); }); + }, true); describeTips(); pressed(); })(); diff --git a/index.html b/index.html index 2dc41cad..afb6478c 100644 --- a/index.html +++ b/index.html @@ -32,10 +32,12 @@ + + - + + -
+
+
@@ -111,7 +114,7 @@

Outcome Breakdown

Computational Reproducibility - + Covers reproductions only: were the original results obtained? Reproduction coverage is still small, so this chart will fill in as more are added. @@ -124,7 +127,7 @@

Outcome Breakdown

Robustness - + Covers reproductions only: do results hold under alternative analysis choices? Not every reproduction has a robustness check yet. @@ -137,7 +140,7 @@

Outcome Breakdown

Replicability - + Currently, this distribution covers replications only. Reproductions are shown separately above, since they're coded along different dimensions. @@ -310,13 +313,13 @@

Recommended Citation

If you use the underlying dataset, please cite it as:

- +
Loading citation…

If you report results based on this website, please cite it as:

- +
Wallrich, L., & Röseler, L. (2026). FLoRA Explorer [Website]. https://forrt.org/flora-explorer/
@@ -327,6 +330,7 @@

Recommended Citation

+

Browse Studies

@@ -340,7 +344,7 @@

Recommended Citation

Choose a study type to see its outcome breakdown.

- +
Citation and reuse guidance
How to cite & re-use guidelines
@@ -354,7 +358,7 @@

Outcome Breakdown

GroupN originalsPer-paper meanMedianWeighted mean
- + @@ -424,11 +428,11 @@

Studies Database

-
+
- +
@@ -438,7 +442,8 @@

Studies Database

Study evidence Original Study Year Replication or reproduction study
- + @@ -650,17 +656,17 @@

Browse all originals

-
Outcome Originals Citations to original Citations to reproduction
+
- - - - + + + - + @@ -697,6 +703,7 @@

About this analysis

+

Mean Citedness

Citation and reuse guidance @@ -743,6 +750,7 @@

Success probability among successful / failed replica - +

Choose a study type to see its outcome breakdown.

- +
Citation and reuse guidance
How to cite & re-use guidelines
@@ -672,7 +672,7 @@

Browse all originals

OriginalYearReplicationsCitations, all yearsCo-citations since first recorded replication + Co-citations since first recorded replication Timeline
- + diff --git a/tests/browser.test.cjs b/tests/browser.test.cjs index f92dd250..0f9658ca 100644 --- a/tests/browser.test.cjs +++ b/tests/browser.test.cjs @@ -1024,6 +1024,7 @@ test('small-sample and unavailable-data messages use reader-facing words',async( test('keyboard landmarks, citation sorting and paging retain focus and expose state',async()=>{ const p=await page('?tab=citations'); await p.waitForSelector('#pagination [aria-current="page"]'); + assert.equal(await p.getByRole('navigation',{name:'Pagination',exact:true}).getAttribute('id'),'pagination'); assert.equal(await p.locator('header nav[aria-label="Explorer views"]').count(),1); assert.equal(await p.locator('footer').count(),1); await p.locator('.skip-link').focus();await p.keyboard.press('Enter'); @@ -1061,7 +1062,7 @@ test('mobile paging focuses the results heading; counts and chip changes have on const position=await p.locator('#browse-results-heading').boundingBox(); await p.waitForFunction(()=>document.getElementById('browse-results-heading').getBoundingClientRect().top<150); assert.ok(position); - assert.equal(await p.locator('#browse-kind-count[role="status"], #browse-empty[role="status"]').count(),0); + assert.equal(await p.locator('#browse-kind-count[role="status"]').count(),0); await p.selectOption('#mobile-navigation','trends-tab'); await p.locator('#trends .trend-filter-btn[data-kind="reproduction-numerical"]').click(); assert.equal(await p.locator('#trend-filter-count').getAttribute('role'),'status'); @@ -1077,9 +1078,54 @@ test('mobile paging focuses the results heading; counts and chip changes have on await p.evaluate(()=>document.querySelector('.app-main').scrollTop+=100); await p.waitForFunction(()=>document.querySelector('#overview .info-icon').classList.contains('tip-dismissed')); assert.equal(await tip.locator('.info-tip').evaluate(el=>getComputedStyle(el).pointerEvents),'none'); + assert.ok(await tip.evaluate(el=>el===document.activeElement)); + // A touch activation must reopen the definition even when focus has not changed. + await tip.dispatchEvent('click'); + await p.waitForFunction(()=>getComputedStyle(document.querySelector('#overview .info-tip')).opacity==='1',null,{timeout:2000}); + await p.keyboard.press('Escape'); + assert.ok(await tip.evaluate(el=>el.classList.contains('tip-dismissed'))); + await tip.dispatchEvent('focus'); + assert.ok(await tip.evaluate(el=>!el.classList.contains('tip-dismissed'))); + assert.deepEqual(p.errors,[]);await p.close(); +}); + +test('Browse empty results announce the explanation on desktop and mobile',async()=>{ + const p=await page('?tab=browse'); + for(const width of [1280,390]) { + await p.setViewportSize({width,height:844}); + await p.fill('#browse-mobile-input','no-matching-flora-reference-zzzz'); + await p.waitForFunction(()=>getChartData().length===0); + const empty=p.locator('#browse-empty'); + assert.ok(await empty.isVisible()); + assert.equal(await empty.getAttribute('role'),'status'); + assert.match(await empty.innerText(),/No reference pairs match/); + await empty.getByRole('button',{name:'Clear search',exact:true}).click(); + assert.ok(await empty.isHidden()); + } assert.deepEqual(p.errors,[]);await p.close(); }); +test('Browse URL kinds and zero-result announcements match the filtered studies',async()=>{ + for(const [kind,label,expectedKind] of [ + ['all','All studies','all'], + ['replication','Replications','replication'], + ['reproduction','Reproductions','reproduction'], + ['reproduction-numerical','Numerical Reproductions','reproduction-numerical'], + ['reproduction-robustness','Robustness Reproductions','reproduction-robustness'], + ['unknown','All studies','all'], + ]) { + const p=await page('?tab=browse&kind='+kind,390); + assert.equal(await p.evaluate(()=>browseKind),expectedKind,kind); + assert.equal(await p.locator('#browse-mobile-meta .visually-hidden').textContent(),label+': ',kind); + const counts=await p.evaluate(kind=>[getChartData().length,filterByKind(fullRowData,kind).length],expectedKind); + assert.ok(counts[0]>0,kind);assert.equal(counts[0],counts[1],kind); + await p.fill('#browse-mobile-input','no-matching-flora-reference-zzzz'); + await p.waitForFunction(()=>getChartData().length===0); + assert.equal(await p.locator('#browse-mobile-meta').textContent(),label+': 0 reference pairs',kind); + assert.deepEqual(p.errors,[]);await p.close(); + } +}); + test('chart alternatives and actions have distinct names; motion and forced colours respect preferences',async()=>{ const p=await page('?tab=overview',1280,p=>p.emulateMedia({reducedMotion:'reduce'})); assert.equal(await p.evaluate(()=>Chart.getChart('overview-outcome-chart').options.animation),false); @@ -1092,6 +1138,7 @@ test('chart alternatives and actions have distinct names; motion and forced colo await p.waitForSelector('#ls-chart-data'); for(const selector of ['.chart-copy','.chart-data summary','.chart-data [role="region"]','.chart-mode']) { const names=await p.locator(selector).evaluateAll(els=>els.map(e=>e.getAttribute('aria-label'))); + assert.ok(names.length>0,`${selector}: no controls found`); assert.ok(names.every(Boolean),selector);assert.equal(new Set(names).size,names.length,`${selector}: ${names}`); } await p.emulateMedia({forcedColors:'active'}); @@ -1100,8 +1147,16 @@ test('chart alternatives and actions have distinct names; motion and forced colo assert.equal(await p.locator('#pub-tab').evaluate(el=>getComputedStyle(el).borderBottomStyle),'double'); await p.click('#citation-tab'); await p.waitForSelector('.rep-seg'); - const fills=await p.locator('.rep-seg').evaluateAll(els=>Object.fromEntries(els.map(e=>[e.className,getComputedStyle(e).backgroundColor+' '+getComputedStyle(e).backgroundImage]))); - assert.ok(new Set(Object.values(fills)).size>=2); + const fills={}; + for(const outcome of ['successful','failed','mixed']) { + // Filtering to an outcome guarantees its segment is present on this page. + await p.selectOption('#filter-outcome',outcome); + const segment=p.locator('.rep-seg.'+outcome).first(); + assert.ok(await segment.isVisible(),outcome); + fills[outcome]=await segment.evaluate(el=>getComputedStyle(el).backgroundColor+' '+getComputedStyle(el).backgroundImage); + } + assert.equal(new Set(Object.values(fills)).size,3); + assert.match(fills.successful,/repeating-linear-gradient/); assert.ok(await p.locator('.rep-seg').first().evaluate(el=>getComputedStyle(el).forcedColorAdjust==='none')); assert.deepEqual(p.errors,[]);await p.close(); }); From 66670f4e8dd179ef67ea23c6637ea07e65588dbf Mon Sep 17 00:00:00 2001 From: Lukas Wallrich Date: Wed, 21 Oct 2026 20:24:10 +0100 Subject: [PATCH 3/3] Announce analysis filter changes by their group label Restored URL state is no longer announced. Co-Authored-By: Claude Opus 5.5 --- assets/view-controls.js | 7 +++---- index.html | 10 +++++----- tests/browser.test.cjs | 9 ++++++++- 3 files changed, 16 insertions(+), 10 deletions(-) diff --git a/assets/view-controls.js b/assets/view-controls.js index 366e25b9..41eca6eb 100644 --- a/assets/view-controls.js +++ b/assets/view-controls.js @@ -83,10 +83,9 @@ const chip = e.target.closest('.chip, .trend-filter-btn'); if(chip) { pressed();recordState(); - if (chip.closest('#ci-study-type, #outcome-chips, #mc-study-type, #ao-study-type, #pubtype-study-type')) { - const pane = chip.closest('.tab-pane'); - document.getElementById('analysis-feedback').textContent = pane.querySelector('h2')?.textContent.trim() + ': ' + chip.textContent.trim() + ' selected.'; - } + // Announce user choices by their group label; state restored from the URL stays silent. + const group = chip.closest('#ci-study-type, #outcome-chips, #mc-study-type, #ao-study-type, #pubtype-study-type'); + if (group && !restoring) document.getElementById('analysis-feedback').textContent = group.getAttribute('aria-label') + ': ' + chip.textContent.trim() + ' selected.'; } }); document.addEventListener('change',e=>{if(Object.keys(controls).includes(e.target.id))recordState();}); diff --git a/index.html b/index.html index 96d9e351..24b1fc2d 100644 --- a/index.html +++ b/index.html @@ -546,7 +546,7 @@

Citation Impact

Citation patterns around the first replication

-
+
@@ -598,7 +598,7 @@

Aggregate impact of replications

Replication outcome -
+
@@ -718,7 +718,7 @@

Journal Mean Citedness and Recorded Outcomes

OpenAlex Mean Citedness (OMC) of an original study's journal predicts replication success. OMC is a proxy for the Journal Impact Factor. Mean Citedness is a journal-level measure from OpenAlex: the mean citations to its works published in the previous two years. It is retrieved at enrichment time, not matched to the original publication year, and is not the original article’s citation count. The update stamp identifies the analysis snapshot. Any fitted model describes an association.

-
+
@@ -768,7 +768,7 @@

Authorship Overlap and Outcomes

This may produce false positives for common family names when two unrelated researchers share a surname.

-
+
@@ -807,7 +807,7 @@

Publication Type

How to cite
Wallrich, L., & Röseler, L. (2026). FLoRA Explorer [Website]. https://forrt.org/flora-explorer/
-
+
diff --git a/tests/browser.test.cjs b/tests/browser.test.cjs index 0f9658ca..f242dca4 100644 --- a/tests/browser.test.cjs +++ b/tests/browser.test.cjs @@ -1070,7 +1070,7 @@ test('mobile paging focuses the results heading; counts and chip changes have on await p.selectOption('#mobile-navigation','mc-tab'); await p.waitForSelector('#mc-dist-chart-data'); await p.locator('#mc-study-type [data-value="reproduction-numerical"]').click(); - assert.match(await p.locator('#analysis-feedback').textContent(),/Numerical Reproductions selected/); + assert.equal(await p.locator('#analysis-feedback').textContent(),'Mean Citedness study type: Numerical Reproductions selected.'); await p.selectOption('#mobile-navigation','overview-tab'); const tip=p.locator('#overview .info-icon').first();await tip.focus(); assert.equal(await tip.getAttribute('role'),'img'); @@ -1193,3 +1193,10 @@ test('focus rings, small targets and the dark success/failure pair keep sufficie } assert.deepEqual(p.errors,[]);await p.close(); }); +test('filter state restored from a shared URL is not announced',async()=>{ + const p=await page('?tab=mean-citedness&mc-kind=reproduction-numerical',390); + await p.waitForSelector('#mc-dist-chart-data'); + assert.equal(await p.locator('#mc-study-type .chip.active').getAttribute('data-value'),'reproduction-numerical'); + assert.equal((await p.locator('#analysis-feedback').textContent()).trim(),''); + await p.close(); +});