diff --git a/app/api/streak/route.error-resilience.test.ts b/app/api/streak/route.error-resilience.test.ts index c7ef5f4b3..6bbc1b183 100644 --- a/app/api/streak/route.error-resilience.test.ts +++ b/app/api/streak/route.error-resilience.test.ts @@ -332,7 +332,7 @@ describe('GET /api/streak — error resilience & exception safety', () => { describe('Cache-Control on error paths not covered by route.test.ts', () => { it('returns a 400 with no-store caching for validation errors so a corrected reload is never served stale data', async () => { - const response = await GET(makeRequest({ user: 'octocat', theme: 'nonexistent_theme_name' })); + const response = await GET(makeRequest({ user: 'a'.repeat(45) })); expect(response.status).toBe(400); expect(response.headers.get('Cache-Control')).toBe('no-store'); diff --git a/app/api/streak/route.test.ts b/app/api/streak/route.test.ts index d03ffc5f5..9a4b9bf17 100644 --- a/app/api/streak/route.test.ts +++ b/app/api/streak/route.test.ts @@ -275,13 +275,12 @@ describe('GET /api/streak', () => { expect(response.status).toBe(400); }); - it('returns 400 when an invalid theme value is provided and lists allowed themes', async () => { - const response = await GET(makeRequest({ user: 'octocat', theme: 'nonexistent_theme_name' })); + it('returns 400 when an invalid user parameter is provided', async () => { + const response = await GET(makeRequest({ user: 'a'.repeat(45), theme: 'default' })); expect(response.status).toBe(400); const body = await response.text(); expect(body).toContain(' { expect(body).toContain('--cp-bg'); }); - it('returns 400 Bad Request listing allowed themes when an invalid theme is provided', async () => { + it('returns 200 OK and applies default theme with a warning header when an unknown theme is provided', async () => { const response = await GET(makeRequest({ user: 'octocat', theme: 'nonexistent_theme_name' })); - expect(response.status).toBe(400); + expect(response.status).toBe(200); + expect(response.headers.get('X-Theme-Warning')).toBe( + "Unknown theme 'nonexistent_theme_name', falling back to 'default'" + ); const body = await response.text(); expect(body).toContain(' { + it('returns 200 OK and falls back to default theme when theme parameter contains only whitespace', async () => { const response = await GET( makeRequest({ user: 'octocat', @@ -809,14 +810,9 @@ describe('GET /api/streak', () => { }) ); - expect(response.status).toBe(400); - + expect(response.status).toBe(200); const body = await response.text(); expect(body).toContain(' { diff --git a/app/api/streak/route.theme-contrast.test.ts b/app/api/streak/route.theme-contrast.test.ts index 616643b52..22aa68127 100644 --- a/app/api/streak/route.theme-contrast.test.ts +++ b/app/api/streak/route.theme-contrast.test.ts @@ -133,4 +133,21 @@ describe('GET /api/streak theme contrast', () => { expect(body).toContain('--cp-bg'); }); + + it('falls back to default theme for unknown themes and sets X-Theme-Warning header', async () => { + const response = await GET( + makeRequest({ + user: 'octocat', + theme: 'unknown_theme_123', + }) + ); + + expect(response.status).toBe(200); + expect(response.headers.get('X-Theme-Warning')).toBe( + "Unknown theme 'unknown_theme_123', falling back to 'default'" + ); + + const body = await response.text(); + expect(body).toContain(' e.trim()); if (etags.includes(weakEtag) || etags.includes(`"${etag}"`)) { + const headers: Record = { + 'Cache-Control': cacheControl, + ETag: weakEtag, + 'X-Request-ID': requestId, + }; + if (themeWarning) headers['X-Theme-Warning'] = themeWarning; return new NextResponse(null, { status: 304, - headers: { - 'Cache-Control': cacheControl, - ETag: weakEtag, - 'X-Request-ID': requestId, - }, + headers, }); } } - return new NextResponse(jsonPayload, { - headers: { - 'Content-Type': 'application/json', - 'Cache-Control': cacheControl, - ETag: weakEtag, - 'X-Cache-Status': cacheStatusHeader, - 'X-Request-ID': requestId, - }, - }); + const headers: Record = { + 'Content-Type': 'application/json', + 'Cache-Control': cacheControl, + ETag: weakEtag, + 'X-Cache-Status': cacheStatusHeader, + 'X-Request-ID': requestId, + }; + if (themeWarning) headers['X-Theme-Warning'] = themeWarning; + return new NextResponse(jsonPayload, { headers }); } // ─── SVG output mode (default) ────────────────────────────────────────── @@ -730,13 +744,15 @@ export async function GET(request: Request) { if (ifNoneMatch) { const etags = ifNoneMatch.split(',').map((e) => e.trim()); if (etags.includes(weakEtag) || etags.includes(`"${etag}"`)) { + const headers: Record = { + 'Cache-Control': cacheControl, + ETag: weakEtag, + 'X-Request-ID': requestId, + }; + if (themeWarning) headers['X-Theme-Warning'] = themeWarning; return new NextResponse(null, { status: 304, - headers: { - 'Cache-Control': cacheControl, - ETag: weakEtag, - 'X-Request-ID': requestId, - }, + headers, }); } } @@ -749,31 +765,33 @@ export async function GET(request: Request) { }); const pngBuffer = resvg.render().asPng(); - return new NextResponse(new Uint8Array(pngBuffer), { - headers: { - 'Content-Type': 'image/png', - 'Cache-Control': cacheControl, - 'X-CommitPulse-Grace-Applied': String(grace), - ETag: weakEtag, - 'X-Cache-Status': shouldBypassCache - ? `BYPASS, fetched=${new Date().toISOString()}` - : `HIT, cached=${new Date().toISOString()}`, - 'X-Request-ID': requestId, - }, - }); - } - - return new NextResponse(svg, { - headers: { - 'Content-Type': 'image/svg+xml; charset=utf-8', + const headers: Record = { + 'Content-Type': 'image/png', 'Cache-Control': cacheControl, - 'Content-Security-Policy': SVG_CSP_HEADER, 'X-CommitPulse-Grace-Applied': String(grace), ETag: weakEtag, - 'X-Cache-Status': shouldBypassCache ? `BYPASS, fetched=${new Date().toISOString()}` : 'HIT', + 'X-Cache-Status': shouldBypassCache + ? `BYPASS, fetched=${new Date().toISOString()}` + : `HIT, cached=${new Date().toISOString()}`, 'X-Request-ID': requestId, - }, - }); + }; + if (themeWarning) headers['X-Theme-Warning'] = themeWarning; + + return new NextResponse(new Uint8Array(pngBuffer), { headers }); + } + + const headers: Record = { + 'Content-Type': 'image/svg+xml; charset=utf-8', + 'Cache-Control': cacheControl, + 'Content-Security-Policy': SVG_CSP_HEADER, + 'X-CommitPulse-Grace-Applied': String(grace), + ETag: weakEtag, + 'X-Cache-Status': shouldBypassCache ? `BYPASS, fetched=${new Date().toISOString()}` : 'HIT', + 'X-Request-ID': requestId, + }; + if (themeWarning) headers['X-Theme-Warning'] = themeWarning; + + return new NextResponse(svg, { headers }); } catch (error: unknown) { return buildErrorResponse(error, parseResult, requestId); } diff --git a/app/api/streak/tests/theme.test.ts b/app/api/streak/tests/theme.test.ts index 29a2950c3..3dbaa162c 100644 --- a/app/api/streak/tests/theme.test.ts +++ b/app/api/streak/tests/theme.test.ts @@ -87,12 +87,14 @@ describe('Streak API - theme parameter integration tests', () => { expect(body).toContain(' { + it('should fall back and return 200 OK when theme parameter is unknown', async () => { const response = await GET(makeRequest({ user: 'octocat', theme: 'not-a-valid-theme' })); - expect(response.status).toBe(400); + expect(response.status).toBe(200); + expect(response.headers.get('X-Theme-Warning')).toBe( + "Unknown theme 'not-a-valid-theme', falling back to 'default'" + ); const body = await response.text(); expect(body).toContain(' { diff --git a/app/components/LandingPageClient.tsx b/app/components/LandingPageClient.tsx index a45f54ff2..e2a8b96da 100644 --- a/app/components/LandingPageClient.tsx +++ b/app/components/LandingPageClient.tsx @@ -38,6 +38,7 @@ import { DiscordButton } from '@/components/DiscordButton'; import { WallOfLove } from '@/components/WallOfLove'; import { validateGitHubUsername } from '@/lib/validations'; +import { THEME_PRESETS } from '@/lib/svg/themes'; const Icons = { Github: () => ( @@ -316,6 +317,7 @@ export default function LandingPageClient() { username: string; status: 'loaded' | 'error'; } | null>(null); + const [selectedTheme, setSelectedTheme] = useState('default'); const guideRef = useRef(null); const heroRef = useRef(null); const scrollTimeoutRef = useRef | null>(null); @@ -372,12 +374,13 @@ export default function LandingPageClient() { latestPreviewUsernameRef.current = previewUsername; }, [previewUsername]); - const badgeUrl = `/api/streak?user=${encodeURIComponent(previewUsername)}`; + const themeParam = selectedTheme !== 'default' ? `&theme=${selectedTheme}` : ''; + const badgeUrl = `/api/streak?user=${encodeURIComponent(previewUsername)}${themeParam}`; const siteUrl = (process.env.NEXT_PUBLIC_SITE_URL ?? 'https://commitpulse.vercel.app').replace( /\/$/, '' ); - const markdown = `![CommitPulse](${siteUrl}/api/streak?user=${encodeURIComponent(trimmedUsername)})`; + const markdown = `![CommitPulse](${siteUrl}/api/streak?user=${encodeURIComponent(trimmedUsername)}${themeParam})`; const DownloadSVG = () => { const link = document.createElement('a'); link.href = badgeUrl; @@ -789,6 +792,31 @@ export default function LandingPageClient() { )} + {/* Theme Gallery */} +
+
+ + {t('landing.theme_presets', { defaultValue: 'Theme Presets:' })} + +
+ {THEME_PRESETS.map((preset) => ( + + ))} +
+
+
+ {/* Footer Section: Demo & Recents */}
diff --git a/diff.txt b/diff.txt new file mode 100644 index 000000000..5b43b1a90 Binary files /dev/null and b/diff.txt differ diff --git a/lib/svg/themes.test.ts b/lib/svg/themes.test.ts index 318e25d31..b4cf0c6c2 100644 --- a/lib/svg/themes.test.ts +++ b/lib/svg/themes.test.ts @@ -45,7 +45,7 @@ describe('theme count', () => { // If this fails, either a theme was added to themes.ts without updating // THEMES.md, or a theme was removed without updating the docs. // Update this count when intentionally adding/removing themes. - expect(themeNames).toHaveLength(33); + expect(themeNames).toHaveLength(35); }); it('contains all expected theme keys', () => { @@ -81,6 +81,8 @@ describe('theme count', () => { 'monokai', 'midnight_ocean', 'india', + 'mono', + 'galaxy', ]; for (const key of expectedKeys) { expect(themeNames).toContain(key); diff --git a/lib/svg/themes.ts b/lib/svg/themes.ts index d57e75033..6d1d7dc0f 100644 --- a/lib/svg/themes.ts +++ b/lib/svg/themes.ts @@ -67,8 +67,12 @@ export const themes: Record = { // India theme — saffron accent (#FF9933), India green negative (#138808) india: makeTheme('0a0a0a', 'ffffff', 'FF9933', '138808'), ayu_mirage: makeTheme('212733', 'D9D7CE', 'FFCC66', 'FF3333'), + mono: makeTheme('000000', 'ffffff', 'aaaaaa', '444444'), + galaxy: makeTheme('0b001a', 'e0d4f5', 'a200ff', 'ff00aa'), }; +export const THEME_PRESETS = ['default', 'ocean', 'sunset', 'mono', 'galaxy'] as const; + // Auto-theme pairs: the SVG switches between these two palettes // using @media (prefers-color-scheme) so the badge adapts to the // viewer's OS-level light/dark setting without any JavaScript. diff --git a/lib/validations.test.ts b/lib/validations.test.ts index 908bb3045..b3667acbb 100644 --- a/lib/validations.test.ts +++ b/lib/validations.test.ts @@ -1045,42 +1045,27 @@ describe('ogParamsSchema', () => { }); describe('streakParamsSchema — theme validation', () => { - it('rejects an invalid theme value with 400 validation error listing allowed themes', () => { + it('allows an invalid theme value to pass validation (for graceful fallback)', () => { const result = streakParamsSchema.safeParse({ user: 'octocat', theme: 'nonexistent_theme_name', }); - expect(result.success).toBe(false); - if (!result.success) { - const fieldError = result.error.flatten().fieldErrors.theme?.[0]; - expect(fieldError).toContain('Invalid theme. Supported themes:'); - expect(fieldError).toContain('dark'); - expect(fieldError).toContain('light'); - expect(fieldError).toContain('neon'); + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.theme).toBe('nonexistent_theme_name'); } }); - it('should reject nonexistent_theme_name and verify allowed themes are listed in error', () => { + it('allows completely unknown theme strings without returning a validation error', () => { const result = streakParamsSchema.safeParse({ user: 'octocat', - theme: 'nonexistent_theme_name', + theme: 'another_nonexistent_theme', }); - expect(result.success).toBe(false); - if (!result.success) { - const fieldErrors = result.error.flatten().fieldErrors; - expect(fieldErrors.theme).toBeDefined(); - const errorMessage = fieldErrors.theme?.[0]; - expect(errorMessage).toContain('Invalid theme'); - expect(errorMessage).toContain('Supported themes:'); - expect(errorMessage).toContain('auto'); - expect(errorMessage).toContain('random'); - expect(errorMessage).toContain('dark'); - expect(errorMessage).toContain('light'); - expect(errorMessage).toContain('neon'); - expect(errorMessage).toContain('github'); - expect(errorMessage).toContain('dracula'); + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.theme).toBe('another_nonexistent_theme'); } }); }); @@ -1299,12 +1284,15 @@ describe('streakParamsSchema — case-insensitive theme matching', () => { } }); - it('rejects completely invalid theme name', () => { + it('accepts completely invalid theme name for graceful fallback', () => { const result = streakParamsSchema.safeParse({ user: 'octocat', theme: 'fictionaltheme', }); - expect(result.success).toBe(false); + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.theme).toBe('fictionaltheme'); + } }); }); diff --git a/lib/validations.theme-contrast.test.ts b/lib/validations.theme-contrast.test.ts index 16651d253..b54a9a6a4 100644 --- a/lib/validations.theme-contrast.test.ts +++ b/lib/validations.theme-contrast.test.ts @@ -43,6 +43,17 @@ describe('Validations color and theme consistency', () => { } }); + it('streakParamsSchema allows unknown themes without failing validation', async () => { + const result = await streakParamsSchema.safeParseAsync({ + user: 'octocat', + theme: 'unknown_theme_123', + }); + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.theme).toBe('unknown_theme_123'); + } + }); + it('streakParamsSchema bg and text hex color validations accept valid with or without #', async () => { const withHash = await streakParamsSchema.safeParseAsync({ user: 'octocat', diff --git a/lib/validations.ts b/lib/validations.ts index 43b02ed9b..9a0b4c90e 100644 --- a/lib/validations.ts +++ b/lib/validations.ts @@ -283,14 +283,6 @@ const baseStreakParamsSchema = z.object({ const matchedKey = Object.keys(themes).find((key) => key.toLowerCase() === normalized); return matchedKey || val; }) - .refine( - (val) => { - return val === 'auto' || val === 'random' || Object.hasOwn(themes, val); - }, - { - message: `Invalid theme. Supported themes: ${['auto', 'random', ...Object.keys(themes)].join(', ')}`, - } - ) .default('dark'), bg: z .string()