Add custom repo hex color picker (#3022)

This commit is contained in:
Neil
2026-05-28 19:38:41 -07:00
committed by GitHub
parent 09e3dfbc23
commit 3d9d4179a1
17 changed files with 384 additions and 41 deletions
+1
View File
@@ -111,6 +111,7 @@
"posthog-node": "^5.33.3",
"qrcode": "^1.5.4",
"radix-ui": "^1.4.3",
"react-colorful": "^5.7.0",
"react-markdown": "^10.1.0",
"rehype-highlight": "^7.0.2",
"rehype-katex": "^7.0.1",
+14
View File
@@ -178,6 +178,9 @@ importers:
radix-ui:
specifier: ^1.4.3
version: 1.4.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)
react-colorful:
specifier: ^5.7.0
version: 5.7.0(react-dom@19.2.5(react@19.2.5))(react@19.2.5)
react-markdown:
specifier: ^10.1.0
version: 10.1.0(@types/react@19.2.14)(react@19.2.5)
@@ -5469,6 +5472,12 @@ packages:
resolution: {integrity: sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==}
hasBin: true
react-colorful@5.7.0:
resolution: {integrity: sha512-fuesYIemttah97XmsIHmz4OORDHiSFzyc9HMAIrCHJou2jaRQmL8cFJ76K4zQhhj8jzwOBlOi4BaGTjjOZCfTg==}
peerDependencies:
react: '>=16.8.0'
react-dom: '>=16.8.0'
react-dom@19.2.5:
resolution: {integrity: sha512-J5bAZz+DXMMwW/wV3xzKke59Af6CHY7G4uYLN1OvBcKEsWOs4pQExj86BBKamxl/Ik5bx9whOrvBlSDfWzgSag==}
peerDependencies:
@@ -11887,6 +11896,11 @@ snapshots:
minimist: 1.2.8
strip-json-comments: 2.0.1
react-colorful@5.7.0(react-dom@19.2.5(react@19.2.5))(react@19.2.5):
dependencies:
react: 19.2.5
react-dom: 19.2.5(react@19.2.5)
react-dom@19.2.5(react@19.2.5):
dependencies:
react: 19.2.5
+9
View File
@@ -17,6 +17,7 @@ import type {
} from '../../shared/types'
import { isFolderRepo } from '../../shared/repo-kind'
import { DEFAULT_REPO_BADGE_COLOR } from '../../shared/constants'
import { normalizeRepoBadgeColor } from '../../shared/repo-badge-color'
import { sanitizeRepoIcon } from '../../shared/repo-icon'
import { normalizeRepoSourceControlAiOverrides } from '../../shared/source-control-ai'
import { invalidateAuthorizedRootsCache } from './filesystem-auth'
@@ -955,6 +956,14 @@ export function registerRepoHandlers(mainWindow: BrowserWindow, store: Store): v
updates.repoIcon = repoIcon
}
}
if ('badgeColor' in updates) {
const badgeColor = normalizeRepoBadgeColor(updates.badgeColor)
if (!badgeColor) {
delete updates.badgeColor
} else {
updates.badgeColor = badgeColor
}
}
if (
'externalWorktreeVisibility' in updates &&
updates.externalWorktreeVisibility !== undefined &&
+20
View File
@@ -1606,6 +1606,26 @@ describe('Store', () => {
expect(store.getRepo('r1')!.repoIcon).toBeUndefined()
})
it('updateRepo normalizes custom repo badge colors before storing', async () => {
const store = await createStore()
store.addRepo(makeRepo())
const updated = store.updateRepo('r1', { badgeColor: ' ABCDEF ' })
expect(updated!.badgeColor).toBe('#abcdef')
expect(store.getRepo('r1')!.badgeColor).toBe('#abcdef')
})
it('updateRepo ignores invalid repo badge colors without clearing the existing color', async () => {
const store = await createStore()
store.addRepo(makeRepo({ badgeColor: '#123456' }))
const updated = store.updateRepo('r1', { badgeColor: 'blue' })
expect(updated!.badgeColor).toBe('#123456')
expect(store.getRepo('r1')!.badgeColor).toBe('#123456')
})
it('getRepo does not expose invalid persisted repo icons', async () => {
const store = await createStore()
store.addRepo(
+12 -3
View File
@@ -100,6 +100,7 @@ import {
} from '../shared/workspace-statuses'
import { isLegacyRepoForExternalWorktreeVisibility } from '../shared/worktree-ownership'
import { sanitizeRepoIcon } from '../shared/repo-icon'
import { normalizeRepoBadgeColor } from '../shared/repo-badge-color'
import {
clearMissingProjectGroupMemberships,
createProjectGroup,
@@ -476,10 +477,18 @@ function readLegacySidekickFlag(parsed: PersistedState | undefined): boolean | u
return (parsed?.settings as { experimentalSidekick?: boolean } | undefined)?.experimentalSidekick
}
function sanitizeRepoUpdatesForPersistence<T extends Partial<Pick<Repo, 'repoIcon'>>>(
updates: T
): T {
function sanitizeRepoUpdatesForPersistence<
T extends Partial<Pick<Repo, 'badgeColor' | 'repoIcon'>>
>(updates: T): T {
const sanitized = { ...updates }
if ('badgeColor' in sanitized) {
const badgeColor = normalizeRepoBadgeColor(sanitized.badgeColor)
if (!badgeColor) {
delete sanitized.badgeColor
} else {
sanitized.badgeColor = badgeColor
}
}
if ('repoIcon' in sanitized) {
const repoIcon = sanitizeRepoIcon(sanitized.repoIcon)
if (repoIcon === undefined) {
@@ -0,0 +1,38 @@
import { describe, expect, it, vi } from 'vitest'
import { RpcDispatcher } from '../dispatcher'
import type { RpcRequest } from '../core'
import type { OrcaRuntimeService } from '../../orca-runtime'
import { REPO_METHODS } from './repo'
function makeRequest(method: string, params?: unknown): RpcRequest {
return { id: 'req-1', authToken: 'tok', method, params }
}
describe('repo badge color RPC updates', () => {
it('normalizes repo badge colors before runtime updates', async () => {
const runtime = {
getRuntimeId: () => 'test-runtime',
updateRepo: vi.fn().mockResolvedValue({
id: 'repo-1',
path: '/srv/repo',
badgeColor: '#abcdef'
})
} as unknown as OrcaRuntimeService
const dispatcher = new RpcDispatcher({ runtime, methods: REPO_METHODS })
const response = await dispatcher.dispatch(
makeRequest('repo.update', {
repo: 'repo-1',
updates: { badgeColor: ' ABCDEF ' }
})
)
expect(runtime.updateRepo).toHaveBeenCalledWith('repo-1', {
badgeColor: '#abcdef'
})
expect(response).toMatchObject({
ok: true,
result: { repo: { id: 'repo-1', badgeColor: '#abcdef' } }
})
})
})
+9 -1
View File
@@ -2,6 +2,7 @@ import { z } from 'zod'
import { defineMethod, type RpcMethod } from '../core'
import { OptionalFiniteNumber, OptionalString, requiredString } from '../schemas'
import { sanitizeRepoIcon } from '../../../../shared/repo-icon'
import { normalizeRepoBadgeColor } from '../../../../shared/repo-badge-color'
import { normalizeRepoSourceControlAiOverrides } from '../../../../shared/source-control-ai'
const RepoSelector = z.object({
@@ -36,10 +37,17 @@ const RepoSourceControlAiOverrides = z
value === undefined ? undefined : normalizeRepoSourceControlAiOverrides(value)
)
const RepoBadgeColor = z
.unknown()
.optional()
.transform((value) =>
value === undefined ? undefined : (normalizeRepoBadgeColor(value) ?? undefined)
)
const RepoUpdate = RepoSelector.extend({
updates: z.object({
displayName: OptionalString,
badgeColor: OptionalString,
badgeColor: RepoBadgeColor,
repoIcon: z
.unknown()
.transform((value) => sanitizeRepoIcon(value))
@@ -3,12 +3,14 @@ import { toast } from 'sonner'
import { Github, Image, Link2, RotateCcw } from 'lucide-react'
import type { Repo } from '../../../../shared/types'
import { faviconUrlFromWebsite, type RepoIcon } from '../../../../shared/repo-icon'
import { REPO_COLORS } from '../../../../shared/constants'
import { DEFAULT_REPO_BADGE_COLOR, REPO_COLORS } from '../../../../shared/constants'
import { normalizeRepoBadgeColor } from '../../../../shared/repo-badge-color'
import { Button } from '../ui/button'
import { Input } from '../ui/input'
import { Label } from '../ui/label'
import { Tabs, TabsContent, TabsList, TabsTrigger } from '../ui/tabs'
import { Tooltip, TooltipContent, TooltipTrigger } from '../ui/tooltip'
import { ColorPicker } from '../ui/color-picker'
import { RepoIconGlyph, REPO_LUCIDE_ICON_OPTIONS } from '../repo/repo-icon'
import { cn } from '@/lib/utils'
import { useAppStore } from '@/store'
@@ -31,6 +33,8 @@ export function RepositoryIconPicker({
const selectedLucideName =
repo.repoIcon?.type === 'lucide' ? repo.repoIcon.name : repo.repoIcon == null ? 'Folder' : null
const selectedEmoji = repo.repoIcon?.type === 'emoji' ? repo.repoIcon.emoji : ''
const selectedBadgeColor = normalizeRepoBadgeColor(repo.badgeColor) ?? DEFAULT_REPO_BADGE_COLOR
const isPresetBadgeColor = REPO_COLORS.some((color) => color === selectedBadgeColor)
const initialTab =
repo.repoIcon?.type === 'image' ? 'image' : repo.repoIcon?.type === 'emoji' ? 'emoji' : 'icon'
const runtimeTarget = useMemo(
@@ -52,6 +56,7 @@ export function RepositoryIconPicker({
}, [repo.repoIcon, selectedLucideName])
const setIcon = (repoIcon: RepoIcon | null) => updateRepo(repo.id, { repoIcon })
const setBadgeColor = (badgeColor: string) => updateRepo(repo.id, { badgeColor })
const handleUploadImage = async () => {
try {
@@ -115,7 +120,7 @@ export function RepositoryIconPicker({
<div className="flex items-center gap-3">
<RepoIconGlyph
repoIcon={repo.repoIcon}
color={repo.badgeColor}
color={selectedBadgeColor}
className="size-10 shrink-0 rounded-md border border-border/70 bg-muted/30"
iconClassName="size-5"
/>
@@ -135,22 +140,39 @@ export function RepositoryIconPicker({
</Button>
</div>
<div className="flex flex-wrap gap-2">
{REPO_COLORS.map((color) => (
<button
key={color}
type="button"
onClick={() => updateRepo(repo.id, { badgeColor: color })}
className={cn(
'size-7 rounded-[4px] transition-all',
repo.badgeColor === color
? 'ring-2 ring-foreground ring-offset-2 ring-offset-background'
: 'hover:ring-1 hover:ring-muted-foreground hover:ring-offset-2 hover:ring-offset-background'
)}
style={{ backgroundColor: color }}
title={color}
<div className="space-y-2">
<Label className="text-sm font-semibold">Color</Label>
<div className="flex flex-wrap items-center gap-2">
{REPO_COLORS.map((color) => (
<button
key={color}
type="button"
onClick={() => setBadgeColor(color)}
aria-label={`Use ${color} repo color`}
aria-pressed={selectedBadgeColor === color}
className={cn(
'size-7 rounded-[4px] outline-none transition-all focus-visible:ring-[3px] focus-visible:ring-ring/50',
selectedBadgeColor === color
? 'ring-2 ring-foreground ring-offset-2 ring-offset-background'
: 'hover:ring-1 hover:ring-muted-foreground hover:ring-offset-2 hover:ring-offset-background'
)}
style={{ backgroundColor: color }}
/>
))}
<ColorPicker
value={selectedBadgeColor}
onChange={setBadgeColor}
label={
isPresetBadgeColor
? 'Choose custom repo color'
: `Custom repo color ${selectedBadgeColor}`
}
selected={!isPresetBadgeColor}
triggerLabel="Custom"
showHexInTrigger={!isPresetBadgeColor}
className="h-7 px-2"
/>
))}
</div>
</div>
<Tabs defaultValue={initialTab} className="gap-3">
@@ -6,6 +6,7 @@ import { Input } from '../ui/input'
import { Label } from '../ui/label'
import { Separator } from '../ui/separator'
import { Trash2 } from 'lucide-react'
import { Tooltip, TooltipContent, TooltipTrigger } from '../ui/tooltip'
import { BaseRefPicker } from './BaseRefPicker'
import { RepositoryHooksSection } from './RepositoryHooksSection'
import { McpConfigSection } from './McpConfigSection'
@@ -107,6 +108,8 @@ export function RepositoryPane({
const mcpEntries = allEntries.filter((entry) => entry.title === 'MCP Configs')
const symlinkEntries = allEntries.filter((entry) => entry.title === 'Worktree Symlinks')
const sourceControlAiEntries = allEntries.filter((entry) => entry.title === 'Source Control AI')
const removeProjectLabel =
confirmingRemove === repo.id ? 'Confirm Remove Project' : 'Remove Project'
const hooksSection =
!isFolder && (forceFullPaneForRepoMatch || matchesSettingsSearch(searchQuery, hooksEntries)) ? (
@@ -129,9 +132,9 @@ export function RepositoryPane({
// most-edited surface and should beat MCP/symlinks/sparse-presets.
const visibleSections = [
forceFullPaneForRepoMatch || matchesSettingsSearch(searchQuery, identityEntries) ? (
<section key="identity" className="space-y-8">
<section key="identity" className="relative space-y-8">
<div className="flex items-start justify-between gap-4">
<div className="space-y-1">
<div className="space-y-1 pr-12">
<h3 className="text-sm font-semibold">Identity</h3>
<p className="text-xs text-muted-foreground">
Project-specific display details for the sidebar and tabs.
@@ -149,18 +152,26 @@ export function RepositoryPane({
title="Remove Project"
description="Remove this project from Orca."
keywords={[repo.displayName, 'delete', 'project', 'repository']}
className="absolute top-0 right-0 z-10 w-auto max-w-none"
forceVisible={forceFullPaneForRepoMatch}
>
<Button
variant={confirmingRemove === repo.id ? 'destructive' : 'outline'}
size="sm"
onClick={() => handleRemoveProject(repo.id)}
onBlur={() => setConfirmingRemove(null)}
className="gap-2"
>
<Trash2 className="size-3.5" />
{confirmingRemove === repo.id ? 'Confirm Remove' : 'Remove Project'}
</Button>
<Tooltip>
<TooltipTrigger asChild>
<Button
type="button"
variant={confirmingRemove === repo.id ? 'destructive' : 'outline'}
size="icon-sm"
onClick={() => handleRemoveProject(repo.id)}
onBlur={() => setConfirmingRemove(null)}
aria-label={removeProjectLabel}
>
<Trash2 className="size-3.5" />
</Button>
</TooltipTrigger>
<TooltipContent side="top" sideOffset={4}>
{removeProjectLabel}
</TooltipContent>
</Tooltip>
</SearchableSetting>
</div>
@@ -18,6 +18,7 @@ export function getRepositoryPaneSearchEntries(repo: Repo): SettingsSearchEntry[
'project icon',
'repository icon',
'color',
'hex',
'badge',
'emoji',
'favicon'
@@ -19,8 +19,12 @@ describe('resolveRepoHeaderColor', () => {
expect(resolveRepoHeaderColor(` ${REPO_COLORS[4].toUpperCase()} `)).toBe(REPO_COLORS[4])
})
it('falls back for out-of-palette colors', () => {
expect(resolveRepoHeaderColor('#123456')).toBe(DEFAULT_REPO_BADGE_COLOR)
it('returns normalized custom hex colors', () => {
expect(resolveRepoHeaderColor(' #123ABC ')).toBe('#123abc')
})
it('falls back for invalid colors', () => {
expect(resolveRepoHeaderColor('blue')).toBe(DEFAULT_REPO_BADGE_COLOR)
})
})
@@ -1,18 +1,17 @@
import { DEFAULT_REPO_BADGE_COLOR, REPO_COLORS } from '../../../../shared/constants'
import { normalizeRepoBadgeColor } from '../../../../shared/repo-badge-color'
const PROJECT_GROUP_HEADER_KEY_PREFIX = 'repo:'
export function resolveRepoHeaderColor(badgeColor: string | null | undefined): string {
const normalizedBadgeColor = badgeColor?.trim().toLowerCase()
const normalizedBadgeColor = normalizeRepoBadgeColor(badgeColor)
if (!normalizedBadgeColor) {
return DEFAULT_REPO_BADGE_COLOR
}
// Why: persisted repo colors are rendered as inline CSS here, so only the
// documented palette should reach the sidebar.
return (
REPO_COLORS.find((repoColor) => repoColor === normalizedBadgeColor) ?? DEFAULT_REPO_BADGE_COLOR
)
// Why: persisted repo colors are rendered as inline CSS here, so only
// normalized hex values from the palette or custom picker reach the sidebar.
return REPO_COLORS.find((repoColor) => repoColor === normalizedBadgeColor) ?? normalizedBadgeColor
}
export function resolveProjectGroupHeaderColor(args: {
@@ -0,0 +1,14 @@
import { renderToStaticMarkup } from 'react-dom/server'
import { describe, expect, it, vi } from 'vitest'
import { ColorPicker } from './color-picker'
describe('ColorPicker', () => {
it('renders a normalized custom color trigger', () => {
const html = renderToStaticMarkup(
<ColorPicker value="#ABCDEF" onChange={vi.fn()} label="Custom repo color" />
)
expect(html).toContain('aria-label="Custom repo color"')
expect(html).toContain('#abcdef')
})
})
@@ -0,0 +1,131 @@
import * as React from 'react'
import { HexColorPicker } from 'react-colorful'
import { normalizeRepoBadgeColor, resolveRepoBadgeColor } from '../../../../shared/repo-badge-color'
import { cn } from '@/lib/utils'
import { Button } from './button'
import { Input } from './input'
import { Label } from './label'
import { Popover, PopoverContent, PopoverTrigger } from './popover'
type ColorPickerProps = {
value: string
onChange: (value: string) => void
label: string
className?: string
defaultOpen?: boolean
selected?: boolean
triggerLabel?: string
showHexInTrigger?: boolean
}
const FULL_HEX_COLOR_PATTERN = /^#?[0-9a-fA-F]{6}$/
export function ColorPicker({
value,
onChange,
label,
className,
defaultOpen,
selected,
triggerLabel,
showHexInTrigger
}: ColorPickerProps): React.JSX.Element {
const inputId = React.useId()
const currentColor = resolveRepoBadgeColor(value)
const [draftState, setDraftState] = React.useState(() => ({
syncedColor: currentColor,
draft: currentColor,
isEditing: false
}))
const draft =
draftState.isEditing || draftState.syncedColor === currentColor
? draftState.draft
: currentColor
const draftColor = normalizeRepoBadgeColor(draft)
const swatchColor = draftColor ?? currentColor
const hasInvalidDraft = draft.trim().length > 0 && !draftColor
const shouldShowTriggerHex = showHexInTrigger ?? !triggerLabel
const updateDraft = (nextDraft: string): void => {
const nextColor = normalizeRepoBadgeColor(nextDraft)
setDraftState({ syncedColor: currentColor, draft: nextDraft, isEditing: true })
if (nextColor && FULL_HEX_COLOR_PATTERN.test(nextDraft.trim())) {
onChange(nextColor)
}
}
const updateColor = (nextColor: string): void => {
const normalized = resolveRepoBadgeColor(nextColor)
setDraftState({ syncedColor: currentColor, draft: normalized, isEditing: true })
onChange(normalized)
}
return (
<Popover defaultOpen={defaultOpen}>
<PopoverTrigger asChild>
<Button
type="button"
variant="outline"
size="sm"
className={cn(
'h-8 gap-2 px-2.5',
selected ? 'ring-2 ring-foreground ring-offset-2 ring-offset-background' : null,
className
)}
aria-label={label}
aria-pressed={selected}
>
<span
aria-hidden="true"
className="size-4 rounded-[4px] border border-border/70"
style={{ backgroundColor: currentColor }}
/>
{triggerLabel ? <span className="text-xs">{triggerLabel}</span> : null}
{shouldShowTriggerHex ? (
<span className="font-mono text-xs uppercase">{currentColor}</span>
) : null}
</Button>
</PopoverTrigger>
<PopoverContent align="start" className="w-64 p-3">
<div className="space-y-3">
<HexColorPicker
color={swatchColor}
onChange={updateColor}
aria-label={`${label} picker`}
className="[&_.react-colorful__hue]:rounded-b-md [&_.react-colorful__interactive:focus_.react-colorful__pointer]:ring-[3px] [&_.react-colorful__interactive:focus_.react-colorful__pointer]:ring-ring/50 [&_.react-colorful__pointer]:border-popover"
style={{ width: '100%', height: 180 }}
/>
<div className="flex items-center justify-between gap-3">
<Label htmlFor={inputId}>Hex</Label>
<span className="font-mono text-xs uppercase text-muted-foreground">{swatchColor}</span>
</div>
<Input
id={inputId}
value={draft}
onFocus={() =>
setDraftState({
syncedColor: currentColor,
draft,
isEditing: true
})
}
onChange={(event) => updateDraft(event.target.value)}
onBlur={() => {
if (draftColor) {
setDraftState({ syncedColor: currentColor, draft: draftColor, isEditing: false })
onChange(draftColor)
} else {
setDraftState({ syncedColor: currentColor, draft: currentColor, isEditing: false })
}
}}
placeholder={currentColor}
aria-invalid={hasInvalidDraft}
className="font-mono text-xs uppercase"
/>
{hasInvalidDraft ? <p className="text-xs text-destructive">Invalid hex color.</p> : null}
</div>
</PopoverContent>
</Popover>
)
}
+9
View File
@@ -13,6 +13,7 @@ import type {
} from '../../../../shared/types'
import { isGitRepoKind } from '../../../../shared/repo-kind'
import { sanitizeRepoIcon } from '../../../../shared/repo-icon'
import { normalizeRepoBadgeColor } from '../../../../shared/repo-badge-color'
import { getProjectGroupSubtreeIds } from '../../../../shared/project-groups'
import { getRepoIdFromWorktreeId } from './worktree-helpers'
import { callRuntimeRpc, getActiveRuntimeTarget } from '../../runtime/runtime-rpc-client'
@@ -41,6 +42,14 @@ type RepoUpdate = Partial<
function sanitizeRepoUpdate(updates: RepoUpdate): RepoUpdate {
const sanitized = { ...updates }
if ('badgeColor' in sanitized) {
const badgeColor = normalizeRepoBadgeColor(sanitized.badgeColor)
if (!badgeColor) {
delete sanitized.badgeColor
} else {
sanitized.badgeColor = badgeColor
}
}
if ('repoIcon' in sanitized) {
const repoIcon = sanitizeRepoIcon(sanitized.repoIcon)
if (repoIcon === undefined) {
+24
View File
@@ -0,0 +1,24 @@
import { describe, expect, it } from 'vitest'
import { DEFAULT_REPO_BADGE_COLOR } from './constants'
import { normalizeRepoBadgeColor, resolveRepoBadgeColor } from './repo-badge-color'
describe('repo badge color normalization', () => {
it('normalizes six-digit hex colors', () => {
expect(normalizeRepoBadgeColor(' ABCDEF ')).toBe('#abcdef')
expect(normalizeRepoBadgeColor('#ABCDEF')).toBe('#abcdef')
})
it('expands shorthand hex colors', () => {
expect(normalizeRepoBadgeColor('#abc')).toBe('#aabbcc')
})
it('rejects non-hex colors', () => {
expect(normalizeRepoBadgeColor('blue')).toBeNull()
expect(normalizeRepoBadgeColor('url(javascript:alert(1))')).toBeNull()
expect(normalizeRepoBadgeColor('#12zz12')).toBeNull()
})
it('falls back to the default repo color when resolving invalid input', () => {
expect(resolveRepoBadgeColor('blue')).toBe(DEFAULT_REPO_BADGE_COLOR)
})
})
+29
View File
@@ -0,0 +1,29 @@
import { DEFAULT_REPO_BADGE_COLOR, REPO_COLORS } from './constants'
const HEX_COLOR_PATTERN = /^#?([0-9a-fA-F]{3}|[0-9a-fA-F]{6})$/
export function normalizeRepoBadgeColor(value: unknown): string | null {
if (typeof value !== 'string') {
return null
}
const match = value.trim().match(HEX_COLOR_PATTERN)
if (!match) {
return null
}
const rawHex = match[1].toLowerCase()
const hex =
rawHex.length === 3
? rawHex
.split('')
.map((part) => part + part)
.join('')
: rawHex
const normalized = `#${hex}`
return REPO_COLORS.find((repoColor) => repoColor === normalized) ?? normalized
}
export function resolveRepoBadgeColor(value: unknown): string {
return normalizeRepoBadgeColor(value) ?? DEFAULT_REPO_BADGE_COLOR
}