Skip to content

Commit 647dcf3

Browse files
committed
feat: add custom gradient_stops and gradient_dir URL parameters
1 parent e6752cd commit 647dcf3

4 files changed

Lines changed: 394 additions & 24 deletions

File tree

lib/svg/generator.additional.test.ts

Lines changed: 241 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -356,3 +356,244 @@ describe('[Issue] generateVersusSVG — zero existing test coverage', () => {
356356
expect(svg).not.toContain('prefers-color-scheme: dark');
357357
});
358358
});
359+
360+
// ─── Custom gradient_stops and gradient_dir parameters ───────────────────────
361+
362+
describe('[Feature] custom gradient_stops and gradient_dir', () => {
363+
it('existing gradient=true renders default gradient without custom stops', () => {
364+
const svg = generateSVG(
365+
baseStats,
366+
{
367+
user: 'chetan',
368+
bg: hexColor('0d1117'),
369+
text: hexColor('ffffff'),
370+
accent: hexColor('ff00ff'),
371+
speed: '8s',
372+
scale: 'linear',
373+
gradient: true,
374+
} satisfies BadgeParams,
375+
baseCalendar
376+
);
377+
378+
// Should use default tower-grad-level-* IDs
379+
expect(svg).toContain('tower-grad-level-1');
380+
expect(svg).toContain('tower-grad-level-2');
381+
});
382+
383+
it('gradient=true with valid gradient_stops renders custom gradient', () => {
384+
const svg = generateSVG(
385+
baseStats,
386+
{
387+
user: 'chetan',
388+
bg: hexColor('0d1117'),
389+
text: hexColor('ffffff'),
390+
accent: hexColor('ff00ff'),
391+
speed: '8s',
392+
scale: 'linear',
393+
gradient: true,
394+
gradient_stops: 'ff6b35,ff007f,7000ff',
395+
} satisfies BadgeParams,
396+
baseCalendar
397+
);
398+
399+
// Should contain custom gradient colors
400+
expect(svg).toContain('#ff6b35');
401+
expect(svg).toContain('#ff007f');
402+
expect(svg).toContain('#7000ff');
403+
// Should have custom gradient IDs, not default tower-grad-level-*
404+
expect(svg).toContain('custom-grad-');
405+
});
406+
407+
it('gradient_stops with # prefix works correctly', () => {
408+
const svg = generateSVG(
409+
baseStats,
410+
{
411+
user: 'chetan',
412+
bg: hexColor('0d1117'),
413+
text: hexColor('ffffff'),
414+
accent: hexColor('ff00ff'),
415+
speed: '8s',
416+
scale: 'linear',
417+
gradient: true,
418+
gradient_stops: '#ff6b35,#ff007f,#7000ff',
419+
} satisfies BadgeParams,
420+
baseCalendar
421+
);
422+
423+
// Should normalize and use the colors
424+
expect(svg).toContain('#ff6b35');
425+
expect(svg).toContain('#ff007f');
426+
expect(svg).toContain('#7000ff');
427+
});
428+
429+
it('invalid gradient_stops falls back to default gradient', () => {
430+
const svg = generateSVG(
431+
baseStats,
432+
{
433+
user: 'chetan',
434+
bg: hexColor('0d1117'),
435+
text: hexColor('ffffff'),
436+
accent: hexColor('ff00ff'),
437+
speed: '8s',
438+
scale: 'linear',
439+
gradient: true,
440+
gradient_stops: 'invalid,colors,here',
441+
} satisfies BadgeParams,
442+
baseCalendar
443+
);
444+
445+
// Should fallback to default gradient (tower-grad-level-*)
446+
expect(svg).toContain('tower-grad-level-1');
447+
expect(svg).not.toContain('custom-grad-');
448+
});
449+
450+
it('fewer than 2 valid colors in gradient_stops falls back to default', () => {
451+
const svg = generateSVG(
452+
baseStats,
453+
{
454+
user: 'chetan',
455+
bg: hexColor('0d1117'),
456+
text: hexColor('ffffff'),
457+
accent: hexColor('ff00ff'),
458+
speed: '8s',
459+
scale: 'linear',
460+
gradient: true,
461+
gradient_stops: 'ff6b35',
462+
} satisfies BadgeParams,
463+
baseCalendar
464+
);
465+
466+
// Should fallback to default gradient
467+
expect(svg).toContain('tower-grad-level-1');
468+
expect(svg).not.toContain('custom-grad-');
469+
});
470+
471+
it('gradient_dir=vertical produces correct SVG coordinates', () => {
472+
const svg = generateSVG(
473+
baseStats,
474+
{
475+
user: 'chetan',
476+
bg: hexColor('0d1117'),
477+
text: hexColor('ffffff'),
478+
accent: hexColor('ff00ff'),
479+
speed: '8s',
480+
scale: 'linear',
481+
gradient: true,
482+
gradient_stops: 'ff6b35,7000ff',
483+
gradient_dir: 'vertical',
484+
} satisfies BadgeParams,
485+
baseCalendar
486+
);
487+
488+
// Vertical gradient should have y1 and y2 different (0% to 100%)
489+
expect(svg).toMatch(/x1="0%"\s+y1="0%"\s+x2="0%"\s+y2="100%"/);
490+
});
491+
492+
it('gradient_dir=horizontal produces correct SVG coordinates', () => {
493+
const svg = generateSVG(
494+
baseStats,
495+
{
496+
user: 'chetan',
497+
bg: hexColor('0d1117'),
498+
text: hexColor('ffffff'),
499+
accent: hexColor('ff00ff'),
500+
speed: '8s',
501+
scale: 'linear',
502+
gradient: true,
503+
gradient_stops: 'ff6b35,7000ff',
504+
gradient_dir: 'horizontal',
505+
} satisfies BadgeParams,
506+
baseCalendar
507+
);
508+
509+
// Horizontal gradient should have x1 and x2 different (0% to 100%)
510+
expect(svg).toMatch(/x1="0%"\s+y1="0%"\s+x2="100%"\s+y2="0%"/);
511+
});
512+
513+
it('gradient_dir=diagonal produces correct SVG coordinates', () => {
514+
const svg = generateSVG(
515+
baseStats,
516+
{
517+
user: 'chetan',
518+
bg: hexColor('0d1117'),
519+
text: hexColor('ffffff'),
520+
accent: hexColor('ff00ff'),
521+
speed: '8s',
522+
scale: 'linear',
523+
gradient: true,
524+
gradient_stops: 'ff6b35,7000ff',
525+
gradient_dir: 'diagonal',
526+
} satisfies BadgeParams,
527+
baseCalendar
528+
);
529+
530+
// Diagonal gradient should have both x and y varying
531+
expect(svg).toMatch(/x1="0%"\s+y1="0%"\s+x2="100%"\s+y2="100%"/);
532+
});
533+
534+
it('invalid gradient_dir falls back to vertical', () => {
535+
const svg = generateSVG(
536+
baseStats,
537+
{
538+
user: 'chetan',
539+
bg: hexColor('0d1117'),
540+
text: hexColor('ffffff'),
541+
accent: hexColor('ff00ff'),
542+
speed: '8s',
543+
scale: 'linear',
544+
gradient: true,
545+
gradient_stops: 'ff6b35,7000ff',
546+
gradient_dir: 'invalid',
547+
} satisfies BadgeParams,
548+
baseCalendar
549+
);
550+
551+
// Should fallback to vertical
552+
expect(svg).toMatch(/x1="0%"\s+y1="0%"\s+x2="0%"\s+y2="100%"/);
553+
});
554+
555+
it('gradient_stops with mixed valid and invalid colors ignores invalid ones', () => {
556+
const svg = generateSVG(
557+
baseStats,
558+
{
559+
user: 'chetan',
560+
bg: hexColor('0d1117'),
561+
text: hexColor('ffffff'),
562+
accent: hexColor('ff00ff'),
563+
speed: '8s',
564+
scale: 'linear',
565+
gradient: true,
566+
gradient_stops: 'ff6b35,invalid,7000ff',
567+
} satisfies BadgeParams,
568+
baseCalendar
569+
);
570+
571+
// Should use the 2 valid colors and ignore invalid
572+
expect(svg).toContain('#ff6b35');
573+
expect(svg).toContain('#7000ff');
574+
expect(svg).toContain('custom-grad-');
575+
});
576+
577+
it('gradient=false ignores gradient_stops and gradient_dir', () => {
578+
const svg = generateSVG(
579+
baseStats,
580+
{
581+
user: 'chetan',
582+
bg: hexColor('0d1117'),
583+
text: hexColor('ffffff'),
584+
accent: hexColor('ff00ff'),
585+
speed: '8s',
586+
scale: 'linear',
587+
gradient: false,
588+
gradient_stops: 'ff6b35,7000ff',
589+
gradient_dir: 'horizontal',
590+
} satisfies BadgeParams,
591+
baseCalendar
592+
);
593+
594+
// Should not contain any gradient definitions
595+
expect(svg).not.toContain('linearGradient');
596+
expect(svg).not.toContain('custom-grad-');
597+
expect(svg).not.toContain('tower-grad-level-');
598+
});
599+
});

lib/svg/generator.ts

Lines changed: 94 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,9 @@ import {
1111
sanitizeRadius,
1212
sanitizeGoogleFontUrl,
1313
getLuminance,
14+
normalizeHexColor,
15+
parseGradientStops,
16+
getGradientCoordinates,
1417
} from './sanitizer';
1518

1619
import { SVG_WIDTH, SVG_HEIGHT, FONT_MAP } from './generatorConstants';
@@ -153,41 +156,104 @@ function renderHeader(
153156
${renderDefs(sf, params)}`;
154157
}
155158

159+
/**
160+
* Generates custom SVG gradient definitions from gradient_stops and gradient_dir parameters.
161+
* Returns a string of linearGradient elements for each intensity level (1-4).
162+
* If custom stops are invalid or insufficient, returns an empty string (fallback to default behavior).
163+
*/
164+
function generateCustomGradients(params: BadgeParams, bgHex: string): string {
165+
const stops = parseGradientStops(params.gradient_stops);
166+
167+
// Require at least 2 valid colors for custom gradient
168+
if (stops.length < 2) {
169+
return '';
170+
}
171+
172+
const coords = getGradientCoordinates(params.gradient_dir);
173+
174+
// Create a deterministic gradient ID based on the color stops and direction
175+
// This ensures consistent output and avoids random/duplicate IDs
176+
const gradientSignature = `${stops.join('-')}-${params.gradient_dir || 'vertical'}`;
177+
const gradientId = `custom-grad-${deterministicRandom(gradientSignature).toString().slice(2, 10)}`;
178+
179+
let gradients = '';
180+
181+
// Generate 4 gradient definitions (one for each intensity level)
182+
// Each uses the same color stops but with different opacity progression
183+
for (let i = 0; i < 4; i++) {
184+
const level = i + 1;
185+
const levelId = `${gradientId}-level-${level}`;
186+
187+
// Build the stop elements
188+
let stopElements = '';
189+
const stopCount = stops.length;
190+
191+
stops.forEach((color, stopIdx) => {
192+
const offset = (stopIdx / (stopCount - 1)) * 100;
193+
// Increase opacity with intensity level (0.4 to 0.8)
194+
const baseOpacity = 0.4 + i * 0.2;
195+
const stopOpacity = Math.min(1, baseOpacity + stopIdx * 0.1);
196+
197+
const colorHex = color.startsWith('#') ? color : `#${color}`;
198+
stopElements += `
199+
<stop offset="${offset}%" stop-color="${colorHex}" stop-opacity="${stopOpacity}" />`;
200+
});
201+
202+
gradients += `
203+
<linearGradient id="${levelId}" x1="${coords.x1}" y1="${coords.y1}" x2="${coords.x2}" y2="${coords.y2}">
204+
${stopElements}
205+
</linearGradient>`;
206+
}
207+
208+
// Store the gradient ID in a temporary attribute so tower rendering can use it
209+
(params as any).__customGradientId = gradientId;
210+
211+
return gradients;
212+
}
213+
156214
function renderDefs(sf: number, params: BadgeParams): string {
157215
const fs = (n: number): number => Math.round(n * sf * 10) / 10;
158216

159217
let gradients = '';
160218
if (params.gradient) {
161-
if (params.autoTheme) {
162-
for (let i = 0; i < 4; i++) {
163-
const level = i + 1;
164-
gradients += `
219+
// Try to use custom gradient if gradient_stops is provided
220+
const bgStr = params.bg || '0d1117';
221+
const bgHex = bgStr.startsWith('#') ? bgStr : `#${bgStr}`;
222+
223+
const customGradients = generateCustomGradients(params, bgHex);
224+
if (customGradients) {
225+
// Custom gradient stops were valid and used
226+
gradients = customGradients;
227+
} else {
228+
// Fallback to default gradient behavior
229+
if (params.autoTheme) {
230+
for (let i = 0; i < 4; i++) {
231+
const level = i + 1;
232+
gradients += `
165233
<linearGradient id="tower-grad-level-${level}" x1="0" y1="1" x2="0" y2="0">
166234
<stop offset="0%" stop-color="var(--cp-bg)" stop-opacity="0.1" />
167235
<stop offset="100%" stop-color="var(--cp-accent)" stop-opacity="${0.4 + i * 0.2}" />
168236
</linearGradient>`;
169-
}
170-
} else {
171-
const accent = params.accent;
172-
const colors = Array.isArray(accent)
173-
? [0, 1, 2, 3].map((i) => {
174-
const idx = Math.min(i, accent.length - 1);
175-
const c = accent[idx] || accent[accent.length - 1] || '00ffaa';
176-
return c.startsWith('#') ? c : `#${c}`;
177-
})
178-
: [0, 1, 2, 3].map(() => (String(accent).startsWith('#') ? String(accent) : `#${accent}`));
179-
180-
const bgStr = params.bg || '0d1117';
181-
const bgHex = bgStr.startsWith('#') ? bgStr : `#${bgStr}`;
182-
183-
colors.forEach((c, idx) => {
184-
const level = idx + 1;
185-
gradients += `
237+
}
238+
} else {
239+
const accent = params.accent;
240+
const colors = Array.isArray(accent)
241+
? [0, 1, 2, 3].map((i) => {
242+
const idx = Math.min(i, accent.length - 1);
243+
const c = accent[idx] || accent[accent.length - 1] || '00ffaa';
244+
return c.startsWith('#') ? c : `#${c}`;
245+
})
246+
: [0, 1, 2, 3].map(() => (String(accent).startsWith('#') ? String(accent) : `#${accent}`));
247+
248+
colors.forEach((c, idx) => {
249+
const level = idx + 1;
250+
gradients += `
186251
<linearGradient id="tower-grad-level-${level}" x1="0" y1="1" x2="0" y2="0">
187252
<stop offset="0%" stop-color="${bgHex}" stop-opacity="0.1" />
188253
<stop offset="100%" stop-color="${c}" stop-opacity="${0.4 + idx * 0.2}" />
189254
</linearGradient>`;
190-
});
255+
});
256+
}
191257
}
192258
}
193259

@@ -334,8 +400,12 @@ function renderTowers(
334400
let finalTopFillAttr = topFillAttr;
335401

336402
if (!isGhost && t.intensityLevel > 0 && params.gradient === true) {
337-
leftFillAttr = `fill="url(#tower-grad-level-${t.intensityLevel})"`;
338-
rightFillAttr = `fill="url(#tower-grad-level-${t.intensityLevel})"`;
403+
// Use custom gradient ID if available, otherwise use default gradient ID
404+
const customGradId = (params as any).__customGradientId;
405+
const gradId = customGradId ? `${customGradId}-level-${t.intensityLevel}` : `tower-grad-level-${t.intensityLevel}`;
406+
407+
leftFillAttr = `fill="url(#${gradId})"`;
408+
rightFillAttr = `fill="url(#${gradId})"`;
339409

340410
if (isAutoTheme) {
341411
finalTopFillAttr = 'class="cp-accent-fill"';

0 commit comments

Comments
 (0)