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 { cn } from '@/lib/utils';
import { BRAND, BRAND_GLYPHS, type BrandGlyphName } from '@/lib/brand'; import { BRAND, BRAND_GLYPHS, type BrandGlyphName } from '@/lib/brand';
import { useTheme } from '@/contexts/ThemeContext';
type ImgProps = Omit<ComponentProps<'img'>, 'src' | 'alt'>; type ImgProps = Omit<ComponentProps<'img'>, 'src' | 'alt'>;
export function BrandMark({ className, ...props }: ImgProps) { export function BrandMark({ className, ...props }: ImgProps) {
const { resolvedTheme } = useTheme();
const src = resolvedTheme === 'light' ? BRAND.assets.logoLight : BRAND.assets.logo;
return ( return (
<img <img
src={BRAND.assets.logo} src={src}
alt={BRAND.name} alt={BRAND.name}
className={cn('object-contain', className)} className={cn('object-contain', className)}
{...props} {...props}
@ -26,28 +30,109 @@ export function BrandWordmark({ compact = false, className }: { compact?: boolea
export function BrandGlyph({ export function BrandGlyph({
name, name,
className, className,
iconClassName,
}: { }: {
name: BrandGlyphName; name: BrandGlyphName;
className?: string; className?: string;
iconClassName?: string;
}) { }) {
const glyph = BRAND_GLYPHS[name]; const glyph = BRAND_GLYPHS[name];
const Icon = glyph.icon; const gradientId = `bt-glyph-${useId().replace(/:/g, '')}`;
return ( return (
<span <span
className={cn( className={cn(
'relative inline-grid h-10 w-10 shrink-0 place-items-center overflow-hidden rounded-xl', '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', '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, className,
)} )}
aria-label={glyph.label} aria-label={glyph.label}
role="img" 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> </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 = { export const BRAND = {
name: 'Bill Tracker', name: 'Bill Tracker',
compactName: 'Bill Tracker', compactName: 'Bill Tracker',
tagline: 'Calm command for household money.', tagline: 'Calm command for household money.',
repositoryUrl: 'https://dream.scheller.ltd/null/BillTracker', repositoryUrl: 'https://dream.scheller.ltd/null/BillTracker',
assets: { assets: {
logo: '/brand/logo.png', logo: '/brand/lockup.svg',
favicon: '/brand/logo.png', logoLight: '/brand/lockup-light.svg',
mark: '/brand/mark.svg',
favicon: '/brand/mark.svg',
appleTouchIcon: '/brand/apple-touch-icon.png', appleTouchIcon: '/brand/apple-touch-icon.png',
pwa192: '/brand/pwa-192.png', pwa192: '/brand/pwa-192.png',
pwa512: '/brand/pwa-512.png', pwa512: '/brand/pwa-512.png',
socialPreview: '/brand/social-preview.svg',
legacyLogo: '/img/logo.png', legacyLogo: '/img/logo.png',
}, },
colors: {
ink: '#101417',
paper: '#f8faf9',
trust: '#40c878',
teal: '#23b6a8',
amber: '#d99a24',
rose: '#df4661',
},
} as const; } as const;
export type BrandGlyphName = export type BrandGlyphName =
@ -35,15 +34,15 @@ export type BrandGlyphName =
| 'snowball' | 'snowball'
| 'security'; | 'security';
export const BRAND_GLYPHS: Record<BrandGlyphName, { label: string; icon: LucideIcon }> = { export const BRAND_GLYPHS: Record<BrandGlyphName, { label: string }> = {
tracker: { label: 'Tracker', icon: BarChart3 }, tracker: { label: 'Tracker' },
bills: { label: 'Bills', icon: Receipt }, bills: { label: 'Bills' },
calendar: { label: 'Calendar', icon: CalendarDays }, calendar: { label: 'Calendar' },
analytics: { label: 'Analytics', icon: BarChart3 }, analytics: { label: 'Analytics' },
data: { label: 'Data', icon: Database }, data: { label: 'Data' },
banking: { label: 'Banking', icon: Landmark }, banking: { label: 'Banking' },
snowball: { label: 'Snowball', icon: TrendingDown }, snowball: { label: 'Snowball' },
security: { label: 'Security', icon: ShieldCheck }, security: { label: 'Security' },
}; };
export const ADMIN_GLYPH = { label: 'Admin', icon: LockKeyhole } as const; export const ADMIN_GLYPH = { label: 'Admin' } as const;