From e90c14a1bc7e2f8043a9b10814d4a08824faa480 Mon Sep 17 00:00:00 2001 From: Harlan Wilton Date: Tue, 6 Oct 2026 18:04:51 +1100 Subject: [PATCH] feat(badge): draw the prompt caret and add a flat style README badges still drew the retired rose chevron that DESIGN.md bans. They now draw the caret and dot from shared/brand-mark.ts. A 22px badge with fixed colours also could not sit in a row of shields.io badges, so style=flat matches their geometry and labelColor, color and logoColor take shields-style hex values. --- server/utils/skill-badge.ts | 211 +++++++++++++++++++++++++++++++--- shared/brand-mark.ts | 12 ++ test/unit/skill-badge.test.ts | 37 ++++++ 3 files changed, 241 insertions(+), 19 deletions(-) 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}` +} + +/** 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 => `${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 ` + ${label} + + + ${placed.map(segment => ``).join('')} + ${placed.map(segment => segment.draw(segment.at)).join('')} +` +} + +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' },