Skip to content

Commit 022ebb1

Browse files
authored
fix(themes): replace hardcoded negative delta color with dynamic contrast-aware resolution (JhaSourav07#1691)
## Description Fixes JhaSourav07#1684 The negative monthly delta text color in `generateMonthlySVG` was hardcoded to `#ff4444` for all static themes. On light backgrounds or themes with red/pink undertones (e.g. `rose`, `sunset`), this color had extremely low contrast, making the vs-last-month stat completely unreadable. **Changes made:** - Added an optional `negative` property of type `HexColor` to the `BadgeTheme` interface in `types/index.ts` - Added custom high-contrast negative colors for all 19 static theme presets in `lib/svg/themes.ts` (e.g. `#ff4b72` for `rose`, `#ff4d4d` for `sunset`, `#cf222e` for `light`) - Implemented a `getLuminance` relative luminance utility in `lib/svg/sanitizer.ts` for dynamic background lightness detection - Updated `generateMonthlySVG` in `lib/svg/generator.ts` to use theme-specific negative colors or fall back dynamically based on background luminance (`#cf222e` for light backgrounds, `#f85149` for dark) - Updated `generateAutoThemeMonthlySVG` to use `--cp-negative` CSS variables for auto theme pairs - Added test cases in `generator.test.ts` to verify correct delta color resolution across themes - Updated `themes.test.ts` to assert every theme defines a valid `negative` hex property - All 150 tests pass ## Pillar - [x] 🎨 Pillar 1 — New Theme Design - [ ] 📐 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&view=monthly&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 208a708 + a3cf9e5 commit 022ebb1

5 files changed

Lines changed: 78 additions & 26 deletions

File tree

lib/svg/generator.test.ts

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -901,6 +901,37 @@ describe('generateMonthlySVG', () => {
901901
expect(svg).toContain('-12 commits');
902902
});
903903

904+
it('resolves high-contrast negative delta colors based on theme and luminance', () => {
905+
const negativeStats: MonthlyStats = {
906+
currentMonthTotal: 18,
907+
previousMonthTotal: 30,
908+
deltaPercentage: -40,
909+
deltaAbsolute: -12,
910+
currentMonthName: 'June',
911+
};
912+
913+
// 1. Default dark theme (bg: '0d1117') should resolve to high-contrast red '#f85149'
914+
const svgDefault = generateMonthlySVG(negativeStats, {
915+
user: 'octocat',
916+
bg: '0d1117',
917+
} as unknown as BadgeParams);
918+
expect(svgDefault).toContain('fill: #f85149');
919+
920+
// 2. Custom light background (bg: 'ffffff') should resolve to light-mode red '#cf222e'
921+
const svgLight = generateMonthlySVG(negativeStats, {
922+
user: 'octocat',
923+
bg: 'ffffff',
924+
} as unknown as BadgeParams);
925+
expect(svgLight).toContain('fill: #cf222e');
926+
927+
// 3. Rose theme background (bg: '1f0d14') should resolve to custom rose negative color '#ff4b72'
928+
const svgRose = generateMonthlySVG(negativeStats, {
929+
user: 'octocat',
930+
bg: '1f0d14',
931+
} as unknown as BadgeParams);
932+
expect(svgRose).toContain('fill: #ff4b72');
933+
});
934+
904935
it('renders monthly stats correctly with percentage delta', () => {
905936
const svg = generateMonthlySVG(mockMonthlyStats, {
906937
user: 'octocat',

lib/svg/generator.ts

Lines changed: 19 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22

33
import type { BadgeParams, ContributionCalendar, StreakStats, MonthlyStats } from '../../types';
44
import { getLabels, type BadgeLabels } from '../i18n/badgeLabels';
5-
import { AUTO_THEME_DARK, AUTO_THEME_LIGHT } from './themes';
5+
import { AUTO_THEME_DARK, AUTO_THEME_LIGHT, themes } from './themes';
66
import { TOWER_ANIMATION_CSS } from './animations';
77
import { computeTowers, type TowerData } from './layout';
88
import {
@@ -708,7 +708,22 @@ export function generateMonthlySVG(stats: MonthlyStats, params: BadgeParams): st
708708
? `${stats.deltaPercentage}%`
709709
: `0%`;
710710
}
711-
const deltaColor = stats.deltaAbsolute >= 0 ? accent : '#ff4444';
711+
// Resolve negative color
712+
let negativeColor = '#ff4444';
713+
const cleanBg = sanitizeHexColor(params.bg, '0d1117');
714+
const matchedTheme = Object.values(themes).find(
715+
(t) => t.bg.toLowerCase() === cleanBg.toLowerCase()
716+
);
717+
718+
if (matchedTheme && matchedTheme.negative) {
719+
negativeColor = `#${matchedTheme.negative}`;
720+
} else {
721+
// Dynamic fallback based on background luminance
722+
const luminance = getLuminance(cleanBg);
723+
negativeColor = luminance > 0.5 ? '#cf222e' : '#f85149';
724+
}
725+
726+
const deltaColor = stats.deltaAbsolute >= 0 ? accent : negativeColor;
712727

713728
return `
714729
<svg
@@ -1086,8 +1101,8 @@ function generateAutoThemeMonthlySVG(stats: MonthlyStats, params: BadgeParams):
10861101
<style>
10871102
@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');
10881103
${googleFontsImport}
1089-
:root { --cp-bg: #${light.bg}; --cp-text: #${light.text}; --cp-accent: #${light.accent}; --cp-negative: #ff4444; }
1090-
@media (prefers-color-scheme: dark) { :root { --cp-bg: #${dark.bg}; --cp-text: #${dark.text}; --cp-accent: #${dark.accent}; --cp-negative: #ff6666; } }
1104+
:root { --cp-bg: #${light.bg}; --cp-text: #${light.text}; --cp-accent: #${light.accent}; --cp-negative: #${light.negative || 'cf222e'}; }
1105+
@media (prefers-color-scheme: dark) { :root { --cp-bg: #${dark.bg}; --cp-text: #${dark.text}; --cp-accent: #${dark.accent}; --cp-negative: #${dark.negative || 'f85149'}; } }
10911106
.cp-bg-fill { fill: var(--cp-bg); }
10921107
.cp-text-fill { fill: var(--cp-text); color: var(--cp-text); }
10931108
.cp-accent-fill { fill: var(--cp-accent); color: var(--cp-accent); }

lib/svg/themes.test.ts

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,20 +2,22 @@ import { describe, expect, it } from 'vitest';
22
import { themes, AUTO_THEME_LIGHT, AUTO_THEME_DARK } from './themes';
33

44
describe('themes', () => {
5-
it('validates every theme has bg, text, and accent as valid 6-character hex strings', () => {
5+
it('validates every theme has bg, text, accent, and negative as valid 6-character hex strings', () => {
66
const hexRegex = /^#[0-9a-f]{6}$/i;
77

88
Object.entries(themes).forEach(([name, theme]) => {
9-
// Validate every theme has bg, text, and accent
9+
// Validate every theme has bg, text, accent, and negative
1010
expect(theme).toHaveProperty('bg');
1111
expect(theme).toHaveProperty('text');
1212
expect(theme).toHaveProperty('accent');
13+
expect(theme).toHaveProperty('negative');
1314

1415
// Assert they are valid 6-character hex strings using the requested regex.
1516
// We prepend '#' because the sanitizer strips it from the final object.
1617
expect(`#${theme.bg}`, `Theme "${name}" bg is invalid`).toMatch(hexRegex);
1718
expect(`#${theme.text}`, `Theme "${name}" text is invalid`).toMatch(hexRegex);
1819
expect(`#${theme.accent}`, `Theme "${name}" accent is invalid`).toMatch(hexRegex);
20+
expect(`#${theme.negative}`, `Theme "${name}" negative is invalid`).toMatch(hexRegex);
1921
});
2022
});
2123

lib/svg/themes.ts

Lines changed: 21 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -2,34 +2,35 @@
22
import { BadgeTheme } from '../../types';
33
import { hexColor } from './sanitizer';
44

5-
function makeTheme(bg: string, text: string, accent: string): BadgeTheme {
5+
function makeTheme(bg: string, text: string, accent: string, negative?: string): BadgeTheme {
66
return {
77
bg: hexColor(bg),
88
text: hexColor(text),
99
accent: hexColor(accent),
10+
negative: negative ? hexColor(negative) : undefined,
1011
};
1112
}
1213

1314
export const themes: Record<string, BadgeTheme> = {
14-
dark: makeTheme('0d1117', 'c9d1d9', '58a6ff'),
15-
light: makeTheme('ffffff', '24292f', '0969da'),
16-
neon: makeTheme('000000', '00ffcc', 'ff00ff'),
17-
github: makeTheme('0d1117', 'ffffff', '39d353'),
18-
dracula: makeTheme('282a36', 'f8f8f2', 'bd93f9'),
19-
ocean: makeTheme('0a192f', 'ccd6f6', '64ffda'),
20-
sunset: makeTheme('1a0a0a', 'ffd6c0', 'ff6b35'),
21-
forest: makeTheme('0d1f0d', 'c8f0c8', '39d353'),
22-
rose: makeTheme('1f0d14', 'f0c8d4', 'ff6b9d'),
23-
nord: makeTheme('2e3440', 'd8dee9', '88c0d0'),
24-
synthwave: makeTheme('0d0221', 'f8f8f2', 'ff2d78'),
25-
gruvbox: makeTheme('282828', 'ebdbb2', 'fe8019'),
26-
aurora_cyberpunk: makeTheme('090B13', 'EAF2FF', '9D5CFF'),
27-
highcontrast: makeTheme('0a0a0a', 'ffffff', 'ff4500'),
28-
catppuccin_latte: makeTheme('eff1f5', '4c4f69', '1e66f5'),
29-
solarized_light: makeTheme('fdf6e3', '586e75', '268bd2'),
30-
gruvbox_light: makeTheme('fbf1c7', '3c3836', 'd65d0e'),
31-
nord_light: makeTheme('eceff4', '2e3440', '5e81ac'),
32-
'cyber-pulse': makeTheme('000000', 'ffffff', '00ffee'),
15+
dark: makeTheme('0d1117', 'c9d1d9', '58a6ff', 'f85149'),
16+
light: makeTheme('ffffff', '24292f', '0969da', 'cf222e'),
17+
neon: makeTheme('000000', '00ffcc', 'ff00ff', 'ff0055'),
18+
github: makeTheme('0d1117', 'ffffff', '39d353', 'f85149'),
19+
dracula: makeTheme('282a36', 'f8f8f2', 'bd93f9', 'ff5555'),
20+
ocean: makeTheme('0a192f', 'ccd6f6', '64ffda', 'ff6b6b'),
21+
sunset: makeTheme('1a0a0a', 'ffd6c0', 'ff6b35', 'ff4d4d'),
22+
forest: makeTheme('0d1f0d', 'c8f0c8', '39d353', 'ff6b6b'),
23+
rose: makeTheme('1f0d14', 'f0c8d4', 'ff6b9d', 'ff4b72'),
24+
nord: makeTheme('2e3440', 'd8dee9', '88c0d0', 'bf616a'),
25+
synthwave: makeTheme('0d0221', 'f8f8f2', 'ff2d78', 'ff3864'),
26+
gruvbox: makeTheme('282828', 'ebdbb2', 'fe8019', 'fb4934'),
27+
aurora_cyberpunk: makeTheme('090B13', 'EAF2FF', '9D5CFF', 'FF3366'),
28+
highcontrast: makeTheme('0a0a0a', 'ffffff', 'ff4500', 'ff3333'),
29+
catppuccin_latte: makeTheme('eff1f5', '4c4f69', '1e66f5', 'd20f39'),
30+
solarized_light: makeTheme('fdf6e3', '586e75', '268bd2', 'dc322f'),
31+
gruvbox_light: makeTheme('fbf1c7', '3c3836', 'd65d0e', '9d0006'),
32+
nord_light: makeTheme('eceff4', '2e3440', '5e81ac', 'bf616a'),
33+
'cyber-pulse': makeTheme('000000', 'ffffff', '00ffee', 'ff0055'),
3334
};
3435

3536
// Auto-theme pairs: the SVG switches between these two palettes

types/index.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,9 @@ export interface BadgeTheme {
3737

3838
/** Tower and glow accent color as a hex string WITHOUT the leading '#' (e.g. '58a6ff'). */
3939
accent: HexColor;
40+
41+
/** Negative/error state color as a hex string WITHOUT the leading '#' (e.g. 'ff4444'). Optional. */
42+
negative?: HexColor;
4043
}
4144

4245
/**

0 commit comments

Comments
 (0)