diff --git a/server/utils/skill-badge.ts b/server/utils/skill-badge.ts
index dcfb5402..1c93b029 100644
--- a/server/utils/skill-badge.ts
+++ b/server/utils/skill-badge.ts
@@ -2,6 +2,7 @@
import type { SkillBadgeTheme } from '../../shared/skill-badge'
import type { TrendingAward } from '../../shared/trending-award'
+import { BRAND_DARK, BRAND_LIGHT, markGeometry } from '../../shared/brand-mark'
import { skillBadgeAwardTextWidth, skillBadgeAwardWidth } from '../../shared/skill-badge'
import { headlineTrendingAward, trendingAwardBadgeLabel, trendingAwardLabel } from '../../shared/trending-award'
@@ -17,27 +18,94 @@ export type SkillBadgeTarget
const BADGE_SEGMENT = /^[\w.-]{1,100}$/
+/** `flat` matches shields.io's default badge, so a skilld badge can sit in a row of them. */
+export type SkillBadgeStyle = 'skilld' | 'flat'
+
+/** Colour overrides, named after the shields.io parameters. Each is a `#rrggbb` hex. */
+export interface SkillBadgeColors {
+ /** The category segment. */
+ label?: string
+ /** The skilld segment. */
+ brand?: string
+ /** The caret and the dot together, for a one-colour mark. */
+ logo?: string
+}
+
export interface SkillBadgeAppearance {
theme: SkillBadgeTheme
showLabel: boolean
+ style: SkillBadgeStyle
+ colors: SkillBadgeColors
}
export interface SkillBadgeResponseInput {
target: SkillBadgeTarget
theme?: SkillBadgeTheme
showLabel?: boolean
+ style?: SkillBadgeStyle
+ colors?: SkillBadgeColors
likeCount?: number
/** Undefined when not asked for. Null when asked for and none was earned. */
award?: TrendingAward | null
}
-export function parseSkillBadgeAppearance(query: { theme?: unknown, label?: unknown }): SkillBadgeAppearance {
+const HEX_COLOR = /^#?([0-9a-f]{3}|[0-9a-f]{6})$/i
+
+/** A shields-style colour value (`16152b`, `#16152b` or `fff`) as `#rrggbb`. Anything else is ignored. */
+function parseHexColor(value: unknown): string | undefined {
+ if (typeof value !== 'string')
+ return undefined
+ const match = HEX_COLOR.exec(value.trim())
+ if (!match)
+ return undefined
+ const hex = match[1]!.toLowerCase()
+ return `#${hex.length === 3 ? [...hex].map(char => char + char).join('') : hex}`
+}
+
+export function parseSkillBadgeAppearance(query: { theme?: unknown, label?: unknown, style?: unknown, labelColor?: unknown, color?: unknown, logoColor?: unknown }): SkillBadgeAppearance {
+ const colors: SkillBadgeColors = {}
+ const label = parseHexColor(query.labelColor)
+ const brand = parseHexColor(query.color)
+ const logo = parseHexColor(query.logoColor)
+ if (label)
+ colors.label = label
+ if (brand)
+ colors.brand = brand
+ if (logo)
+ colors.logo = logo
return {
theme: query.theme === 'dark' ? 'dark' : 'light',
showLabel: query.label !== '0',
+ style: query.style === 'flat' ? 'flat' : 'skilld',
+ colors,
}
}
+/** Relative luminance of a `#rrggbb` colour, 0 for black to 1 for white. */
+function luminance(hex: string): number {
+ const channel = (offset: number) => {
+ const value = Number.parseInt(hex.slice(offset, offset + 2), 16) / 255
+ return value <= 0.03928 ? value / 12.92 : ((value + 0.055) / 1.055) ** 2.4
+ }
+ return 0.2126 * channel(1) + 0.7152 * channel(3) + 0.0722 * channel(5)
+}
+
+const isDarkFill = (hex: string) => luminance(hex) < 0.4
+
+/** Text colour for a fill: white on dark fills, stone 800 on light ones. */
+const textOn = (fill: string) => isDarkFill(fill) ? '#ffffff' : '#292524'
+
+/**
+ * The skilld mark: the caret in the text colour and one rose dot, in the shade
+ * DESIGN.md pairs with the surface. A logo colour paints both in one colour.
+ */
+function badgeMark(input: { x: number, y: number, size: number, fill: string, logo?: string }): string {
+ const { viewBox, caret, dot } = markGeometry(input.size <= 16 ? 'small' : 'regular')
+ const caretFill = input.logo ?? textOn(input.fill)
+ const dotFill = input.logo ?? (isDarkFill(input.fill) ? BRAND_DARK.dot : BRAND_LIGHT.dot)
+ return ``
+}
+
export function parseSkillBadgeTarget(slug: string): SkillBadgeTarget | null {
const segments = slug.split('/')
const [owner, repo, name] = segments as [string, string, string?]
@@ -89,12 +157,12 @@ export async function loadSkillBadgeAward(db: D1Database, target: SkillBadgeTarg
* The trending mark (`⣀⣤⣶⣿`, see `TrendingMark.vue`) drawn as dots. A badge
* renders in GitHub's image proxy, where no font is sure to carry braille.
*/
-function trendingMarkDots(x: number): string {
+function trendingMarkDots(x: number, baseline = 15.4): string {
const dots: string[] = []
for (const [cell, level] of [1, 2, 3, 4].entries()) {
for (let row = 0; row < level; row++) {
for (const column of [0, 2.2])
- dots.push(``)
+ dots.push(``)
}
}
return dots.join('')
@@ -108,12 +176,11 @@ function badgeLikeLabel(likeCount: number): string {
}
function skillBadgeSvg(input: SkillBadgeResponseInput): string {
- const { target, theme = 'light', likeCount, award } = input
+ const { target, theme = 'light', likeCount, award, colors = {} } = input
const showLabel = input.showLabel !== false
const safeLikeCount = likeCount === undefined ? null : Math.max(0, Math.floor(likeCount))
const showLikes = safeLikeCount !== null
const likeLabel = showLikes ? badgeLikeLabel(safeLikeCount) : ''
- const likeWord = safeLikeCount === 1 ? 'like' : 'likes'
const countWidth = showLikes ? Math.max(46, 25 + likeLabel.length * 7) : 0
const badgeWidth = showLabel ? 153 : 81
const awardLabel = award ? trendingAwardBadgeLabel(award) : ''
@@ -122,17 +189,12 @@ function skillBadgeSvg(input: SkillBadgeResponseInput): string {
const width = awardX + awardWidth
const brandX = showLabel ? 72 : 0
const categoryLabel = target._tag === 'repository' ? 'Skill repo' : 'Agent skill'
- const targetLabel = target._tag === 'repository' ? 'Skill repository' : 'Agent skill'
- const accessibleLabel = [
- `${targetLabel} on skilld.dev`,
- showLikes ? `${safeLikeCount} ${likeWord}` : null,
- award ? trendingAwardLabel(award) : null,
- ].filter(Boolean).join(', ')
+ const accessibleLabel = badgeAccessibleLabel(input)
const darkTheme = theme === 'dark'
- const categoryFill = darkTheme ? '#3f3833' : '#f5f5f4'
- const categoryText = darkTheme ? '#ffffff' : '#292524'
- const brandFill = darkTheme ? '#f5f5f4' : '#2f2925'
- const brandText = darkTheme ? '#292524' : '#ffffff'
+ const categoryFill = colors.label ?? (darkTheme ? '#3f3833' : '#f5f5f4')
+ const categoryText = textOn(categoryFill)
+ const brandFill = colors.brand ?? (darkTheme ? '#f5f5f4' : '#2f2925')
+ const brandText = textOn(brandFill)
const likesFill = darkTheme ? '#e7e5e4' : '#3f3833'
const likesText = darkTheme ? '#292524' : '#ffffff'
const categorySegment = showLabel
@@ -170,18 +232,129 @@ function skillBadgeSvg(input: SkillBadgeResponseInput): string {
${categorySegment}
${likesSegment}${awardSegment}
${categoryContent}
-
+ ${badgeMark({ x: brandX + 7.4, y: 5, size: 12, fill: brandFill, logo: colors.logo })}
skilld.dev${likesContent}${awardContent}
`
}
+const FLAT_FONT = 'Verdana,Geneva,DejaVu Sans,sans-serif'
+/** shields.io's default label grey, for a flat badge with no label colour. */
+const FLAT_LABEL_FILL = '#555555'
+
+/**
+ * Verdana advance widths at 11px, the size shields.io sets badge text in. The
+ * SVG pins each text run to this width, so the layout never depends on the
+ * reader's installed fonts.
+ */
+function verdanaWidth(text: string): number {
+ let width = 0
+ for (const char of text) {
+ if ('ijl'.includes(char))
+ width += 3
+ else if (' .,:;·\''.includes(char))
+ width += 3.9
+ else if ('ftr'.includes(char))
+ width += 4.5
+ else if (char === 'm')
+ width += 10.5
+ else if (char === 'w')
+ width += 9
+ else if ('csz'.includes(char))
+ width += 5.8
+ else if (/[0-9#]/.test(char))
+ width += 7
+ else if (/[A-Z]/.test(char))
+ width += 7.5
+ else
+ width += 6.7
+ }
+ return Math.round(width * 10) / 10
+}
+
+interface FlatSegment {
+ fill: string
+ width: number
+ draw: (x: number) => string
+}
+
+/** A text run in shields.io's flat style: 11px Verdana with a one pixel drop shadow. */
+function flatText(text: string, x: number, fill: string): string {
+ const width = verdanaWidth(text)
+ const centre = Math.round((x + width / 2) * 10)
+ const shadow = isDarkFill(fill) ? '#010101' : '#cccccc'
+ const color = textOn(fill)
+ return `${text}`
+ + `${text}`
+}
+
+/** The badge in shields.io's default flat style, so it lines up in a row of shields badges. */
+function flatBadgeSvg(input: SkillBadgeResponseInput): string {
+ const { target, theme = 'light', likeCount, award, colors = {} } = input
+ const showLabel = input.showLabel !== false
+ const safeLikeCount = likeCount === undefined ? null : Math.max(0, Math.floor(likeCount))
+ const labelFill = colors.label ?? FLAT_LABEL_FILL
+ const brandFill = colors.brand ?? (theme === 'dark' ? BRAND_DARK.ink : BRAND_LIGHT.ink)
+ const segments: FlatSegment[] = []
+ if (showLabel) {
+ const text = target._tag === 'repository' ? 'Skill repo' : 'Agent skill'
+ segments.push({ fill: labelFill, width: verdanaWidth(text) + 10, draw: x => flatText(text, x + 5, labelFill) })
+ }
+ segments.push({
+ fill: brandFill,
+ width: verdanaWidth('skilld.dev') + 27,
+ draw: x => badgeMark({ x: x + 5, y: 3, size: 14, fill: brandFill, logo: colors.logo }) + flatText('skilld.dev', x + 22, brandFill),
+ })
+ if (safeLikeCount !== null) {
+ const text = badgeLikeLabel(safeLikeCount)
+ const fill = labelFill
+ segments.push({
+ fill,
+ width: verdanaWidth(text) + 27,
+ draw: x => `${
+ flatText(text, x + 22, fill)}`,
+ })
+ }
+ if (award) {
+ const text = trendingAwardBadgeLabel(award)
+ segments.push({
+ fill: AWARD_FILL,
+ width: verdanaWidth(text) + 37,
+ draw: x => `${trendingMarkDots(x + 5, 14.4)}${flatText(text, x + 32, AWARD_FILL)}`,
+ })
+ }
+
+ let x = 0
+ const placed = segments.map((segment) => {
+ const at = x
+ x += segment.width
+ return { ...segment, at }
+ })
+ const width = Math.ceil(x)
+ const label = badgeAccessibleLabel(input)
+ return ``
+}
+
+function badgeAccessibleLabel(input: SkillBadgeResponseInput): string {
+ const { target, likeCount, award } = input
+ const safeLikeCount = likeCount === undefined ? null : Math.max(0, Math.floor(likeCount))
+ return [
+ `${target._tag === 'repository' ? 'Skill repository' : 'Agent skill'} on skilld.dev`,
+ safeLikeCount === null ? null : `${safeLikeCount} ${safeLikeCount === 1 ? 'like' : 'likes'}`,
+ award ? trendingAwardLabel(award) : null,
+ ].filter(Boolean).join(', ')
+}
+
export function createSkillBadgeResponse(input: SkillBadgeResponseInput): Response {
const cachePolicy = input.likeCount !== undefined
? LIKES_CACHE_POLICY
: input.award !== undefined ? AWARD_CACHE_POLICY : PLAIN_CACHE_POLICY
- return new Response(skillBadgeSvg(input), {
+ return new Response(input.style === 'flat' ? flatBadgeSvg(input) : skillBadgeSvg(input), {
status: 200,
headers: {
'access-control-allow-origin': '*',
diff --git a/shared/brand-mark.ts b/shared/brand-mark.ts
index a6f54991..30aae846 100644
--- a/shared/brand-mark.ts
+++ b/shared/brand-mark.ts
@@ -34,6 +34,18 @@ const MARK_DOT: Record = {
small: { cx: 80, cy: 50, r: 12 },
}
+export interface MarkGeometry {
+ viewBox: string
+ caret: string
+ dot: { cx: number, cy: number, r: number }
+}
+
+/** The mark's parts, for callers that compose their own SVG, such as the README badge. */
+export function markGeometry(cut: MarkCut = 'regular'): MarkGeometry {
+ const { x, y, width, height } = MARK_VIEWBOX
+ return { viewBox: `${x} ${y} ${width} ${height}`, caret: CARET_PATH[cut], dot: MARK_DOT[cut] }
+}
+
/** The caret's ink, in mark units. Both cuts share it. */
const CARET_INK = { left: 18, right: 58, top: 18, bottom: 82, centreY: 50 } as const
diff --git a/test/unit/skill-badge.test.ts b/test/unit/skill-badge.test.ts
index fd01997f..14213a9c 100644
--- a/test/unit/skill-badge.test.ts
+++ b/test/unit/skill-badge.test.ts
@@ -78,13 +78,50 @@ describe('skill badge', () => {
expect(parseSkillBadgeAppearance({ theme: 'dark', label: '0' })).toEqual({
theme: 'dark',
showLabel: false,
+ style: 'skilld',
+ colors: {},
})
expect(parseSkillBadgeAppearance({ theme: 'sepia', label: 'yes' })).toEqual({
theme: 'light',
showLabel: true,
+ style: 'skilld',
+ colors: {},
})
})
+ it('parses the flat style and shields colours, and drops colours that are not hex', () => {
+ expect(parseSkillBadgeAppearance({ style: 'flat', labelColor: '16152B', color: '#0a3', logoColor: 'red' })).toEqual({
+ theme: 'light',
+ showLabel: true,
+ style: 'flat',
+ colors: { label: '#16152b', brand: '#00aa33' },
+ })
+ expect(parseSkillBadgeAppearance({ color: 'url(#x)' }).colors).toEqual({})
+ })
+
+ it('draws the prompt caret mark, never the retired chevron', async () => {
+ const svg = await createSkillBadgeResponse({
+ target: { _tag: 'repository', owner: 'danielroe', repo: 'empathy' },
+ }).text()
+ expect(svg).toContain('d="M58 50 L18 82')
+ expect(svg).not.toContain('M80 34 L135 104')
+ })
+
+ it('renders a 20px flat badge in the requested colours, with readable text', async () => {
+ const svg = await createSkillBadgeResponse({
+ target: { _tag: 'repository', owner: 'harlan-zw', repo: 'nuxt-seo' },
+ style: 'flat',
+ colors: { label: '#16152b', brand: '#e7f6ec', logo: '#ffffff' },
+ }).text()
+ expect(svg).toMatch(/height="20" viewBox="0 0 \d+ 20"/)
+ expect(svg).toContain('')
+ expect(svg).toContain('fill="#e7f6ec"/>')
+ // White on the dark label, stone on the pale brand fill.
+ expect(svg).toContain('fill="#ffffff" textLength')
+ expect(svg).toContain('fill="#292524" textLength')
+ expect(svg).toContain('Skill repository on skilld.dev')
+ })
+
it('returns a plain badge without querying for likes', async () => {
const response = createSkillBadgeResponse({
target: { _tag: 'repository', owner: 'danielroe', repo: 'empathy' },