Skip to content

Commit 828ecf2

Browse files
accessibility(svg): respect prefers-reduced-motion
1 parent 3636917 commit 828ecf2

2 files changed

Lines changed: 95 additions & 15 deletions

File tree

lib/svg/generator.test.ts

Lines changed: 35 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,11 @@
11
import { describe, it, expect } from 'vitest';
2-
import { generateSVG, generateMonthlySVG, particleCount, escapeXML } from './generator';
2+
import {
3+
generateSVG,
4+
generateMonthlySVG,
5+
generateNotFoundSVG,
6+
particleCount,
7+
escapeXML,
8+
} from './generator';
39
import type { BadgeParams, ContributionCalendar, StreakStats, MonthlyStats } from '../../types';
410
import { hexColor } from './sanitizer';
511

@@ -220,6 +226,14 @@ describe('generateSVG', () => {
220226
expect(svg).toMatch(/style="animation-delay: \d+\.\d+s;"/);
221227
});
222228

229+
it('includes reduced-motion CSS for the scan line in the main SVG output', () => {
230+
const svg = generateSVG(mockStats, { user: 'avi' } as unknown as BadgeParams, mockCalendar);
231+
232+
expect(svg).toContain('prefers-reduced-motion: reduce');
233+
expect(svg).toContain('.scan-line');
234+
expect(svg).toContain('animation: none !important');
235+
});
236+
223237
it('uses English labels by default', () => {
224238
const svg = generateSVG(mockStats, { user: 'avi' } as unknown as BadgeParams, mockCalendar);
225239
expect(svg).toContain('CURRENT_STREAK');
@@ -289,8 +303,8 @@ describe('generateSVG', () => {
289303
// Active towers should use the accent class
290304
expect(svg).toContain('class="cp-accent-fill"');
291305

292-
// The radar scan line should also use the accent class
293-
expect(svg).toMatch(/rect[^>]*class="cp-accent-fill"/);
306+
// The radar scan line should also use the accent class and scan-line hook
307+
expect(svg).toContain('class="cp-accent-fill scan-line"');
294308

295309
// cp-text-fill is emitted only in Ghost City mode (0 total contributions)
296310
const ghostCalendar: ContributionCalendar = {
@@ -409,6 +423,24 @@ describe('generateSVG', () => {
409423
});
410424
});
411425

426+
describe('notFoundSVG', () => {
427+
it('includes reduced-motion CSS for the scan line and ghost pulse', () => {
428+
const svg = generateNotFoundSVG(
429+
'avi',
430+
hexColor('0d1117'),
431+
hexColor('00ffaa'),
432+
hexColor('ffffff'),
433+
8,
434+
'8s'
435+
);
436+
437+
expect(svg).toContain('prefers-reduced-motion: reduce');
438+
expect(svg).toContain('.scan-line');
439+
expect(svg).toContain('animation: none !important');
440+
expect(svg).toContain('class="scan-line"');
441+
});
442+
});
443+
412444
// ── Timezone-aware pulse animation tests ─────────────────────────────────
413445
describe('todayDate pulse animation', () => {
414446
const calendar: ContributionCalendar = {

lib/svg/generator.ts

Lines changed: 60 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -156,11 +156,26 @@ function renderStyle(
156156
@import url('https://fonts.googleapis.com/css2?family=Fira+Code&family=JetBrains+Mono&family=Roboto&family=Syncopate:wght@400;700&family=Space+Grotesk:wght@400;500;600;700&display=swap');
157157
${googleFontsImport}
158158
${TOWER_ANIMATION_CSS}
159+
.scan-line {
160+
animation: scan-sweep var(--scan-speed, 8s) linear infinite;
161+
transform-box: fill-box;
162+
transform-origin: center;
163+
}
164+
@keyframes scan-sweep {
165+
from { transform: translateY(var(--scan-start, ${fs(20)}px)); }
166+
to { transform: translateY(var(--scan-end, ${fs(260)}px)); }
167+
}
159168
.title { font-family: ${selectedFont || '"Syncopate", sans-serif'}; fill: ${text}; font-size: ${fs(18)}px; letter-spacing: ${fs(6)}px; font-weight: 400; opacity: 0.8; }
160169
.stats { font-family: ${statsFont}; fill: ${text}; font-size: ${fs(42)}px; font-weight: 500; letter-spacing: 0; }
161170
.total-val { font-family: ${statsFont}; fill: ${accent}; font-size: ${fs(24)}px; font-weight: 500; }
162171
.label { font-family: "Roboto", sans-serif; fill: ${accent}; font-size: ${fs(11)}px; font-weight: 400; letter-spacing: ${fs(2)}px; opacity: 0.7; }
163-
@media (prefers-reduced-motion: reduce) { .heat-particles { display: none; } }
172+
@media (prefers-reduced-motion: reduce) {
173+
.heat-particles { display: none; }
174+
.scan-line {
175+
animation: none !important;
176+
transform: translateY(var(--scan-start, ${fs(20)}px)) !important;
177+
}
178+
}
164179
</style>`;
165180
}
166181

@@ -198,9 +213,15 @@ function renderFooter(
198213
return `
199214
${!params.hide_stats ? renderStatsSection(stats, labels, s, params) : ''}
200215
${!params.hide_title ? `<text x="${s(300)}" y="${s(50)}" text-anchor="middle" class="title">${truncateUsername(safeUser).toUpperCase()}</text>` : ''}
201-
<rect x="${s(100)}" y="${s(60)}" width="${s(400)}" height="${sf}" fill="${accent}" fill-opacity="0.3">
202-
<animate attributeName="y" values="${s(80)};${s(320)};${s(80)}" dur="${params.speed || '8s'}" repeatCount="indefinite" />
203-
</rect>`;
216+
<rect
217+
x="${s(100)}"
218+
y="${s(60)}"
219+
width="${s(400)}"
220+
height="${sf}"
221+
class="cp-accent-fill scan-line"
222+
fill-opacity="0.3"
223+
style="--scan-speed: ${params.speed || '8s'}; --scan-start: ${s(20)}px; --scan-end: ${s(260)}px;"
224+
/>`;
204225
}
205226

206227
// ── Main static-theme renderer ────────────────────────────────────────────
@@ -319,12 +340,27 @@ function generateAutoThemeSVG(
319340
@media (prefers-color-scheme: dark) { :root { --cp-bg: #${dark.bg}; --cp-text: #${dark.text}; --cp-accent: #${dark.accent}; } }
320341
.cp-bg-fill { fill: var(--cp-bg); } .cp-text-fill { fill: var(--cp-text); color: var(--cp-text); } .cp-accent-fill { fill: var(--cp-accent); color: var(--cp-accent); }
321342
${TOWER_ANIMATION_CSS}
343+
.scan-line {
344+
animation: scan-sweep var(--scan-speed, 8s) linear infinite;
345+
transform-box: fill-box;
346+
transform-origin: center;
347+
}
348+
@keyframes scan-sweep {
349+
from { transform: translateY(var(--scan-start, ${s(20)}px)); }
350+
to { transform: translateY(var(--scan-end, ${s(260)}px)); }
351+
}
322352
.title { font-family: ${selectedFont || '"Syncopate", sans-serif'}; fill: var(--cp-text); font-size: ${fs(18)}px; letter-spacing: ${fs(6)}px; font-weight: 400; opacity: 0.8; }
323353
.stats { font-family: ${statsFont}; fill: var(--cp-text); font-size: ${fs(42)}px; font-weight: 500; letter-spacing: 0; }
324354
.total-val { font-family: ${statsFont}; fill: var(--cp-accent); font-size: ${fs(24)}px; font-weight: 500; }
325355
.label { font-family: "Roboto", sans-serif; fill: var(--cp-accent); font-size: ${fs(11)}px; font-weight: 400; letter-spacing: ${fs(2)}px; opacity: 0.7; }
326356
327-
@media (prefers-reduced-motion: reduce) { .heat-particles { display: none; } }
357+
@media (prefers-reduced-motion: reduce) {
358+
.heat-particles { display: none; }
359+
.scan-line {
360+
animation: none !important;
361+
transform: translateY(var(--scan-start, ${s(20)}px)) !important;
362+
}
363+
}
328364
</style>
329365
330366
<rect width="${W}" height="${H}" rx="${radius}" ${params.hideBackground ? 'fill="transparent"' : 'class="cp-bg-fill"'} />
@@ -338,9 +374,15 @@ ${
338374
: ''
339375
}
340376
341-
<rect x="${s(100)}" y="${s(60)}" width="${s(400)}" height="${sf}" class="cp-accent-fill" fill-opacity="0.3">
342-
<animate attributeName="y" values="${s(80)};${s(320)};${s(80)}" dur="${params.speed || '8s'}" repeatCount="indefinite" />
343-
</rect>
377+
<rect
378+
x="${s(100)}"
379+
y="${s(60)}"
380+
width="${s(400)}"
381+
height="${sf}"
382+
class="cp-accent-fill scan-line"
383+
fill-opacity="0.3"
384+
style="--scan-speed: ${params.speed || '8s'}; --scan-start: ${s(20)}px; --scan-end: ${s(260)}px;"
385+
/>
344386
</svg>
345387
`;
346388
}
@@ -669,8 +711,16 @@ export function generateNotFoundSVG(
669711
.label { font-family: "Roboto", sans-serif; fill: ${accent}; font-size: 11px; letter-spacing: 2px; opacity: 0.4; }
670712
.stats { font-family: "Space Grotesk", sans-serif; fill: ${text}; font-size: 42px; font-weight: 500; opacity: 0.2; }
671713
.ghost-pulse { animation: gp 2.6s ease-in-out infinite; }
714+
.scan-line { animation: scan-sweep var(--scan-speed, 8s) linear infinite; }
672715
@keyframes gp { 0%,100%{opacity:.55} 50%{opacity:1} }
673-
@media (prefers-reduced-motion: reduce) { .ghost-pulse { animation: none; } }
716+
@keyframes scan-sweep { from { transform: translateY(20px); } to { transform: translateY(260px); } }
717+
@media (prefers-reduced-motion: reduce) {
718+
.ghost-pulse { animation: none; }
719+
.scan-line {
720+
animation: none !important;
721+
transform: translateY(20px) !important;
722+
}
723+
}
674724
</style>
675725
676726
<!-- Background -->
@@ -685,9 +735,7 @@ export function generateNotFoundSVG(
685735
<rect width="${SVG_WIDTH}" height="${SVG_HEIGHT}" rx="${radius}" fill="url(#ghostFade)"/>
686736
687737
<!-- Radar scan line -->
688-
<rect x="100" y="60" width="400" height="1" fill="${accent}" fill-opacity="0.12">
689-
<animate attributeName="y" values="80;320;80" dur="${speed}" repeatCount="indefinite"/>
690-
</rect>
738+
<rect x="100" y="60" width="400" height="1" class="scan-line" fill="${accent}" fill-opacity="0.12" style="--scan-speed: ${speed};"/>
691739
692740
<text x="300" y="50" text-anchor="middle" class="title">${safeName}</text>
693741

0 commit comments

Comments
 (0)