Skip to content

Commit 36bebce

Browse files
sandros94alexdln
andauthored
feat: improve badge customization with dynamic text based on contrast (#1979)
Co-authored-by: Alex Savelyev <91429106+alexdln@users.noreply.github.com>
1 parent 089a73b commit 36bebce

File tree

3 files changed

+125
-45
lines changed

3 files changed

+125
-45
lines changed

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: 76 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -14,12 +14,17 @@ const BUNDLEPHOBIA_API = 'https://bundlephobia.com/api/size'
1414
const NPMS_API = 'https://api.npms.io/v2/package'
1515

1616
const SafeStringSchema = v.pipe(v.string(), v.regex(/^[^<>"&]*$/, 'Invalid characters'))
17+
const SafeColorSchema = v.pipe(
18+
v.string(),
19+
v.transform(value => (value.startsWith('#') ? value : `#${value}`)),
20+
v.hexColor(),
21+
)
1722

1823
const QUERY_SCHEMA = v.object({
19-
color: v.optional(SafeStringSchema),
2024
name: v.optional(v.string()),
21-
labelColor: v.optional(SafeStringSchema),
2225
label: v.optional(SafeStringSchema),
26+
color: v.optional(SafeColorSchema),
27+
labelColor: v.optional(SafeColorSchema),
2328
})
2429

2530
const COLORS = {
@@ -95,6 +100,23 @@ function escapeXML(str: string): string {
95100
.replace(/"/g, '&quot;')
96101
}
97102

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

@@ -110,8 +132,11 @@ function renderDefaultBadgeSvg(params: {
110132
finalLabel: string
111133
finalLabelColor: string
112134
finalValue: string
135+
labelTextColor: string
136+
valueTextColor: string
113137
}): string {
114-
const { finalColor, finalLabel, finalLabelColor, finalValue } = params
138+
const { finalColor, finalLabel, finalLabelColor, finalValue, labelTextColor, valueTextColor } =
139+
params
115140
const leftWidth = finalLabel.trim().length === 0 ? 0 : measureDefaultTextWidth(finalLabel)
116141
const rightWidth = measureDefaultTextWidth(finalValue)
117142
const totalWidth = leftWidth + rightWidth
@@ -120,19 +145,19 @@ function renderDefaultBadgeSvg(params: {
120145
const escapedValue = escapeXML(finalValue)
121146

122147
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>
148+
<svg xmlns="http://www.w3.org/2000/svg" width="${totalWidth}" height="${height}" role="img" aria-label="${escapedLabel}: ${escapedValue}">
149+
<clipPath id="r">
150+
<rect width="${totalWidth}" height="${height}" rx="3" fill="#fff"/>
151+
</clipPath>
152+
<g clip-path="url(#r)">
153+
<rect width="${leftWidth}" height="${height}" fill="${finalLabelColor}"/>
154+
<rect x="${leftWidth}" width="${rightWidth}" height="${height}" fill="${finalColor}"/>
155+
</g>
156+
<g text-anchor="middle" font-family="Geist, system-ui, -apple-system, sans-serif" font-size="11">
157+
<text x="${leftWidth / 2}" y="14" fill="${labelTextColor}">${escapedLabel}</text>
158+
<text x="${leftWidth + rightWidth / 2}" y="14" fill="${valueTextColor}">${escapedValue}</text>
159+
</g>
160+
</svg>
136161
`.trim()
137162
}
138163

@@ -141,8 +166,11 @@ function renderShieldsBadgeSvg(params: {
141166
finalLabel: string
142167
finalLabelColor: string
143168
finalValue: string
169+
labelTextColor: string
170+
valueTextColor: string
144171
}): string {
145-
const { finalColor, finalLabel, finalLabelColor, finalValue } = params
172+
const { finalColor, finalLabel, finalLabelColor, finalValue, labelTextColor, valueTextColor } =
173+
params
146174
const hasLabel = finalLabel.trim().length > 0
147175

148176
const leftTextLength = hasLabel ? measureShieldsTextLength(finalLabel) : 0
@@ -161,26 +189,26 @@ function renderShieldsBadgeSvg(params: {
161189
const rightTextLengthAttr = rightTextLength * 10
162190

163191
return `
164-
<svg xmlns="http://www.w3.org/2000/svg" width="${totalWidth}" height="${height}" role="img" aria-label="${title}">
165-
<linearGradient id="s" x2="0" y2="100%">
166-
<stop offset="0" stop-color="#bbb" stop-opacity=".1"/>
167-
<stop offset="1" stop-opacity=".1"/>
168-
</linearGradient>
169-
<clipPath id="r">
170-
<rect width="${totalWidth}" height="${height}" rx="3" fill="#fff"/>
171-
</clipPath>
172-
<g clip-path="url(#r)">
173-
<rect width="${leftWidth}" height="${height}" fill="${finalLabelColor}"/>
174-
<rect x="${leftWidth}" width="${rightWidth}" height="${height}" fill="${finalColor}"/>
175-
<rect width="${totalWidth}" height="${height}" fill="url(#s)"/>
176-
</g>
177-
<g fill="#fff" text-anchor="middle" font-family="Verdana, Geneva, DejaVu Sans, sans-serif" text-rendering="geometricPrecision" font-size="110">
178-
<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>
180-
<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>
182-
</g>
183-
</svg>
192+
<svg xmlns="http://www.w3.org/2000/svg" width="${totalWidth}" height="${height}" role="img" aria-label="${title}">
193+
<linearGradient id="s" x2="0" y2="100%">
194+
<stop offset="0" stop-color="#bbb" stop-opacity=".1"/>
195+
<stop offset="1" stop-opacity=".1"/>
196+
</linearGradient>
197+
<clipPath id="r">
198+
<rect width="${totalWidth}" height="${height}" rx="3" fill="#fff"/>
199+
</clipPath>
200+
<g clip-path="url(#r)">
201+
<rect width="${leftWidth}" height="${height}" fill="${finalLabelColor}"/>
202+
<rect x="${leftWidth}" width="${rightWidth}" height="${height}" fill="${finalColor}"/>
203+
<rect width="${totalWidth}" height="${height}" fill="url(#s)"/>
204+
</g>
205+
<g text-anchor="middle" font-family="Verdana, Geneva, DejaVu Sans, sans-serif" text-rendering="geometricPrecision" font-size="110">
206+
<text aria-hidden="true" x="${leftCenter}" y="150" fill="#010101" fill-opacity=".3" transform="scale(.1)" textLength="${leftTextLengthAttr}">${escapedLabel}</text>
207+
<text x="${leftCenter}" y="140" transform="scale(.1)" fill="${labelTextColor}" textLength="${leftTextLengthAttr}">${escapedLabel}</text>
208+
<text aria-hidden="true" x="${rightCenter}" y="150" fill="#010101" fill-opacity=".3" transform="scale(.1)" textLength="${rightTextLengthAttr}">${escapedValue}</text>
209+
<text x="${rightCenter}" y="140" transform="scale(.1)" fill="${valueTextColor}" textLength="${rightTextLengthAttr}">${escapedValue}</text>
210+
</g>
211+
</svg>
184212
`.trim()
185213
}
186214

@@ -442,8 +470,18 @@ export default defineCachedEventHandler(
442470
const rawLabelColor = labelColor ?? defaultLabelColor
443471
const finalLabelColor = rawLabelColor.startsWith('#') ? rawLabelColor : `#${rawLabelColor}`
444472

473+
const labelTextColor = getContrastTextColor(finalLabelColor)
474+
const valueTextColor = getContrastTextColor(finalColor)
475+
445476
const renderFn = badgeStyle === 'shieldsio' ? renderShieldsBadgeSvg : renderDefaultBadgeSvg
446-
const svg = renderFn({ finalColor, finalLabel, finalLabelColor, finalValue })
477+
const svg = renderFn({
478+
finalColor,
479+
finalLabel,
480+
finalLabelColor,
481+
finalValue,
482+
labelTextColor,
483+
valueTextColor,
484+
})
447485

448486
setHeader(event, 'Content-Type', 'image/svg+xml')
449487
setHeader(

test/e2e/badge.spec.ts

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -118,6 +118,48 @@ 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+
const url = toLocalUrl(baseURL, '/api/registry/badge/version/nuxt?color=FFDC3B')
123+
const { body } = await fetchBadge(page, url)
124+
125+
expect(body).toContain('fill="#ffffff">version')
126+
expect(body).toMatch(/fill="#000000">v\d/)
127+
})
128+
129+
test('dark color keeps white text for contrast', async ({ page, baseURL }) => {
130+
const url = toLocalUrl(baseURL, '/api/registry/badge/version/nuxt?color=0a0a0a')
131+
const { body } = await fetchBadge(page, url)
132+
133+
expect(body).toContain('fill="#ffffff">version')
134+
expect(body).toMatch(/fill="#ffffff">v\d/)
135+
})
136+
137+
test('light labelColor produces dark label text for contrast', async ({ page, baseURL }) => {
138+
const url = toLocalUrl(baseURL, '/api/registry/badge/version/nuxt?labelColor=ffffff')
139+
const { body } = await fetchBadge(page, url)
140+
141+
expect(body).toContain('fill="#000000">version')
142+
})
143+
144+
test('3-char hex color is handled correctly for contrast', async ({ page, baseURL }) => {
145+
const url = toLocalUrl(baseURL, '/api/registry/badge/version/nuxt?color=CCC')
146+
const { body } = await fetchBadge(page, url)
147+
148+
expect(body).toContain('fill="#ffffff">version')
149+
expect(body).toMatch(/fill="#000000">v\d/)
150+
})
151+
152+
test('light colour produces dark text for contrast in shieldsio style', async ({
153+
page,
154+
baseURL,
155+
}) => {
156+
const url = toLocalUrl(baseURL, '/api/registry/badge/version/nuxt?style=shieldsio&color=FFDC3B')
157+
const { body } = await fetchBadge(page, url)
158+
159+
expect(body).toMatch(/fill="#ffffff"(\stextLength="\d+")?>version/)
160+
expect(body).toMatch(/fill="#000000"(\stextLength="\d+")?>v\d/)
161+
})
162+
121163
test('custom label parameter is applied to SVG', async ({ page, baseURL }) => {
122164
const customLabel = 'my-label'
123165
const url = toLocalUrl(baseURL, `/api/registry/badge/version/nuxt?label=${customLabel}`)

0 commit comments

Comments
 (0)