diff --git a/app/api/streak/route.ts b/app/api/streak/route.ts index 1ddcf9fd..112632e5 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; @@ -204,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/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 04e147eb..cbe3b0b5 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, }, ]; @@ -67,6 +69,7 @@ it('generates structurally valid ASCII STL facets', () => { strokeWidth: 1, row: 0, col: 0, + intensityLevel: 2, }, ]; diff --git a/lib/svg/generator.test.ts b/lib/svg/generator.test.ts index 87739760..2c4997e2 100644 --- a/lib/svg/generator.test.ts +++ b/lib/svg/generator.test.ts @@ -811,6 +811,158 @@ 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"'); + }); + + 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('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 b279e43c..8071dcb9 100644 --- a/lib/svg/generator.ts +++ b/lib/svg/generator.ts @@ -104,18 +104,56 @@ 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 colors = Array.isArray(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 bgStr = params.bg || '0d1117'; + const bgHex = bgStr.startsWith('#') ? bgStr : `#${bgStr}`; + + colors.forEach((c, idx) => { + const level = idx + 1; + gradients += ` + + + + `; + }); + } + } + return ` + ${gradients} `; } @@ -181,24 +219,109 @@ 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 leftRightFillAttr = ''; + let topFillAttr = ''; + + if (isAutoTheme) { + strokeColor = isGhost ? 'var(--cp-text)' : 'var(--cp-accent)'; + leftRightFillAttr = isGhost ? 'class="cp-text-fill"' : 'class="cp-accent-fill"'; + topFillAttr = leftRightFillAttr; + } else { + const baseAccentColor = Array.isArray(accent) + ? accent[accent.length - 1] || '00ffaa' + : accent || '00ffaa'; + + 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 quartileIdx = Math.min(t.intensityLevel - 1, accent.length - 1); + const quartileColor = accent[quartileIdx] || accent[accent.length - 1] || '00ffaa'; + resolvedSolidColor = quartileColor.startsWith('#') ? quartileColor : `#${quartileColor}`; + } + + strokeColor = resolvedSolidColor; + leftRightFillAttr = `fill="${resolvedSolidColor}"`; + topFillAttr = leftRightFillAttr; + } + + let leftFaceOpacity = t.faceOpacity.left; + let rightFaceOpacity = t.faceOpacity.right; + let topFaceOpacity = t.faceOpacity.top; + + if (!isGhost && t.intensityLevel > 0 && params.shading === true) { + const mult = opacityMultipliers[t.intensityLevel - 1]; + leftFaceOpacity = Math.round(leftFaceOpacity * mult * 100) / 100; + rightFaceOpacity = Math.round(rightFaceOpacity * mult * 100) / 100; + topFaceOpacity = Math.round(topFaceOpacity * mult * 100) / 100; + } + + 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) { + finalTopFillAttr = 'class="cp-accent-fill"'; + } else { + 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}`; + finalTopFillAttr = `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 pIdx = Math.min(t.intensityLevel - 1, accent.length - 1); + const pColorResolved = Array.isArray(accent) + ? accent[pIdx] || accent[accent.length - 1] || '00ffaa' + : accent || '00ffaa'; + const pColor = isAutoTheme + ? '' + : pColorResolved.startsWith('#') + ? pColorResolved + : `#${pColorResolved}`; + towers += generateParticles(t.x, t.y, t.h, t.contributionCount, sf, isAutoTheme, pColor); + } } return towers; } @@ -323,7 +446,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 +483,21 @@ 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] || '00ffaa' + : accent || '00ffaa'; + const mainAccentHex = mainAccent.startsWith('#') ? mainAccent : `#${mainAccent}`; return ` ${renderHeader(safeUser, stats, sf, params)} - ${renderStyle(selectedFont, statsFont, googleFontsImport, text, accent, sf)} + ${renderStyle(selectedFont, statsFont, googleFontsImport, text, mainAccentHex, sf)} ${towers} ${renderIsometricLabels(calendar, params, text, sf)} - ${renderFooter(stats, params, labels, safeUser, accent, sf)} + ${renderFooter(stats, params, labels, safeUser, mainAccentHex, sf)} `; } @@ -392,27 +524,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 +603,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); @@ -900,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); @@ -935,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'; @@ -945,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)} @@ -1055,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)}