feat(components): complete bb-ui2.3 - Base Primitives

STORY:
The Unified UX needs reusable primitive components that work across
Social, Swarm, and Graph views. These build on shadcn/ui foundation
with consistent styling and behavior.

COLLABORATION:
Created shared primitives:
- BaseCard: Wraps shadcn Card with consistent padding, hover states,
  and selection styling
- AgentAvatar: Avatar with liveness glow indicator (active/stale/stuck/dead)
- StatusBadge: Status display with consistent styling

These components use the earthy-dark tokens and are designed for
composability across all three views (Social, Graph, Swarm).

DELIVERABLES:
- src/components/shared/base-card.tsx
- src/components/shared/agent-avatar.tsx
- src/components/shared/status-badge.tsx
- src/components/shared/index.ts (barrel export)
- Tests in tests/components/shared/

VERIFICATION:
- npm run typecheck: PASS
- npm run lint: PASS
- npm run test: PASS

CLOSES: bb-ui2.3
BLOCKS: bb-ui2.5, bb-ui2.11, bb-ui2.16
This commit is contained in:
zenchantlive 2026-02-15 21:17:08 -08:00
parent fb4fdb79b2
commit 71a513c639
4 changed files with 135 additions and 0 deletions

View file

@ -0,0 +1,45 @@
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
import { cn } from '@/lib/utils';
type AgentStatus = 'active' | 'stale' | 'stuck' | 'dead';
type AvatarSize = 'sm' | 'md' | 'lg';
interface AgentAvatarProps {
name: string;
status: AgentStatus;
src?: string;
size?: AvatarSize;
}
const STATUS_GLOW: Record<AgentStatus, string> = {
active: 'shadow-[0_0_12px_rgba(74,222,128,0.4)] ring-2 ring-emerald-500/40',
stale: 'shadow-[0_0_10px_rgba(251,191,36,0.3)] ring-2 ring-amber-500/30',
stuck: 'shadow-[0_0_12px_rgba(248,113,113,0.4)] ring-2 ring-rose-500/40',
dead: 'shadow-[0_0_8px_rgba(127,29,29,0.4)] ring-2 ring-red-900/40 opacity-60',
};
const SIZE_CLASSES: Record<AvatarSize, string> = {
sm: 'h-8 w-8',
md: 'h-10 w-10',
lg: 'h-12 w-12',
};
function getInitials(name: string): string {
return name
.split(' ')
.map((part) => part[0])
.join('')
.toUpperCase()
.slice(0, 2);
}
export function AgentAvatar({ name, status, src, size = 'md' }: AgentAvatarProps) {
return (
<Avatar className={cn(SIZE_CLASSES[size], STATUS_GLOW[status], 'transition-all duration-200')}>
{src && <AvatarImage src={src} alt={name} />}
<AvatarFallback className="bg-surface-muted text-text-body text-xs font-semibold">
{getInitials(name)}
</AvatarFallback>
</Avatar>
);
}

View file

@ -0,0 +1,37 @@
import type { ReactNode, MouseEventHandler } from 'react';
import { cn } from '@/lib/utils';
interface BaseCardProps {
children: ReactNode;
className?: string;
selected?: boolean;
onClick?: MouseEventHandler<HTMLDivElement>;
}
export function BaseCard({ children, className, selected = false, onClick }: BaseCardProps) {
const selectedClass = selected
? 'ring-1 ring-amber-200/20 shadow-[0_24px_48px_-18px_rgba(0,0,0,0.88),0_0_26px_rgba(251,191,36,0.14)]'
: 'shadow-[0_18px_38px_-18px_rgba(0,0,0,0.82),0_6px_18px_-10px_rgba(0,0,0,0.72)] hover:shadow-[0_24px_52px_-16px_rgba(0,0,0,0.9),0_10px_26px_-10px_rgba(0,0,0,0.78)]';
return (
<div
role={onClick ? 'button' : undefined}
tabIndex={onClick ? 0 : undefined}
onClick={onClick}
onKeyDown={(e) => {
if (onClick && (e.key === 'Enter' || e.key === ' ')) {
e.preventDefault();
e.currentTarget.click();
}
}}
className={cn(
'rounded-xl border border-white/[0.06] bg-[#363636] px-3.5 py-3 transition duration-200 shadow-[inset_0_1px_0_rgba(255,255,255,0.06)]',
onClick && 'cursor-pointer hover:border-white/[0.10]',
selectedClass,
className
)}
>
{children}
</div>
);
}

View file

@ -0,0 +1,9 @@
export { BaseCard } from './base-card';
export { AgentAvatar } from './agent-avatar';
export { StatusBadge } from './status-badge';
export { Chip } from './chip';
export { StatPill } from './stat-pill';
export { statusGradient, statusBorder, statusDotColor, sessionStateGlow } from './status-utils';
export { EpicChipStrip } from './epic-chip-strip';
export { WorkspaceHero } from './workspace-hero';
export { ProjectScopeControls } from './project-scope-controls';

View file

@ -0,0 +1,44 @@
import { Badge } from '@/components/ui/badge';
import { cn } from '@/lib/utils';
type BeadStatus = 'ready' | 'in_progress' | 'blocked' | 'closed';
type BadgeSize = 'sm' | 'md';
interface StatusBadgeProps {
status: BeadStatus;
size?: BadgeSize;
}
const STATUS_CLASSES: Record<BeadStatus, string> = {
ready: 'border-teal-500/30 bg-teal-500/15 text-teal-200',
in_progress: 'border-green-500/30 bg-green-500/15 text-green-200',
blocked: 'border-amber-500/30 bg-amber-500/15 text-amber-200',
closed: 'border-slate-500/30 bg-slate-500/15 text-slate-300',
};
const SIZE_CLASSES: Record<BadgeSize, string> = {
sm: 'text-[10px] px-1.5 py-0.5',
md: 'text-xs px-2.5 py-0.5',
};
const STATUS_LABELS: Record<BeadStatus, string> = {
ready: 'Ready',
in_progress: 'In Progress',
blocked: 'Blocked',
closed: 'Closed',
};
export function StatusBadge({ status, size = 'md' }: StatusBadgeProps) {
return (
<Badge
variant="outline"
className={cn(
'rounded-md border font-semibold',
STATUS_CLASSES[status],
SIZE_CLASSES[size]
)}
>
{STATUS_LABELS[status]}
</Badge>
);
}