From 6ee90ed9b79ec19f591dda80721be1f6e7f3771e Mon Sep 17 00:00:00 2001 From: Sandro Circi Date: Sat, 7 Mar 2026 16:29:23 +0100 Subject: [PATCH 1/9] feat(api): improve badge customization with dynamic text colors based on contrast --- docs/content/2.guide/1.features.md | 14 ++-- .../api/registry/badge/[type]/[...pkg].get.ts | 71 ++++++++++++++----- test/e2e/badge.spec.ts | 36 ++++++++++ 3 files changed, 95 insertions(+), 26 deletions(-) diff --git a/docs/content/2.guide/1.features.md b/docs/content/2.guide/1.features.md index a0549ce54e..1949f854a2 100644 --- a/docs/content/2.guide/1.features.md +++ b/docs/content/2.guide/1.features.md @@ -159,7 +159,7 @@ You can further customize your badges by appending query parameters to the badge ##### `labelColor` -Overrides the default label color. You can pass a standard hex code (with or without the `#` prefix). +Overrides the default label color. You can pass a standard hex code (with or without the `#` prefix). The label text color is automatically chosen (black or white) based on WCAG contrast ratio so the badge remains readable. - **Default**: `#0a0a0a` - **Usage**: `?labelColor=HEX_CODE` @@ -173,16 +173,16 @@ Overrides the default label text. You can pass any string to customize the label ##### `color` -Overrides the default strategy color. You can pass a standard hex code (with or without the `#` prefix). +Overrides the default strategy color. You can pass a standard hex code (with or without the `#` prefix). The text color is automatically chosen (black or white) based on WCAG contrast ratio so the badge remains readable. - **Default**: Depends on the badge type (e.g., version is blue, downloads are orange). - **Usage**: `?color=HEX_CODE` -| Example | URL | -| :------------- | :------------------------------------- | -| **Hot Pink** | `.../badge/version/nuxt?colorB=ff69b4` | -| **Pure Black** | `.../badge/version/nuxt?colorB=000000` | -| **Brand Blue** | `.../badge/version/nuxt?colorB=3b82f6` | +| Example | URL | +| :------------- | :------------------------------------ | +| **Hot Pink** | `.../badge/version/nuxt?color=ff69b4` | +| **Pure Black** | `.../badge/version/nuxt?color=000000` | +| **Brand Blue** | `.../badge/version/nuxt?color=3b82f6` | ##### `name` diff --git a/server/api/registry/badge/[type]/[...pkg].get.ts b/server/api/registry/badge/[type]/[...pkg].get.ts index cbab8ac374..f9c4bf8cd1 100644 --- a/server/api/registry/badge/[type]/[...pkg].get.ts +++ b/server/api/registry/badge/[type]/[...pkg].get.ts @@ -95,6 +95,23 @@ function escapeXML(str: string): string { .replace(/"/g, '"') } +function toLinear(c: number): number { + return c <= 0.04045 ? c / 12.92 : Math.pow((c + 0.055) / 1.055, 2.4) +} + +function getContrastTextColor(bgHex: string): string { + let clean = bgHex.replace('#', '') + if (clean.length === 3) + clean = clean[0]! + clean[0]! + clean[1]! + clean[1]! + clean[2]! + clean[2]! + if (!/^[0-9a-f]{6}$/i.test(clean)) return '#ffffff' + const r = parseInt(clean.slice(0, 2), 16) / 255 + const g = parseInt(clean.slice(2, 4), 16) / 255 + const b = parseInt(clean.slice(4, 6), 16) / 255 + const luminance = 0.2126 * toLinear(r) + 0.7152 * toLinear(g) + 0.0722 * toLinear(b) + // threshold where contrast ratio with white equals contrast ratio with black + return luminance > 0.179 ? '#000000' : '#ffffff' +} + function measureShieldsTextLength(text: string): number { const measuredWidth = measureTextWidth(text, SHIELDS_FONT_SHORTHAND) @@ -110,8 +127,11 @@ function renderDefaultBadgeSvg(params: { finalLabel: string finalLabelColor: string finalValue: string + labelTextColor: string + valueTextColor: string }): string { - const { finalColor, finalLabel, finalLabelColor, finalValue } = params + const { finalColor, finalLabel, finalLabelColor, finalValue, labelTextColor, valueTextColor } = + params const leftWidth = finalLabel.trim().length === 0 ? 0 : measureDefaultTextWidth(finalLabel) const rightWidth = measureDefaultTextWidth(finalValue) const totalWidth = leftWidth + rightWidth @@ -120,19 +140,19 @@ function renderDefaultBadgeSvg(params: { const escapedValue = escapeXML(finalValue) return ` - - - - - - - - - - ${escapedLabel} - ${escapedValue} - - + + + + + + + + + + ${escapedLabel} + ${escapedValue} + + `.trim() } @@ -141,8 +161,11 @@ function renderShieldsBadgeSvg(params: { finalLabel: string finalLabelColor: string finalValue: string + labelTextColor: string + valueTextColor: string }): string { - const { finalColor, finalLabel, finalLabelColor, finalValue } = params + const { finalColor, finalLabel, finalLabelColor, finalValue, labelTextColor, valueTextColor } = + params const hasLabel = finalLabel.trim().length > 0 const leftTextLength = hasLabel ? measureShieldsTextLength(finalLabel) : 0 @@ -174,11 +197,11 @@ function renderShieldsBadgeSvg(params: { - + - ${escapedLabel} + ${escapedLabel} - ${escapedValue} + ${escapedValue} `.trim() @@ -442,8 +465,18 @@ export default defineCachedEventHandler( const rawLabelColor = labelColor ?? defaultLabelColor const finalLabelColor = rawLabelColor.startsWith('#') ? rawLabelColor : `#${rawLabelColor}` + const labelTextColor = getContrastTextColor(finalLabelColor) + const valueTextColor = getContrastTextColor(finalColor) + const renderFn = badgeStyle === 'shieldsio' ? renderShieldsBadgeSvg : renderDefaultBadgeSvg - const svg = renderFn({ finalColor, finalLabel, finalLabelColor, finalValue }) + const svg = renderFn({ + finalColor, + finalLabel, + finalLabelColor, + finalValue, + labelTextColor, + valueTextColor, + }) setHeader(event, 'Content-Type', 'image/svg+xml') setHeader( diff --git a/test/e2e/badge.spec.ts b/test/e2e/badge.spec.ts index ffd258cdda..7c3f4ab58c 100644 --- a/test/e2e/badge.spec.ts +++ b/test/e2e/badge.spec.ts @@ -118,6 +118,42 @@ test.describe('badge API', () => { expect(body).toContain(`fill="#${customColor}"`) }) + test('light color produces dark text for contrast', async ({ page, baseURL }) => { + // FFDC3B is a bright yellow — should get #000000 text + const url = toLocalUrl(baseURL, '/api/registry/badge/version/nuxt?color=FFDC3B') + const { body } = await fetchBadge(page, url) + + expect(body).toContain('fill="#ffffff">version') + expect(body).toContain('fill="#000000">v') + }) + + test('dark color keeps white text for contrast', async ({ page, baseURL }) => { + // 0a0a0a is near-black — should keep #ffffff text + const url = toLocalUrl(baseURL, '/api/registry/badge/version/nuxt?color=0a0a0a') + const { body } = await fetchBadge(page, url) + + expect(body).toContain('fill="#ffffff">version') + expect(body).toContain('fill="#ffffff">v') + }) + + test('light labelColor produces dark label text for contrast', async ({ page, baseURL }) => { + // ffffff label background — should get #000000 label text + const url = toLocalUrl(baseURL, '/api/registry/badge/version/nuxt?labelColor=ffffff') + const { body } = await fetchBadge(page, url) + + expect(body).toContain('fill="#000000">version') + expect(body).toContain('fill="#ffffff">v') + }) + + test('3-char hex color is handled correctly for contrast', async ({ page, baseURL }) => { + // CCC expands to CCCCCC — a light grey, should get dark text + const url = toLocalUrl(baseURL, '/api/registry/badge/version/nuxt?color=CCC') + const { body } = await fetchBadge(page, url) + + expect(body).toContain('fill="#000000">version') + expect(body).toContain('fill="#ffffff">v') + }) + test('custom label parameter is applied to SVG', async ({ page, baseURL }) => { const customLabel = 'my-label' const url = toLocalUrl(baseURL, `/api/registry/badge/version/nuxt?label=${customLabel}`) From 5b2011bd6029d50e0f810e773a3497492afface4 Mon Sep 17 00:00:00 2001 From: Sandro Circi Date: Sat, 7 Mar 2026 16:38:15 +0100 Subject: [PATCH 2/9] fix(api): properly validate bagde colors --- .../api/registry/badge/[type]/[...pkg].get.ts | 49 ++++++++++--------- 1 file changed, 27 insertions(+), 22 deletions(-) diff --git a/server/api/registry/badge/[type]/[...pkg].get.ts b/server/api/registry/badge/[type]/[...pkg].get.ts index f9c4bf8cd1..5b4033e642 100644 --- a/server/api/registry/badge/[type]/[...pkg].get.ts +++ b/server/api/registry/badge/[type]/[...pkg].get.ts @@ -14,12 +14,17 @@ const BUNDLEPHOBIA_API = 'https://bundlephobia.com/api/size' const NPMS_API = 'https://api.npms.io/v2/package' const SafeStringSchema = v.pipe(v.string(), v.regex(/^[^<>"&]*$/, 'Invalid characters')) +const SafeColorSchema = v.pipe( + v.string(), + v.transform(value => (value.startsWith('#') ? value : `#${value}`)), + v.hexColor(), +) const QUERY_SCHEMA = v.object({ - color: v.optional(SafeStringSchema), name: v.optional(v.string()), - labelColor: v.optional(SafeStringSchema), label: v.optional(SafeStringSchema), + color: v.optional(SafeColorSchema), + labelColor: v.optional(SafeColorSchema), }) const COLORS = { @@ -184,26 +189,26 @@ function renderShieldsBadgeSvg(params: { const rightTextLengthAttr = rightTextLength * 10 return ` - - - - - - - - - - - - - - - - ${escapedLabel} - - ${escapedValue} - - + + + + + + + + + + + + + + + + ${escapedLabel} + + ${escapedValue} + + `.trim() } From 40f2aa812a669ecc01d785d889a0dd39a3569a62 Mon Sep 17 00:00:00 2001 From: Sandro Circi Date: Sat, 7 Mar 2026 17:04:21 +0100 Subject: [PATCH 3/9] up --- test/e2e/badge.spec.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/test/e2e/badge.spec.ts b/test/e2e/badge.spec.ts index 7c3f4ab58c..e38c5ae5fd 100644 --- a/test/e2e/badge.spec.ts +++ b/test/e2e/badge.spec.ts @@ -150,8 +150,8 @@ test.describe('badge API', () => { const url = toLocalUrl(baseURL, '/api/registry/badge/version/nuxt?color=CCC') const { body } = await fetchBadge(page, url) - expect(body).toContain('fill="#000000">version') - expect(body).toContain('fill="#ffffff">v') + expect(body).toContain('fill="#ffffff">version') + expect(body).toContain('fill="#000000">v') }) test('custom label parameter is applied to SVG', async ({ page, baseURL }) => { From 0b0d806ebd5854148ecc05fa8e4b9d72c13f9f65 Mon Sep 17 00:00:00 2001 From: Sandro Circi Date: Sun, 8 Mar 2026 11:40:48 +0100 Subject: [PATCH 4/9] up --- test/e2e/badge.spec.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/test/e2e/badge.spec.ts b/test/e2e/badge.spec.ts index e38c5ae5fd..56a67dcaeb 100644 --- a/test/e2e/badge.spec.ts +++ b/test/e2e/badge.spec.ts @@ -124,7 +124,7 @@ test.describe('badge API', () => { const { body } = await fetchBadge(page, url) expect(body).toContain('fill="#ffffff">version') - expect(body).toContain('fill="#000000">v') + expect(body).toContain(/fill="#000000">v\d/) }) test('dark color keeps white text for contrast', async ({ page, baseURL }) => { @@ -133,7 +133,7 @@ test.describe('badge API', () => { const { body } = await fetchBadge(page, url) expect(body).toContain('fill="#ffffff">version') - expect(body).toContain('fill="#ffffff">v') + expect(body).toContain(/fill="#ffffff">v\d/) }) test('light labelColor produces dark label text for contrast', async ({ page, baseURL }) => { @@ -142,7 +142,7 @@ test.describe('badge API', () => { const { body } = await fetchBadge(page, url) expect(body).toContain('fill="#000000">version') - expect(body).toContain('fill="#ffffff">v') + expect(body).toContain(/fill="#ffffff">v\d/) }) test('3-char hex color is handled correctly for contrast', async ({ page, baseURL }) => { @@ -151,7 +151,7 @@ test.describe('badge API', () => { const { body } = await fetchBadge(page, url) expect(body).toContain('fill="#ffffff">version') - expect(body).toContain('fill="#000000">v') + expect(body).toContain(/fill="#000000">v\d/) }) test('custom label parameter is applied to SVG', async ({ page, baseURL }) => { From 6dae6a6e3d0b5d8c454372f6eea4480feaef1f50 Mon Sep 17 00:00:00 2001 From: Sandro Circi Date: Sun, 8 Mar 2026 11:53:15 +0100 Subject: [PATCH 5/9] up --- test/e2e/badge.spec.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/test/e2e/badge.spec.ts b/test/e2e/badge.spec.ts index 56a67dcaeb..ded578d147 100644 --- a/test/e2e/badge.spec.ts +++ b/test/e2e/badge.spec.ts @@ -124,7 +124,7 @@ test.describe('badge API', () => { const { body } = await fetchBadge(page, url) expect(body).toContain('fill="#ffffff">version') - expect(body).toContain(/fill="#000000">v\d/) + expect(body).toMatch(/fill="#000000">v\d/) }) test('dark color keeps white text for contrast', async ({ page, baseURL }) => { @@ -133,7 +133,7 @@ test.describe('badge API', () => { const { body } = await fetchBadge(page, url) expect(body).toContain('fill="#ffffff">version') - expect(body).toContain(/fill="#ffffff">v\d/) + expect(body).toMatch(/fill="#ffffff">v\d/) }) test('light labelColor produces dark label text for contrast', async ({ page, baseURL }) => { @@ -142,7 +142,7 @@ test.describe('badge API', () => { const { body } = await fetchBadge(page, url) expect(body).toContain('fill="#000000">version') - expect(body).toContain(/fill="#ffffff">v\d/) + expect(body).toMatch(/fill="#ffffff">v\d/) }) test('3-char hex color is handled correctly for contrast', async ({ page, baseURL }) => { @@ -151,7 +151,7 @@ test.describe('badge API', () => { const { body } = await fetchBadge(page, url) expect(body).toContain('fill="#ffffff">version') - expect(body).toContain(/fill="#000000">v\d/) + expect(body).toMatch(/fill="#000000">v\d/) }) test('custom label parameter is applied to SVG', async ({ page, baseURL }) => { From 47e83b45e345a8a84212fc06b8f4d71be48d67a2 Mon Sep 17 00:00:00 2001 From: Sandro Circi Date: Sun, 8 Mar 2026 11:55:35 +0100 Subject: [PATCH 6/9] docs: improve sentence cadence --- docs/content/2.guide/1.features.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/content/2.guide/1.features.md b/docs/content/2.guide/1.features.md index 1949f854a2..75e8decb24 100644 --- a/docs/content/2.guide/1.features.md +++ b/docs/content/2.guide/1.features.md @@ -159,7 +159,7 @@ You can further customize your badges by appending query parameters to the badge ##### `labelColor` -Overrides the default label color. You can pass a standard hex code (with or without the `#` prefix). The label text color is automatically chosen (black or white) based on WCAG contrast ratio so the badge remains readable. +Overrides the default label color. You can pass a standard hex code (with or without the `#` prefix). The label text color is automatically chosen (black or white) based on WCAG contrast ratio, so the badge remains readable. - **Default**: `#0a0a0a` - **Usage**: `?labelColor=HEX_CODE` @@ -173,7 +173,7 @@ Overrides the default label text. You can pass any string to customize the label ##### `color` -Overrides the default strategy color. You can pass a standard hex code (with or without the `#` prefix). The text color is automatically chosen (black or white) based on WCAG contrast ratio so the badge remains readable. +Overrides the default strategy color. You can pass a standard hex code (with or without the `#` prefix). The text color is automatically chosen (black or white) based on WCAG contrast ratio, so the badge remains readable. - **Default**: Depends on the badge type (e.g., version is blue, downloads are orange). - **Usage**: `?color=HEX_CODE` From be08424af3bf6a9978475adaef5e2c4ed4c54ab5 Mon Sep 17 00:00:00 2001 From: Sandro Circi Date: Sun, 8 Mar 2026 12:01:31 +0100 Subject: [PATCH 7/9] up --- test/e2e/badge.spec.ts | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/test/e2e/badge.spec.ts b/test/e2e/badge.spec.ts index ded578d147..5b0577648e 100644 --- a/test/e2e/badge.spec.ts +++ b/test/e2e/badge.spec.ts @@ -142,7 +142,7 @@ test.describe('badge API', () => { const { body } = await fetchBadge(page, url) expect(body).toContain('fill="#000000">version') - expect(body).toMatch(/fill="#ffffff">v\d/) + expect(body).toMatch(/fill="#000000">v\d/) }) test('3-char hex color is handled correctly for contrast', async ({ page, baseURL }) => { @@ -154,6 +154,17 @@ test.describe('badge API', () => { expect(body).toMatch(/fill="#000000">v\d/) }) + test('light colour produces dark text for contrast in shieldsio style', async ({ + page, + baseURL, + }) => { + const url = toLocalUrl(baseURL, '/api/registry/badge/version/nuxt?style=shieldsio&color=FFDC3B') + const { body } = await fetchBadge(page, url) + + expect(body).toContain('fill="#ffffff">version') + expect(body).toMatch(/fill="#000000">v\d/) + }) + test('custom label parameter is applied to SVG', async ({ page, baseURL }) => { const customLabel = 'my-label' const url = toLocalUrl(baseURL, `/api/registry/badge/version/nuxt?label=${customLabel}`) From e88eba98672d59e627d228f5c4488d9d8ef971a2 Mon Sep 17 00:00:00 2001 From: Sandro Circi Date: Sun, 8 Mar 2026 12:08:43 +0100 Subject: [PATCH 8/9] up --- test/e2e/badge.spec.ts | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/test/e2e/badge.spec.ts b/test/e2e/badge.spec.ts index 5b0577648e..7afb1fec03 100644 --- a/test/e2e/badge.spec.ts +++ b/test/e2e/badge.spec.ts @@ -119,7 +119,6 @@ test.describe('badge API', () => { }) test('light color produces dark text for contrast', async ({ page, baseURL }) => { - // FFDC3B is a bright yellow — should get #000000 text const url = toLocalUrl(baseURL, '/api/registry/badge/version/nuxt?color=FFDC3B') const { body } = await fetchBadge(page, url) @@ -128,7 +127,6 @@ test.describe('badge API', () => { }) test('dark color keeps white text for contrast', async ({ page, baseURL }) => { - // 0a0a0a is near-black — should keep #ffffff text const url = toLocalUrl(baseURL, '/api/registry/badge/version/nuxt?color=0a0a0a') const { body } = await fetchBadge(page, url) @@ -137,7 +135,6 @@ test.describe('badge API', () => { }) test('light labelColor produces dark label text for contrast', async ({ page, baseURL }) => { - // ffffff label background — should get #000000 label text const url = toLocalUrl(baseURL, '/api/registry/badge/version/nuxt?labelColor=ffffff') const { body } = await fetchBadge(page, url) @@ -146,7 +143,6 @@ test.describe('badge API', () => { }) test('3-char hex color is handled correctly for contrast', async ({ page, baseURL }) => { - // CCC expands to CCCCCC — a light grey, should get dark text const url = toLocalUrl(baseURL, '/api/registry/badge/version/nuxt?color=CCC') const { body } = await fetchBadge(page, url) @@ -161,7 +157,7 @@ test.describe('badge API', () => { const url = toLocalUrl(baseURL, '/api/registry/badge/version/nuxt?style=shieldsio&color=FFDC3B') const { body } = await fetchBadge(page, url) - expect(body).toContain('fill="#ffffff">version') + expect(body).toMatch(/fill="#ffffff"(\stextLength="\d+")?>version/) expect(body).toMatch(/fill="#000000">v\d/) }) From 59c26da96309b1bfe014d93c7d673665aeecd349 Mon Sep 17 00:00:00 2001 From: Sandro Circi Date: Sun, 8 Mar 2026 12:24:04 +0100 Subject: [PATCH 9/9] up --- test/e2e/badge.spec.ts | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/test/e2e/badge.spec.ts b/test/e2e/badge.spec.ts index 7afb1fec03..4cf8a33d37 100644 --- a/test/e2e/badge.spec.ts +++ b/test/e2e/badge.spec.ts @@ -139,7 +139,6 @@ test.describe('badge API', () => { const { body } = await fetchBadge(page, url) expect(body).toContain('fill="#000000">version') - expect(body).toMatch(/fill="#000000">v\d/) }) test('3-char hex color is handled correctly for contrast', async ({ page, baseURL }) => { @@ -158,7 +157,7 @@ test.describe('badge API', () => { const { body } = await fetchBadge(page, url) expect(body).toMatch(/fill="#ffffff"(\stextLength="\d+")?>version/) - expect(body).toMatch(/fill="#000000">v\d/) + expect(body).toMatch(/fill="#000000"(\stextLength="\d+")?>v\d/) }) test('custom label parameter is applied to SVG', async ({ page, baseURL }) => {