From 1dd4f2ebb256b9d084b6469d4393dbcbffd3d6b9 Mon Sep 17 00:00:00 2001 From: Sahitya Chaddha Date: Fri, 29 May 2026 15:44:11 +0530 Subject: [PATCH 01/10] feat: add dynamic contribution-level color shading and volumetric gradients # Conflicts: # lib/svg/generator.ts # lib/validations.ts # Conflicts: # app/api/streak/route.ts # lib/svg/generator.ts # lib/validations.ts # types/index.ts --- app/api/streak/route.ts | 4 + lib/svg/generator.test.ts | 66 ++++++++++++++ lib/svg/generator.ts | 178 ++++++++++++++++++++++++++++++-------- lib/svg/layout.ts | 21 ++++- lib/validations.ts | 32 ++++++- types/index.ts | 9 +- 6 files changed, 269 insertions(+), 41 deletions(-) diff --git a/app/api/streak/route.ts b/app/api/streak/route.ts index 1ddcf9fd..23f25947 100644 --- a/app/api/streak/route.ts +++ b/app/api/streak/route.ts @@ -62,6 +62,8 @@ export async function GET(request: Request) { labels, labelColor, versus, + shading, + gradient, } = parseResult.data; const themeName = theme || 'dark'; @@ -133,6 +135,8 @@ export async function GET(request: Request) { labels, labelColor, versus, + shading, + gradient, }; let calendar; diff --git a/lib/svg/generator.test.ts b/lib/svg/generator.test.ts index 87739760..09ced53a 100644 --- a/lib/svg/generator.test.ts +++ b/lib/svg/generator.test.ts @@ -811,6 +811,72 @@ describe('generateMonthlySVG', () => { }); }); +describe('shading and gradients', () => { + const mockStats: StreakStats = { + currentStreak: 5, + longestStreak: 10, + totalContributions: 100, + todayDate: '2024-06-12', + }; + + const mockCalendar = { + weeks: [ + { + contributionDays: [ + { contributionCount: 0, date: '2024-06-10' }, + { contributionCount: 5, date: '2024-06-11' }, + { contributionCount: 15, date: '2024-06-12' }, + ], + }, + ], + } as ContributionCalendar; + + it('renders linearGradient definitions when gradient=true', () => { + const svg = generateSVG( + mockStats, + { user: 'avi', gradient: true } as unknown as BadgeParams, + mockCalendar + ); + expect(svg).toContain(' { + const calendarWithAllQuartiles = { + weeks: [ + { + contributionDays: [ + { contributionCount: 2, date: '2024-06-10' }, // Level 1 (2/15 <= 0.25) + { contributionCount: 6, date: '2024-06-11' }, // Level 2 (6/15 <= 0.5) + { contributionCount: 10, date: '2024-06-12' }, // Level 3 (10/15 <= 0.75) + { contributionCount: 15, date: '2024-06-13' }, // Level 4 + ], + }, + ], + } as ContributionCalendar; + + const svg = generateSVG( + mockStats, + { user: 'avi', accent: ['111111', '222222', '333333', '444444'] } as unknown as BadgeParams, + calendarWithAllQuartiles + ); + expect(svg).toContain('fill="#111111"'); + expect(svg).toContain('fill="#222222"'); + expect(svg).toContain('fill="#333333"'); + expect(svg).toContain('fill="#444444"'); + }); +}); + describe('escapeXML', () => { it('escapes ampersands (&)', () => { expect(escapeXML('foo & bar')).toBe('foo & bar'); diff --git a/lib/svg/generator.ts b/lib/svg/generator.ts index b279e43c..96c3011c 100644 --- a/lib/svg/generator.ts +++ b/lib/svg/generator.ts @@ -104,18 +104,52 @@ function renderHeader( ): string { const unit = params.mode === 'loc' ? 'lines of code' : 'total contributions'; const entity = params.org ? 'Organization' : params.repo ? 'Repository' : 'User'; + return ` CommitPulse ${entity} Stats for ${safeUser} ${safeUser} has ${stats.totalContributions} ${unit} and a longest streak of ${stats.longestStreak} days. - ${renderDefs(sf)}`; + ${renderDefs(sf, params)}`; } -function renderDefs(sf: number): string { +function renderDefs(sf: number, params: BadgeParams): string { const fs = (n: number): number => Math.round(n * sf * 10) / 10; + + let gradients = ''; + if (params.gradient) { + if (params.autoTheme) { + for (let i = 0; i < 4; i++) { + const level = i + 1; + gradients += ` + + + + `; + } + } else { + const accent = params.accent; + const bg = params.bg; + const colors = Array.isArray(accent) + ? accent.map((c) => (c.startsWith('#') ? c : `#${c}`)) + : [1, 2, 3, 4].map(() => (String(accent).startsWith('#') ? String(accent) : `#${accent}`)); + + const bgHex = bg.startsWith('#') ? bg : `#${bg}`; + + colors.forEach((c, idx) => { + const level = idx + 1; + gradients += ` + + + + `; + }); + } + } + return ` + ${gradients} `; } @@ -181,24 +215,103 @@ function renderStyle( `; } -function renderTowers(towerData: TowerData[], accent: string, text: string, sf: number): string { +function renderTowers( + towerData: TowerData[], + params: BadgeParams, + accent: string | string[], + text: string, + sf: number, + isAutoTheme: boolean = false +): string { let towers = ''; + const opacityMultipliers = [0.4, 0.6, 0.8, 1.0]; + for (const t of towerData) { - const color = t.isGhost ? text : accent; + const isGhost = t.isGhost; + let strokeColor = ''; + let fillClassLeftRight = ''; + let fillClassTop = ''; + + if (isAutoTheme) { + strokeColor = isGhost ? 'var(--cp-text)' : 'var(--cp-accent)'; + fillClassLeftRight = isGhost ? 'class="cp-text-fill"' : 'class="cp-accent-fill"'; + fillClassTop = fillClassLeftRight; + } else { + const baseAccentColor = Array.isArray(accent) ? accent[accent.length - 1] : accent; + + const accentColorHex = baseAccentColor.startsWith('#') + ? baseAccentColor + : `#${baseAccentColor}`; + const textColorHex = text.startsWith('#') ? text : `#${text}`; + + let resolvedSolidColor = isGhost ? textColorHex : accentColorHex; + if (!isGhost && t.intensityLevel > 0 && Array.isArray(accent)) { + const quartileColor = accent[t.intensityLevel - 1]; + resolvedSolidColor = quartileColor.startsWith('#') ? quartileColor : `#${quartileColor}`; + } + + strokeColor = resolvedSolidColor; + fillClassLeftRight = `fill="${resolvedSolidColor}"`; + fillClassTop = fillClassLeftRight; + } + + let leftFaceOpacity = t.faceOpacity.left; + let rightFaceOpacity = t.faceOpacity.right; + let topFaceOpacity = t.faceOpacity.top; + + if (!isGhost && t.intensityLevel > 0 && params.shading !== false) { + const mult = opacityMultipliers[t.intensityLevel - 1]; + leftFaceOpacity *= mult; + rightFaceOpacity *= mult; + topFaceOpacity *= mult; + } + + let leftFillAttr = fillClassLeftRight; + let rightFillAttr = fillClassLeftRight; + let topFillAttr = fillClassTop; + + if (!isGhost && t.intensityLevel > 0 && params.gradient === true) { + leftFillAttr = `fill="url(#tower-grad-level-${t.intensityLevel})"`; + rightFillAttr = `fill="url(#tower-grad-level-${t.intensityLevel})"`; + + if (isAutoTheme) { + topFillAttr = 'class="cp-accent-fill"'; + } else { + const baseAccentColor = Array.isArray(accent) ? accent[t.intensityLevel - 1] : accent; + const capColor = baseAccentColor.startsWith('#') ? baseAccentColor : `#${baseAccentColor}`; + topFillAttr = `fill="${capColor}"`; + } + } + + const strokeAttr = isGhost + ? `stroke="${strokeColor}" stroke-opacity="${t.strokeOpacity}" stroke-width="${t.strokeWidth}"` + : ''; const delay = ((t.row + t.col) * 0.015).toFixed(3); + towers += ` ${t.isTodayWithCommits ? '' : ''} ${escapeXML(t.tooltip)} - - - + + + ${t.contributionCount > 5 ? `` : ''} `; - if (t.contributionCount >= 10) - towers += generateParticles(t.x, t.y, t.h, t.contributionCount, sf, false, accent); + + if (t.contributionCount >= 10) { + const pColor = isAutoTheme + ? '' + : Array.isArray(accent) + ? accent[t.intensityLevel - 1].startsWith('#') + ? accent[t.intensityLevel - 1] + : `#${accent[t.intensityLevel - 1]}` + : accent.startsWith('#') + ? accent + : `#${accent}`; + towers += generateParticles(t.x, t.y, t.h, t.contributionCount, sf, isAutoTheme, pColor); + } } return towers; } @@ -323,7 +436,11 @@ export function generateSVG( const safeUser = escapeXML(params.user || 'GitHub User'); const bg = `#${sanitizeHexColor(params.bg, '0d1117')}`; - const accent = `#${sanitizeHexColor(params.accent, '00ffaa')}`; + + const accent = Array.isArray(params.accent) + ? params.accent.map((c) => sanitizeHexColor(c, '00ffaa')) + : sanitizeHexColor(params.accent, '00ffaa'); + const text = `#${sanitizeHexColor(params.text, 'ffffff')}`; // NEW LOGIC: Conditionally create the stroke attributes @@ -356,16 +473,24 @@ export function generateSVG( computeTowers(calendar, params.scale, stats.todayDate, params.mode), sf ); - const towers = renderTowers(towerData, accent, text, sf); + const towers = renderTowers(towerData, params, accent, text, sf, false); + + const mainAccent = Array.isArray(accent) ? accent[accent.length - 1] : accent; + const mainAccentHex = mainAccent.startsWith('#') ? mainAccent : `#${mainAccent}`; return ` ${renderHeader(safeUser, stats, sf, params)} +<<<<<<< HEAD ${renderStyle(selectedFont, statsFont, googleFontsImport, text, accent, sf)} +======= + ${renderStyle(selectedFont, statsFont, googleFontsImport, text, mainAccentHex, sf)} + +>>>>>>> f03a8e8 (feat: add dynamic contribution-level color shading and volumetric gradients) ${towers} ${renderIsometricLabels(calendar, params, text, sf)} - ${renderFooter(stats, params, labels, safeUser, accent, sf)} + ${renderFooter(stats, params, labels, safeUser, mainAccentHex, sf)} `; } @@ -392,27 +517,7 @@ function generateAutoThemeSVG( computeTowers(calendar, params.scale, stats.todayDate, params.mode), sf ); - let towers = ''; - - for (const t of towerData) { - const fillClass = t.isGhost ? 'cp-text-fill' : 'cp-accent-fill'; - const strokeColor = t.isGhost ? 'var(--cp-text)' : 'var(--cp-accent)'; - const delay = ((t.row + t.col) * 0.015).toFixed(3); - - towers += ` - - - ${t.isTodayWithCommits ? '' : ''} - ${escapeXML(t.tooltip)} - - - - ${t.contributionCount > 5 ? `` : ''} - - `; - if (t.contributionCount >= 10) - towers += generateParticles(t.x, t.y, t.h, t.contributionCount, sf, true); - } + const towers = renderTowers(towerData, params, '', '', sf, true); const s = createScaler(sf); const fs = (n: number): number => Math.round(n * sf * 10) / 10; @@ -491,7 +596,12 @@ export function generateMonthlySVG(stats: MonthlyStats, params: BadgeParams): st const safeUser = escapeXML(params.user || 'GitHub User'); const bg = `#${sanitizeHexColor(params.bg, '0d1117')}`; - const accent = `#${sanitizeHexColor(params.accent, '00ffaa')}`; + + const rawAccent = Array.isArray(params.accent) + ? params.accent[params.accent.length - 1] + : params.accent; + const accent = `#${sanitizeHexColor(rawAccent, '00ffaa')}`; + const text = `#${sanitizeHexColor(params.text, 'ffffff')}`; const sanitizedFont = sanitizeFont(params.font); diff --git a/lib/svg/layout.ts b/lib/svg/layout.ts index 0b9d6173..b40878cf 100644 --- a/lib/svg/layout.ts +++ b/lib/svg/layout.ts @@ -32,6 +32,7 @@ export interface TowerData { /** Grid position used to compute the staggered animation-delay (row + col) * offset */ row: number; col: number; + intensityLevel: number; // Quartile level (0 for no commits, 1 to 4 based on contribution intensity) } function computeTowerHeight( @@ -84,13 +85,17 @@ export function computeTowers( const weeks = calendar.weeks.slice(-14); const towers: TowerData[] = []; - // Calculate if the entire monolith is empty based on the selected mode metric + // Calculate if the entire monolith is empty and retrieve the maximum count (commits or LoC) let totalVisibleContributions = 0; + let maxCommits = 0; weeks.forEach((week) => { week.contributionDays.forEach((day) => { const count = mode === 'loc' ? (day.locAdditions || 0) + (day.locDeletions || 0) : day.contributionCount; totalVisibleContributions += count; + if (count > maxCommits) { + maxCommits = count; + } }); }); @@ -119,6 +124,19 @@ export function computeTowers( const coords = projectIsometric(i, j); + let intensityLevel = 0; + if (hasCommits) { + if (maxCommits <= 4) { + intensityLevel = Math.min(4, day.contributionCount); + } else { + const ratio = day.contributionCount / maxCommits; + if (ratio <= 0.25) intensityLevel = 1; + else if (ratio <= 0.5) intensityLevel = 2; + else if (ratio <= 0.75) intensityLevel = 3; + else intensityLevel = 4; + } + } + towers.push({ x: coords.x, y: coords.y, @@ -134,6 +152,7 @@ export function computeTowers( strokeWidth: isGhost ? 0.5 : 0, row: i, col: j, + intensityLevel, }); }); }); diff --git a/lib/validations.ts b/lib/validations.ts index bcd6808b..69e1077a 100644 --- a/lib/validations.ts +++ b/lib/validations.ts @@ -55,10 +55,23 @@ export const streakParamsSchema = z.object({ accent: z .string() .optional() - .refine((val) => !val || /^[0-9a-fA-F]{3,4}$|^[0-9a-fA-F]{6,8}$/.test(val.replace('#', '')), { - message: 'accent must be a valid 3 or 6 character hex color without #', - }) - .transform((val) => (val ? sanitizeHexColor(val, '00ffaa') : undefined)), + .refine( + (val) => { + if (!val) return true; + const parts = val.includes(',') ? val.split(',') : [val]; + return parts.every((p) => /^[0-9a-fA-F]{3,4}$|^[0-9a-fA-F]{6,8}$/.test(p.trim().replace('#', ''))); + }, + { + message: 'accent must be a valid 3 or 6 character hex color without #, or a comma-separated list of them', + } + ) + .transform((val) => { + if (!val) return undefined; + if (val.includes(',')) { + return val.split(',').map((c) => sanitizeHexColor(c.trim(), '00ffaa')); + } + return sanitizeHexColor(val, '00ffaa'); + }), // Silently fall back to 'linear' for unknown values (matches old behavior) scale: z.enum(['linear', 'log']).catch('linear').default('linear'), @@ -160,6 +173,7 @@ export const streakParamsSchema = z.object({ .string() .optional() .transform((val) => (val ? sanitizeHexColor(val, '7f8c8d') : undefined)), +<<<<<<< HEAD versus: z .string() .optional() @@ -170,6 +184,16 @@ export const streakParamsSchema = z.object({ }, { message: 'Invalid versus GitHub username' } ), +======= + shading: z + .string() + .optional() + .transform((val) => val !== 'false'), + gradient: z + .string() + .optional() + .transform((val) => val === 'true'), +>>>>>>> f03a8e8 (feat: add dynamic contribution-level color shading and volumetric gradients) }); export const githubParamsSchema = z.object({ diff --git a/types/index.ts b/types/index.ts index 2c627da1..3301fe9f 100644 --- a/types/index.ts +++ b/types/index.ts @@ -100,7 +100,6 @@ export interface MonthlyStats { export interface BadgeParams { /** GitHub username whose contribution data will be fetched and rendered. Required. */ user: string; - /** GitHub username of the opponent to compare against. */ versus?: string; @@ -114,7 +113,7 @@ export interface BadgeParams { text: HexColor; /** Tower and glow accent color as a hex string WITHOUT the leading '#'. Overrides theme default. */ - accent: HexColor; + accent: HexColor | HexColor[]; /** Duration of the radar scan line animation (e.g. '4s', '8s', '12s'). Defaults to '8s'. */ speed: SpeedString; @@ -175,4 +174,10 @@ export interface BadgeParams { /** Custom text color for the labels. Overrides text parameter. */ labelColor?: HexColor; + + /** Opt-in to shade columns based on contribution count. */ + shading?: boolean; + + /** Opt-in to show volumetric gradients on the monolith floor. */ + gradient?: boolean; } From ed28727973aac1ec83cdbeb913ce4365f0227918 Mon Sep 17 00:00:00 2001 From: Sahitya Chaddha Date: Fri, 29 May 2026 15:45:05 +0530 Subject: [PATCH 02/10] refactor: address Copilot AI review feedback on shading and gradients # Conflicts: # lib/validations.ts --- lib/svg/generator.test.ts | 22 ++++++++++++++++ lib/svg/generator.ts | 54 ++++++++++++++++++++++----------------- lib/validations.ts | 20 ++++++++++++--- 3 files changed, 69 insertions(+), 27 deletions(-) diff --git a/lib/svg/generator.test.ts b/lib/svg/generator.test.ts index 09ced53a..93bf4a44 100644 --- a/lib/svg/generator.test.ts +++ b/lib/svg/generator.test.ts @@ -875,6 +875,28 @@ describe('shading and gradients', () => { expect(svg).toContain('fill="#333333"'); expect(svg).toContain('fill="#444444"'); }); + + it('gracefully handles and clamps accent color arrays with fewer than 4 items without crashing', () => { + const calendarWithAllQuartiles = { + weeks: [ + { + contributionDays: [ + { contributionCount: 2, date: '2024-06-10' }, + { contributionCount: 6, date: '2024-06-11' }, + { contributionCount: 10, date: '2024-06-12' }, + { contributionCount: 15, date: '2024-06-13' }, + ], + }, + ], + } as ContributionCalendar; + + const svg = generateSVG( + mockStats, + { user: 'avi', accent: ['111111'] } as unknown as BadgeParams, + calendarWithAllQuartiles + ); + expect(svg).toContain('fill="#111111"'); + }); }); describe('escapeXML', () => { diff --git a/lib/svg/generator.ts b/lib/svg/generator.ts index 96c3011c..3d77d3f6 100644 --- a/lib/svg/generator.ts +++ b/lib/svg/generator.ts @@ -131,8 +131,12 @@ function renderDefs(sf: number, params: BadgeParams): string { const accent = params.accent; const bg = params.bg; const colors = Array.isArray(accent) - ? accent.map((c) => (c.startsWith('#') ? c : `#${c}`)) - : [1, 2, 3, 4].map(() => (String(accent).startsWith('#') ? String(accent) : `#${accent}`)); + ? [0, 1, 2, 3].map((i) => { + const idx = Math.min(i, accent.length - 1); + const c = accent[idx] || accent[accent.length - 1] || '00ffaa'; + return c.startsWith('#') ? c : `#${c}`; + }) + : [0, 1, 2, 3].map(() => (String(accent).startsWith('#') ? String(accent) : `#${accent}`)); const bgHex = bg.startsWith('#') ? bg : `#${bg}`; @@ -229,13 +233,13 @@ function renderTowers( for (const t of towerData) { const isGhost = t.isGhost; let strokeColor = ''; - let fillClassLeftRight = ''; - let fillClassTop = ''; + let leftRightFillAttr = ''; + let topFillAttr = ''; if (isAutoTheme) { strokeColor = isGhost ? 'var(--cp-text)' : 'var(--cp-accent)'; - fillClassLeftRight = isGhost ? 'class="cp-text-fill"' : 'class="cp-accent-fill"'; - fillClassTop = fillClassLeftRight; + leftRightFillAttr = isGhost ? 'class="cp-text-fill"' : 'class="cp-accent-fill"'; + topFillAttr = leftRightFillAttr; } else { const baseAccentColor = Array.isArray(accent) ? accent[accent.length - 1] : accent; @@ -246,13 +250,14 @@ function renderTowers( let resolvedSolidColor = isGhost ? textColorHex : accentColorHex; if (!isGhost && t.intensityLevel > 0 && Array.isArray(accent)) { - const quartileColor = accent[t.intensityLevel - 1]; + const quartileIdx = Math.min(t.intensityLevel - 1, accent.length - 1); + const quartileColor = accent[quartileIdx] || accent[accent.length - 1]; resolvedSolidColor = quartileColor.startsWith('#') ? quartileColor : `#${quartileColor}`; } strokeColor = resolvedSolidColor; - fillClassLeftRight = `fill="${resolvedSolidColor}"`; - fillClassTop = fillClassLeftRight; + leftRightFillAttr = `fill="${resolvedSolidColor}"`; + topFillAttr = leftRightFillAttr; } let leftFaceOpacity = t.faceOpacity.left; @@ -266,20 +271,23 @@ function renderTowers( topFaceOpacity *= mult; } - let leftFillAttr = fillClassLeftRight; - let rightFillAttr = fillClassLeftRight; - let topFillAttr = fillClassTop; + let leftFillAttr = leftRightFillAttr; + let rightFillAttr = leftRightFillAttr; + let finalTopFillAttr = topFillAttr; if (!isGhost && t.intensityLevel > 0 && params.gradient === true) { leftFillAttr = `fill="url(#tower-grad-level-${t.intensityLevel})"`; rightFillAttr = `fill="url(#tower-grad-level-${t.intensityLevel})"`; if (isAutoTheme) { - topFillAttr = 'class="cp-accent-fill"'; + finalTopFillAttr = 'class="cp-accent-fill"'; } else { - const baseAccentColor = Array.isArray(accent) ? accent[t.intensityLevel - 1] : accent; + const capIdx = Math.min(t.intensityLevel - 1, accent.length - 1); + const baseAccentColor = Array.isArray(accent) + ? accent[capIdx] || accent[accent.length - 1] + : accent; const capColor = baseAccentColor.startsWith('#') ? baseAccentColor : `#${baseAccentColor}`; - topFillAttr = `fill="${capColor}"`; + finalTopFillAttr = `fill="${capColor}"`; } } @@ -295,21 +303,21 @@ function renderTowers( ${escapeXML(t.tooltip)} - + ${t.contributionCount > 5 ? `` : ''} `; if (t.contributionCount >= 10) { + const pIdx = Math.min(t.intensityLevel - 1, accent.length - 1); + const pColorResolved = Array.isArray(accent) + ? accent[pIdx] || accent[accent.length - 1] + : accent; const pColor = isAutoTheme ? '' - : Array.isArray(accent) - ? accent[t.intensityLevel - 1].startsWith('#') - ? accent[t.intensityLevel - 1] - : `#${accent[t.intensityLevel - 1]}` - : accent.startsWith('#') - ? accent - : `#${accent}`; + : pColorResolved.startsWith('#') + ? pColorResolved + : `#${pColorResolved}`; towers += generateParticles(t.x, t.y, t.h, t.contributionCount, sf, isAutoTheme, pColor); } } diff --git a/lib/validations.ts b/lib/validations.ts index 69e1077a..1b2157d6 100644 --- a/lib/validations.ts +++ b/lib/validations.ts @@ -68,7 +68,12 @@ export const streakParamsSchema = z.object({ .transform((val) => { if (!val) return undefined; if (val.includes(',')) { - return val.split(',').map((c) => sanitizeHexColor(c.trim(), '00ffaa')); + return val + .split(',') + .map((c) => c.trim()) + .filter((c) => c.length > 0) + .slice(0, 4) + .map((c) => sanitizeHexColor(c, '00ffaa')); } return sanitizeHexColor(val, '00ffaa'); }), @@ -188,12 +193,19 @@ export const streakParamsSchema = z.object({ shading: z .string() .optional() - .transform((val) => val !== 'false'), + .transform((val) => { + if (val === undefined) return undefined; + return val !== 'false'; + }) + .default(true), gradient: z .string() .optional() - .transform((val) => val === 'true'), ->>>>>>> f03a8e8 (feat: add dynamic contribution-level color shading and volumetric gradients) + .transform((val) => { + if (val === undefined) return undefined; + return val === 'true'; + }) + .default(false), }); export const githubParamsSchema = z.object({ From d0f2f819568684d5160f8872766c9d63125976bb Mon Sep 17 00:00:00 2001 From: Sahitya Chaddha Date: Fri, 29 May 2026 01:52:49 +0530 Subject: [PATCH 03/10] fix: change shading default from true to false (make opt-in) # Conflicts: # types/index.ts --- lib/validations.ts | 4 ++-- types/index.ts | 6 +++++- 2 files changed, 7 insertions(+), 3 deletions(-) diff --git a/lib/validations.ts b/lib/validations.ts index 1b2157d6..7b4e7b38 100644 --- a/lib/validations.ts +++ b/lib/validations.ts @@ -195,9 +195,9 @@ export const streakParamsSchema = z.object({ .optional() .transform((val) => { if (val === undefined) return undefined; - return val !== 'false'; + return val === 'true'; }) - .default(true), + .default(false), gradient: z .string() .optional() diff --git a/types/index.ts b/types/index.ts index 3301fe9f..3875331f 100644 --- a/types/index.ts +++ b/types/index.ts @@ -175,7 +175,11 @@ export interface BadgeParams { /** Custom text color for the labels. Overrides text parameter. */ labelColor?: HexColor; - /** Opt-in to shade columns based on contribution count. */ + /** + * When true, applies intensity-based opacity shading to tower faces so + * lower intensity levels appear slightly translucent/dimmer. + * Default is false (opt-in). + */ shading?: boolean; /** Opt-in to show volumetric gradients on the monolith floor. */ From 9cf3463fd0fb78c35d12e723f04b9042032b5fe0 Mon Sep 17 00:00:00 2001 From: Sahitya Chaddha Date: Fri, 29 May 2026 01:53:59 +0530 Subject: [PATCH 04/10] refactor: address Copilot PR review feedback on shading and generator code quality # Conflicts: # lib/svg/generator.test.ts # lib/svg/generator.ts --- app/api/streak/route.ts | 8 ++++- lib/svg/generator.test.ts | 64 +++++++++++++++++++++++++++++++++++++++ lib/svg/generator.ts | 10 +++--- 3 files changed, 76 insertions(+), 6 deletions(-) diff --git a/app/api/streak/route.ts b/app/api/streak/route.ts index 23f25947..112632e5 100644 --- a/app/api/streak/route.ts +++ b/app/api/streak/route.ts @@ -208,7 +208,13 @@ function buildErrorResponse(error: unknown, parseResult: ParseResult): NextRespo message.toLowerCase().includes('could not resolve'); const errBg = `#${(parseResult.success && parseResult.data.bg) || '0d1117'}`; - const errAccent = `#${(parseResult.success && parseResult.data.accent) || '58a6ff'}`; + const errAccent = `#${ + (parseResult.success && + (Array.isArray(parseResult.data.accent) + ? parseResult.data.accent[parseResult.data.accent.length - 1] + : parseResult.data.accent)) || + '58a6ff' + }`; const errText = `#${(parseResult.success && parseResult.data.text) || 'c9d1d9'}`; const errRadius = parseResult.success ? (() => { diff --git a/lib/svg/generator.test.ts b/lib/svg/generator.test.ts index 93bf4a44..2c4997e2 100644 --- a/lib/svg/generator.test.ts +++ b/lib/svg/generator.test.ts @@ -899,6 +899,70 @@ describe('shading and gradients', () => { }); }); +describe('shading', () => { + // A calendar with a mix of contribution counts to produce towers at different + // intensity levels — needed so that shading multipliers actually apply. + const shadingCalendar = { + weeks: [ + { + contributionDays: [ + { contributionCount: 2, date: '2024-06-10' }, // low intensity + { contributionCount: 10, date: '2024-06-11' }, // mid intensity + { contributionCount: 15, date: '2024-06-12' }, // high intensity + ], + }, + ], + } as ContributionCalendar; + + const shadingStats: StreakStats = { + currentStreak: 3, + longestStreak: 10, + totalContributions: 27, + todayDate: '2024-06-12', + }; + + it('applies reduced face-opacity (shading) when shading is not disabled', () => { + // With shading on, low-intensity towers use opacity multiplier 0.4, so their + // face-opacity should be lower than the unshaded base value. + const svgShading = generateSVG( + shadingStats, + { user: 'avi', shading: true } as unknown as BadgeParams, + shadingCalendar + ); + // The shaded SVG should still contain the tower paths + expect(svgShading).toContain('class="cp-tower"'); + // For level 1 (mult=0.4), base top face opacity 0.7 becomes 0.28 + // Check for that specific derived value to ensure shading actually multiplied it. + expect(svgShading).toContain('fill-opacity="0.28"'); + }); + + it('does not apply shading multipliers when shading=false', () => { + const svgShading = generateSVG( + shadingStats, + { user: 'avi', shading: true } as unknown as BadgeParams, + shadingCalendar + ); + const svgNoShading = generateSVG( + shadingStats, + { user: 'avi', shading: false } as unknown as BadgeParams, + shadingCalendar + ); + // The two renders must differ — shading changes face opacities + expect(svgShading).not.toBe(svgNoShading); + }); + + it('falls back to default accent #00ffaa when accent array is empty', () => { + const svg = generateSVG( + shadingStats, + // Simulate what validation returns for accent=,,, (empty array is + // now normalised to undefined, but if it somehow reached the renderer + // as [] the fallback should still fire without crashing). + { user: 'avi', accent: [] } as unknown as BadgeParams, + shadingCalendar + ); + expect(svg).toContain('00ffaa'); + }); +}); describe('escapeXML', () => { it('escapes ampersands (&)', () => { expect(escapeXML('foo & bar')).toBe('foo & bar'); diff --git a/lib/svg/generator.ts b/lib/svg/generator.ts index 3d77d3f6..b8c419cf 100644 --- a/lib/svg/generator.ts +++ b/lib/svg/generator.ts @@ -115,7 +115,7 @@ function renderHeader( function renderDefs(sf: number, params: BadgeParams): string { const fs = (n: number): number => Math.round(n * sf * 10) / 10; - + let gradients = ''; if (params.gradient) { if (params.autoTheme) { @@ -264,11 +264,11 @@ function renderTowers( let rightFaceOpacity = t.faceOpacity.right; let topFaceOpacity = t.faceOpacity.top; - if (!isGhost && t.intensityLevel > 0 && params.shading !== false) { + if (!isGhost && t.intensityLevel > 0 && params.shading === true) { const mult = opacityMultipliers[t.intensityLevel - 1]; - leftFaceOpacity *= mult; - rightFaceOpacity *= mult; - topFaceOpacity *= mult; + leftFaceOpacity = Math.round(leftFaceOpacity * mult * 100) / 100; + rightFaceOpacity = Math.round(rightFaceOpacity * mult * 100) / 100; + topFaceOpacity = Math.round(topFaceOpacity * mult * 100) / 100; } let leftFillAttr = leftRightFillAttr; From 5079708477609182ba7dd98d132a751b352ed094 Mon Sep 17 00:00:00 2001 From: Sahitya Chaddha Date: Fri, 29 May 2026 01:58:37 +0530 Subject: [PATCH 05/10] fix: resolve generator undefined bugs with empty array accents --- lib/svg/generator.ts | 17 +++++++++++------ 1 file changed, 11 insertions(+), 6 deletions(-) diff --git a/lib/svg/generator.ts b/lib/svg/generator.ts index b8c419cf..dbcd1b7d 100644 --- a/lib/svg/generator.ts +++ b/lib/svg/generator.ts @@ -138,7 +138,8 @@ function renderDefs(sf: number, params: BadgeParams): string { }) : [0, 1, 2, 3].map(() => (String(accent).startsWith('#') ? String(accent) : `#${accent}`)); - const bgHex = bg.startsWith('#') ? bg : `#${bg}`; + const bgStr = params.bg || '0d1117'; + const bgHex = bgStr.startsWith('#') ? bgStr : `#${bgStr}`; colors.forEach((c, idx) => { const level = idx + 1; @@ -241,7 +242,9 @@ function renderTowers( leftRightFillAttr = isGhost ? 'class="cp-text-fill"' : 'class="cp-accent-fill"'; topFillAttr = leftRightFillAttr; } else { - const baseAccentColor = Array.isArray(accent) ? accent[accent.length - 1] : accent; + const baseAccentColor = Array.isArray(accent) + ? accent[accent.length - 1] || '00ffaa' + : accent || '00ffaa'; const accentColorHex = baseAccentColor.startsWith('#') ? baseAccentColor @@ -251,7 +254,7 @@ function renderTowers( let resolvedSolidColor = isGhost ? textColorHex : accentColorHex; if (!isGhost && t.intensityLevel > 0 && Array.isArray(accent)) { const quartileIdx = Math.min(t.intensityLevel - 1, accent.length - 1); - const quartileColor = accent[quartileIdx] || accent[accent.length - 1]; + const quartileColor = accent[quartileIdx] || accent[accent.length - 1] || '00ffaa'; resolvedSolidColor = quartileColor.startsWith('#') ? quartileColor : `#${quartileColor}`; } @@ -311,8 +314,8 @@ function renderTowers( if (t.contributionCount >= 10) { const pIdx = Math.min(t.intensityLevel - 1, accent.length - 1); const pColorResolved = Array.isArray(accent) - ? accent[pIdx] || accent[accent.length - 1] - : accent; + ? accent[pIdx] || accent[accent.length - 1] || '00ffaa' + : accent || '00ffaa'; const pColor = isAutoTheme ? '' : pColorResolved.startsWith('#') @@ -483,7 +486,9 @@ export function generateSVG( ); const towers = renderTowers(towerData, params, accent, text, sf, false); - const mainAccent = Array.isArray(accent) ? accent[accent.length - 1] : accent; + const mainAccent = Array.isArray(accent) + ? accent[accent.length - 1] || '00ffaa' + : accent || '00ffaa'; const mainAccentHex = mainAccent.startsWith('#') ? mainAccent : `#${mainAccent}`; return ` From 1350562e55b52c4117aae259d24eee187c629ffb Mon Sep 17 00:00:00 2001 From: Sahitya Chaddha Date: Fri, 29 May 2026 02:11:13 +0530 Subject: [PATCH 06/10] fix: add missing intensityLevel to TowerData mocks and remove unused imports --- lib/export3d.test.ts | 2 ++ lib/svg/generator.ts | 1 - 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/lib/export3d.test.ts b/lib/export3d.test.ts index 04e147eb..ce6e3f30 100644 --- a/lib/export3d.test.ts +++ b/lib/export3d.test.ts @@ -21,6 +21,7 @@ describe('generateMonolithSTL', () => { strokeWidth: 1, row: 0, col: 0, + intensityLevel: 2, }, { x: 0, @@ -37,6 +38,7 @@ describe('generateMonolithSTL', () => { strokeWidth: 1, row: 1, col: 1, + intensityLevel: 0, }, ]; diff --git a/lib/svg/generator.ts b/lib/svg/generator.ts index dbcd1b7d..481fae73 100644 --- a/lib/svg/generator.ts +++ b/lib/svg/generator.ts @@ -129,7 +129,6 @@ function renderDefs(sf: number, params: BadgeParams): string { } } else { const accent = params.accent; - const bg = params.bg; const colors = Array.isArray(accent) ? [0, 1, 2, 3].map((i) => { const idx = Math.min(i, accent.length - 1); From 4c2ffe1f028e64e3cff2f7011d67075664915bdc Mon Sep 17 00:00:00 2001 From: Sahitya Chaddha Date: Fri, 29 May 2026 12:29:57 +0530 Subject: [PATCH 07/10] fix: resolve CI failures for missing intensityLevel mock and unused variables --- components/dashboard/ComparisonStatsCard.test.tsx | 6 ------ lib/export3d.test.ts | 1 + 2 files changed, 1 insertion(+), 6 deletions(-) diff --git a/components/dashboard/ComparisonStatsCard.test.tsx b/components/dashboard/ComparisonStatsCard.test.tsx index b5acfc7c..9f5605a1 100644 --- a/components/dashboard/ComparisonStatsCard.test.tsx +++ b/components/dashboard/ComparisonStatsCard.test.tsx @@ -138,12 +138,6 @@ describe('ComparisonStatsCard', () => { /> ); - const progressSegments = container.querySelectorAll( - '.w-full.bg-gray-700\\/50 div, .relative div' - ); - - const allDivs = Array.from(container.querySelectorAll('div')); - const emeraldElement = container.querySelector('[className*="emerald"]') || container.querySelector('.text-emerald-400'); diff --git a/lib/export3d.test.ts b/lib/export3d.test.ts index ce6e3f30..cbe3b0b5 100644 --- a/lib/export3d.test.ts +++ b/lib/export3d.test.ts @@ -69,6 +69,7 @@ it('generates structurally valid ASCII STL facets', () => { strokeWidth: 1, row: 0, col: 0, + intensityLevel: 2, }, ]; From 702e17bba994053ea404452e6628e0bdc0a89618 Mon Sep 17 00:00:00 2001 From: Sahitya Chaddha Date: Fri, 29 May 2026 12:34:18 +0530 Subject: [PATCH 08/10] style: run prettier on validations.ts --- lib/validations.ts | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/lib/validations.ts b/lib/validations.ts index 7b4e7b38..c186e9fb 100644 --- a/lib/validations.ts +++ b/lib/validations.ts @@ -59,10 +59,13 @@ export const streakParamsSchema = z.object({ (val) => { if (!val) return true; const parts = val.includes(',') ? val.split(',') : [val]; - return parts.every((p) => /^[0-9a-fA-F]{3,4}$|^[0-9a-fA-F]{6,8}$/.test(p.trim().replace('#', ''))); + return parts.every((p) => + /^[0-9a-fA-F]{3,4}$|^[0-9a-fA-F]{6,8}$/.test(p.trim().replace('#', '')) + ); }, { - message: 'accent must be a valid 3 or 6 character hex color without #, or a comma-separated list of them', + message: + 'accent must be a valid 3 or 6 character hex color without #, or a comma-separated list of them', } ) .transform((val) => { From 486fe9dc9eca2b930a2179b997598c4ae7f80772 Mon Sep 17 00:00:00 2001 From: Sahitya Chaddha Date: Fri, 29 May 2026 18:32:39 +0530 Subject: [PATCH 09/10] fix: resolve remaining conflict marker and TS type errors in generator.ts versus mode --- lib/svg/generator.ts | 20 +++++++++----------- 1 file changed, 9 insertions(+), 11 deletions(-) diff --git a/lib/svg/generator.ts b/lib/svg/generator.ts index 481fae73..8071dcb9 100644 --- a/lib/svg/generator.ts +++ b/lib/svg/generator.ts @@ -493,13 +493,8 @@ export function generateSVG( return ` ${renderHeader(safeUser, stats, sf, params)} -<<<<<<< HEAD - ${renderStyle(selectedFont, statsFont, googleFontsImport, text, accent, sf)} - -======= ${renderStyle(selectedFont, statsFont, googleFontsImport, text, mainAccentHex, sf)} - ->>>>>>> f03a8e8 (feat: add dynamic contribution-level color shading and volumetric gradients) + ${towers} ${renderIsometricLabels(calendar, params, text, sf)} ${renderFooter(stats, params, labels, safeUser, mainAccentHex, sf)} @@ -1022,7 +1017,10 @@ export function generateVersusSVG( const safeUser1 = escapeXML(params.user || 'User 1'); const safeUser2 = escapeXML(params.versus || 'User 2'); const bg = `#${sanitizeHexColor(params.bg, '0d1117')}`; - const accent = `#${sanitizeHexColor(params.accent, '00ffaa')}`; + const rawAccent = Array.isArray(params.accent) + ? params.accent[params.accent.length - 1] + : params.accent; + const accent = `#${sanitizeHexColor(rawAccent, '00ffaa')}`; const text = `#${sanitizeHexColor(params.text, 'ffffff')}`; const sanitizedFont = sanitizeFont(params.font); @@ -1057,8 +1055,8 @@ export function generateVersusSVG( sf ); - const towers1 = renderTowers(towerData1, accent, text, sf); - const towers2 = renderTowers(towerData2, accent, text, sf); + const towers1 = renderTowers(towerData1, params, accent, text, sf, false); + const towers2 = renderTowers(towerData2, params, accent, text, sf, false); const s = createScaler(sf); const unit = params.mode === 'loc' ? 'lines of code' : 'total contributions'; @@ -1067,7 +1065,7 @@ export function generateVersusSVG( CommitPulse Versus Stats: ${safeUser1} vs ${safeUser2} ${safeUser1} has ${stats1.totalContributions} ${unit}. ${safeUser2} has ${stats2.totalContributions} ${unit}. - ${renderDefs(sf)} + ${renderDefs(sf, params)} ${renderStyle(selectedFont, statsFont, googleFontsImport, text, accent, sf)} @@ -1177,7 +1175,7 @@ function generateAutoThemeVersusSVG( CommitPulse Versus Stats: ${safeUser1} vs ${safeUser2} ${safeUser1} has ${stats1.totalContributions} ${unit}. ${safeUser2} has ${stats2.totalContributions} ${unit}. - ${renderDefs(sf)} + ${renderDefs(sf, params)}