Skip to content

Commit 8046187

Browse files
Explorer: accessibility, mobile performance budget, artifact revalidation
Five improvements from a repository review. Every figure below was measured, and each change is pinned by a test that fails against the defect it describes. Mobile total blocking time 1210 ms -> 120 ms. Unlabeled sliders 8 -> 0. 1. Accessible names and a text equivalent for the chart All 8 range sliders had no accessible name -- a screen reader announced "slider, 353" with no indication of the ingredient (WCAG 4.1.2). The sibling value input was already labelled; the range input was missed. Both now share one shortIngredientName() helper and follow the unit toggle, so the announced unit cannot drift from the displayed one. The canvases had an aria-label but exposed no data, so #curve-summary publishes the 1/7/28-day predictions into a polite live region -- debounced, emitted only once the composition settles, and skipped when unchanged, since assigning identical text re-announces. 2. Lighthouse measures mobile The config used preset "desktop" only, so the platform where the problems live was never scored -- which is how a 6 px touch target and 1.2 s of blocking time both shipped unnoticed. The new profile failed on its first run. Kept as one job with two steps rather than a matrix: a matrix job's check runs are named "lighthouse (<values>)", which would strand any branch protection requiring the "lighthouse" context. Budgets are calibrated honestly. No "perf" preset -- it silently forces throttlingMethod "devtools", which measured LCP 3.2 s against simulate's 11.6 s for the same page and diverged from the desktop profile. Error gates are limited to metrics this harness measures representatively (CPU, accessibility, layout); http-server serves uncompressed bytes where Pages gzips them (188 KB -> 22 KB), which inflates transfer-bound metrics ~7x, so those are warn-only and say so in the config. 3. The UI shell renders before the model buildSliders() and setupEventListeners() ran after awaiting the strength GP, so moving that work into a worker removed the freeze but not the delay. The shell now builds from compositions.json plus the linear GWP/cost models, and the draw paths tolerate a null model. The model-dependent guard in drawScatter is scoped to the selected- composition marker alone. An early return there also skipped the axes and the canvas._pad/_xMin/... scale stash, and the mousemove handler bails without _pad -- so for the whole model-load window hover did nothing and clicking a point was a silent no-op. 4. Startup animation was the real mobile cost Profiling under 4x CPU throttle showed only two large init tasks followed by a continuous stream of ~55 ms tasks from 1.2 s to 2.8 s: the startup fade-in, where every animated frame redraws both canvases and is therefore itself a main-thread long task. Time-to-interactive can never find a quiet window against that. Easing now snaps on the first animated frame and under prefers-reduced-motion, which also covers the interaction transitions (composition slide, Material Source crossfade, unit transition) and CSS -- gradientShift is a 30 s infinite animation with no pause control, a WCAG 2.2.2 Level A exposure rather than merely 2.3.3. Slider input additionally coalesces onto one rAF, since touch and high-polling mice fire faster than 60 Hz. Measured but deliberately unchanged: a drag redraw is 24 ms at 4x throttle (~40 fps) and 98% of that is the strength-curve GP, not the scatter (2%). Cutting curve resolution would trade visible quality -- 32 vs 48 points already differ by 17% in the steep gate region. 5. Model artifacts revalidate together loadJSON was a bare fetch. The artifacts are mutually index-dependent (mix_analyses.json is keyed by position in compositions.json) but Pages serves them with max-age=600 and independent age values, so a visitor could hold a fresh compositions.json beside a stale mix_analyses.json for ten minutes after any deploy. Observed in production after the 3-class merge. `cache: "no-cache"` revalidates while still reusing the cached body on a 304, and a non-ok response now throws instead of surfacing later as a JSON parse error. Accessibility items found while reviewing the above: - The Pareto pill failed WCAG 1.4.3 AA in dark theme: white on #fb7185 is 2.69:1, and at 9.6 px bold the bar is 4.5:1, not 3:1. Added --pareto-on following the existing --accent-on precedent, which already documents the identical problem for --accent. 4.70:1 light, 5.81:1 dark. Only visible on the locked toolchain -- node_modules held Lighthouse 12.1.0 while the lockfile and CI pin 12.6.1, and 12.1.0 does not flag it. Both profiles re-verified on 12.6.1. - Range sliders had `outline: none` with no replacement anywhere, a WCAG 2.4.7 (AA) failure on the control this change otherwise improves. Lighthouse's accessibility category does not audit focus visibility, so the score gate cannot catch it; there is now an explicit test. - Both modal overlays were hidden with opacity and pointer-events only, leaving their close buttons, links and iframe permanently tabbable and in the accessibility tree. Added visibility toggling. That in turn exposed a real heading skip the invisible dialog had been masking: with the modal's h2 gone, the mobile outline read H1 -> H3. The two insight panels are siblings of the h2 chart panels, so their titles are now h2. - The unit toggle showed "SI" but was named "Toggle metric/imperial units", failing WCAG 2.5.3 Label in Name. The name now contains the visible text and tracks it. Pre-existing and weight-zero in the Lighthouse score, so nothing flagged it. - No aria-describedby on the curve canvas. The sr-only summary is clip-hidden rather than display:none, so it already sits in reading order after the canvas; referencing it as well makes a browse-mode user hear the same sentence twice, and the canvas is not focusable so the description would be unreliable regardless. Also exposes __test.modelReady: "page loaded" no longer implies "model ready", and several specs were asserting on predictions without waiting for one. Deliberately not addressed here, so the next person does not have to re-derive why: - Modals still have no focus management -- no role="dialog", focus is not moved in on open nor restored on close. This change only stops them being reachable while closed. - requestRedraw and animLoop both redraw during a drag, so the work happens twice per frame. Pre-existing; untangling redraw ownership is a separate refactor. - #scatter-canvas has no text equivalent; only the curve canvas got one. - _reduceMotion is sampled once at module load, so flipping the OS setting at runtime updates the CSS half but not the JS canvas easing. - Whether "lighthouse" is a required status check could not be verified from here. The workflow deliberately keeps a single job so the existing check name is preserved, but confirm against branch protection. Verification: lint 0, test-js 13/13, test-py 270 at 100% coverage, e2e 100 on macOS and 104 on Linux in the matching Playwright image (visual baselines still matching), and both Lighthouse profiles green on the locked toolchain (12.6.1) with zero failing accessibility audits and zero phantom assertions. The pre-model, unit-toggle, reduced-motion CSS, animation-delay and script-animation tests were each confirmed to fail against the defect they pin.
1 parent 31843c4 commit 8046187

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)