Skip to content

Commit f89890b

Browse files
committed
fix(themes): replace hardcoded negative delta color with dynamic contrast-aware resolution
1 parent cb47e46 commit f89890b

6 files changed

Lines changed: 103 additions & 27 deletions

File tree

lib/svg/generator.test.ts

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

884+
it('resolves high-contrast negative delta colors based on theme and luminance', () => {
885+
const negativeStats: MonthlyStats = {
886+
currentMonthTotal: 18,
887+
previousMonthTotal: 30,
888+
deltaPercentage: -40,
889+
deltaAbsolute: -12,
890+
currentMonthName: 'June',
891+
};
892+
893+
// 1. Default dark theme (bg: '0d1117') should resolve to high-contrast red '#f85149'
894+
const svgDefault = generateMonthlySVG(negativeStats, {
895+
user: 'octocat',
896+
bg: '0d1117',
897+
} as unknown as BadgeParams);
898+
expect(svgDefault).toContain('fill: #f85149');
899+
900+
// 2. Custom light background (bg: 'ffffff') should resolve to light-mode red '#cf222e'
901+
const svgLight = generateMonthlySVG(negativeStats, {
902+
user: 'octocat',
903+
bg: 'ffffff',
904+
} as unknown as BadgeParams);
905+
expect(svgLight).toContain('fill: #cf222e');
906+
907+
// 3. Rose theme background (bg: '1f0d14') should resolve to custom rose negative color '#ff4b72'
908+
const svgRose = generateMonthlySVG(negativeStats, {
909+
user: 'octocat',
910+
bg: '1f0d14',
911+
} as unknown as BadgeParams);
912+
expect(svgRose).toContain('fill: #ff4b72');
913+
});
914+
884915
it('renders monthly stats correctly with percentage delta', () => {
885916
const svg = generateMonthlySVG(mockMonthlyStats, {
886917
user: 'octocat',

lib/svg/generator.ts

Lines changed: 26 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -2,10 +2,16 @@
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';
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

@@ -681,7 +687,22 @@ export function generateMonthlySVG(stats: MonthlyStats, params: BadgeParams): st
681687
? `${stats.deltaPercentage}%`
682688
: `0%`;
683689
}
684-
const deltaColor = stats.deltaAbsolute >= 0 ? accent : '#ff4444';
690+
// Resolve negative color
691+
let negativeColor = '#ff4444';
692+
const cleanBg = sanitizeHexColor(params.bg, '0d1117');
693+
const matchedTheme = Object.values(themes).find(
694+
(t) => t.bg.toLowerCase() === cleanBg.toLowerCase()
695+
);
696+
697+
if (matchedTheme && matchedTheme.negative) {
698+
negativeColor = `#${matchedTheme.negative}`;
699+
} else {
700+
// Dynamic fallback based on background luminance
701+
const luminance = getLuminance(cleanBg);
702+
negativeColor = luminance > 0.5 ? '#cf222e' : '#f85149';
703+
}
704+
705+
const deltaColor = stats.deltaAbsolute >= 0 ? accent : negativeColor;
685706

686707
return `
687708
<svg
@@ -1059,8 +1080,8 @@ function generateAutoThemeMonthlySVG(stats: MonthlyStats, params: BadgeParams):
10591080
<style>
10601081
@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');
10611082
${googleFontsImport}
1062-
:root { --cp-bg: #${light.bg}; --cp-text: #${light.text}; --cp-accent: #${light.accent}; --cp-negative: #ff4444; }
1063-
@media (prefers-color-scheme: dark) { :root { --cp-bg: #${dark.bg}; --cp-text: #${dark.text}; --cp-accent: #${dark.accent}; --cp-negative: #ff6666; } }
1083+
:root { --cp-bg: #${light.bg}; --cp-text: #${light.text}; --cp-accent: #${light.accent}; --cp-negative: #${light.negative || 'cf222e'}; }
1084+
@media (prefers-color-scheme: dark) { :root { --cp-bg: #${dark.bg}; --cp-text: #${dark.text}; --cp-accent: #${dark.accent}; --cp-negative: #${dark.negative || 'f85149'}; } }
10641085
.cp-bg-fill { fill: var(--cp-bg); }
10651086
.cp-text-fill { fill: var(--cp-text); color: var(--cp-text); }
10661087
.cp-accent-fill { fill: var(--cp-accent); color: var(--cp-accent); }

lib/svg/sanitizer.ts

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -114,3 +114,21 @@ 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+
/**
119+
* Calculates the relative luminance of a hex color (without leading '#').
120+
*/
121+
export function getLuminance(hex: string): number {
122+
let cleanHex = hex.trim().replace(/^#+/, '');
123+
if (cleanHex.length === 3 || cleanHex.length === 4) {
124+
cleanHex = `${cleanHex[0]}${cleanHex[0]}${cleanHex[1]}${cleanHex[1]}${cleanHex[2]}${cleanHex[2]}`;
125+
}
126+
const r = parseInt(cleanHex.slice(0, 2), 16) / 255 || 0;
127+
const g = parseInt(cleanHex.slice(2, 4), 16) / 255 || 0;
128+
const b = parseInt(cleanHex.slice(4, 6), 16) / 255 || 0;
129+
130+
const [R, G, B] = [r, g, b].map((c) =>
131+
c <= 0.03928 ? c / 12.92 : Math.pow((c + 0.055) / 1.055, 2.4)
132+
);
133+
return 0.2126 * R + 0.7152 * G + 0.0722 * B;
134+
}

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)