Skip to content

Commit 60e6ced

Browse files
committed
[2.0.1] feat(a11y): Add animation toggle and rewire motion system to data attribute
Before: Reduced motion was a one-way street. Users relied on their OS-level prefers-reduced-motion setting — all-or-nothing, site can't override, site can't even know they care. Fifteen components each rolled their own @media block, isolated and untestable. After: A sparkles toggle in the nav lets users quiet animations on this site alone, persisted to localStorage. The entire motion system now flows through a single html[data-motion] attribute, resolved at page load via blocking script: localStorage first, OS preference as fallback. One global CSS kill switch forces near-zero durations on everything (transitions, animations, view transitions) while each component retains its own reduced-motion overrides for graceful static states (opacity snaps, transforms reset, cursors hold still). The toggle practices what it preaches: celebratory bounce when sparkles come back to life, instant quiet when they go dark. Architecture mirrors the existing theme system: motion-contract.ts for shared constants, initAll for lifecycle across view transitions, astro:before-swap to stamp the new document before paint. Also while the plumbing was exposed: - Fix horizontal overflow caused by left/right: -100vw in CatalogHero and ArticleLayout; use translateX(-50%) centering - Add tooltip system for nav icon buttons via data-tooltip attribute with CSS arrow and hover delay, replacing native title attributes - Fix ThemeToggle spin animation clipping tooltip pseudo-elements; target inner icons instead of the button, drop overflow: hidden - Harden mobile nav against view-transition DOM replacement: lazy element lookups, re-bind listeners on astro:page-load, guard against duplicate event bindings with data attributes - Shore up E2E tests for mobile: extract helpers for consent flow, catalog filter scoping, and stable click-with-scroll patterns; skip clipboard tests on non-Chromium; add force-click fallbacks - Fix webkit E2E failure in theme-toggle navigation test: use scroll-into-view + force-click fallback pattern - Add CHANGELOG entry for 2.0.1
1 parent d9be345 commit 60e6ced

29 files changed

Lines changed: 845 additions & 301 deletions

docs/CHANGELOG.md

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,30 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
66

77
---
88

9+
## [`2.0.1`] - 2026-03-29
10+
11+
**Users can now quiet animations without touching their OS settings.** A sparkles toggle in the nav gives per-site control, persisted across sessions.
12+
13+
### Added
14+
15+
- **Animation toggle** — sparkles button in the nav lets users reduce motion on this site alone. Bounces when sparkles come back to life; goes instantly quiet when they don't.
16+
- **Tooltip system** for nav icon buttons — CSS-only `data-tooltip` with arrow, hover delay, and keyboard focus support, replacing native `title` attributes
17+
- **Motion contract** (`motion-contract.ts`) — shared constants and types mirroring the theme system architecture
18+
- **Motion preference blocking script** — resolves `html[data-motion]` before first paint (localStorage first, OS `prefers-reduced-motion` as fallback), stamps new documents on view transitions
19+
20+
### Changed
21+
22+
- Motion system rewired from fifteen independent `@media (prefers-reduced-motion)` blocks to one `html[data-motion]` attribute driving a global CSS kill switch — near-zero durations on all transitions, animations, and view transitions when reduced
23+
- Mobile nav hardened against view-transition DOM replacement: lazy element lookups, re-bound listeners on `astro:page-load`, duplicate-event guards via data attributes
24+
- E2E test helpers extracted for consent flow, catalog filter scoping, and stable click-with-scroll patterns; clipboard tests skip non-Chromium; force-click fallbacks for mobile viewports
25+
26+
### Fixed
27+
28+
- Horizontal overflow from `left: -100vw; right: -100vw` on full-bleed decorative backgrounds in CatalogHero and ArticleLayout — replaced with `translateX(-50%)` centering
29+
- ThemeToggle spin animation clipping tooltip pseudo-elements — now targets inner icons instead of the button, `overflow: hidden` dropped
30+
31+
---
32+
933
## [`2.0.0`] - 2026-03-23
1034

1135
**Complete rewrite — Gatsby → Astro 5.** New architecture, new design, same 56 smells.
@@ -327,5 +351,6 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
327351
[`1.0.20`]: https://github.com/Luzkan/smells/releases/tag/1.0.20
328352
[`1.0.21`]: https://github.com/Luzkan/smells/releases/tag/1.0.21
329353
[`1.0.22`]: https://github.com/Luzkan/smells/releases/tag/1.0.22
354+
[`2.0.1`]: https://github.com/Luzkan/smells/releases/tag/2.0.1
330355
[`2.0.0`]: https://github.com/Luzkan/smells/releases/tag/2.0.0
331356
[`1.0.23-alpha.1`]: https://github.com/Luzkan/smells/releases/tag/1.0.23-alpha.1

src/components/about/AboutAnatomy.astro

Lines changed: 27 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -1308,41 +1308,39 @@ const smellsJson = JSON.stringify(smells);
13081308

13091309
/* ─── Reduced motion ─── */
13101310

1311-
@media (prefers-reduced-motion: reduce) {
1312-
.anatomy-card {
1313-
opacity: 1;
1314-
transform: none;
1315-
}
1311+
:global(html[data-motion='reduced']) .anatomy-card {
1312+
opacity: 1;
1313+
transform: none;
1314+
}
13161315

1317-
.anatomy-card-bar {
1318-
transform: scaleX(1);
1319-
}
1316+
:global(html[data-motion='reduced']) .anatomy-card-bar {
1317+
transform: scaleX(1);
1318+
}
13201319

1321-
.anatomy-annotation {
1322-
opacity: 1;
1323-
transform: none;
1324-
}
1320+
:global(html[data-motion='reduced']) .anatomy-annotation {
1321+
opacity: 1;
1322+
transform: none;
1323+
}
13251324

1326-
.anatomy-annotation::after {
1327-
transform: scaleX(1);
1328-
}
1325+
:global(html[data-motion='reduced']) .anatomy-annotation::after {
1326+
transform: scaleX(1);
1327+
}
13291328

1330-
.anatomy-annotation::before {
1331-
transform: translateY(-50%) scale(1);
1332-
}
1329+
:global(html[data-motion='reduced']) .anatomy-annotation::before {
1330+
transform: translateY(-50%) scale(1);
1331+
}
13331332

1334-
:global(.anatomy-tag) {
1335-
opacity: 1;
1336-
transform: none;
1337-
}
1333+
:global(html[data-motion='reduced']) :global(.anatomy-tag) {
1334+
opacity: 1;
1335+
transform: none;
1336+
}
13381337

1339-
.anatomy-depth {
1340-
opacity: 1;
1341-
transform: none;
1342-
}
1338+
:global(html[data-motion='reduced']) .anatomy-depth {
1339+
opacity: 1;
1340+
transform: none;
1341+
}
13431342

1344-
.anatomy-cta {
1345-
opacity: 1;
1346-
}
1343+
:global(html[data-motion='reduced']) .anatomy-cta {
1344+
opacity: 1;
13471345
}
13481346
</style>

src/components/about/AboutHero.astro

Lines changed: 5 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -363,12 +363,10 @@ const dailySmell = sortedSmells[epochDay % sortedSmells.length];
363363
}
364364
}
365365

366-
@media (prefers-reduced-motion: reduce) {
367-
.about-hero__constellation :global(circle),
368-
.about-hero__constellation :global(line) {
369-
animation: none;
370-
opacity: 1;
371-
stroke-dashoffset: 0;
372-
}
366+
:global(html[data-motion='reduced']) .about-hero__constellation :global(circle),
367+
:global(html[data-motion='reduced']) .about-hero__constellation :global(line) {
368+
animation: none;
369+
opacity: 1;
370+
stroke-dashoffset: 0;
373371
}
374372
</style>

src/components/about/AboutOrigin.astro

Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -179,9 +179,7 @@ import { SPRINGER_PAPER_URL, THESIS_URL } from '../../lib/constants';
179179
line-height: 1.7;
180180
}
181181

182-
@media (prefers-reduced-motion: reduce) {
183-
.origin-struck__text::after {
184-
transform: none;
185-
}
182+
:global(html[data-motion='reduced']) .origin-struck__text::after {
183+
transform: none;
186184
}
187185
</style>

src/components/about/AboutResearch.astro

Lines changed: 22 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -1335,30 +1335,28 @@ const academicCitations =
13351335
}
13361336

13371337
/* Reduced motion */
1338-
@media (prefers-reduced-motion: reduce) {
1339-
.paper-card,
1340-
.paper-card::before,
1341-
.paper-visual::before,
1342-
.research-stats,
1343-
.curator-bridge,
1344-
.curator-bridge-line,
1345-
.curator-bridge-text,
1346-
.curator-voice,
1347-
.curator-identity,
1348-
.curator-photo-wrap,
1349-
.curator-thesis,
1350-
.curator-links,
1351-
.curator-link,
1352-
.curator-signoff {
1353-
opacity: 1;
1354-
transform: none;
1355-
transition: none;
1356-
}
1338+
:global(html[data-motion='reduced']) .paper-card,
1339+
:global(html[data-motion='reduced']) .paper-card::before,
1340+
:global(html[data-motion='reduced']) .paper-visual::before,
1341+
:global(html[data-motion='reduced']) .research-stats,
1342+
:global(html[data-motion='reduced']) .curator-bridge,
1343+
:global(html[data-motion='reduced']) .curator-bridge-line,
1344+
:global(html[data-motion='reduced']) .curator-bridge-text,
1345+
:global(html[data-motion='reduced']) .curator-voice,
1346+
:global(html[data-motion='reduced']) .curator-identity,
1347+
:global(html[data-motion='reduced']) .curator-photo-wrap,
1348+
:global(html[data-motion='reduced']) .curator-thesis,
1349+
:global(html[data-motion='reduced']) .curator-links,
1350+
:global(html[data-motion='reduced']) .curator-link,
1351+
:global(html[data-motion='reduced']) .curator-signoff {
1352+
opacity: 1;
1353+
transform: none;
1354+
transition: none;
1355+
}
13571356

1358-
.cite-sweep.sweeping,
1359-
.curator-bridge.revealed .curator-bridge-monogram,
1360-
.curator-signoff.revealed .curator-signoff-arrow {
1361-
animation: none;
1362-
}
1357+
:global(html[data-motion='reduced']) .cite-sweep.sweeping,
1358+
:global(html[data-motion='reduced']) .curator-bridge.revealed .curator-bridge-monogram,
1359+
:global(html[data-motion='reduced']) .curator-signoff.revealed .curator-signoff-arrow {
1360+
animation: none;
13631361
}
13641362
</style>

src/components/about/AboutSectionDots.astro

Lines changed: 4 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -49,7 +49,7 @@ const sections: DotSection[] = [
4949
.map((id) => document.getElementById(id))
5050
.filter((el): el is HTMLElement => el !== null);
5151

52-
const prefersReducedMotion = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
52+
const prefersReducedMotion = document.documentElement.dataset.motion === 'reduced';
5353

5454
dots.forEach((dot) => {
5555
dot.addEventListener('click', () => {
@@ -164,10 +164,8 @@ const sections: DotSection[] = [
164164
}
165165
}
166166

167-
@media (prefers-reduced-motion: reduce) {
168-
.section-dots {
169-
opacity: 1;
170-
pointer-events: auto;
171-
}
167+
:global(html[data-motion='reduced']) .section-dots {
168+
opacity: 1;
169+
pointer-events: auto;
172170
}
173171
</style>

src/components/about/AboutTaxonomy.astro

Lines changed: 7 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -627,15 +627,13 @@ function chipHref(card: FilterableDimensionCard, chip: ChipItem): string {
627627
}
628628
}
629629

630-
@media (prefers-reduced-motion: reduce) {
631-
.dim-card {
632-
opacity: 1;
633-
transform: none;
634-
}
630+
:global(html[data-motion='reduced']) .dim-card {
631+
opacity: 1;
632+
transform: none;
633+
}
635634

636-
.taxonomy-aside {
637-
opacity: 1;
638-
transform: none;
639-
}
635+
:global(html[data-motion='reduced']) .taxonomy-aside {
636+
opacity: 1;
637+
transform: none;
640638
}
641639
</style>

src/components/catalog/CatalogHero.astro

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -62,9 +62,11 @@ const { totalCount, categoryCount, hierarchyCount } = Astro.props;
6262
.catalog-hero::before {
6363
content: '';
6464
position: absolute;
65-
inset: 0;
66-
left: -100vw;
67-
right: -100vw;
65+
top: 0;
66+
bottom: 0;
67+
left: 50%;
68+
width: 100vw;
69+
transform: translateX(-50%);
6870
background: radial-gradient(
6971
ellipse 60% 80% at 50% 30%,
7072
rgba(180, 83, 9, 0.04) 0%,

src/components/islands/CodeExample.css

Lines changed: 34 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -719,50 +719,48 @@
719719
Reduced motion
720720
============================================ */
721721

722-
@media (prefers-reduced-motion: reduce) {
723-
.code-panel--slide-right,
724-
.code-panel--slide-left {
725-
animation: none;
726-
opacity: 1;
727-
transform: none;
728-
}
722+
html[data-motion='reduced'] .code-panel--slide-right,
723+
html[data-motion='reduced'] .code-panel--slide-left {
724+
animation: none;
725+
opacity: 1;
726+
transform: none;
727+
}
729728

730-
.code-toggle__indicator {
731-
transition: none;
732-
}
729+
html[data-motion='reduced'] .code-toggle__indicator {
730+
transition: none;
731+
}
733732

734-
.code-example__caption {
735-
transition: none;
736-
}
733+
html[data-motion='reduced'] .code-example__caption {
734+
transition: none;
735+
}
737736

738-
.code-example__caption--fading {
739-
opacity: 0;
740-
transform: none;
741-
filter: none;
742-
}
737+
html[data-motion='reduced'] .code-example__caption--fading {
738+
opacity: 0;
739+
transform: none;
740+
filter: none;
741+
}
743742

744-
.code-panel .smell-mark,
745-
.code-panel .fix-mark {
746-
animation: none !important;
747-
opacity: 1;
748-
}
743+
html[data-motion='reduced'] .code-panel .smell-mark,
744+
html[data-motion='reduced'] .code-panel .fix-mark {
745+
animation: none !important;
746+
opacity: 1;
747+
}
749748

750-
.code-panel .smell-mark {
751-
text-decoration-color: var(--red);
752-
}
749+
html[data-motion='reduced'] .code-panel .smell-mark {
750+
text-decoration-color: var(--red);
751+
}
753752

754-
.code-panel .fix-mark {
755-
background: color-mix(in srgb, var(--green) 14%, transparent);
756-
}
753+
html[data-motion='reduced'] .code-panel .fix-mark {
754+
background: color-mix(in srgb, var(--green) 14%, transparent);
755+
}
757756

758-
.code-example__solution-wrapper {
759-
transition: none;
760-
}
757+
html[data-motion='reduced'] .code-example__solution-wrapper {
758+
transition: none;
759+
}
761760

762-
.code-example__compare-divider {
763-
animation: none;
764-
opacity: 1;
765-
}
761+
html[data-motion='reduced'] .code-example__compare-divider {
762+
animation: none;
763+
opacity: 1;
766764
}
767765

768766
/* ============================================

src/components/islands/CodeExample.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -45,7 +45,7 @@ function useCompareTransition(
4545
wrapper.classList.add('is-visible');
4646
});
4747

48-
const reducedMotion = globalThis.matchMedia('(prefers-reduced-motion: reduce)').matches;
48+
const reducedMotion = document.documentElement.dataset.motion === 'reduced';
4949
if (reducedMotion) {
5050
isAnimatingRef.current = false;
5151
return;

0 commit comments

Comments
 (0)