fix(catalog): consistent brand marks — Zen always ensō, real third-party logos
The model catalog rendered logos from each model's raw provider string, so the
Zen family flip-flopped (models tagged 'hanzo' → block-H, others → ensō) and
every third-party family fell through to a gray initials chip ('QW'). Fixes:
- Rows + detail now render the model's FAMILY brand (not raw provider), so a
family is internally consistent: Zen is ALWAYS the ensō; Qwen/OpenAI/etc. each
show one mark. (Directive: Zen provider always uses Zen.)
- Extract pure brand resolution into ui/brand.ts (normalizeBrand + BRANDS
registry) — unit tested, no GUI deps. Every curated family key resolves to a
real brand-colored tile (Qwen/OpenAI/DeepSeek/Meta/Mistral/Google + Anthropic/
GLM/Kimi/MiniMax/Nvidia/xAI); only genuinely-unknown providers get neutral
initials (honest, no fabricated trademark logos).
- Mobile: row hides the context column on phones + narrows numeric columns so
rows never overflow (mobile-first, $md restores the desktop layout).
Tests: 27 pass (brand 6 + families 21). tsc --noEmit clean.
This commit is contained in:
@@ -37,6 +37,7 @@ import {
|
||||
import {
|
||||
groupByFamily,
|
||||
filterFamilies,
|
||||
familyOf,
|
||||
totalModels,
|
||||
displayLabel,
|
||||
DEFAULT_MODEL,
|
||||
@@ -112,7 +113,8 @@ function ModelDetailPanel({ m, plans, onBack }: { m: CatalogEntry; plans: Plan[]
|
||||
|
||||
<YStack gap="$2">
|
||||
<XStack items="center" gap="$3" flexWrap="wrap">
|
||||
<ProviderLogo provider={m.provider ?? 'Other'} size={40} />
|
||||
{/* Family brand so the detail matches the catalog row (Zen → ensō). */}
|
||||
<ProviderLogo provider={familyOf(m)?.logo ?? m.provider ?? 'Other'} size={40} />
|
||||
<Text fontSize="$8" fontWeight="800" color="$color12">
|
||||
{modelDisplayName(m)}
|
||||
</Text>
|
||||
@@ -200,7 +202,7 @@ function ModelDetailPanel({ m, plans, onBack }: { m: CatalogEntry; plans: Plan[]
|
||||
}
|
||||
|
||||
/** One model row inside a family section — click for the full detail. */
|
||||
function ModelRow({ m, onOpen }: { m: CatalogEntry; onOpen: () => void }) {
|
||||
function ModelRow({ m, brand, onOpen }: { m: CatalogEntry; brand: string; onOpen: () => void }) {
|
||||
const isDefault = modelId(m).toLowerCase() === DEFAULT_MODEL
|
||||
return (
|
||||
<XStack
|
||||
@@ -213,7 +215,8 @@ function ModelRow({ m, onOpen }: { m: CatalogEntry; onOpen: () => void }) {
|
||||
hoverStyle={{ bg: '$color3' }}
|
||||
onPress={onOpen}
|
||||
>
|
||||
<ProviderLogo provider={m.provider ?? 'Zen'} size={26} />
|
||||
{/* Family brand — every model in a family shares its header mark (Zen → ensō). */}
|
||||
<ProviderLogo provider={brand} size={26} />
|
||||
<YStack flex={1} minW={0} gap={1}>
|
||||
<XStack items="center" gap="$2" flexWrap="wrap">
|
||||
<Text fontSize="$3" color="$color12" numberOfLines={1}>
|
||||
@@ -232,13 +235,14 @@ function ModelRow({ m, onOpen }: { m: CatalogEntry; onOpen: () => void }) {
|
||||
</Text>
|
||||
) : null}
|
||||
</YStack>
|
||||
<Text className={TNUM} fontSize="$2" color="$color11" width={72} text="right">
|
||||
{/* Context column: hidden on phones (mobile-first) so rows never overflow. */}
|
||||
<Text className={TNUM} fontSize="$2" color="$color11" width={72} text="right" display="none" $md={{ display: 'flex' }}>
|
||||
{fmtContext(modelContext(m))}
|
||||
</Text>
|
||||
<Text className={TNUM} fontSize="$2" color="$color12" width={80} text="right">
|
||||
<Text className={TNUM} fontSize="$2" color="$color12" width={72} $md={{ width: 80 }} text="right">
|
||||
{fmtPrice(m.pricing?.input)}/M
|
||||
</Text>
|
||||
<XStack width={96} justify="flex-end">
|
||||
<XStack width={72} $md={{ width: 96 }} justify="flex-end">
|
||||
{m.available ? <Pill label="● Live" tone="live" /> : <Pill label="Catalog" />}
|
||||
</XStack>
|
||||
</XStack>
|
||||
@@ -286,7 +290,7 @@ function FamilySection({
|
||||
{open ? (
|
||||
<YStack px="$1.5" pb="$2" borderTopWidth={1} borderColor="$borderColor">
|
||||
{rows.map((mo) => (
|
||||
<ModelRow key={modelId(mo)} m={mo} onOpen={() => onOpen(mo)} />
|
||||
<ModelRow key={modelId(mo)} m={mo} brand={group.logo} onOpen={() => onOpen(mo)} />
|
||||
))}
|
||||
{hidden > 0 ? (
|
||||
<Button size="$2" chromeless self="flex-start" mt="$1" ml="$2" onPress={() => setShowAll(true)}>
|
||||
|
||||
@@ -1,36 +1,26 @@
|
||||
'use client'
|
||||
|
||||
/**
|
||||
* ProviderLogo — a small provider/model avatar resolved from a provider NAME
|
||||
* alone (no external logo URLs, no network). Self-contained and prop-driven so
|
||||
* it lifts cleanly into `@hanzo/ui` for hanzo.ai / @hanzo/dev / the desktop app.
|
||||
* ProviderLogo — a small provider/model brand avatar resolved from a provider or
|
||||
* family NAME alone (no external logo URLs, no network). Self-contained and
|
||||
* prop-driven so it lifts cleanly into `@hanzo/ui` for hanzo.ai / @hanzo/dev /
|
||||
* the desktop app.
|
||||
*
|
||||
* Resolution order:
|
||||
* 1. First-party — Zen renders the real ensō mark, Hanzo the real block-H mark
|
||||
* (same geometry as @zenlm/logo / @hanzo/logo), knocked out of a filled
|
||||
* rounded tile so our own models read distinctly and on-brand.
|
||||
* 2. A known provider → its mapped @hanzogui/lucide-icons-2 glyph.
|
||||
* 3. Otherwise → a stable initials chip (1–2 letters from the name).
|
||||
*
|
||||
* Honest by construction: we never claim an official brand logo we don't ship —
|
||||
* unknown providers get clean initials, not a guessed icon. Colors are @hanzo/gui
|
||||
* theme tokens so the mark themes with the shell.
|
||||
* The pure brand resolution lives in ./brand (unit tested). This is the render
|
||||
* layer, three steps:
|
||||
* 1. Normalize the string → a canonical brand key (`normalizeBrand`).
|
||||
* 2. First-party — **Zen** always renders the ensō (every zen* model, and the
|
||||
* "hanzo"/"zen" providers the Zen records carry), **Hanzo** the block-H —
|
||||
* knocked out of a filled tile so our own models read on-brand.
|
||||
* 3. Known third-party family → its brand-colored tile with a crisp monogram
|
||||
* (scannable by colour); truly-unknown → a neutral initials chip.
|
||||
*/
|
||||
import { Text, XStack, useTheme } from '@hanzo/gui'
|
||||
import { Boxes, Server, Globe, Cpu } from '@hanzogui/lucide-icons-2'
|
||||
import { Server } from '@hanzogui/lucide-icons-2'
|
||||
|
||||
type IconCmp = typeof Globe
|
||||
import { normalizeBrand, BRANDS, providerInitials } from './brand'
|
||||
|
||||
/** Curated, extendable provider→glyph map (lowercased keys). Brand-neutral avatars. */
|
||||
const KNOWN: Record<string, IconCmp> = {
|
||||
openrouter: Globe,
|
||||
nvidia: Cpu,
|
||||
}
|
||||
|
||||
const isFirstParty = (provider: string): boolean => {
|
||||
const p = provider.trim().toLowerCase()
|
||||
return p === 'hanzo' || p === 'zen'
|
||||
}
|
||||
export { providerInitials } from './brand'
|
||||
|
||||
/** The Zen ensō mark — identical geometry to @zenlm/logo (svg/zen-enso.svg). */
|
||||
function EnsoMark({ size, color }: { size: number; color: string }) {
|
||||
@@ -59,67 +49,68 @@ function HanzoHMark({ size, color }: { size: number; color: string }) {
|
||||
)
|
||||
}
|
||||
|
||||
/** 1–2 uppercase initials from a provider name: words→first letters, else first 2 chars. */
|
||||
export function providerInitials(provider: string): string {
|
||||
const name = provider.trim()
|
||||
if (!name) return '•'
|
||||
const words = name.split(/[\s/_-]+/).filter(Boolean)
|
||||
if (words.length >= 2) return (words[0][0] + words[1][0]).toUpperCase()
|
||||
return name.slice(0, 2).toUpperCase()
|
||||
}
|
||||
|
||||
export function ProviderLogo({ provider, size = 24 }: { provider: string; size?: number }) {
|
||||
const theme = useTheme()
|
||||
const radius = Math.round(size * 0.28)
|
||||
const iconSize = Math.round(size * 0.56)
|
||||
|
||||
// First-party (Zen/Hanzo) — the real brand mark knocked out of a filled tile.
|
||||
if (isFirstParty(provider)) {
|
||||
const fg = theme.color1?.get() ?? '#000000' // cut-out mark: the tile's contrast color
|
||||
const markSize = Math.round(size * 0.66)
|
||||
const isZen = provider.trim().toLowerCase() === 'zen'
|
||||
return (
|
||||
<XStack width={size} height={size} items="center" justify="center" rounded={radius} bg="$color12">
|
||||
{isZen ? <EnsoMark size={markSize} color={fg} /> : <HanzoHMark size={Math.round(size * 0.56)} color={fg} />}
|
||||
</XStack>
|
||||
)
|
||||
}
|
||||
|
||||
// A known provider with a curated, brand-neutral glyph.
|
||||
const Known = KNOWN[provider.trim().toLowerCase()]
|
||||
if (Known) {
|
||||
return (
|
||||
<XStack
|
||||
width={size}
|
||||
height={size}
|
||||
items="center"
|
||||
justify="center"
|
||||
rounded={radius}
|
||||
bg="$color3"
|
||||
borderWidth={1}
|
||||
borderColor="$borderColor"
|
||||
>
|
||||
<Known size={iconSize} color="$color11" />
|
||||
</XStack>
|
||||
)
|
||||
}
|
||||
|
||||
// Otherwise — a clean, stable initials chip (no fabricated brand logo).
|
||||
/**
|
||||
* A filled square tile — the shared frame for every mark (keeps sizing DRY).
|
||||
* `bg`/`borderColor` are RAW css colours (theme tokens are resolved to values by
|
||||
* the caller via `useTheme`), applied through `style` so a brand hex and a themed
|
||||
* colour take the exact same, type-clean path.
|
||||
*/
|
||||
function Tile({ size, bg, borderColor, children }: { size: number; bg: string; borderColor?: string; children: React.ReactNode }) {
|
||||
return (
|
||||
<XStack
|
||||
width={size}
|
||||
height={size}
|
||||
items="center"
|
||||
justify="center"
|
||||
rounded={radius}
|
||||
bg="$color3"
|
||||
borderWidth={1}
|
||||
borderColor="$borderColor"
|
||||
rounded={Math.round(size * 0.28)}
|
||||
style={{ flexShrink: 0, backgroundColor: bg, ...(borderColor ? { borderWidth: 1, borderColor } : {}) }}
|
||||
>
|
||||
<Text fontSize={Math.round(size * 0.4)} fontWeight="800" color="$color11">
|
||||
{children}
|
||||
</XStack>
|
||||
)
|
||||
}
|
||||
|
||||
export function ProviderLogo({ provider, size = 24 }: { provider: string; size?: number }) {
|
||||
const theme = useTheme()
|
||||
const brand = normalizeBrand(provider)
|
||||
|
||||
// First-party — the real brand mark knocked out of a filled tile.
|
||||
if (brand === 'zen' || brand === 'hanzo') {
|
||||
const tileBg = theme.color12?.get() ?? '#111111'
|
||||
const fg = theme.color1?.get() ?? '#ffffff' // cut-out mark: the tile's contrast color
|
||||
return (
|
||||
<Tile size={size} bg={tileBg}>
|
||||
{brand === 'zen'
|
||||
? <EnsoMark size={Math.round(size * 0.66)} color={fg} />
|
||||
: <HanzoHMark size={Math.round(size * 0.56)} color={fg} />}
|
||||
</Tile>
|
||||
)
|
||||
}
|
||||
|
||||
// Known third-party family — brand-colored tile + crisp white monogram.
|
||||
if (brand) {
|
||||
const { bg, label } = BRANDS[brand]
|
||||
// Shrink the glyph a touch as the monogram gets longer so 2–3 chars still fit.
|
||||
const fontScale = label.length >= 3 ? 0.34 : label.length === 2 ? 0.4 : 0.46
|
||||
return (
|
||||
<Tile size={size} bg={bg}>
|
||||
<Text fontSize={Math.round(size * fontScale)} fontWeight="800" color="#ffffff" style={{ letterSpacing: -0.5 }}>
|
||||
{label}
|
||||
</Text>
|
||||
</Tile>
|
||||
)
|
||||
}
|
||||
|
||||
// Unknown provider — a clean, stable neutral initials chip (no fabricated brand).
|
||||
const neutralBg = theme.color3?.get() ?? '#e5e5e5'
|
||||
const neutralBorder = theme.borderColor?.get() ?? 'rgba(0,0,0,0.1)'
|
||||
const neutralFg = theme.color11?.get() ?? '#555555'
|
||||
return (
|
||||
<Tile size={size} bg={neutralBg} borderColor={neutralBorder}>
|
||||
<Text fontSize={Math.round(size * 0.4)} fontWeight="800" style={{ color: neutralFg }}>
|
||||
{providerInitials(provider)}
|
||||
</Text>
|
||||
</XStack>
|
||||
</Tile>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -134,4 +125,4 @@ export function GenericLogo({ size = 24 }: { size?: number }) {
|
||||
|
||||
// Boxes is re-exported as the conventional "custom model" mark for callers that
|
||||
// render a non-provider tile next to ProviderLogo (keeps the icon source single).
|
||||
export { Boxes as CustomModelMark }
|
||||
export { Boxes as CustomModelMark } from '@hanzogui/lucide-icons-2'
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
/**
|
||||
* ProviderLogo brand resolution — the guardrail for the catalog's logos.
|
||||
*
|
||||
* Two bugs this locks down:
|
||||
* 1. Zen models tagged provider "hanzo" rendered the block-H instead of the
|
||||
* ensō → within one Zen family the mark flip-flopped. Directive: Zen is
|
||||
* ALWAYS the Zen (ensō) brand.
|
||||
* 2. Every third-party family (Qwen/OpenAI/DeepSeek/Meta/Mistral/Google) fell
|
||||
* through to a gray initials chip ("QW"). Each curated family logo key must
|
||||
* resolve to a real brand tile — no family shows the neutral fallback.
|
||||
*/
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { normalizeBrand } from './brand'
|
||||
import { FAMILIES } from '~/lib/api/families'
|
||||
|
||||
describe('normalizeBrand', () => {
|
||||
it('resolves every Zen signal to the zen brand (always ensō)', () => {
|
||||
for (const s of ['zen', 'Zen', 'zenlm', 'zen3-vl', 'zen5-mini', 'zen5-coder', 'zen5-flash']) {
|
||||
expect(normalizeBrand(s)).toBe('zen')
|
||||
}
|
||||
})
|
||||
|
||||
it('keeps the bare Hanzo company provider on the block-H (non-model surfaces)', () => {
|
||||
expect(normalizeBrand('hanzo')).toBe('hanzo')
|
||||
})
|
||||
|
||||
it('maps every curated family logo key to a real brand (never the gray fallback)', () => {
|
||||
for (const f of FAMILIES) {
|
||||
expect(normalizeBrand(f.logo), `family ${f.id} logo=${f.logo}`).not.toBeNull()
|
||||
}
|
||||
})
|
||||
|
||||
it('resolves the third-party families to their canonical brand', () => {
|
||||
expect(normalizeBrand('Qwen')).toBe('qwen')
|
||||
expect(normalizeBrand('qwen')).toBe('qwen')
|
||||
expect(normalizeBrand('OpenAI')).toBe('openai')
|
||||
expect(normalizeBrand('DeepSeek')).toBe('deepseek')
|
||||
expect(normalizeBrand('Meta')).toBe('meta')
|
||||
expect(normalizeBrand('Mistral')).toBe('mistral')
|
||||
expect(normalizeBrand('Google')).toBe('google')
|
||||
})
|
||||
|
||||
it('resolves common raw provider strings and model tells', () => {
|
||||
expect(normalizeBrand('anthropic')).toBe('anthropic')
|
||||
expect(normalizeBrand('claude-opus-4-6')).toBe('anthropic')
|
||||
expect(normalizeBrand('glm-5.2')).toBe('zhipu')
|
||||
expect(normalizeBrand('kimi-k2')).toBe('moonshot')
|
||||
expect(normalizeBrand('grok-2')).toBe('xai')
|
||||
expect(normalizeBrand('llama-3.3-70b')).toBe('meta')
|
||||
})
|
||||
|
||||
it('returns null for genuinely unknown providers (honest initials fallback)', () => {
|
||||
expect(normalizeBrand('')).toBeNull()
|
||||
expect(normalizeBrand('SomeIndieLab')).toBeNull()
|
||||
expect(normalizeBrand('Other')).toBeNull()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,72 @@
|
||||
/**
|
||||
* Brand resolution — the ONE pure place a provider/family/model string becomes a
|
||||
* canonical brand + its avatar treatment. No GUI, no JSX, no IO, so it is unit
|
||||
* tested directly and reused by every surface (ProviderLogo renders it).
|
||||
*
|
||||
* Honest by construction: brand-colored monograms are avatars, not a claim to be
|
||||
* an official trademark logo; a string that matches no brand returns null so the
|
||||
* view falls back to neutral initials rather than a guessed logo.
|
||||
*/
|
||||
|
||||
/** Canonical brand keys the whole app resolves provider/family strings down to. */
|
||||
export type BrandKey =
|
||||
| 'zen' | 'hanzo'
|
||||
| 'openai' | 'qwen' | 'deepseek' | 'meta' | 'mistral' | 'google'
|
||||
| 'anthropic' | 'zhipu' | 'moonshot' | 'minimax' | 'nvidia' | 'xai' | 'cohere' | 'microsoft'
|
||||
|
||||
/**
|
||||
* Normalize any provider name / family logo key / model id to a canonical brand.
|
||||
* Order matters: first-party (zen/hanzo) first, then substring matches. Returns
|
||||
* null when nothing matches (→ neutral initials fallback, kept honest).
|
||||
*/
|
||||
export function normalizeBrand(raw: string): BrandKey | null {
|
||||
const p = raw.trim().toLowerCase()
|
||||
if (!p) return null
|
||||
// First-party. A zen* model id or the zen/zenlm provider → Zen (ensō). Bare
|
||||
// "hanzo" (the company/gateway provider, non-model surfaces) → the block-H.
|
||||
if (p === 'zen' || p === 'zenlm' || /^zen[\d-]/.test(p)) return 'zen'
|
||||
if (p === 'hanzo') return 'hanzo'
|
||||
// Third-party families (match provider strings AND their model-name tells).
|
||||
if (p.includes('openai') || p.includes('gpt')) return 'openai'
|
||||
if (p.includes('qwen') || p.includes('tongyi') || p.includes('alibaba') || p.includes('qwq')) return 'qwen'
|
||||
if (p.includes('deepseek')) return 'deepseek'
|
||||
if (p.includes('meta') || p.includes('llama')) return 'meta'
|
||||
if (p.includes('mistral') || p.includes('mixtral') || p.includes('magistral') || p.includes('codestral')) return 'mistral'
|
||||
if (p.includes('google') || p.includes('gemma') || p.includes('gemini')) return 'google'
|
||||
if (p.includes('anthropic') || p.includes('claude')) return 'anthropic'
|
||||
if (p.includes('zhipu') || p.includes('glm')) return 'zhipu'
|
||||
if (p.includes('moonshot') || p.includes('kimi')) return 'moonshot'
|
||||
if (p.includes('minimax')) return 'minimax'
|
||||
if (p.includes('nvidia') || p.includes('nemotron')) return 'nvidia'
|
||||
if (p.includes('xai') || p.includes('grok')) return 'xai'
|
||||
if (p.includes('cohere') || p.includes('command')) return 'cohere'
|
||||
if (p.includes('microsoft') || p.includes('phi-') || p === 'phi') return 'microsoft'
|
||||
return null
|
||||
}
|
||||
|
||||
/** Brand-colored monogram tiles for third-party families. Fixed brand hues. */
|
||||
export const BRANDS: Record<Exclude<BrandKey, 'zen' | 'hanzo'>, { bg: string; label: string }> = {
|
||||
openai: { bg: '#000000', label: 'AI' },
|
||||
qwen: { bg: '#615CED', label: 'Q' },
|
||||
deepseek: { bg: '#4D6BFE', label: 'DS' },
|
||||
meta: { bg: '#0866FF', label: 'Me' },
|
||||
mistral: { bg: '#FA520F', label: 'Mi' },
|
||||
google: { bg: '#1A73E8', label: 'G' },
|
||||
anthropic: { bg: '#D97757', label: 'A' },
|
||||
zhipu: { bg: '#3859FF', label: 'GLM' },
|
||||
moonshot: { bg: '#16171B', label: 'K' },
|
||||
minimax: { bg: '#E1483B', label: 'MM' },
|
||||
nvidia: { bg: '#76B900', label: 'nV' },
|
||||
xai: { bg: '#111111', label: 'x' },
|
||||
cohere: { bg: '#39594D', label: 'co' },
|
||||
microsoft: { bg: '#0067B8', label: 'Ph' },
|
||||
}
|
||||
|
||||
/** 1–2 uppercase initials from a provider name: words→first letters, else first 2 chars. */
|
||||
export function providerInitials(provider: string): string {
|
||||
const name = provider.trim()
|
||||
if (!name) return '•'
|
||||
const words = name.split(/[\s/_-]+/).filter(Boolean)
|
||||
if (words.length >= 2) return (words[0][0] + words[1][0]).toUpperCase()
|
||||
return name.slice(0, 2).toUpperCase()
|
||||
}
|
||||
Reference in New Issue
Block a user