Skip to content

Commit 6ee90ed

Browse files
committed
feat(api): improve badge customization with dynamic text colors based on contrast
1 parent 0d952fa commit 6ee90ed

3 files changed

Lines changed: 95 additions & 26 deletions

File tree

docs/content/2.guide/1.features.md

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -159,7 +159,7 @@ You can further customize your badges by appending query parameters to the badge
159159

160160
##### `labelColor`
161161

162-
Overrides the default label color. You can pass a standard hex code (with or without the `#` prefix).
162+
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.
163163

164164
- **Default**: `#0a0a0a`
165165
- **Usage**: `?labelColor=HEX_CODE`
@@ -173,16 +173,16 @@ Overrides the default label text. You can pass any string to customize the label
173173

174174
##### `color`
175175

176-
Overrides the default strategy color. You can pass a standard hex code (with or without the `#` prefix).
176+
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.
177177

178178
- **Default**: Depends on the badge type (e.g., version is blue, downloads are orange).
179179
- **Usage**: `?color=HEX_CODE`
180180

181-
| Example | URL |
182-
| :------------- | :------------------------------------- |
183-
| **Hot Pink** | `.../badge/version/nuxt?colorB=ff69b4` |
184-
| **Pure Black** | `.../badge/version/nuxt?colorB=000000` |
185-
| **Brand Blue** | `.../badge/version/nuxt?colorB=3b82f6` |
181+
| Example | URL |
182+
| :------------- | :------------------------------------ |
183+
| **Hot Pink** | `.../badge/version/nuxt?color=ff69b4` |
184+
| **Pure Black** | `.../badge/version/nuxt?color=000000` |
185+
| **Brand Blue** | `.../badge/version/nuxt?color=3b82f6` |
186186

187187
##### `name`
188188

server/api/registry/badge/[type]/[...pkg].get.ts

Lines changed: 52 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -95,6 +95,23 @@ function escapeXML(str: string): string {
9595
.replace(/"/g, '"')
9696
}
9797

98+
function toLinear(c: number): number {
99+
return c <= 0.04045 ? c / 12.92 : Math.pow((c + 0.055) / 1.055, 2.4)
100+
}
101+
102+
function getContrastTextColor(bgHex: string): string {
103+
let clean = bgHex.replace('#', '')
104+
if (clean.length === 3)
105+
clean = clean[0]! + clean[0]! + clean[1]! + clean[1]! + clean[2]! + clean[2]!
106+
if (!/^[0-9a-f]{6}$/i.test(clean)) return '#ffffff'
107+
const r = parseInt(clean.slice(0, 2), 16) / 255
108+
const g = parseInt(clean.slice(2, 4), 16) / 255
109+
const b = parseInt(clean.slice(4, 6), 16) / 255
110+
const luminance = 0.2126 * toLinear(r) + 0.7152 * toLinear(g) + 0.0722 * toLinear(b)
111+
// threshold where contrast ratio with white equals contrast ratio with black
112+
return luminance > 0.179 ? '#000000' : '#ffffff'
113+
}
114+
98115
function measureShieldsTextLength(text: string): number {
99116
const measuredWidth = measureTextWidth(text, SHIELDS_FONT_SHORTHAND)
100117

@@ -110,8 +127,11 @@ function renderDefaultBadgeSvg(params: {
110127
finalLabel: string
111128
finalLabelColor: string
112129
finalValue: string
130+
labelTextColor: string
131+
valueTextColor: string
113132
}): string {
114-
const { finalColor, finalLabel, finalLabelColor, finalValue } = params
133+
const { finalColor, finalLabel, finalLabelColor, finalValue, labelTextColor, valueTextColor } =
134+
params
115135
const leftWidth = finalLabel.trim().length === 0 ? 0 : measureDefaultTextWidth(finalLabel)
116136
const rightWidth = measureDefaultTextWidth(finalValue)
117137
const totalWidth = leftWidth + rightWidth
@@ -120,19 +140,19 @@ function renderDefaultBadgeSvg(params: {
120140
const escapedValue = escapeXML(finalValue)
121141

122142
return `
123-
<svg xmlns="http://www.w3.org/2000/svg" width="${totalWidth}" height="${height}" role="img" aria-label="${escapedLabel}: ${escapedValue}">
124-
<clipPath id="r">
125-
<rect width="${totalWidth}" height="${height}" rx="3" fill="#fff"/>
126-
</clipPath>
127-
<g clip-path="url(#r)">
128-
<rect width="${leftWidth}" height="${height}" fill="${finalLabelColor}"/>
129-
<rect x="${leftWidth}" width="${rightWidth}" height="${height}" fill="${finalColor}"/>
130-
</g>
131-
<g text-anchor="middle" font-family="Geist, system-ui, -apple-system, sans-serif" font-size="11">
132-
<text x="${leftWidth / 2}" y="14" fill="#ffffff">${escapedLabel}</text>
133-
<text x="${leftWidth + rightWidth / 2}" y="14" fill="#ffffff">${escapedValue}</text>
134-
</g>
135-
</svg>
143+
<svg xmlns="http://www.w3.org/2000/svg" width="${totalWidth}" height="${height}" role="img" aria-label="${escapedLabel}: ${escapedValue}">
144+
<clipPath id="r">
145+
<rect width="${totalWidth}" height="${height}" rx="3" fill="#fff"/>
146+
</clipPath>
147+
<g clip-path="url(#r)">
148+
<rect width="${leftWidth}" height="${height}" fill="${finalLabelColor}"/>
149+
<rect x="${leftWidth}" width="${rightWidth}" height="${height}" fill="${finalColor}"/>
150+
</g>
151+
<g text-anchor="middle" font-family="Geist, system-ui, -apple-system, sans-serif" font-size="11">
152+
<text x="${leftWidth / 2}" y="14" fill="${labelTextColor}">${escapedLabel}</text>
153+
<text x="${leftWidth + rightWidth / 2}" y="14" fill="${valueTextColor}">${escapedValue}</text>
154+
</g>
155+
</svg>
136156
`.trim()
137157
}
138158

@@ -141,8 +161,11 @@ function renderShieldsBadgeSvg(params: {
141161
finalLabel: string
142162
finalLabelColor: string
143163
finalValue: string
164+
labelTextColor: string
165+
valueTextColor: string
144166
}): string {
145-
const { finalColor, finalLabel, finalLabelColor, finalValue } = params
167+
const { finalColor, finalLabel, finalLabelColor, finalValue, labelTextColor, valueTextColor } =
168+
params
146169
const hasLabel = finalLabel.trim().length > 0
147170

148171
const leftTextLength = hasLabel ? measureShieldsTextLength(finalLabel) : 0
@@ -174,11 +197,11 @@ function renderShieldsBadgeSvg(params: {
174197
<rect x="${leftWidth}" width="${rightWidth}" height="${height}" fill="${finalColor}"/>
175198
<rect width="${totalWidth}" height="${height}" fill="url(#s)"/>
176199
</g>
177-
<g fill="#fff" text-anchor="middle" font-family="Verdana, Geneva, DejaVu Sans, sans-serif" text-rendering="geometricPrecision" font-size="110">
200+
<g text-anchor="middle" font-family="Verdana, Geneva, DejaVu Sans, sans-serif" text-rendering="geometricPrecision" font-size="110">
178201
<text aria-hidden="true" x="${leftCenter}" y="150" fill="#010101" fill-opacity=".3" transform="scale(.1)" textLength="${leftTextLengthAttr}">${escapedLabel}</text>
179-
<text x="${leftCenter}" y="140" transform="scale(.1)" fill="#fff" textLength="${leftTextLengthAttr}">${escapedLabel}</text>
202+
<text x="${leftCenter}" y="140" transform="scale(.1)" fill="${labelTextColor}" textLength="${leftTextLengthAttr}">${escapedLabel}</text>
180203
<text aria-hidden="true" x="${rightCenter}" y="150" fill="#010101" fill-opacity=".3" transform="scale(.1)" textLength="${rightTextLengthAttr}">${escapedValue}</text>
181-
<text x="${rightCenter}" y="140" transform="scale(.1)" fill="#fff" textLength="${rightTextLengthAttr}">${escapedValue}</text>
204+
<text x="${rightCenter}" y="140" transform="scale(.1)" fill="${valueTextColor}" textLength="${rightTextLengthAttr}">${escapedValue}</text>
182205
</g>
183206
</svg>
184207
`.trim()
@@ -442,8 +465,18 @@ export default defineCachedEventHandler(
442465
const rawLabelColor = labelColor ?? defaultLabelColor
443466
const finalLabelColor = rawLabelColor.startsWith('#') ? rawLabelColor : `#${rawLabelColor}`
444467

468+
const labelTextColor = getContrastTextColor(finalLabelColor)
469+
const valueTextColor = getContrastTextColor(finalColor)
470+
445471
const renderFn = badgeStyle === 'shieldsio' ? renderShieldsBadgeSvg : renderDefaultBadgeSvg
446-
const svg = renderFn({ finalColor, finalLabel, finalLabelColor, finalValue })
472+
const svg = renderFn({
473+
finalColor,
474+
finalLabel,
475+
finalLabelColor,
476+
finalValue,
477+
labelTextColor,
478+
valueTextColor,
479+
})
447480

448481
setHeader(event, 'Content-Type', 'image/svg+xml')
449482
setHeader(

test/e2e/badge.spec.ts

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -118,6 +118,42 @@ test.describe('badge API', () => {
118118
expect(body).toContain(`fill="#${customColor}"`)
119119
})
120120

121+
test('light color produces dark text for contrast', async ({ page, baseURL }) => {
122+
// FFDC3B is a bright yellow — should get #000000 text
123+
const url = toLocalUrl(baseURL, '/api/registry/badge/version/nuxt?color=FFDC3B')
124+
const { body } = await fetchBadge(page, url)
125+
126+
expect(body).toContain('fill="#ffffff">version')
127+
expect(body).toContain('fill="#000000">v')
128+
})
129+
130+
test('dark color keeps white text for contrast', async ({ page, baseURL }) => {
131+
// 0a0a0a is near-black — should keep #ffffff text
132+
const url = toLocalUrl(baseURL, '/api/registry/badge/version/nuxt?color=0a0a0a')
133+
const { body } = await fetchBadge(page, url)
134+
135+
expect(body).toContain('fill="#ffffff">version')
136+
expect(body).toContain('fill="#ffffff">v')
137+
})
138+
139+
test('light labelColor produces dark label text for contrast', async ({ page, baseURL }) => {
140+
// ffffff label background — should get #000000 label text
141+
const url = toLocalUrl(baseURL, '/api/registry/badge/version/nuxt?labelColor=ffffff')
142+
const { body } = await fetchBadge(page, url)
143+
144+
expect(body).toContain('fill="#000000">version')
145+
expect(body).toContain('fill="#ffffff">v')
146+
})
147+
148+
test('3-char hex color is handled correctly for contrast', async ({ page, baseURL }) => {
149+
// CCC expands to CCCCCC — a light grey, should get dark text
150+
const url = toLocalUrl(baseURL, '/api/registry/badge/version/nuxt?color=CCC')
151+
const { body } = await fetchBadge(page, url)
152+
153+
expect(body).toContain('fill="#000000">version')
154+
expect(body).toContain('fill="#ffffff">v')
155+
})
156+
121157
test('custom label parameter is applied to SVG', async ({ page, baseURL }) => {
122158
const customLabel = 'my-label'
123159
const url = toLocalUrl(baseURL, `/api/registry/badge/version/nuxt?label=${customLabel}`)

0 commit comments

Comments
 (0)