Skip to content

Commit d03a482

Browse files
authored
fix(a11y): add aria-labelledby and aria-describedby to SVG badges for screen reader support (JhaSourav07#1687)
## Description Fixes JhaSourav07#1683 Although the generated SVGs included `<title>` and `<desc>` elements, screen readers completely ignored them because the outer `<svg>` element was missing `aria-labelledby` and `aria-describedby` attributes. This fix adds proper screen reader support across all generated SVG badge types. **Changes made:** - Added unique `id` attributes to `<title>` and `<desc>` elements based on sanitized usernames (e.g. `id="cp-title-username"`, `id="cp-desc-username"`) - Added `aria-labelledby` and `aria-describedby` on the outer `<svg>` tag referencing the unique IDs - Fix applied across all SVG rendering functions: standard, auto-theme, monthly, versus, wrapped, and error-state badges - Updated existing test assertions in `generator.test.ts` and `generator.additional.test.ts` to verify the presence of these attributes - All 146 tests pass ## Pillar - [ ] 🎨 Pillar 1 — New Theme Design - [x] 📐 Pillar 2 — Geometric SVG Improvement - [ ] 🕐 Pillar 3 — Timezone Logic Optimization - [ ] 🛠️ Other (Bug fix, refactoring, docs) ## Visual Preview No visual change — this is an accessibility fix. Screen readers will now correctly announce badge content to visually impaired users. ## Checklist before requesting a review: - [x] I have read the `CONTRIBUTING.md` file. - [x] I have tested these changes locally (`localhost:3000/api/streak?user=Pranav-IIITM`). - [x] I have run `npm run format` and `npm run lint` locally and resolved all errors (CI will fail otherwise). - [x] My commits follow the Conventional Commits format (e.g., `feat(themes): ...`, `fix(calculate): ...`). - [ ] I have updated `README.md` if I added a new theme or URL parameter. - [x] I have started the repo. - [x] I have made sure that i have only one commit to merge in this PR. - [x] The SVG output matches the CommitPulse "premium qualit
2 parents f336b9c + fa465c5 commit d03a482

3 files changed

Lines changed: 69 additions & 16 deletions

File tree

lib/svg/generator.additional.test.ts

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -179,7 +179,7 @@ describe('[Issue] org and repo params change entity type in SVG <title>', () =>
179179
it('renders "User Stats" in title when neither org nor repo is set', () => {
180180
const svg = generateSVG(baseStats, { user: 'chetan' } as unknown as BadgeParams, baseCalendar);
181181

182-
expect(svg).toContain('<title>CommitPulse User Stats for chetan</title>');
182+
expect(svg).toContain('<title id="cp-title-chetan">CommitPulse User Stats for chetan</title>');
183183
});
184184

185185
it('renders "Organization Stats" in title when org param is set', () => {
@@ -189,7 +189,9 @@ describe('[Issue] org and repo params change entity type in SVG <title>', () =>
189189
baseCalendar
190190
);
191191

192-
expect(svg).toContain('<title>CommitPulse Organization Stats for chetan</title>');
192+
expect(svg).toContain(
193+
'<title id="cp-title-chetan">CommitPulse Organization Stats for chetan</title>'
194+
);
193195
});
194196

195197
it('renders "Repository Stats" in title when repo param is set', () => {
@@ -199,7 +201,9 @@ describe('[Issue] org and repo params change entity type in SVG <title>', () =>
199201
baseCalendar
200202
);
201203

202-
expect(svg).toContain('<title>CommitPulse Repository Stats for chetan</title>');
204+
expect(svg).toContain(
205+
'<title id="cp-title-chetan">CommitPulse Repository Stats for chetan</title>'
206+
);
203207
});
204208
});
205209

lib/svg/generator.test.ts

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -440,7 +440,7 @@ describe('generateSVG', () => {
440440

441441
it('includes desc element in auto-theme SVG output', () => {
442442
const svg = generateSVG(mockStats, autoParams, mockCalendar);
443-
expect(svg).toContain('<desc>');
443+
expect(svg).toContain('<desc id="cp-desc-avi">');
444444
expect(svg).toContain(String(mockStats.totalContributions));
445445
});
446446

@@ -694,8 +694,12 @@ describe('generateSVG', () => {
694694
mockCalendar
695695
);
696696

697-
expect(svg).toContain('<title>CommitPulse User Stats for octocat</title>');
698-
expect(svg).toContain('<desc>');
697+
expect(svg).toContain(
698+
'<title id="cp-title-octocat">CommitPulse User Stats for octocat</title>'
699+
);
700+
expect(svg).toContain('<desc id="cp-desc-octocat">');
701+
expect(svg).toContain('aria-labelledby="cp-title-octocat"');
702+
expect(svg).toContain('aria-describedby="cp-desc-octocat"');
699703
expect(svg).toContain('100');
700704
expect(svg).toContain('10');
701705
});

lib/svg/generator.ts

Lines changed: 55 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -108,14 +108,15 @@ function renderHeader(
108108
safeUser: string,
109109
stats: StreakStats,
110110
sf: number,
111-
params: BadgeParams
111+
params: BadgeParams,
112+
safeId: string
112113
): string {
113114
const unit = params.mode === 'loc' ? 'lines of code' : 'total contributions';
114115
const entity = params.org ? 'Organization' : params.repo ? 'Repository' : 'User';
115116

116117
return `
117-
<title>CommitPulse ${entity} Stats for ${safeUser}</title>
118-
<desc>
118+
<title id="cp-title-${safeId}">CommitPulse ${entity} Stats for ${safeUser}</title>
119+
<desc id="cp-desc-${safeId}">
119120
${safeUser} has ${stats.totalContributions} ${unit} and a longest streak of ${stats.longestStreak} days.
120121
</desc>
121122
${renderDefs(sf, params)}`;
@@ -528,9 +529,15 @@ export function generateSVG(
528529
: accent || '00ffaa';
529530
const mainAccentHex = mainAccent.startsWith('#') ? mainAccent : `#${mainAccent}`;
530531

532+
const safeId = safeUser.replace(/[^a-zA-Z0-9-]/g, '_').toLowerCase();
533+
531534
return `
535+
<svg xmlns="http://www.w3.org/2000/svg" width="${W}" height="${H}" viewBox="0 0 ${W} ${H}" fill="none" role="img" aria-labelledby="cp-title-${safeId}" aria-describedby="cp-desc-${safeId}">
536+
${renderHeader(safeUser, stats, sf, params, safeId)}
532537
<svg xmlns="http://www.w3.org/2000/svg" width="100%" viewBox="0 0 ${W} ${H}" fill="none" role="img">
533-
${renderHeader(safeUser, stats, sf, params)}
538+
${renderHeader(safeUser, stats, sf, params, safeId)}
539+
${renderStyle(selectedFont, statsFont, googleFontsImport, text, mainAccentHex, sf, bg)}
540+
${renderHeader(safeUser, stats, sf, params, safeId)}
534541
${renderStyle(selectedFont, statsFont, googleFontsImport, text, mainAccentHex, sf, bg, params.entrance || 'rise')}
535542
<rect width="${W}" height="${H}" rx="${radius}" fill="${params.hideBackground ? 'transparent' : bg}" ${borderAttr} />
536543
<g id="cp-towers" style="transform-origin: center; transform-box: fill-box;" transform="translate(0, ${Math.round(20 * sf)})">${towers}</g>
@@ -576,15 +583,19 @@ function generateAutoThemeSVG(
576583
const s = createScaler(sf);
577584
const fs = (n: number): number => Math.round(n * sf * 10) / 10;
578585

586+
const safeId = safeUser.replace(/[^a-zA-Z0-9-]/g, '_').toLowerCase();
587+
579588
return `
580589
<svg
581590
xmlns="http://www.w3.org/2000/svg"
582591
width="100%"
583592
viewBox="0 0 ${W} ${H}"
584593
fill="none"
585594
role="img"
595+
aria-labelledby="cp-title-${safeId}"
596+
aria-describedby="cp-desc-${safeId}"
586597
>
587-
${renderHeader(safeUser, stats, sf, params)}
598+
${renderHeader(safeUser, stats, sf, params, safeId)}
588599
589600
<style>
590601
@import url('https://fonts.googleapis.com/css2?family=Fira+Code&amp;family=JetBrains+Mono&amp;family=Roboto&amp;family=Syncopate:wght@400;700&amp;family=Space+Grotesk:wght@400;500;600;700&amp;display=swap');
@@ -730,6 +741,8 @@ export function generateMonthlySVG(stats: MonthlyStats, params: BadgeParams): st
730741

731742
const deltaColor = stats.deltaAbsolute >= 0 ? accent : negativeColor;
732743

744+
const safeId = safeUser.replace(/[^a-zA-Z0-9-]/g, '_').toLowerCase();
745+
733746
return `
734747
<svg
735748
xmlns="http://www.w3.org/2000/svg"
@@ -738,8 +751,11 @@ export function generateMonthlySVG(stats: MonthlyStats, params: BadgeParams): st
738751
viewBox="0 0 ${width} ${height}"
739752
fill="none"
740753
role="img"
754+
aria-labelledby="cp-title-${safeId}"
755+
aria-describedby="cp-desc-${safeId}"
741756
>
742-
<title>Monthly Stats for ${safeUser}</title>
757+
<title id="cp-title-${safeId}">Monthly Stats for ${safeUser}</title>
758+
<desc id="cp-desc-${safeId}">Monthly stats for ${safeUser}: ${stats.currentMonthTotal} ${commitsLabel} vs previous month delta of ${deltaText}.</desc>
743759
<style>
744760
@import url('https://fonts.googleapis.com/css2?family=Fira+Code&amp;family=JetBrains+Mono&amp;family=Roboto&amp;family=Syncopate:wght@400;700&amp;family=Space+Grotesk:wght@400;500;600;700&amp;display=swap');
745761
${googleFontsImport}
@@ -913,6 +929,7 @@ export function generateWrappedSVG(
913929
? 'class="cp-accent-stroke" stroke-opacity="0.15" stroke-width="1.5"'
914930
: borderAttr;
915931

932+
const safeId = safeUser.replace(/[^a-zA-Z0-9-]/g, '_').toLowerCase();
916933
const filterGlow =
917934
params.glow !== false
918935
? `<filter id="glow" x="-50%" y="-50%" width="200%" height="200%">
@@ -931,8 +948,11 @@ export function generateWrappedSVG(
931948
viewBox="0 0 420 260"
932949
fill="none"
933950
role="img"
951+
aria-labelledby="cp-title-${safeId}"
952+
aria-describedby="cp-desc-${safeId}"
934953
>
935-
<title>${safeUser}'s GitHub Wrapped ${year}</title>
954+
<title id="cp-title-${safeId}">${safeUser}'s GitHub Wrapped ${year}</title>
955+
<desc id="cp-desc-${safeId}">GitHub Wrapped stats for ${safeUser} in ${year}: ${stats.totalContributions} total contributions, top language is ${stats.topLanguage || 'Unknown'}, busiest month is ${stats.busiestMonth || 'Unknown'}.</desc>
936956
<defs>
937957
${filterGlow}
938958
</defs>
@@ -1084,6 +1104,8 @@ function generateAutoThemeMonthlySVG(stats: MonthlyStats, params: BadgeParams):
10841104
: `0%`;
10851105
}
10861106

1107+
const safeId = safeUser.replace(/[^a-zA-Z0-9-]/g, '_').toLowerCase();
1108+
10871109
return `
10881110
<svg
10891111
xmlns="http://www.w3.org/2000/svg"
@@ -1092,8 +1114,11 @@ function generateAutoThemeMonthlySVG(stats: MonthlyStats, params: BadgeParams):
10921114
viewBox="0 0 ${width} ${height}"
10931115
fill="none"
10941116
role="img"
1117+
aria-labelledby="cp-title-${safeId}"
1118+
aria-describedby="cp-desc-${safeId}"
10951119
>
1096-
<title>Monthly Stats for ${safeUser}</title>
1120+
<title id="cp-title-${safeId}">Monthly Stats for ${safeUser}</title>
1121+
<desc id="cp-desc-${safeId}">Monthly stats for ${safeUser}: ${stats.currentMonthTotal} ${commitsLabel} vs previous month delta of ${deltaText}.</desc>
10971122
<style>
10981123
@import url('https://fonts.googleapis.com/css2?family=Fira+Code&amp;family=JetBrains+Mono&amp;family=Roboto&amp;family=Syncopate:wght@400;700&amp;family=Space+Grotesk:wght@400;500;600;700&amp;display=swap');
10991124
${googleFontsImport}
@@ -1662,14 +1687,19 @@ export function generateNotFoundSVG(
16621687
</g>`;
16631688
}
16641689

1690+
const safeId = safeName.replace(/[^a-zA-Z0-9-]/g, '_').toLowerCase();
1691+
16651692
return `<svg
16661693
xmlns="http://www.w3.org/2000/svg"
16671694
width="100%"
16681695
viewBox="0 0 ${SVG_WIDTH} ${SVG_HEIGHT}"
16691696
fill="none"
16701697
role="img"
1698+
aria-labelledby="cp-title-${safeId}"
1699+
aria-describedby="cp-desc-${safeId}"
16711700
>
1672-
<title>User not found — ${safeName}</title>
1701+
<title id="cp-title-${safeId}">User not found — ${safeName}</title>
1702+
<desc id="cp-desc-${safeId}">The GitHub user ${safeName} was not found or has no contribution data.</desc>
16731703
<defs>
16741704
<filter id="glow" x="-50%" y="-50%" width="200%" height="200%">
16751705
<feGaussianBlur stdDeviation="5" result="blur"/>
@@ -1812,7 +1842,12 @@ export function generateVersusSVG(
18121842
const s = createScaler(sf);
18131843
const unit = params.mode === 'loc' ? 'lines of code' : 'total contributions';
18141844

1845+
const safeId = `${safeUser1}_vs_${safeUser2}`.replace(/[^a-zA-Z0-9-]/g, '_').toLowerCase();
1846+
18151847
return `
1848+
<svg xmlns="http://www.w3.org/2000/svg" width="${W}" height="${H}" viewBox="0 0 ${W} ${H}" fill="none" role="img" aria-labelledby="cp-title-${safeId}" aria-describedby="cp-desc-${safeId}">
1849+
<title id="cp-title-${safeId}">CommitPulse Versus Stats: ${safeUser1} vs ${safeUser2}</title>
1850+
<desc id="cp-desc-${safeId}">${safeUser1} has ${stats1.totalContributions} ${unit}. ${safeUser2} has ${stats2.totalContributions} ${unit}.</desc>
18161851
<svg xmlns="http://www.w3.org/2000/svg" width="100%" viewBox="0 0 ${W} ${H}" fill="none" role="img">
18171852
<title>CommitPulse Versus Stats: ${safeUser1} vs ${safeUser2}</title>
18181853
<desc>${safeUser1} has ${stats1.totalContributions} ${unit}. ${safeUser2} has ${stats2.totalContributions} ${unit}.</desc>
@@ -1949,7 +1984,12 @@ function generateAutoThemeVersusSVG(
19491984
const fs = (n: number): number => Math.round(n * sf * 10) / 10;
19501985
const unit = params.mode === 'loc' ? 'lines of code' : 'total contributions';
19511986

1987+
const safeId = `${safeUser1}_vs_${safeUser2}`.replace(/[^a-zA-Z0-9-]/g, '_').toLowerCase();
1988+
19521989
return `
1990+
<svg xmlns="http://www.w3.org/2000/svg" width="${W}" height="${H}" viewBox="0 0 ${W} ${H}" fill="none" role="img" aria-labelledby="cp-title-${safeId}" aria-describedby="cp-desc-${safeId}">
1991+
<title id="cp-title-${safeId}">CommitPulse Versus Stats: ${safeUser1} vs ${safeUser2}</title>
1992+
<desc id="cp-desc-${safeId}">${safeUser1} has ${stats1.totalContributions} ${unit}. ${safeUser2} has ${stats2.totalContributions} ${unit}.</desc>
19531993
<svg xmlns="http://www.w3.org/2000/svg" width="100%" viewBox="0 0 ${W} ${H}" fill="none" role="img">
19541994
<title>CommitPulse Versus Stats: ${safeUser1} vs ${safeUser2}</title>
19551995
<desc>${safeUser1} has ${stats1.totalContributions} ${unit}. ${safeUser2} has ${stats2.totalContributions} ${unit}.</desc>
@@ -2458,14 +2498,19 @@ export function generateRateLimitSVG(
24582498
</g>`;
24592499
}
24602500

2501+
const safeId = 'rate_limit';
2502+
24612503
return `<svg
24622504
xmlns="http://www.w3.org/2000/svg"
24632505
width="100%"
24642506
viewBox="0 0 ${SVG_WIDTH} ${SVG_HEIGHT}"
24652507
fill="none"
24662508
role="img"
2509+
aria-labelledby="cp-title-${safeId}"
2510+
aria-describedby="cp-desc-${safeId}"
24672511
>
2468-
<title>Rate Limit Exceeded</title>
2512+
<title id="cp-title-${safeId}">Rate Limit Exceeded</title>
2513+
<desc id="cp-desc-${safeId}">GitHub API rate limit exceeded. Please try again later.</desc>
24692514
<defs>
24702515
<filter id="glow" x="-50%" y="-50%" width="200%" height="200%">
24712516
<feGaussianBlur stdDeviation="5" result="blur"/>

0 commit comments

Comments
 (0)