Skip to content

Commit bed8625

Browse files
authored
fix(svg): add aria attributes and desc to pulse badge SVG functions (JhaSourav07#3250)
## Description Fixes JhaSourav07#3230 Added missing accessibility attributes to `generatePulseSVG` and `generateAutoThemePulseSVG` in `lib/svg/generator.ts`, making pulse badges consistent with all other badge types which are already fully accessible. **Changes made:** `lib/svg/generator.ts`: - Added `aria-labelledby="cp-title-${safeId}"` to root `<svg>` - Added `aria-describedby="cp-desc-${safeId}"` to root `<svg>` - Added `id="cp-title-${safeId}"` to existing `<title>` element - Added `<desc id="cp-desc-${safeId}">` summarizing commit activity - Added `safeId` derived from `safeUser` for valid HTML id format `lib/svg/generator.test.ts`: - Added `generatePulseSVG` to imports - Added `describe('generatePulseSVG accessibility')` with 12 new tests - Tests cover: aria attributes, title/desc ids, safeId sanitization, pulseTotal in desc text, valid SVG structure, auto-theme variant - All 143 tests passing ✅ ## 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 — accessibility attributes are invisible in rendered output but allow screen readers to correctly describe pulse badges. Before: Screen readers saw an unlabelled SVG image After: Screen readers announce "Heartbeat sparkline for {user} showing commit activity over the last 30 days (total commits: {N})" ## Checklist before requesting a review: - [x] I have read the `CONTRIBUTING.md` file. - [x] I have tested these changes locally. - [x] I have run `npm run format` and `npm run lint` locally and resolved all errors. - [x] My commits follow the Conventional Commits format (`fix(svg): ...`). - [ ] I have updated `README.md` if I added a new theme or URL parameter. (Not needed) - [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 quality" aesthetic standard. - [x] (Recommended) I joined the CommitPulse Discord community.
2 parents e44f989 + 91bd41d commit bed8625

2 files changed

Lines changed: 121 additions & 2 deletions

File tree

lib/svg/generator.test.ts

Lines changed: 109 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import {
55
generateNotFoundSVG,
66
generateRateLimitSVG,
77
generateHeatmapSVG,
8+
generatePulseSVG,
89
particleCount,
910
escapeXML,
1011
getSizeScale,
@@ -1778,3 +1779,111 @@ describe('buildTowerPaths', () => {
17781779
expect(paths.top).toBe('M0 -9 L7.2 -4.5 L0 0 L-7.2 -4.5 Z');
17791780
});
17801781
});
1782+
1783+
// ── generatePulseSVG accessibility ─────────────────────────────────────────
1784+
1785+
describe('generatePulseSVG accessibility', () => {
1786+
const mockStats: StreakStats = {
1787+
currentStreak: 3,
1788+
longestStreak: 7,
1789+
totalContributions: 50,
1790+
todayDate: '2024-06-12',
1791+
};
1792+
1793+
// Build 5 weeks × 6 days = 30 days of deterministic contribution data
1794+
const weeks: ContributionCalendar['weeks'] = [];
1795+
for (let w = 0; w < 5; w++) {
1796+
const contributionDays = [];
1797+
for (let d = 0; d < 6; d++) {
1798+
const idx = w * 6 + d;
1799+
contributionDays.push({
1800+
contributionCount: idx % 5,
1801+
date: `2024-05-${String(idx + 1).padStart(2, '0')}`,
1802+
});
1803+
}
1804+
weeks.push({ contributionDays } as ContributionCalendar['weeks'][number]);
1805+
}
1806+
const mockCalendar = { weeks } as ContributionCalendar;
1807+
1808+
const baseParams: BadgeParams = {
1809+
user: 'octocat',
1810+
} as unknown as BadgeParams;
1811+
1812+
it('includes role="img" on the root svg', () => {
1813+
const svg = generatePulseSVG(mockStats, baseParams, mockCalendar);
1814+
expect(svg).toContain('role="img"');
1815+
});
1816+
1817+
it('includes aria-labelledby referencing cp-title-<safeId>', () => {
1818+
const svg = generatePulseSVG(mockStats, baseParams, mockCalendar);
1819+
expect(svg).toContain('aria-labelledby="cp-title-octocat"');
1820+
});
1821+
1822+
it('includes aria-describedby referencing cp-desc-<safeId>', () => {
1823+
const svg = generatePulseSVG(mockStats, baseParams, mockCalendar);
1824+
expect(svg).toContain('aria-describedby="cp-desc-octocat"');
1825+
});
1826+
1827+
it('includes <title> with the correct id attribute', () => {
1828+
const svg = generatePulseSVG(mockStats, baseParams, mockCalendar);
1829+
expect(svg).toContain('<title id="cp-title-octocat">Heartbeat Sparkline for octocat</title>');
1830+
});
1831+
1832+
it('includes <desc> with the correct id attribute and pulseTotal in its text', () => {
1833+
const svg = generatePulseSVG(mockStats, baseParams, mockCalendar);
1834+
expect(svg).toContain('<desc id="cp-desc-octocat">');
1835+
expect(svg).toContain('showing commit activity over the last 30 days');
1836+
expect(svg).toMatch(/total commits: \d+/);
1837+
});
1838+
1839+
it('sanitizes special characters in username for the safeId', () => {
1840+
const svg = generatePulseSVG(
1841+
mockStats,
1842+
{ ...baseParams, user: 'user.name+test' } as unknown as BadgeParams,
1843+
mockCalendar
1844+
);
1845+
expect(svg).toContain('aria-labelledby="cp-title-user_name_test"');
1846+
expect(svg).toContain('aria-describedby="cp-desc-user_name_test"');
1847+
expect(svg).toContain('id="cp-title-user_name_test"');
1848+
expect(svg).toContain('id="cp-desc-user_name_test"');
1849+
});
1850+
1851+
it('produces valid SVG markup (no parsererror)', () => {
1852+
const svg = generatePulseSVG(mockStats, baseParams, mockCalendar);
1853+
assertValidSVG(svg);
1854+
});
1855+
1856+
describe('auto-theme variant', () => {
1857+
const autoParams: BadgeParams = {
1858+
user: 'octocat',
1859+
autoTheme: true,
1860+
} as unknown as BadgeParams;
1861+
1862+
it('includes aria-labelledby in auto-theme output', () => {
1863+
const svg = generatePulseSVG(mockStats, autoParams, mockCalendar);
1864+
expect(svg).toContain('aria-labelledby="cp-title-octocat"');
1865+
});
1866+
1867+
it('includes aria-describedby in auto-theme output', () => {
1868+
const svg = generatePulseSVG(mockStats, autoParams, mockCalendar);
1869+
expect(svg).toContain('aria-describedby="cp-desc-octocat"');
1870+
});
1871+
1872+
it('includes id-bearing <title> in auto-theme output', () => {
1873+
const svg = generatePulseSVG(mockStats, autoParams, mockCalendar);
1874+
expect(svg).toContain('<title id="cp-title-octocat">Heartbeat Sparkline for octocat</title>');
1875+
});
1876+
1877+
it('includes id-bearing <desc> with pulseTotal in auto-theme output', () => {
1878+
const svg = generatePulseSVG(mockStats, autoParams, mockCalendar);
1879+
expect(svg).toContain('<desc id="cp-desc-octocat">');
1880+
expect(svg).toContain('showing commit activity over the last 30 days');
1881+
expect(svg).toMatch(/total commits: \d+/);
1882+
});
1883+
1884+
it('produces valid SVG markup in auto-theme mode (no parsererror)', () => {
1885+
const svg = generatePulseSVG(mockStats, autoParams, mockCalendar);
1886+
assertValidSVG(svg);
1887+
});
1888+
});
1889+
});

lib/svg/generator.ts

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2331,6 +2331,8 @@ export function generatePulseSVG(
23312331
const lastNormalized = (lastCount - minCount) / range;
23322332
const lastY = paddingYTop + graphHeight - lastNormalized * graphHeight;
23332333

2334+
const safeId = safeUser.replace(/[^a-zA-Z0-9-]/g, '_').toLowerCase();
2335+
23342336
return `
23352337
<svg
23362338
xmlns="http://www.w3.org/2000/svg"
@@ -2339,8 +2341,11 @@ export function generatePulseSVG(
23392341
viewBox="0 0 ${width} ${height}"
23402342
fill="none"
23412343
role="img"
2344+
aria-labelledby="cp-title-${safeId}"
2345+
aria-describedby="cp-desc-${safeId}"
23422346
>
2343-
<title>Heartbeat Sparkline for ${safeUser}</title>
2347+
<title id="cp-title-${safeId}">Heartbeat Sparkline for ${safeUser}</title>
2348+
<desc id="cp-desc-${safeId}">Heartbeat sparkline for ${safeUser} showing commit activity over the last 30 days (total commits: ${pulseTotal}).</desc>
23442349
<style>
23452350
@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');
23462351
${googleFontsImport}
@@ -2510,6 +2515,8 @@ function generateAutoThemePulseSVG(
25102515
const lastNormalized = (lastCount - minCount) / range;
25112516
const lastY = paddingYTop + graphHeight - lastNormalized * graphHeight;
25122517

2518+
const safeId = safeUser.replace(/[^a-zA-Z0-9-]/g, '_').toLowerCase();
2519+
25132520
return `
25142521
<svg
25152522
xmlns="http://www.w3.org/2000/svg"
@@ -2518,8 +2525,11 @@ function generateAutoThemePulseSVG(
25182525
viewBox="0 0 ${width} ${height}"
25192526
fill="none"
25202527
role="img"
2528+
aria-labelledby="cp-title-${safeId}"
2529+
aria-describedby="cp-desc-${safeId}"
25212530
>
2522-
<title>Heartbeat Sparkline for ${safeUser}</title>
2531+
<title id="cp-title-${safeId}">Heartbeat Sparkline for ${safeUser}</title>
2532+
<desc id="cp-desc-${safeId}">Heartbeat sparkline for ${safeUser} showing commit activity over the last 30 days (total commits: ${pulseTotal}).</desc>
25232533
<style>
25242534
@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');
25252535
${googleFontsImport}

0 commit comments

Comments
 (0)