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:
Hanzo Dev
2026-07-03 14:19:27 -07:00
parent 2d7f5b1512
commit 6dfa9c0598
4 changed files with 209 additions and 85 deletions
+11 -7
View File
@@ -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)}>
+69 -78
View File
@@ -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 (12 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 }) {
)
}
/** 12 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 23 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'
+57
View File
@@ -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()
})
})
+72
View File
@@ -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' },
}
/** 12 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()
}