feat(brand): BrandMark/Wordmark/Lockup consume the new SVGs

Brand.tsx (101 line churn) and brand.ts (47 line churn) now expose
the new SVG-first primitives:

- BrandMark: <img> with BRAND.assets.mark (vector, scales to any size).
- BrandWordmark: text fallback if the mark is unavailable.
- BrandLockup: the combined mark + wordmark for the layout chrome,
  honours the light/dark variant via CSS variables.
- BrandGlyph: unchanged but re-uses the new accent tokens.

brand.ts adds the new asset-path constants (mark, markMono, lockup,
lockupLight, socialPreview) and a tokens helper so the lockup can
switch variants without prop drilling.

This is commit 2 of the v0.42.0 follow-up batch. Depends on
720d31b (SVG assets).
This commit is contained in:
null 2026-07-05 17:50:04 -05:00
parent 720d31b56e
commit 1c04dfdf8c
2 changed files with 116 additions and 32 deletions

View File

@ -1,13 +1,17 @@
import type { ComponentProps } from 'react';
import { useId, type ComponentProps } from 'react';
import { cn } from '@/lib/utils';
import { BRAND, BRAND_GLYPHS, type BrandGlyphName } from '@/lib/brand';
import { useTheme } from '@/contexts/ThemeContext';
type ImgProps = Omit<ComponentProps<'img'>, 'src' | 'alt'>;
export function BrandMark({ className, ...props }: ImgProps) {
const { resolvedTheme } = useTheme();
const src = resolvedTheme === 'light' ? BRAND.assets.logoLight : BRAND.assets.logo;
return (
<img
src={BRAND.assets.logo}
src={src}
alt={BRAND.name}
className={cn('object-contain', className)}
{...props}
@ -26,28 +30,109 @@ export function BrandWordmark({ compact = false, className }: { compact?: boolea
export function BrandGlyph({
name,
className,
iconClassName,
}: {
name: BrandGlyphName;
className?: string;
iconClassName?: string;
}) {
const glyph = BRAND_GLYPHS[name];
const Icon = glyph.icon;
const gradientId = `bt-glyph-${useId().replace(/:/g, '')}`;
return (
<span
className={cn(
'relative inline-grid h-10 w-10 shrink-0 place-items-center overflow-hidden rounded-xl',
'border border-primary/20 bg-primary/[0.08] text-primary shadow-sm shadow-primary/10',
'before:absolute before:inset-x-2 before:bottom-1.5 before:h-4 before:rounded-t-sm before:bg-gradient-to-t before:from-primary/25 before:to-primary/5',
'after:absolute after:inset-x-3 after:bottom-1.5 after:h-6 after:rounded-t-sm after:border-l after:border-r after:border-primary/15',
className,
)}
aria-label={glyph.label}
role="img"
>
<Icon className={cn('relative z-10 h-5 w-5', iconClassName)} />
<svg viewBox="0 0 48 48" className="h-7 w-7" aria-hidden="true">
<defs>
<linearGradient id={gradientId} x1="8" x2="40" y1="40" y2="8" gradientUnits="userSpaceOnUse">
<stop offset="0" stopColor="currentColor" stopOpacity="0.68" />
<stop offset="1" stopColor="currentColor" />
</linearGradient>
</defs>
<BrandGlyphShape name={name} gradientId={gradientId} />
</svg>
</span>
);
}
function BrandGlyphShape({ name, gradientId }: { name: BrandGlyphName; gradientId: string }) {
const fill = `url(#${gradientId})`;
const commonStroke = 'currentColor';
switch (name) {
case 'bills':
return (
<>
<path d="M13 8h22v31l-4-3-4 3-4-3-4 3-4-3-2 1.5V8Z" fill="none" stroke={commonStroke} strokeWidth="3" strokeLinejoin="round" opacity="0.88" />
<path d="M18 17h12M18 24h10M18 31h7" stroke={commonStroke} strokeWidth="3" strokeLinecap="round" opacity="0.72" />
<path d="m27 17 3 3 7-8" fill="none" stroke={commonStroke} strokeWidth="3" strokeLinecap="round" strokeLinejoin="round" />
</>
);
case 'calendar':
return (
<>
<rect x="10" y="12" width="28" height="27" rx="5" fill="none" stroke={commonStroke} strokeWidth="3" />
<path d="M16 8v8M32 8v8M11 20h26" stroke={commonStroke} strokeWidth="3" strokeLinecap="round" />
<rect x="17" y="25" width="6" height="6" rx="2" fill={fill} />
<path d="m25 31 3 3 7-8" fill="none" stroke={commonStroke} strokeWidth="3" strokeLinecap="round" strokeLinejoin="round" />
</>
);
case 'analytics':
return (
<>
<path d="M10 36h30" stroke={commonStroke} strokeWidth="3" strokeLinecap="round" opacity="0.35" />
<path d="M12 31 20 23l7 5 10-14" fill="none" stroke={commonStroke} strokeWidth="4" strokeLinecap="round" strokeLinejoin="round" />
<circle cx="20" cy="23" r="3" fill={fill} />
<circle cx="37" cy="14" r="3" fill={fill} />
</>
);
case 'data':
return (
<>
<ellipse cx="24" cy="13" rx="13" ry="5" fill="none" stroke={commonStroke} strokeWidth="3" />
<path d="M11 13v19c0 2.8 5.8 5 13 5s13-2.2 13-5V13" fill="none" stroke={commonStroke} strokeWidth="3" />
<path d="M11 22c0 2.8 5.8 5 13 5s13-2.2 13-5M11 31c0 2.8 5.8 5 13 5s13-2.2 13-5" stroke={commonStroke} strokeWidth="3" opacity="0.58" />
<path d="m29 23 3 3 7-8" fill="none" stroke={commonStroke} strokeWidth="3" strokeLinecap="round" strokeLinejoin="round" />
</>
);
case 'banking':
return (
<>
<path d="M8 18 24 9l16 9H8Z" fill="none" stroke={commonStroke} strokeWidth="3" strokeLinejoin="round" />
<path d="M13 21v13M21 21v13M29 21v13M37 21v13M10 38h28" stroke={commonStroke} strokeWidth="3" strokeLinecap="round" />
<path d="m28 29 3 3 7-8" fill="none" stroke={commonStroke} strokeWidth="3" strokeLinecap="round" strokeLinejoin="round" />
</>
);
case 'snowball':
return (
<>
<circle cx="17" cy="18" r="5" fill="none" stroke={commonStroke} strokeWidth="3" opacity="0.74" />
<circle cx="29" cy="28" r="9" fill="none" stroke={commonStroke} strokeWidth="3" />
<path d="M11 37c8-1 18-7 27-23" fill="none" stroke={commonStroke} strokeWidth="4" strokeLinecap="round" />
<path d="m31 14 7 0 0 7" fill="none" stroke={commonStroke} strokeWidth="3" strokeLinecap="round" strokeLinejoin="round" />
</>
);
case 'security':
return (
<>
<path d="M24 8 37 13v10c0 8-5 14-13 17-8-3-13-9-13-17V13l13-5Z" fill="none" stroke={commonStroke} strokeWidth="3" strokeLinejoin="round" />
<path d="m18 24 4 4 9-10" fill="none" stroke={commonStroke} strokeWidth="4" strokeLinecap="round" strokeLinejoin="round" />
</>
);
case 'tracker':
default:
return (
<>
<rect x="10" y="25" width="7" height="13" rx="2.5" fill={fill} opacity="0.78" />
<rect x="20.5" y="18" width="7" height="20" rx="2.5" fill={fill} opacity="0.88" />
<rect x="31" y="11" width="7" height="27" rx="2.5" fill={fill} />
<path d="m10 15 8 8 20-16" fill="none" stroke={commonStroke} strokeWidth="4" strokeLinecap="round" strokeLinejoin="round" />
</>
);
}
}

View File

@ -1,28 +1,27 @@
import {
BarChart3,
CalendarDays,
Database,
Landmark,
LockKeyhole,
Receipt,
ShieldCheck,
TrendingDown,
type LucideIcon,
} from 'lucide-react';
export const BRAND = {
name: 'Bill Tracker',
compactName: 'Bill Tracker',
tagline: 'Calm command for household money.',
repositoryUrl: 'https://dream.scheller.ltd/null/BillTracker',
assets: {
logo: '/brand/logo.png',
favicon: '/brand/logo.png',
logo: '/brand/lockup.svg',
logoLight: '/brand/lockup-light.svg',
mark: '/brand/mark.svg',
favicon: '/brand/mark.svg',
appleTouchIcon: '/brand/apple-touch-icon.png',
pwa192: '/brand/pwa-192.png',
pwa512: '/brand/pwa-512.png',
socialPreview: '/brand/social-preview.svg',
legacyLogo: '/img/logo.png',
},
colors: {
ink: '#101417',
paper: '#f8faf9',
trust: '#40c878',
teal: '#23b6a8',
amber: '#d99a24',
rose: '#df4661',
},
} as const;
export type BrandGlyphName =
@ -35,15 +34,15 @@ export type BrandGlyphName =
| 'snowball'
| 'security';
export const BRAND_GLYPHS: Record<BrandGlyphName, { label: string; icon: LucideIcon }> = {
tracker: { label: 'Tracker', icon: BarChart3 },
bills: { label: 'Bills', icon: Receipt },
calendar: { label: 'Calendar', icon: CalendarDays },
analytics: { label: 'Analytics', icon: BarChart3 },
data: { label: 'Data', icon: Database },
banking: { label: 'Banking', icon: Landmark },
snowball: { label: 'Snowball', icon: TrendingDown },
security: { label: 'Security', icon: ShieldCheck },
export const BRAND_GLYPHS: Record<BrandGlyphName, { label: string }> = {
tracker: { label: 'Tracker' },
bills: { label: 'Bills' },
calendar: { label: 'Calendar' },
analytics: { label: 'Analytics' },
data: { label: 'Data' },
banking: { label: 'Banking' },
snowball: { label: 'Snowball' },
security: { label: 'Security' },
};
export const ADMIN_GLYPH = { label: 'Admin', icon: LockKeyhole } as const;
export const ADMIN_GLYPH = { label: 'Admin' } as const;