Files
CherryHQ-cherry-studio/packages/ui/scripts/normalize-viewbox.ts
SuYao 43f2a6bc54 refactor(ui): overhaul icon system and migrate Avatar to shadcn/radix (#12858)
### What this PR does

Before this PR:
- Provider/model icons were scattered image imports (PNG/WebP) with no
unified API
- Avatar primitive was based on HeroUI with hardcoded `shadow-lg` and
`border-[0.5px]`
- Full-bleed and padded avatar variants used different rendering
approaches
- Multiple files duplicated IIFE patterns for rendering CompoundIcon vs
string logos
- No type-safe icon catalogs

After this PR:
- **Compound Icon API**: Each icon exposes `.Color`, `.Mono`, and
`.Avatar` sub-components via a unified `CompoundIcon` interface
- **Auto-generated catalogs**: `PROVIDER_ICON_CATALOG` and
`MODEL_ICON_CATALOG` with `resolveProviderIcon` / `resolveModelIcon`
helpers
- **SVG pipeline**: Codegen processes SVGs → generates Color/Mono/Avatar
components
- **Avatar migrated to shadcn/radix**: Replaced HeroUI Avatar with
`Avatar` + `AvatarFallback` pattern, removed hardcoded shadow/border
- **EmojiAvatar moved**: From `primitives/Avatar/` to
`composites/EmojiAvatar/`
- **LogoAvatar component**: Reusable component replacing repeated IIFE
patterns across 5+ files
- **getMCPProviderLogo helper**: Centralized MCP provider icon mapping
- 80+ monochrome icon components, stroke attribute support, deprecated
logos cleanup

<img width="714" height="820" alt="image"
src="https://github.com/user-attachments/assets/a3f14348-5781-494a-8c3b-1f40391e2ec0"
/>

<img width="1008" height="593" alt="image"
src="https://github.com/user-attachments/assets/8ba7fa42-fa33-4e49-ba76-647ba1438e0c"
/>

### Why we need it and why it was done in this way

The v2 refactoring requires moving away from HeroUI toward shadcn/radix
primitives, and needs a scalable, type-safe icon system to replace
scattered image imports. The compound icon pattern (`Icon.Color`,
`Icon.Mono`, `Icon.Avatar`) provides a consistent API while enabling
tree-shaking. The Avatar primitive now uses radix-based `Avatar` +
`AvatarFallback`, aligning with the project's shadcn migration.

The following tradeoffs were made:
- Each icon is a separate TSX file for tree-shaking and lazy loading
support
- Avatar components use `AvatarFallback` to render icons — no image
loading overhead

The following alternatives were considered:
- Runtime SVG color manipulation — rejected for better performance and
consistency
- Keeping HeroUI Avatar — rejected as it conflicts with v2 shadcn
migration goals

### Breaking changes

- Avatar primitive API changed: `HeroUI Avatar` → shadcn `Avatar` +
`AvatarFallback` + `AvatarImage`
- `EmojiAvatar` moved from `primitives/Avatar` to
`composites/EmojiAvatar`
- `shadow-lg` and `border-[0.5px]` removed from generated avatars — now
opt-in via `className`

### Special notes for your reviewer

- ~214 files changed, but the bulk are auto-generated avatar/icon
components under `packages/ui/src/components/icons/`
- Key files to review:
- `packages/ui/src/components/primitives/avatar.tsx` — new shadcn Avatar
primitive
- `packages/ui/scripts/codegen.ts` — avatar generation using
AvatarFallback
- `src/renderer/src/components/Icons/LogoAvatar.tsx` — reusable logo
renderer
- Renderer files using the new Avatar API (Sidebar, UserPopup,
ModelAvatar, etc.)

### Checklist

- [x] PR: The PR description is expressive enough and will help future
contributors
- [x] Code: Write code that humans can understand and Keep it simple
- [x] Refactor: You have left the code cleaner than you found it (Boy
Scout Rule)
- [ ] Upgrade: Impact of this change on upgrade flows was considered and
addressed if required
- [ ] Documentation: N/A - internal component changes

### Release note

```release-note
NONE
```

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Signed-off-by: suyao <sy20010504@gmail.com>
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
Co-authored-by: icarus <eurfelux@gmail.com>
2026-03-16 17:13:25 +08:00

113 lines
3.1 KiB
TypeScript

/**
* Normalize SVG width/height to 32x32.
*
* Updates the width and height attributes on the <svg> tag to 32,
* keeping the viewBox unchanged so SVG content scales naturally.
*
* Usage:
* pnpm tsx scripts/normalize-viewbox.ts --dir=providers
* pnpm tsx scripts/normalize-viewbox.ts --dir=models
* pnpm tsx scripts/normalize-viewbox.ts --dir=providers --dry-run
*/
import fs from 'fs/promises'
import path from 'path'
const TARGET_SIZE = 32
type SourceDir = 'providers' | 'models'
function parseArgs(): { dir: SourceDir; dryRun: boolean } {
const dirArg = process.argv.find((a) => a.startsWith('--dir='))
const dir = (dirArg?.split('=')[1] as SourceDir) || 'providers'
const dryRun = process.argv.includes('--dry-run')
return { dir, dryRun }
}
function normalizeSvg(content: string): { result: string; changed: boolean; reason?: string } {
const svgTagMatch = content.match(/<svg([^>]*)>/s)
if (!svgTagMatch) {
return { result: content, changed: false, reason: 'no <svg> tag' }
}
let attrs = svgTagMatch[1]
let changed = false
// Update width
const wMatch = attrs.match(/\bwidth="([^"]*)"/)
if (wMatch && wMatch[1] !== String(TARGET_SIZE)) {
attrs = attrs.replace(/\bwidth="[^"]*"/, `width="${TARGET_SIZE}"`)
changed = true
} else if (!wMatch) {
attrs += ` width="${TARGET_SIZE}"`
changed = true
}
// Update height
const hMatch = attrs.match(/\bheight="([^"]*)"/)
if (hMatch && hMatch[1] !== String(TARGET_SIZE)) {
attrs = attrs.replace(/\bheight="[^"]*"/, `height="${TARGET_SIZE}"`)
changed = true
} else if (!hMatch) {
attrs += ` height="${TARGET_SIZE}"`
changed = true
}
if (!changed) {
return { result: content, changed: false, reason: 'already 32x32' }
}
const result = content.replace(/<svg[^>]*>/s, `<svg${attrs}>`)
return { result, changed: true }
}
async function main() {
const { dir, dryRun } = parseArgs()
const baseDir = path.join(__dirname, '../icons', dir)
console.log(
`Normalizing SVG dimensions to ${TARGET_SIZE}x${TARGET_SIZE} (source: ${dir})${dryRun ? ' [DRY RUN]' : ''}...\n`
)
let files: string[]
try {
files = (await fs.readdir(baseDir)).filter((f) => f.endsWith('.svg')).sort()
} catch {
console.error(`Directory not found: ${baseDir}`)
process.exit(1)
}
if (files.length === 0) {
console.log('No SVG files found.')
return
}
console.log(`Found ${files.length} SVG files\n`)
let changedCount = 0
let skippedCount = 0
for (const file of files) {
const filePath = path.join(baseDir, file)
const content = await fs.readFile(filePath, 'utf-8')
const { result, changed, reason } = normalizeSvg(content)
if (changed) {
if (!dryRun) {
await fs.writeFile(filePath, result, 'utf-8')
}
console.log(` + ${file}${dryRun ? ' (would change)' : ''}`)
changedCount++
} else {
console.log(` - ${file} (skipped: ${reason})`)
skippedCount++
}
}
console.log(`\nDone: ${changedCount} changed, ${skippedCount} skipped`)
}
main().catch((err) => {
console.error(err)
process.exit(1)
})