2026-07-05 17:18:23 -05:00
|
|
|
import type { ComponentType, ReactNode } from 'react';
|
|
|
|
|
import { Inbox } from 'lucide-react';
|
|
|
|
|
import { cn } from '@/lib/utils';
|
|
|
|
|
import { BrandGlyph } from '@/components/brand/Brand';
|
|
|
|
|
import type { BrandGlyphName } from '@/lib/brand';
|
|
|
|
|
|
|
|
|
|
type Tone = 'neutral' | 'good' | 'warn' | 'danger' | 'info';
|
|
|
|
|
|
|
|
|
|
const toneClasses: Record<Tone, string> = {
|
|
|
|
|
neutral: 'border-border/75 bg-card/95 text-muted-foreground',
|
|
|
|
|
good: 'border-emerald-500/25 bg-emerald-500/[0.07] text-emerald-700 dark:text-emerald-300',
|
2026-07-10 18:03:15 -05:00
|
|
|
warn: 'border-amber-500/30 bg-amber-500/8 text-amber-700 dark:text-amber-300',
|
|
|
|
|
danger: 'border-rose-500/30 bg-rose-500/8 text-rose-700 dark:text-rose-300',
|
2026-07-05 17:18:23 -05:00
|
|
|
info: 'border-sky-500/25 bg-sky-500/[0.07] text-sky-700 dark:text-sky-300',
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const dotClasses: Record<Tone, string> = {
|
|
|
|
|
neutral: 'bg-muted-foreground/55',
|
|
|
|
|
good: 'bg-emerald-500',
|
|
|
|
|
warn: 'bg-amber-500',
|
|
|
|
|
danger: 'bg-rose-500',
|
|
|
|
|
info: 'bg-sky-500',
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
interface PageHeaderProps {
|
|
|
|
|
eyebrow?: ReactNode;
|
|
|
|
|
title: ReactNode;
|
|
|
|
|
description?: ReactNode;
|
|
|
|
|
glyph?: BrandGlyphName;
|
|
|
|
|
icon?: ComponentType<{ className?: string }>;
|
|
|
|
|
actions?: ReactNode;
|
|
|
|
|
meta?: ReactNode;
|
|
|
|
|
className?: string;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export function PageHeader({
|
|
|
|
|
eyebrow,
|
|
|
|
|
title,
|
|
|
|
|
description,
|
|
|
|
|
glyph,
|
|
|
|
|
icon: Icon,
|
|
|
|
|
actions,
|
|
|
|
|
meta,
|
|
|
|
|
className,
|
|
|
|
|
}: PageHeaderProps) {
|
|
|
|
|
return (
|
2026-07-06 14:23:53 -05:00
|
|
|
<section
|
|
|
|
|
className={cn('flex flex-col gap-4 sm:flex-row sm:items-end sm:justify-between', className)}
|
|
|
|
|
>
|
2026-07-05 17:18:23 -05:00
|
|
|
<div className="flex min-w-0 items-start gap-3">
|
feat(brand): render the real product glyph SVGs (not inline approximations)
BrandGlyph drew hardcoded monochrome shapes from an inline switch and
ignored the actual /brand/glyphs/*.svg files. Render the real files via a
robust <img> (fail-soft onError, intrinsic width/height so no layout
shift, alt for a11y parity) so every existing placement — PageHeader
(Tracker/About/Release), Command Palette, Release Notes, Onboarding,
Login — shows the full-color brand tiles. Deletes the ~85-line dead
BrandGlyphShape switch and the now-unused useId import.
Senior-review while-here:
- Guard test (client/lib/brand.test.ts): asserts every BrandGlyphName
asset — and every /brand asset in brand.ts — resolves to a real,
non-empty file on disk. Catches the "image added but path wrong /
renamed" regression TS can't see.
- Widen vitest include to {js,jsx,ts,tsx}: TypeScript client tests were
silently never run (client/hooks/useAutoSave.test.ts). Now 6 files / 50
tests run (was 4 / 42).
- Remove dead brand.ts entries: legacyLogo (pointed at the OLD pre-brand
/img/logo.png) and the unused ADMIN_GLYPH const.
- PageHeader glyph class inline-grid -> inline-block for the <img>.
Verified: typecheck + lint clean; test:client 50/50; build (glyphs land
in dist/brand/glyphs, served 200 as image/svg+xml); e2e probe 17/17;
login visual baseline unchanged.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-06 13:32:37 -05:00
|
|
|
{glyph && <BrandGlyph name={glyph} className="mt-0.5 hidden sm:inline-block" />}
|
2026-07-05 17:18:23 -05:00
|
|
|
{Icon && !glyph && (
|
2026-07-10 18:03:15 -05:00
|
|
|
<span className="mt-0.5 hidden h-10 w-10 shrink-0 place-items-center rounded-xl border border-primary/20 bg-primary/8 text-primary shadow-sm shadow-primary/10 sm:grid">
|
2026-07-05 17:18:23 -05:00
|
|
|
<Icon className="h-5 w-5" />
|
|
|
|
|
</span>
|
|
|
|
|
)}
|
|
|
|
|
<div className="min-w-0">
|
|
|
|
|
{eyebrow && (
|
|
|
|
|
<p className="mb-1 text-[11px] font-semibold uppercase tracking-[0.18em] text-muted-foreground">
|
|
|
|
|
{eyebrow}
|
|
|
|
|
</p>
|
|
|
|
|
)}
|
|
|
|
|
<h1 className="text-balance text-2xl font-semibold tracking-tight text-foreground sm:text-3xl">
|
|
|
|
|
{title}
|
|
|
|
|
</h1>
|
|
|
|
|
{description && (
|
2026-07-06 14:23:53 -05:00
|
|
|
<p className="mt-1 max-w-2xl text-sm leading-6 text-muted-foreground">{description}</p>
|
2026-07-05 17:18:23 -05:00
|
|
|
)}
|
|
|
|
|
{meta && <div className="mt-2 text-xs text-muted-foreground">{meta}</div>}
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
{actions && <div className="flex flex-wrap items-center gap-2 sm:justify-end">{actions}</div>}
|
|
|
|
|
</section>
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export function SectionSurface({
|
|
|
|
|
children,
|
|
|
|
|
className,
|
|
|
|
|
tone = 'neutral',
|
|
|
|
|
}: {
|
|
|
|
|
children: ReactNode;
|
|
|
|
|
className?: string;
|
|
|
|
|
tone?: Tone;
|
|
|
|
|
}) {
|
|
|
|
|
return (
|
|
|
|
|
<section className={cn('surface-premium', tone !== 'neutral' && toneClasses[tone], className)}>
|
|
|
|
|
{children}
|
|
|
|
|
</section>
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-06 14:23:53 -05:00
|
|
|
export function StatusDot({
|
|
|
|
|
tone = 'neutral',
|
|
|
|
|
pulse = false,
|
|
|
|
|
className,
|
|
|
|
|
}: {
|
|
|
|
|
tone?: Tone;
|
|
|
|
|
pulse?: boolean;
|
|
|
|
|
className?: string;
|
|
|
|
|
}) {
|
2026-07-05 17:18:23 -05:00
|
|
|
return (
|
|
|
|
|
<span className={cn('relative inline-flex h-2.5 w-2.5 shrink-0', className)} aria-hidden="true">
|
2026-07-06 14:23:53 -05:00
|
|
|
{pulse && (
|
|
|
|
|
<span
|
|
|
|
|
className={cn(
|
|
|
|
|
'absolute inline-flex h-full w-full animate-ping rounded-full opacity-40',
|
|
|
|
|
dotClasses[tone],
|
|
|
|
|
)}
|
|
|
|
|
/>
|
|
|
|
|
)}
|
2026-07-05 17:18:23 -05:00
|
|
|
<span className={cn('relative inline-flex h-2.5 w-2.5 rounded-full', dotClasses[tone])} />
|
|
|
|
|
</span>
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export function MetricCard({
|
|
|
|
|
label,
|
|
|
|
|
value,
|
|
|
|
|
hint,
|
|
|
|
|
icon: Icon,
|
|
|
|
|
tone = 'neutral',
|
|
|
|
|
action,
|
|
|
|
|
className,
|
|
|
|
|
}: {
|
|
|
|
|
label: ReactNode;
|
|
|
|
|
value: ReactNode;
|
|
|
|
|
hint?: ReactNode;
|
|
|
|
|
icon?: ComponentType<{ className?: string }>;
|
|
|
|
|
tone?: Tone;
|
|
|
|
|
action?: ReactNode;
|
|
|
|
|
className?: string;
|
|
|
|
|
}) {
|
|
|
|
|
return (
|
|
|
|
|
<div className={cn('metric-card', tone !== 'neutral' && toneClasses[tone], className)}>
|
|
|
|
|
<div className="flex items-start justify-between gap-3">
|
|
|
|
|
<div className="flex min-w-0 items-center gap-2">
|
2026-07-06 13:47:34 -05:00
|
|
|
{Icon && (
|
2026-07-10 18:03:15 -05:00
|
|
|
<span className="grid h-7 w-7 shrink-0 place-items-center rounded-lg border border-current/15 bg-current/8 text-current">
|
2026-07-06 13:47:34 -05:00
|
|
|
<Icon className="h-3.5 w-3.5 opacity-90" />
|
|
|
|
|
</span>
|
|
|
|
|
)}
|
2026-07-05 17:18:23 -05:00
|
|
|
<p className="truncate text-[11px] font-semibold uppercase tracking-[0.16em] text-muted-foreground">
|
|
|
|
|
{label}
|
|
|
|
|
</p>
|
|
|
|
|
</div>
|
|
|
|
|
{action}
|
|
|
|
|
</div>
|
2026-07-06 13:47:34 -05:00
|
|
|
<p className="metric-value mt-4 text-2xl leading-none sm:text-[1.72rem]">{value}</p>
|
2026-07-05 17:18:23 -05:00
|
|
|
{hint && <p className="mt-2 text-xs leading-5 text-muted-foreground">{hint}</p>}
|
|
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export function EmptyState({
|
|
|
|
|
title,
|
|
|
|
|
description,
|
|
|
|
|
icon: Icon = Inbox,
|
|
|
|
|
action,
|
|
|
|
|
className,
|
|
|
|
|
}: {
|
|
|
|
|
title: ReactNode;
|
|
|
|
|
description?: ReactNode;
|
|
|
|
|
icon?: ComponentType<{ className?: string }>;
|
|
|
|
|
action?: ReactNode;
|
|
|
|
|
className?: string;
|
|
|
|
|
}) {
|
|
|
|
|
return (
|
2026-07-06 14:23:53 -05:00
|
|
|
<div
|
|
|
|
|
className={cn(
|
|
|
|
|
'surface-premium flex flex-col items-center justify-center px-6 py-12 text-center',
|
|
|
|
|
className,
|
|
|
|
|
)}
|
|
|
|
|
>
|
2026-07-05 17:18:23 -05:00
|
|
|
<span className="grid h-11 w-11 place-items-center rounded-xl border border-border/70 bg-muted/35 text-muted-foreground">
|
|
|
|
|
<Icon className="h-5 w-5" />
|
|
|
|
|
</span>
|
|
|
|
|
<h2 className="mt-4 text-base font-semibold text-foreground">{title}</h2>
|
2026-07-06 14:23:53 -05:00
|
|
|
{description && (
|
|
|
|
|
<p className="mt-1 max-w-md text-sm leading-6 text-muted-foreground">{description}</p>
|
|
|
|
|
)}
|
2026-07-05 17:18:23 -05:00
|
|
|
{action && <div className="mt-4">{action}</div>}
|
|
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
}
|