Skip to content

Commit eed1196

Browse files
authored
feat(core)!: implement dynamic SVG entrance animations engine for 3D monolith (JhaSourav07#1920)
## Description Fixes JhaSourav07#1919 **🚨 CRITICAL ARCHITECTURE UPDATE 🚨** This PR introduces a major refactoring to the core SVG rendering engine by implementing a **dynamic CSS keyframe injection system** for the 3D monolith. Previously, the SVG generator relied on a single hardcoded `grow-up` animation. While aesthetically pleasing, it severely restricted embedding the badge in static environments (like restrictive Markdown parsers, PDF generation, or offline viewers) and caused accessibility concerns for users preferring reduced motion. This PR solves this architectural limitation by introducing the `entrance` API parameter, allowing on-the-fly injection of matrix transformations and opacity states. **Supported Core States:** - `entrance=rise` (Maintains backward compatibility with the staggered scaleY wave effect) - `entrance=fade` (Smooth opacity transitions for premium aesthetics) - `entrance=slide` (Complex translateY + opacity transformations) - `entrance=none` (**CRITICAL:** Fallback state for static embeddings and full accessibility compliance) This fundamentally upgrades the flexibility, embeddability, and accessibility of the CommitPulse engine without breaking existing URLs. ## Pillar - [x] 🎨 Pillar 1 — New Theme Design (Core Animation & Aesthetics Engine) - [x] 📐 Pillar 2 — Geometric SVG Improvement (Dynamic Keyframe Generation) - [ ] 🕐 Pillar 3 — Timezone Logic Optimization - [x] 🛠️ Other (Accessibility, Refactoring core generator logic) ## Visual Preview *(These can be verified directly via the API endpoint)* - **Default / Rise:** `http://localhost:3000/api/streak?user=YOUR_USERNAME&entrance=rise` - **Fade:** `http://localhost:3000/api/streak?user=YOUR_USERNAME&entrance=fade` - **Slide:** `http://localhost:3000/api/streak?user=YOUR_USERNAME&entrance=slide` - **Static (A11y friendly):** `http://localhost:3000/api/streak?user=YOUR_USERNAME&entrance=none` ## 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=YOUR_USERNAME&entrance=fade`). - [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(core)!: ...`). - [x] I have updated `README.md` to document the new core parameter. - [x] I have starred 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 4fc855a + 8d512ce commit eed1196

6 files changed

Lines changed: 96 additions & 31 deletions

File tree

README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -177,6 +177,7 @@ URL Parameter > Theme Default > System Fallback
177177
| `tz` | `string` | No | Omitted = UTC | IANA timezone (e.g. `Asia/Kolkata`, `America/New_York`) — aligns "today" with the user local midnight. Note: `?tz=UTC` is valid but cached separately from omitting `tz`. |
178178
| `lang` | `string` | No | `en` | Language code for labels (`en`, `es`, `hi`, `fr`, `pt`, `ko`, `ja`, `de`, `zh`) |
179179
| `view` | `string` | No | `default` | Rendering mode: `default` (3D Monolith), `monthly` (Compact monthly stats), or `heatmap` (flat 2D contribution heatmap) |
180+
| `entrance` | `string` | No | `rise` | Entrance animation for towers: `rise` (default), `fade`, `slide`, or `none`. |
180181
| `delta_format` | `string` | No | `percent` | Format for month-over-month delta in monthly view: `percent` (e.g. +12%), `absolute` (e.g. +15 commits), or `both` |
181182
| `width` | `number` | No | `300` | Custom width for the SVG canvas (currently only applies to `view=monthly`) |
182183
| `height` | `number` | No | `120` | Custom height for the SVG canvas (currently only applies to `view=monthly`) |

lib/svg/animations.test.ts

Lines changed: 27 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,24 +1,38 @@
11
import { describe, it, expect } from 'vitest';
2-
import { TOWER_ANIMATION_CSS } from './animations';
2+
import { getTowerAnimationCSS } from './animations';
33

4-
describe('TOWER_ANIMATION_CSS', () => {
5-
it('contains required CSS classes and keyframes', () => {
6-
expect(TOWER_ANIMATION_CSS).toContain('.cp-tower');
7-
expect(TOWER_ANIMATION_CSS).toContain('@keyframes grow-up');
4+
describe('getTowerAnimationCSS', () => {
5+
it('returns rise animation by default', () => {
6+
const css = getTowerAnimationCSS();
7+
expect(css).toContain('.cp-tower');
8+
expect(css).toContain('@keyframes grow-up');
9+
expect(css).toContain('transform: scaleY(0)');
10+
expect(css).toContain('transform: scaleY(1)');
11+
expect(css).toContain('transform-origin: 0 10px');
812
});
913

10-
it('defines correct transform states for the grow-up animation', () => {
11-
expect(TOWER_ANIMATION_CSS).toContain('transform: scaleY(0)');
12-
expect(TOWER_ANIMATION_CSS).toContain('transform: scaleY(1)');
14+
it('returns fade animation when requested', () => {
15+
const css = getTowerAnimationCSS('fade');
16+
expect(css).toContain('@keyframes fade-in');
17+
expect(css).toContain('opacity: 0');
18+
expect(css).toContain('opacity: 1');
1319
});
1420

15-
it('sets the correct transform-origin for towers to grow from the ground', () => {
16-
expect(TOWER_ANIMATION_CSS).toContain('transform-origin: 0 10px');
21+
it('returns slide animation when requested', () => {
22+
const css = getTowerAnimationCSS('slide');
23+
expect(css).toContain('@keyframes slide-down');
24+
expect(css).toContain('transform: translateY(-20px)');
25+
});
26+
27+
it('returns static render when entrance is none', () => {
28+
const css = getTowerAnimationCSS('none');
29+
expect(css).toContain('transform: scaleY(1)');
30+
expect(css).not.toContain('@keyframes');
1731
});
1832

1933
it('includes accessibility support for prefers-reduced-motion', () => {
20-
expect(TOWER_ANIMATION_CSS).toContain('prefers-reduced-motion');
21-
// Ensure that the animation is disabled for reduced motion
22-
expect(TOWER_ANIMATION_CSS).toContain('animation: none !important');
34+
const css = getTowerAnimationCSS('rise');
35+
expect(css).toContain('prefers-reduced-motion');
36+
expect(css).toContain('animation: none !important');
2337
});
2438
});

lib/svg/animations.ts

Lines changed: 57 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -20,17 +20,62 @@
2020
// towers scale upward from their ground tile rather than from the SVG origin.
2121
const TOWER_BASE_Y = 10;
2222

23-
export const TOWER_ANIMATION_CSS = `
24-
.cp-tower {
25-
transform: scaleY(0);
26-
transform-origin: 0 ${TOWER_BASE_Y}px;
27-
animation: grow-up 1.2s cubic-bezier(0.16, 1, 0.3, 1) forwards;
23+
export function getTowerAnimationCSS(
24+
entrance: 'rise' | 'fade' | 'slide' | 'none' = 'rise'
25+
): string {
26+
if (entrance === 'none') {
27+
return `
28+
.cp-tower { transform: scaleY(1); opacity: 1; }
29+
`;
2830
}
29-
@keyframes grow-up {
30-
from { transform: scaleY(0); }
31-
to { transform: scaleY(1); }
32-
}
33-
@media (prefers-reduced-motion: reduce) {
34-
.cp-tower { animation: none !important; transform: scaleY(1) !important; }
31+
32+
let baseStyles = '';
33+
let keyframes = '';
34+
35+
if (entrance === 'rise') {
36+
baseStyles = `
37+
transform: scaleY(0);
38+
transform-origin: 0 ${TOWER_BASE_Y}px;
39+
animation: grow-up 1.2s cubic-bezier(0.16, 1, 0.3, 1) forwards;
40+
`;
41+
keyframes = `
42+
@keyframes grow-up {
43+
from { transform: scaleY(0); }
44+
to { transform: scaleY(1); }
45+
}
46+
`;
47+
} else if (entrance === 'fade') {
48+
baseStyles = `
49+
opacity: 0;
50+
animation: fade-in 1.2s ease-out forwards;
51+
`;
52+
keyframes = `
53+
@keyframes fade-in {
54+
from { opacity: 0; }
55+
to { opacity: 1; }
56+
}
57+
`;
58+
} else if (entrance === 'slide') {
59+
baseStyles = `
60+
opacity: 0;
61+
transform: translateY(-20px);
62+
animation: slide-down 1.2s cubic-bezier(0.16, 1, 0.3, 1) forwards;
63+
`;
64+
keyframes = `
65+
@keyframes slide-down {
66+
from { opacity: 0; transform: translateY(-20px); }
67+
to { opacity: 1; transform: translateY(0); }
68+
}
69+
`;
3570
}
36-
`;
71+
72+
return `
73+
.cp-tower {
74+
${baseStyles}
75+
}
76+
${keyframes}
77+
@media (prefers-reduced-motion: reduce) {
78+
.cp-tower { animation: none !important; transform: scaleY(1) translateY(0) !important; opacity: 1 !important; }
79+
}
80+
`;
81+
}

lib/svg/generator.ts

Lines changed: 7 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
import type { BadgeParams, ContributionCalendar, StreakStats, MonthlyStats } from '../../types';
44
import { getLabels, type BadgeLabels } from '../i18n/badgeLabels';
55
import { AUTO_THEME_DARK, AUTO_THEME_LIGHT, themes } from './themes';
6-
import { TOWER_ANIMATION_CSS } from './animations';
6+
import { getTowerAnimationCSS } from './animations';
77
import { computeTowers, type TowerData } from './layout';
88
import {
99
sanitizeFont,
@@ -195,7 +195,8 @@ function renderStyle(
195195
text: string,
196196
accent: string,
197197
sf: number,
198-
bg: string
198+
bg: string,
199+
entrance: 'rise' | 'fade' | 'slide' | 'none' = 'rise'
199200
): string {
200201
const fs = (n: number) => Math.round(n * sf * 10) / 10;
201202
const isLightBg = getLuminance(bg) > 0.5;
@@ -206,7 +207,7 @@ function renderStyle(
206207
<style>
207208
@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');
208209
${googleFontsImport}
209-
${TOWER_ANIMATION_CSS}
210+
${getTowerAnimationCSS(entrance)}
210211
.scan-line {
211212
animation: scan-sweep var(--scan-speed, 8s) linear infinite;
212213
transform-box: fill-box;
@@ -524,7 +525,7 @@ export function generateSVG(
524525
return `
525526
<svg xmlns="http://www.w3.org/2000/svg" width="100%" viewBox="0 0 ${W} ${H}" fill="none" role="img">
526527
${renderHeader(safeUser, stats, sf, params)}
527-
${renderStyle(selectedFont, statsFont, googleFontsImport, text, mainAccentHex, sf, bg)}
528+
${renderStyle(selectedFont, statsFont, googleFontsImport, text, mainAccentHex, sf, bg, params.entrance || 'rise')}
528529
<rect width="${W}" height="${H}" rx="${radius}" fill="${params.hideBackground ? 'transparent' : bg}" ${borderAttr} />
529530
<g transform="translate(0, ${Math.round(20 * sf)})">${towers}</g>
530531
${renderIsometricLabels(calendar, params, text, sf)}
@@ -585,7 +586,7 @@ function generateAutoThemeSVG(
585586
:root { --cp-bg: #${light.bg}; --cp-text: #${light.text}; --cp-accent: #${light.accent}; --cp-label-fill: ${lightLabelFill}; --cp-label-opacity: ${lightLabelOpacity}; }
586587
@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}; } }
587588
.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); }
588-
${TOWER_ANIMATION_CSS}
589+
${getTowerAnimationCSS(params.entrance || 'rise')}
589590
.scan-line {
590591
animation: scan-sweep var(--scan-speed, 8s) linear infinite;
591592
transform-box: fill-box;
@@ -1917,7 +1918,7 @@ function generateAutoThemeVersusSVG(
19171918
:root { --cp-bg: #${light.bg}; --cp-text: #${light.text}; --cp-accent: #${light.accent}; --cp-label-fill: ${lightLabelFill}; --cp-label-opacity: ${lightLabelOpacity}; }
19181919
@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}; } }
19191920
.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); }
1920-
${TOWER_ANIMATION_CSS}
1921+
${getTowerAnimationCSS(params.entrance || 'rise')}
19211922
.scan-line {
19221923
animation: scan-sweep var(--scan-speed, 8s) linear infinite;
19231924
transform-box: fill-box;

lib/validations.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -240,6 +240,7 @@ export const streakParamsSchema = z.object({
240240
.string()
241241
.optional()
242242
.transform((val) => val === 'true' || val === '1'),
243+
entrance: z.enum(['rise', 'fade', 'slide', 'none']).catch('rise').default('rise'),
243244
});
244245

245246
export const githubParamsSchema = z.object({

types/index.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -142,6 +142,9 @@ export interface BadgeParams {
142142
/** Duration of the radar scan line animation (e.g. '4s', '8s', '12s'). Defaults to '8s'. */
143143
speed: SpeedString;
144144

145+
/** Animation style for the isometric towers on load: 'rise' (default), 'fade', 'slide', or 'none'. */
146+
entrance?: 'rise' | 'fade' | 'slide' | 'none';
147+
145148
/** Tower height scaling algorithm. 'linear' scales proportionally; 'log' uses logarithmic scale for high contributors. Defaults to 'linear'. */
146149
scale: Scale;
147150

0 commit comments

Comments
 (0)