feat(ui3): elegant earthy task feed redesign

This commit is contained in:
zenchantlive 2026-02-16 23:20:47 -08:00
parent 9c703072d1
commit 395f90ed2a
9 changed files with 379 additions and 184 deletions

View file

@ -0,0 +1,88 @@
import type { ReactNode, MouseEventHandler } from 'react';
import { cn } from '@/lib/utils';
import type { SocialCardStatus } from '@/lib/social-cards';
interface ModuleCardProps {
children: ReactNode;
className?: string;
selected?: boolean;
status?: SocialCardStatus;
onClick?: MouseEventHandler<HTMLDivElement>;
}
const STATUS_COLORS: Record<SocialCardStatus, string> = {
ready: 'bg-emerald-500',
in_progress: 'bg-amber-500',
blocked: 'bg-rose-500',
closed: 'bg-slate-500',
};
const STATUS_BORDER_COLORS: Record<SocialCardStatus, string> = {
ready: 'border-emerald-500/30',
in_progress: 'border-amber-500/30',
blocked: 'border-rose-500/30',
closed: 'border-slate-500/30',
};
export function ModuleCard({
children,
className,
selected = false,
status = 'ready',
onClick
}: ModuleCardProps) {
// "Industrial Sci-Fi" Aesthetic
// 1. Top status line (LED bar)
// 2. Chamfered-feel (using borders/shadows)
// 3. Technical containment
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(
// Base Geometry
'relative group flex flex-col',
'bg-[#1e1e1e] overflow-hidden', // Darker, matte background
// Borders: Tech-styled
'border border-white/5',
STATUS_BORDER_COLORS[status],
// Selection State: "Active Signal"
selected ? 'ring-1 ring-amber-400 shadow-[0_0_20px_rgba(251,191,36,0.15)]' : 'hover:border-white/20',
// Layout
'transition-all duration-200',
className
)}
style={{
// Custom clip-path for chamfered corners?
// Let's stick to tight radius for now, simpler to maintain, maybe "cut" corners later.
borderRadius: '4px', // Tighter radius for industrial feel
}}
>
{/* Top Status Indicator Bar (The "LED Strip") */}
<div className={cn(
"absolute top-0 left-0 w-full h-[3px] transition-colors",
STATUS_COLORS[status],
selected ? 'opacity-100 shadow-[0_0_8px_currentColor]' : 'opacity-70'
)} />
{/* Content Container */}
<div className="p-4 pt-5 flex flex-col gap-3 h-full">
{children}
</div>
{/* Decorative "Rivets" or Tech-marks */}
<div className="absolute top-2 right-2 w-1 h-1 rounded-full bg-white/10" />
<div className="absolute bottom-2 right-2 w-1.5 h-1.5 border border-white/10 rounded-[1px]" />
</div>
);
}

View file

@ -13,25 +13,24 @@ interface SocialCardProps {
onJumpToKanban?: (id: string) => void;
}
function RelationshipItem({ id, color }: { id: string; color: 'unlocks' | 'blocks' }) {
const dotColor = color === 'unlocks' ? 'bg-rose-400' : 'bg-amber-400';
const borderColor = color === 'unlocks' ? 'border-rose-500/20' : 'border-amber-500/20';
const hoverBorder = color === 'unlocks' ? 'group-hover:border-rose-500/40' : 'group-hover:border-amber-500/40';
function DependencyPill({ id, type }: { id: string; type: 'blocked-by' | 'blocking' }) {
// Soft, friendly pills. Rose for "blocked by", Amber for "blocking".
const styles = type === 'blocked-by'
? 'bg-rose-500/10 text-rose-200 hover:bg-rose-500/20'
: 'bg-amber-500/10 text-amber-200 hover:bg-amber-500/20';
return (
<div className={cn(
"group flex items-center gap-2 rounded border bg-white/5 px-2.5 py-2 transition-colors",
borderColor,
hoverBorder,
"hover:bg-white/10"
<span className={cn(
"inline-flex items-center px-2.5 py-1 rounded-full text-[10px] font-medium transition-colors cursor-default",
styles
)}>
<span className={cn("h-1.5 w-1.5 rounded-full shrink-0", dotColor)} />
<span className="font-mono text-[10px] text-text-muted">{id}</span>
</div>
{type === 'blocked-by' ? 'Waiting on ' : 'Blocks '}
<span className="font-mono ml-1 opacity-80">{id}</span>
</span>
);
}
function ViewJumpIcon({
function ActionButton({
icon,
label,
onClick,
@ -44,8 +43,12 @@ function ViewJumpIcon({
<button
type="button"
aria-label={label}
onClick={onClick}
className="p-1 text-text-muted hover:text-text-body transition-colors rounded hover:bg-white/5"
onClick={(e) => {
e.stopPropagation();
onClick?.();
}}
className="p-2 text-text-muted hover:text-white hover:bg-white/10 rounded-full transition-all duration-200"
title={label}
>
{icon}
</button>
@ -54,53 +57,40 @@ function ViewJumpIcon({
function GraphIcon() {
return (
<svg
width="16"
height="16"
viewBox="0 0 16 16"
fill="none"
stroke="currentColor"
strokeWidth="1.5"
>
<circle cx="4" cy="4" r="2" />
<circle cx="12" cy="4" r="2" />
<circle cx="8" cy="12" r="2" />
<line x1="5.5" y1="5.5" x2="7" y2="10" />
<line x1="10.5" y1="5.5" x2="9" y2="10" />
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<circle cx="18" cy="5" r="3"></circle>
<circle cx="6" cy="12" r="3"></circle>
<circle cx="18" cy="19" r="3"></circle>
<line x1="8.59" y1="13.51" x2="15.42" y2="17.49"></line>
<line x1="15.41" y1="6.51" x2="8.59" y2="10.49"></line>
</svg>
);
}
function KanbanIcon() {
return (
<svg
width="16"
height="16"
viewBox="0 0 16 16"
fill="none"
stroke="currentColor"
strokeWidth="1.5"
>
<rect x="2" y="2" width="4" height="12" rx="1" />
<rect x="6" y="2" width="4" height="8" rx="1" />
<rect x="10" y="2" width="4" height="6" rx="1" />
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<rect x="3" y="3" width="18" height="18" rx="2" ry="2"></rect>
<line x1="9" y1="3" x2="9" y2="21"></line>
</svg>
);
}
function ExpandIcon() {
function StatusBadge({ status }: { status: string }) {
const styles = {
ready: 'bg-teal-500/10 text-teal-300 border-teal-500/20',
in_progress: 'bg-emerald-500/10 text-emerald-300 border-emerald-500/20',
blocked: 'bg-amber-500/10 text-amber-300 border-amber-500/20',
closed: 'bg-slate-500/10 text-slate-400 border-slate-500/20',
}[status as keyof typeof styles] || 'bg-slate-500/10 text-slate-400 border-slate-500/20';
return (
<svg
width="14"
height="14"
viewBox="0 0 14 14"
fill="none"
stroke="currentColor"
strokeWidth="1.5"
>
<circle cx="6" cy="6" r="4" />
<line x1="9" y1="9" x2="12.5" y2="12.5" />
</svg>
<span className={cn(
"px-2.5 py-0.5 rounded-full text-[10px] font-bold uppercase tracking-wider border",
styles
)}>
{status.replace('_', ' ')}
</span>
);
}
@ -112,106 +102,84 @@ export function SocialCard({
onJumpToGraph,
onJumpToKanban,
}: SocialCardProps) {
// NEW semantic: blocks = what I block (amber), unblocks = what blocks me (rose)
const hasBlocks = data.blocks.length > 0;
const hasUnblocks = data.unblocks.length > 0;
return (
<BaseCard
className={cn('min-w-[220px] max-w-[320px]', className)}
// "Post" Styling: hover lift, soft shadow handled by BaseCard update
className={cn('flex flex-col gap-4 p-5 min-h-[180px]', className)}
selected={selected}
status={data.status}
onClick={onClick}
>
<div className="space-y-3">
<div className="flex items-center justify-between">
<span className="text-teal-400 font-mono text-sm font-medium">
{data.id}
</span>
<button
type="button"
aria-label="Expand"
className="p-1 text-text-muted hover:text-text-body transition-colors rounded hover:bg-white/5"
>
<ExpandIcon />
</button>
{/* Header: ID & Status */}
<div className="flex items-center justify-between">
<span className="font-mono text-xs font-medium text-teal-400/80">
{data.id}
</span>
<StatusBadge status={data.status} />
</div>
{/* Hero: Title */}
<h3 className="text-lg font-bold text-text-primary leading-tight">
{data.title}
</h3>
{/* Content: Dependencies (Pill Cloud) */}
{(hasBlocks || hasUnblocks) && (
<div className="flex flex-wrap gap-2 mt-auto pt-2">
{/* Unblocks = Blocked By me? No.
data.unblocks = tasks blocking THIS task (upstream) -> "Waiting on"
data.blocks = tasks THIS task blocks (downstream) -> "Blocks"
*/}
{data.unblocks.slice(0, 3).map((id) => (
<DependencyPill key={id} id={id} type="blocked-by" />
))}
{data.blocks.slice(0, 3).map((id) => (
<DependencyPill key={id} id={id} type="blocking" />
))}
{(data.unblocks.length + data.blocks.length > 6) && (
<span className="px-2 py-1 text-[10px] text-text-muted/60 italic">
+{data.unblocks.length + data.blocks.length - 6} more
</span>
)}
</div>
)}
<h3 className="text-text-strong font-semibold text-sm leading-tight line-clamp-2">
{data.title}
</h3>
{(hasBlocks || hasUnblocks) && (
<div className="space-y-2 pt-1">
{/* BLOCKED BY: tasks blocking THIS task (rose) */}
{hasUnblocks && (
<div className="rounded-lg bg-black/20 p-2 border border-white/5">
<p className="mb-1.5 text-[9px] font-bold uppercase tracking-widest text-rose-400/80 pl-0.5">Blocked By</p>
<div className="flex flex-col gap-1.5">
{data.unblocks.slice(0, 3).map((id) => (
<RelationshipItem key={id} id={id} color="unlocks" />
))}
{data.unblocks.length > 3 && (
<div className="text-[10px] text-rose-400/60 px-2 py-1 italic">
+{data.unblocks.length - 3} more
</div>
)}
</div>
</div>
)}
{/* BLOCKING: tasks THIS task blocks (amber) */}
{hasBlocks && (
<div className="rounded-lg bg-black/20 p-2 border border-white/5">
<p className="mb-1.5 text-[9px] font-bold uppercase tracking-widest text-amber-400/80 pl-0.5">Blocking</p>
<div className="flex flex-col gap-1.5">
{data.blocks.slice(0, 3).map((id) => (
<RelationshipItem key={id} id={id} color="blocks" />
))}
{data.blocks.length > 3 && (
<div className="text-[10px] text-amber-400/60 px-2 py-1 italic">
+{data.blocks.length - 3} more
</div>
)}
</div>
</div>
)}
</div>
)}
<div className="flex items-center justify-between pt-2 border-t border-white/5">
<div className="flex items-center gap-1">
{data.agents.slice(0, 3).map((agent) => (
{/* Footer: Agents & Actions */}
<div className="flex items-center justify-between pt-4 border-t border-white/5 mt-2">
{/* Crew */}
<div className="flex items-center -space-x-2 pl-1">
{data.agents.map((agent) => (
<div key={agent.name} className="relative z-0 hover:z-10 transition-transform hover:scale-110">
<AgentAvatar
key={agent.name}
name={agent.name}
status={agent.status as AgentStatus}
role={agent.role}
size="sm"
/>
))}
{data.agents.length > 3 && (
<span className="text-text-muted text-xs ml-1">
+{data.agents.length - 3}
</span>
)}
</div>
</div>
))}
{data.agents.length === 0 && (
<span className="text-xs text-text-muted/40 italic">Unassigned</span>
)}
</div>
<div className="flex items-center gap-0.5">
<ViewJumpIcon
icon={<GraphIcon />}
label="View in Graph"
onClick={() => onJumpToGraph?.(data.id)}
/>
<ViewJumpIcon
icon={<KanbanIcon />}
label="View in Kanban"
onClick={() => onJumpToKanban?.(data.id)}
/>
</div>
{/* Actions (Share/View) */}
<div className="flex items-center gap-1">
<ActionButton
icon={<GraphIcon />}
label="View Graph"
onClick={() => onJumpToGraph?.(data.id)}
/>
<ActionButton
icon={<KanbanIcon />}
label="View Kanban"
onClick={() => onJumpToKanban?.(data.id)}
/>
</div>
</div>
</BaseCard>
);
}
}

View file

@ -15,10 +15,10 @@ export function SocialPage({ issues, selectedId, onSelect }: SocialPageProps) {
const cards = useMemo(() => buildSocialCards(issues), [issues]);
return (
<div className="flex flex-col h-full">
{/* Top: Scrollable Grid Container (approx 4x2 visible) */}
<div className="flex-none h-[60vh] min-h-[400px] overflow-y-auto p-6 border-b border-white/5 custom-scrollbar bg-black/10">
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4 max-w-[1600px] mx-auto">
<div className="flex flex-col h-full bg-earthy-gradient">
{/* Feed Container */}
<div className="flex-1 overflow-y-auto custom-scrollbar p-6 md:p-8">
<div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-6 max-w-7xl mx-auto">
{cards.map((card) => (
<SocialCard
key={card.id}
@ -28,20 +28,20 @@ export function SocialPage({ issues, selectedId, onSelect }: SocialPageProps) {
/>
))}
{cards.length === 0 && (
<div className="col-span-full text-center py-12 text-text-muted">
No tasks found.
<div className="col-span-full flex flex-col items-center justify-center py-20 text-text-muted opacity-60">
<div className="text-4xl mb-4">📭</div>
<p>No active tasks found in stream.</p>
</div>
)}
</div>
</div>
{/* Bottom: Detail Area Placeholder */}
<div className="flex-1 bg-surface-muted/30 p-6 flex items-center justify-center text-text-muted/50">
<div className="text-center">
<p className="text-sm font-medium">Select a task to view details</p>
<p className="text-xs mt-1 opacity-70">(Chat & Activity stream coming soon)</p>
</div>
{/* Bottom Console (Conversation Deck) - Placeholder for future chat integration */}
<div className="flex-none h-16 border-t border-white/5 bg-black/20 backdrop-blur-md flex items-center justify-center">
<p className="text-xs font-medium text-text-muted/60 tracking-wide uppercase">
Select a task to view conversation
</p>
</div>
</div>
);
}
}