Skip to content

Commit 2030ce9

Browse files
authored
fix(a11y): improve label contrast on light theme to meet WCAG AA requirements (JhaSourav07#1693)
## Description Fixes JhaSourav07#1685 In the default `light` theme (`bg: ffffff`, `accent: 0969da`), the `.label` class was rendered using the accent color at 70% opacity, producing a pale blue-gray (`#5396e5`) on a pure white background with a contrast ratio of only ~2.8:1 — severely violating the WCAG AA minimum of 4.5:1. **Changes made:** - Updated `renderStyle` in `lib/svg/generator.ts` to accept the `bg` color and use `getLuminance` to detect background brightness - On light backgrounds (`luminance > 0.5`), `.label` now uses the primary `text` color (`#24292f`) at 80% opacity, achieving a contrast ratio of ~5.5:1 - On dark backgrounds, `.label` retains the existing accent color fill at 70% opacity - Updated `generateAutoThemeSVG` and `generateAutoThemeVersusSVG` to compute `--cp-label-fill` and `--cp-label-opacity` CSS custom properties dynamically for light/dark theme pairs - Added `getLuminance` utility to `lib/svg/sanitizer.ts` for background brightness detection - Added test case in `generator.test.ts` asserting correct label styling on both light and dark backgrounds - All 150 tests pass ## Pillar - [ ] 🎨 Pillar 1 — New Theme Design - [x] 📐 Pillar 2 — Geometric SVG Improvement - [ ] 🕐 Pillar 3 — Timezone Logic Optimization - [ ] 🛠️ Other (Bug fix, refactoring, docs) ## 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&theme=light`). - [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 quality" aesthetic standard (no raw elements, smooth animations, correct fonts). - [x] (Recommended) I joined the CommitPulse Discord community for contributor discussions, mentorship, and faster PR support.
2 parents 5240145 + a3b108d commit 2030ce9

3 files changed

Lines changed: 65 additions & 11 deletions

File tree

lib/svg/generator.test.ts

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -220,6 +220,26 @@ describe('generateSVG', () => {
220220
expect(svg).toContain('ffffff'); // default text
221221
});
222222

223+
it('adjusts label styling contrast on light backgrounds versus dark backgrounds', () => {
224+
// 1. Light background (bg: 'ffffff') should use text color for label fill and 0.8 opacity
225+
const svgLight = generateSVG(
226+
mockStats,
227+
{ user: 'avi', bg: 'ffffff', text: '24292f', accent: '0969da' } as unknown as BadgeParams,
228+
mockCalendar
229+
);
230+
expect(svgLight).toContain('.label { font-family: "Roboto", sans-serif; fill: #24292f;');
231+
expect(svgLight).toContain('opacity: 0.8;');
232+
233+
// 2. Dark background (bg: '0d1117') should use accent color for label fill and 0.7 opacity
234+
const svgDark = generateSVG(
235+
mockStats,
236+
{ user: 'avi', bg: '0d1117', text: 'ffffff', accent: '58a6ff' } as unknown as BadgeParams,
237+
mockCalendar
238+
);
239+
expect(svgDark).toContain('.label { font-family: "Roboto", sans-serif; fill: #58a6ff;');
240+
expect(svgDark).toContain('opacity: 0.7;');
241+
});
242+
223243
it('falls back to default typography for completely invalid font names', () => {
224244
const svg = generateSVG(
225245
mockStats,

lib/svg/generator.ts

Lines changed: 30 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,13 @@ import { getLabels, type BadgeLabels } from '../i18n/badgeLabels';
55
import { AUTO_THEME_DARK, AUTO_THEME_LIGHT } from './themes';
66
import { TOWER_ANIMATION_CSS } from './animations';
77
import { computeTowers, type TowerData } from './layout';
8-
import { sanitizeFont, sanitizeHexColor, sanitizeRadius, sanitizeGoogleFontUrl } from './sanitizer';
8+
import {
9+
sanitizeFont,
10+
sanitizeHexColor,
11+
sanitizeRadius,
12+
sanitizeGoogleFontUrl,
13+
getLuminance,
14+
} from './sanitizer';
915

1016
import { SVG_WIDTH, SVG_HEIGHT, FONT_MAP } from './generatorConstants';
1117

@@ -181,9 +187,14 @@ function renderStyle(
181187
googleFontsImport: string,
182188
text: string,
183189
accent: string,
184-
sf: number
190+
sf: number,
191+
bg: string
185192
): string {
186193
const fs = (n: number) => Math.round(n * sf * 10) / 10;
194+
const isLightBg = getLuminance(bg) > 0.5;
195+
const labelFill = isLightBg ? text : accent;
196+
const labelOpacity = isLightBg ? 0.8 : 0.7;
197+
187198
return `
188199
<style>
189200
@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');
@@ -201,7 +212,7 @@ function renderStyle(
201212
.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; }
202213
.stats { font-family: ${statsFont}; fill: ${text}; font-size: ${fs(42)}px; font-weight: 500; letter-spacing: 0; }
203214
.total-val { font-family: ${statsFont}; fill: ${accent}; font-size: ${fs(24)}px; font-weight: 500; }
204-
.label { font-family: "Roboto", sans-serif; fill: ${accent}; font-size: ${fs(11)}px; font-weight: 400; letter-spacing: ${fs(2)}px; opacity: 0.7; }
215+
.label { font-family: "Roboto", sans-serif; fill: ${labelFill}; font-size: ${fs(11)}px; font-weight: 400; letter-spacing: ${fs(2)}px; opacity: ${labelOpacity}; }
205216
@media (prefers-reduced-motion: reduce) {
206217
.heat-particles { display: none; }
207218
.scan-line {
@@ -502,7 +513,7 @@ export function generateSVG(
502513
return `
503514
<svg xmlns="http://www.w3.org/2000/svg" width="${W}" height="${H}" viewBox="0 0 ${W} ${H}" fill="none" role="img">
504515
${renderHeader(safeUser, stats, sf, params)}
505-
${renderStyle(selectedFont, statsFont, googleFontsImport, text, mainAccentHex, sf)}
516+
${renderStyle(selectedFont, statsFont, googleFontsImport, text, mainAccentHex, sf, bg)}
506517
<rect width="${W}" height="${H}" rx="${radius}" fill="${params.hideBackground ? 'transparent' : bg}" ${borderAttr} />
507518
<g transform="translate(0, ${Math.round(20 * sf)})">${towers}</g>
508519
${renderIsometricLabels(calendar, params, text, sf)}
@@ -517,6 +528,10 @@ function generateAutoThemeSVG(
517528
): string {
518529
const light = AUTO_THEME_LIGHT;
519530
const dark = AUTO_THEME_DARK;
531+
const lightLabelFill = getLuminance(light.bg) > 0.5 ? 'var(--cp-text)' : 'var(--cp-accent)';
532+
const lightLabelOpacity = getLuminance(light.bg) > 0.5 ? '0.8' : '0.7';
533+
const darkLabelFill = getLuminance(dark.bg) > 0.5 ? 'var(--cp-text)' : 'var(--cp-accent)';
534+
const darkLabelOpacity = getLuminance(dark.bg) > 0.5 ? '0.8' : '0.7';
520535
const safeUser = escapeXML(params.user || 'GitHub User');
521536
const sanitizedFont = sanitizeFont(params.font);
522537
const selectedFont = sanitizedFont
@@ -557,8 +572,8 @@ function generateAutoThemeSVG(
557572
<style>
558573
@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');
559574
${googleFontsImport}
560-
:root { --cp-bg: #${light.bg}; --cp-text: #${light.text}; --cp-accent: #${light.accent}; }
561-
@media (prefers-color-scheme: dark) { :root { --cp-bg: #${dark.bg}; --cp-text: #${dark.text}; --cp-accent: #${dark.accent}; } }
575+
:root { --cp-bg: #${light.bg}; --cp-text: #${light.text}; --cp-accent: #${light.accent}; --cp-label-fill: ${lightLabelFill}; --cp-label-opacity: ${lightLabelOpacity}; }
576+
@media (prefers-color-scheme: dark) { :root { --cp-bg: #${dark.bg}; --cp-text: #${dark.text}; --cp-accent: #${dark.accent}; --cp-label-fill: ${darkLabelFill}; --cp-label-opacity: ${darkLabelOpacity}; } }
562577
.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); }
563578
${TOWER_ANIMATION_CSS}
564579
.scan-line {
@@ -573,7 +588,7 @@ function generateAutoThemeSVG(
573588
.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; }
574589
.stats { font-family: ${statsFont}; fill: var(--cp-text); font-size: ${fs(42)}px; font-weight: 500; letter-spacing: 0; }
575590
.total-val { font-family: ${statsFont}; fill: var(--cp-accent); font-size: ${fs(24)}px; font-weight: 500; }
576-
.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; }
591+
.label { font-family: "Roboto", sans-serif; fill: var(--cp-label-fill); font-size: ${fs(11)}px; font-weight: 400; letter-spacing: ${fs(2)}px; opacity: var(--cp-label-opacity); }
577592
.isometric-label { font-family: ${selectedFont || '"Roboto", sans-serif'}; font-size: ${fs(10)}px; font-weight: 400; letter-spacing: 1px; fill-opacity: 0.6; }
578593
579594
@media (prefers-reduced-motion: reduce) {
@@ -1325,7 +1340,7 @@ export function generateVersusSVG(
13251340
<title>CommitPulse Versus Stats: ${safeUser1} vs ${safeUser2}</title>
13261341
<desc>${safeUser1} has ${stats1.totalContributions} ${unit}. ${safeUser2} has ${stats2.totalContributions} ${unit}.</desc>
13271342
${renderDefs(sf, params)}
1328-
${renderStyle(selectedFont, statsFont, googleFontsImport, text, accent, sf)}
1343+
${renderStyle(selectedFont, statsFont, googleFontsImport, text, accent, sf, bg)}
13291344
<rect width="${W}" height="${H}" rx="${radius}" fill="${params.hideBackground ? 'transparent' : bg}" />
13301345
13311346
<g transform="translate(0, 0)">
@@ -1358,6 +1373,10 @@ function generateAutoThemeVersusSVG(
13581373
): string {
13591374
const light = AUTO_THEME_LIGHT;
13601375
const dark = AUTO_THEME_DARK;
1376+
const lightLabelFill = getLuminance(light.bg) > 0.5 ? 'var(--cp-text)' : 'var(--cp-accent)';
1377+
const lightLabelOpacity = getLuminance(light.bg) > 0.5 ? '0.8' : '0.7';
1378+
const darkLabelFill = getLuminance(dark.bg) > 0.5 ? 'var(--cp-text)' : 'var(--cp-accent)';
1379+
const darkLabelOpacity = getLuminance(dark.bg) > 0.5 ? '0.8' : '0.7';
13611380
const safeUser1 = escapeXML(params.user || 'User 1');
13621381
const safeUser2 = escapeXML(params.versus || 'User 2');
13631382
const sanitizedFont = sanitizeFont(params.font);
@@ -1461,8 +1480,8 @@ function generateAutoThemeVersusSVG(
14611480
14621481
<style>
14631482
@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');
1464-
:root { --cp-bg: #${light.bg}; --cp-text: #${light.text}; --cp-accent: #${light.accent}; }
1465-
@media (prefers-color-scheme: dark) { :root { --cp-bg: #${dark.bg}; --cp-text: #${dark.text}; --cp-accent: #${dark.accent}; } }
1483+
:root { --cp-bg: #${light.bg}; --cp-text: #${light.text}; --cp-accent: #${light.accent}; --cp-label-fill: ${lightLabelFill}; --cp-label-opacity: ${lightLabelOpacity}; }
1484+
@media (prefers-color-scheme: dark) { :root { --cp-bg: #${dark.bg}; --cp-text: #${dark.text}; --cp-accent: #${dark.accent}; --cp-label-fill: ${darkLabelFill}; --cp-label-opacity: ${darkLabelOpacity}; } }
14661485
.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); }
14671486
${TOWER_ANIMATION_CSS}
14681487
.scan-line {
@@ -1477,7 +1496,7 @@ function generateAutoThemeVersusSVG(
14771496
.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; }
14781497
.stats { font-family: ${statsFont}; fill: var(--cp-text); font-size: ${fs(42)}px; font-weight: 500; letter-spacing: 0; }
14791498
.total-val { font-family: ${statsFont}; fill: var(--cp-accent); font-size: ${fs(24)}px; font-weight: 500; }
1480-
.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; }
1499+
.label { font-family: "Roboto", sans-serif; fill: var(--cp-label-fill); font-size: ${fs(11)}px; font-weight: 400; letter-spacing: ${fs(2)}px; opacity: var(--cp-label-opacity); }
14811500
.isometric-label { font-family: ${selectedFont || '"Roboto", sans-serif'}; font-size: ${fs(10)}px; font-weight: 400; letter-spacing: 1px; fill-opacity: 0.6; }
14821501
14831502
@media (prefers-reduced-motion: reduce) {

lib/svg/sanitizer.ts

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -114,3 +114,18 @@ export function sanitizeGoogleFontUrl(fontName: string | undefined | null): stri
114114
// Return the encoded font name suitable for Google Fonts API URL (spaces replaced with '+')
115115
return encodeURIComponent(cleaned).replace(/%20/g, '+');
116116
}
117+
118+
export function getLuminance(hex: string): number {
119+
let normalized = hex.trim().replace(/^#/, '');
120+
if (normalized.length === 3 || normalized.length === 4) {
121+
normalized = `${normalized[0]}${normalized[0]}${normalized[1]}${normalized[1]}${normalized[2]}${normalized[2]}`;
122+
}
123+
const r = parseInt(normalized.slice(0, 2), 16) / 255 || 0;
124+
const g = parseInt(normalized.slice(2, 4), 16) / 255 || 0;
125+
const b = parseInt(normalized.slice(4, 6), 16) / 255 || 0;
126+
127+
const [R, G, B] = [r, g, b].map((c) =>
128+
c <= 0.03928 ? c / 12.92 : Math.pow((c + 0.055) / 1.055, 2.4)
129+
);
130+
return 0.2126 * R + 0.7152 * G + 0.0722 * B;
131+
}

0 commit comments

Comments
 (0)