Skip to content

Commit 4fa4e07

Browse files
Merge pull request #37 from facebookresearch/explorer-a11y-perf-and-cache
Explorer: accessibility, mobile performance budget, artifact revalidation
2 parents 31843c4 + 8046187 commit 4fa4e07

12 files changed

Lines changed: 984 additions & 49 deletions

File tree

.github/workflows/lighthouse.yml

Lines changed: 22 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ on:
1515
paths: &lighthouse_paths
1616
- 'docs/**'
1717
- 'lighthouserc.json'
18+
- 'lighthouserc.mobile.json'
1819
- '.github/workflows/lighthouse.yml'
1920
pull_request:
2021
branches: [main, master]
@@ -30,7 +31,7 @@ env:
3031
jobs:
3132
lighthouse:
3233
runs-on: ubuntu-latest
33-
timeout-minutes: 15
34+
timeout-minutes: 25
3435

3536
steps:
3637
- uses: actions/checkout@v5
@@ -43,9 +44,27 @@ jobs:
4344
- name: Install dependencies
4445
run: npm ci
4546

46-
- name: Run Lighthouse CI
47-
run: npx lhci autorun
47+
# Desktop and mobile are separate Lighthouse profiles, not one run with
48+
# two viewports. Only desktop was ever measured, which is why a 6 px
49+
# touch target and 1.2 s of mobile blocking time both shipped unnoticed.
50+
#
51+
# Deliberately two steps in ONE job rather than a matrix: a matrix job's
52+
# check-run name becomes "lighthouse (<matrix values>)", so any branch
53+
# protection requiring the "lighthouse" context would wait forever on a
54+
# status that is never reported again. Keeping a single job preserves
55+
# the existing check name.
56+
- name: Run Lighthouse CI (desktop)
57+
run: npx lhci autorun --config=lighthouserc.json
4858
env:
4959
# Public uploads to temporary-public-storage are anonymous;
5060
# no token required for our config.
5161
LHCI_GITHUB_APP_TOKEN: ${{ secrets.LHCI_GITHUB_APP_TOKEN }}
62+
63+
# `if: always()` so a desktop failure still reports the mobile result --
64+
# otherwise the profile this change exists to add would be skipped
65+
# exactly when something is wrong.
66+
- name: Run Lighthouse CI (mobile)
67+
if: always()
68+
run: npx lhci autorun --config=lighthouserc.mobile.json
69+
env:
70+
LHCI_GITHUB_APP_TOKEN: ${{ secrets.LHCI_GITHUB_APP_TOKEN }}

docs/index.html

Lines changed: 25 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -164,7 +164,7 @@ <h3>Papers</h3>
164164
<div class="panel sliders fade-in-up" id="sliders-panel" style="--delay: 0ms">
165165
<div class="sliders-header">
166166
<h2 id="sliders-title">Composition (kg/m³)</h2>
167-
<button class="unit-toggle" id="unit-toggle" aria-label="Toggle metric/imperial units">
167+
<button class="unit-toggle" id="unit-toggle" aria-label="SI units - switch to US units">
168168
<span class="unit-label" id="unit-label">SI</span>
169169
</button>
170170
</div>
@@ -179,7 +179,7 @@ <h2 id="sliders-title">Composition (kg/m³)</h2>
179179
<div class="mobile-panel-header">
180180
<button id="mobile-show-scatter" class="active">Performance Tradeoffs</button>
181181
<button id="mobile-show-sliders">Composition (kg/m³)</button>
182-
<button class="unit-toggle mobile-unit-toggle" id="mobile-unit-toggle" aria-label="Toggle metric/imperial units">
182+
<button class="unit-toggle mobile-unit-toggle" id="mobile-unit-toggle" aria-label="SI units - switch to US units">
183183
<span class="unit-label" id="mobile-unit-label">SI</span>
184184
</button>
185185
</div>
@@ -208,7 +208,7 @@ <h2>Performance Tradeoffs</h2>
208208
<!-- Mix Insight (below scatter) -->
209209
<div class="panel mix-insight-panel fade-in-up" id="mix-insight" style="--delay: 300ms">
210210
<div class="mix-insight-header">
211-
<h3 class="mix-insight-title">Mix Insight</h3>
211+
<h2 class="mix-insight-title">Mix Insight</h2>
212212
<span class="pareto-pill" id="pareto-pill">Pareto-optimal</span>
213213
</div>
214214
<div class="mix-insight-body">
@@ -218,7 +218,7 @@ <h3 class="mix-insight-title">Mix Insight</h3>
218218

219219
<!-- Ingredient Insights (below mix insight) -->
220220
<div class="panel ingredient-insight-panel fade-in-up" id="ingredient-insight" style="--delay: 350ms">
221-
<h3 class="ingredient-insight-title">Ingredient Insight</h3>
221+
<h2 class="ingredient-insight-title">Ingredient Insight</h2>
222222
<div class="ingredient-insight-body">
223223
<p class="ingredient-insight-text" id="ingredient-insight-text"><span class="mix-insight-placeholder">Click an ingredient name in the Composition panel to learn more.</span></p>
224224
</div>
@@ -232,6 +232,21 @@ <h3 class="ingredient-insight-title">Ingredient Insight</h3>
232232
<span class="extrapolation-warning" id="extrapolation-warning">⚠ Far from data</span>
233233
<h2>Predicted Strength Curve</h2>
234234
<canvas id="curve-canvas" aria-label="Predicted concrete strength curve over curing time"></canvas>
235+
<!-- Text equivalent of the curve for screen readers. A canvas exposes
236+
no data, so aria-label conveys that a chart exists but not what it
237+
says. Updated only once the composition settles, and skipped when
238+
unchanged, so the live region does not chatter.
239+
240+
Deliberately NOT wired up via aria-describedby on the canvas. These
241+
divs are clip-hidden, not display:none, so they already sit in
242+
reading order immediately after it; referencing them as well makes
243+
a browse-mode user hear the same sentence twice. The canvas is also
244+
not focusable, so the description would be unreliable anyway. -->
245+
<div id="curve-summary" class="sr-only" role="status" aria-live="polite"></div>
246+
<!-- Static caveat, deliberately OUTSIDE the live region: it never
247+
changes, so re-speaking it on every update would add ~9 words to
248+
each announcement for no information. -->
249+
<div id="curve-summary-note" class="sr-only">Strength figures are model predictions with a 95 percent interval.</div>
235250
<div class="readouts" id="readouts">
236251
<div>GWP: <span class="value" id="gwp-value"></span> <span class="unit" id="gwp-unit">kg CO₂e/m³</span></div>
237252
<div>Cost: <span class="value" id="cost-value"></span> <span id="cost-uncertainty"></span> <span class="unit" id="cost-unit">$/m³</span></div>
@@ -340,7 +355,12 @@ <h3>References</h3>
340355
});
341356

342357
// Crossfade helper: fade out current, swap, fade in new
358+
// 150 ms matches the .fading opacity transition. Under reduced motion that
359+
// transition is instant, so the wait would leave the panel blank for
360+
// 150 ms instead of fading -- collapse it to match.
361+
const REDUCE_MOTION = matchMedia('(prefers-reduced-motion: reduce)').matches;
343362
function crossfade(fadeOut, fadeIn, onSwap) {
363+
const fadeMs = REDUCE_MOTION ? 0 : 150;
344364
fadeOut.classList.add('fading');
345365
setTimeout(() => {
346366
fadeOut.classList.add('hidden');
@@ -351,7 +371,7 @@ <h3>References</h3>
351371
requestAnimationFrame(() => {
352372
requestAnimationFrame(() => fadeIn.classList.remove('fading'));
353373
});
354-
}, 150);
374+
}, fadeMs);
355375
}
356376

357377
mobileScatterBtn.addEventListener('click', () => {

docs/style.css

Lines changed: 111 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,10 @@
1111
--accent-on: #ffffff;
1212
--band: rgba(37, 99, 235, 0.18);
1313
--pareto: #e11d48;
14+
/* Foreground for text ON the pareto pill, same pattern as --accent-on.
15+
White on #e11d48 is 4.70:1, which clears WCAG AA for the pill's 9.6 px
16+
bold text (not "large text", so the bar is 4.5:1, not 3:1). */
17+
--pareto-on: #ffffff;
1418
--observation: #475569;
1519
--point: #475569;
1620
--glass-bg: rgba(var(--card-bg-rgb), 0.7);
@@ -30,6 +34,10 @@
3034
--accent-on: #0f0f1a;
3135
--band: rgba(96, 165, 250, 0.2);
3236
--pareto: #fb7185;
37+
/* White on #fb7185 is only 2.69:1 and FAILED WCAG AA at the pill's 9.6 px
38+
bold size. Keep the bright chip -- it needs to read against a dark panel
39+
-- and darken the text instead, exactly as --accent-on does: 5.81:1. */
40+
--pareto-on: #4c0519;
3341
--observation: #94a3b8;
3442
--point: #94a3b8;
3543
--glass-bg: rgba(var(--card-bg-rgb), 0.7);
@@ -232,11 +240,18 @@ body {
232240
justify-content: center;
233241
opacity: 0;
234242
pointer-events: none;
235-
transition: opacity 0.25s ease;
243+
/* Also remove it from the tab order and the accessibility tree while closed.
244+
opacity alone left .about-close, the two arXiv links, .video-close and the
245+
iframe permanently tabbable, and put the modal's h2/h3 headings ahead of
246+
the app's own in the heading outline. `visibility` still animates, so the
247+
fade is preserved. */
248+
visibility: hidden;
249+
transition: opacity 0.25s ease, visibility 0.25s ease;
236250
}
237251
.about-overlay.visible {
238252
opacity: 1;
239253
pointer-events: all;
254+
visibility: visible;
240255
}
241256
.about-modal {
242257
background: var(--glass-bg);
@@ -325,11 +340,18 @@ body {
325340
justify-content: center;
326341
opacity: 0;
327342
pointer-events: none;
328-
transition: opacity 0.25s ease;
343+
/* Also remove it from the tab order and the accessibility tree while closed.
344+
opacity alone left .about-close, the two arXiv links, .video-close and the
345+
iframe permanently tabbable, and put the modal's h2/h3 headings ahead of
346+
the app's own in the heading outline. `visibility` still animates, so the
347+
fade is preserved. */
348+
visibility: hidden;
349+
transition: opacity 0.25s ease, visibility 0.25s ease;
329350
}
330351
.video-overlay.visible {
331352
opacity: 1;
332353
pointer-events: all;
354+
visibility: visible;
333355
}
334356
.video-modal {
335357
background: var(--glass-bg);
@@ -606,6 +628,12 @@ h2 {
606628
border-color: var(--accent);
607629
background: rgba(var(--card-bg-rgb), 0.6);
608630
}
631+
/* A border-colour change alone is colour-only signalling; keyboard users get
632+
a real outline. */
633+
.slider-value:focus-visible {
634+
outline: 2px solid var(--accent);
635+
outline-offset: 2px;
636+
}
609637
/* The input is the TOUCH TARGET; the visible 6 px bar is drawn by the track
610638
pseudo-elements below. Previously the element itself was the 6 px bar, which
611639
meant the whole hit area was 6 px tall -- measured 220x6 on mobile, against a
@@ -622,6 +650,15 @@ h2 {
622650
background: transparent;
623651
outline: none;
624652
}
653+
/* `outline: none` above removed the only focus indicator these controls had,
654+
with no replacement anywhere -- a WCAG 2.4.7 Focus Visible (AA) failure on
655+
the app's primary controls. Note Lighthouse's accessibility category does
656+
NOT audit focus visibility, so the a11y score gate cannot catch this. */
657+
.slider-group input[type=range]:focus-visible {
658+
outline: 2px solid var(--accent);
659+
outline-offset: 3px;
660+
border-radius: 3px;
661+
}
625662
.slider-group input[type=range]::-webkit-slider-runnable-track {
626663
height: 6px;
627664
background: var(--border);
@@ -898,6 +935,20 @@ h2 {
898935
.filter-row-wrapper.collapsed {
899936
display: none;
900937
}
938+
/* Visible to assistive technology only. Standard clip-rect pattern: stays in
939+
the accessibility tree (unlike display:none) while occupying no space. */
940+
.sr-only {
941+
position: absolute;
942+
width: 1px;
943+
height: 1px;
944+
padding: 0;
945+
margin: -1px;
946+
overflow: hidden;
947+
clip: rect(0, 0, 0, 0);
948+
white-space: nowrap;
949+
border: 0;
950+
}
951+
901952
.filter-row {
902953
display: flex;
903954
align-items: center;
@@ -951,6 +1002,10 @@ h2 {
9511002
border-color: var(--accent);
9521003
outline: none;
9531004
}
1005+
.filter-row input[type=number]:focus-visible {
1006+
outline: 2px solid var(--accent);
1007+
outline-offset: 1px;
1008+
}
9541009
/* Categorical filter (Material Source). An unordered class axis has no
9551010
meaningful min/max, so its row renders one toggle per class instead of
9561011
numeric bounds. All start active, making a fresh row a no-op -- the same
@@ -1183,7 +1238,7 @@ a.ref-link {
11831238
text-transform: uppercase;
11841239
letter-spacing: 0.04em;
11851240
background: var(--pareto);
1186-
color: #fff;
1241+
color: var(--pareto-on);
11871242
padding: 2px 8px;
11881243
border-radius: 999px;
11891244
opacity: 0;
@@ -1624,3 +1679,56 @@ a.ref-link {
16241679
width: calc(50% - 0.2rem);
16251680
}
16261681
}
1682+
1683+
/* ---------------------------------------------------------------------------
1684+
Reduced motion (WCAG 2.3.3, and 2.2.2 for the infinite animations).
1685+
1686+
The JS honours prefers-reduced-motion for canvas easing and the transition
1687+
durations, but CSS animations are independent of that and were not covered.
1688+
Two of them run forever:
1689+
1690+
- `gradientShift` (30 s, infinite) on the page title. Automatically
1691+
starting, moving, presented alongside other content and lasting well
1692+
over 5 s with no pause control -- that is a Pause/Stop/Hide (Level A)
1693+
exposure, not merely a 2.3.3 (AAA) one.
1694+
- `previewPulse` (1.5 s, infinite) on the slider preview marker.
1695+
1696+
`fadeInUp` and the transitions are one-shot, but a reduced-motion user has
1697+
asked not to see them either.
1698+
1699+
Motion is removed rather than merely shortened; `animation: none` also stops
1700+
the infinite ones from holding a compositor layer awake. Durations are set
1701+
to a near-zero value rather than 0 so that transitionend/animationend
1702+
listeners still fire, which is the standard form of this reset.
1703+
-------------------------------------------------------------------------- */
1704+
@media (prefers-reduced-motion: reduce) {
1705+
*,
1706+
*::before,
1707+
*::after {
1708+
animation-duration: 0.01ms !important;
1709+
animation-iteration-count: 1 !important;
1710+
/* Required, and easy to omit: .fade-in-up uses `animation-fill-mode: both`
1711+
with staggered per-panel delays (0-350 ms via --delay). Without this the
1712+
backwards fill holds the `from` keyframe (opacity 0) for the delay, so
1713+
the six panels stay invisible and then pop in one at a time -- a staged
1714+
reveal, which is precisely what the preference asks to remove. */
1715+
animation-delay: -1ms !important;
1716+
transition-duration: 0.01ms !important;
1717+
transition-delay: 0ms !important;
1718+
scroll-behavior: auto !important;
1719+
}
1720+
1721+
/* The two infinite decorative animations: stop outright, and pin the title
1722+
gradient to a static position so it does not freeze mid-sweep. */
1723+
h1 {
1724+
animation: none !important;
1725+
/* gradientShift runs 200% -> 0%, so this pins the END keyframe (a settled
1726+
gradient), not a mid-sweep frame. */
1727+
background-position: 0% 50% !important;
1728+
}
1729+
.slider-preview-marker {
1730+
/* The base rule already sets the centring transform, so stopping the
1731+
animation is sufficient -- no transform override needed. */
1732+
animation: none !important;
1733+
}
1734+
}

0 commit comments

Comments
 (0)