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 );
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