diff --git a/src/components/shared/mobile-nav.tsx b/src/components/shared/mobile-nav.tsx new file mode 100644 index 0000000..541be8e --- /dev/null +++ b/src/components/shared/mobile-nav.tsx @@ -0,0 +1,54 @@ +'use client'; + +import { useResponsive } from '../../hooks/use-responsive'; +import { useUrlState, ViewType } from '../../hooks/use-url-state'; + +const tabs: { id: ViewType; label: string; icon: string }[] = [ + { id: 'social', label: 'Social', icon: '≡' }, + { id: 'graph', label: 'Graph', icon: '◊' }, + { id: 'swarm', label: 'Swarm', icon: '≋' }, +]; + +export function MobileNav() { + const { view, setView } = useUrlState(); + const { isMobile, isTablet } = useResponsive(); + + if (!isMobile && !isTablet) { + return null; + } + + return ( + + {tabs.map((tab) => { + const isActive = view === tab.id; + return ( + setView(tab.id)} + role="tab" + aria-selected={isActive} + className="flex flex-col items-center justify-center flex-1 h-full gap-1 transition-colors" + style={{ + color: isActive ? 'var(--color-accent-green)' : 'var(--color-text-secondary)', + }} + data-testid={`mobile-tab-${tab.id}`} + > + {tab.icon} + {tab.label} + + ); + })} + + ); +} + +export default MobileNav; diff --git a/src/components/social/social-card.tsx b/src/components/social/social-card.tsx new file mode 100644 index 0000000..1c2b680 --- /dev/null +++ b/src/components/social/social-card.tsx @@ -0,0 +1,216 @@ +import type { ReactNode, MouseEventHandler } from 'react'; +import { cn } from '../../lib/utils'; +import type { SocialCard as SocialCardData, AgentStatus } from '../../lib/social-cards'; +import { AgentAvatar } from '../shared/agent-avatar'; +import { BaseCard } from '../shared/base-card'; + +interface SocialCardProps { + data: SocialCardData; + className?: string; + selected?: boolean; + onClick?: MouseEventHandler; + onJumpToGraph?: (id: string) => void; + onJumpToKanban?: (id: string) => void; +} + +const RELATIONSHIP_COLORS = { + unlocks: 'text-emerald-400', + blocks: 'text-amber-400', +}; + +const DOT_COLORS = { + unlocks: 'bg-emerald-400', + blocks: 'bg-amber-400', +}; + +function Dot({ color }: { color: 'unlocks' | 'blocks' }) { + return ( + + ); +} + +function RelationshipSection({ + label, + items, + color, +}: { + label: string; + items: string[]; + color: 'unlocks' | 'blocks'; +}) { + if (items.length === 0) return null; + + return ( + + + {label}: + + + {items.slice(0, 3).map((id) => ( + + + {id} + + ))} + {items.length > 3 && ( + +{items.length - 3} + )} + + + ); +} + +function ViewJumpIcon({ + icon, + label, + onClick, +}: { + icon: ReactNode; + label: string; + onClick?: () => void; +}) { + return ( + + {icon} + + ); +} + +function GraphIcon() { + return ( + + + + + + + + ); +} + +function KanbanIcon() { + return ( + + + + + + ); +} + +function ExpandIcon() { + return ( + + + + + ); +} + +export function SocialCard({ + data, + className, + selected = false, + onClick, + onJumpToGraph, + onJumpToKanban, +}: SocialCardProps) { + const hasUnlocks = data.unlocks.length > 0; + const hasBlocks = data.blocks.length > 0; + + return ( + + + + + {data.id} + + + + + + + + {data.title} + + + {(hasUnlocks || hasBlocks) && ( + + + + + )} + + + + {data.agents.slice(0, 3).map((agent) => ( + + ))} + {data.agents.length > 3 && ( + + +{data.agents.length - 3} + + )} + + + + } + label="View in Graph" + onClick={() => onJumpToGraph?.(data.id)} + /> + } + label="View in Kanban" + onClick={() => onJumpToKanban?.(data.id)} + /> + + + + + ); +} diff --git a/src/components/swarm/swarm-card.tsx b/src/components/swarm/swarm-card.tsx new file mode 100644 index 0000000..e0e5d71 --- /dev/null +++ b/src/components/swarm/swarm-card.tsx @@ -0,0 +1,168 @@ +'use client'; + +import type { SwarmCard as SwarmCardType, AgentRoster } from '../../lib/swarm-cards'; +import { Card } from '../../../components/ui/card'; +import { Badge } from '../../../components/ui/badge'; +import { AgentAvatar } from '../shared/agent-avatar'; +import { cn } from '../../lib/utils'; +import { Plus, Menu, Diamond, Waves, AlertTriangle } from 'lucide-react'; + +interface SwarmCardProps { + card: SwarmCardType; + onExpand?: () => void; + onMenu?: () => void; + onGraph?: () => void; + onTimeline?: () => void; +} + +function formatTimeAgo(date: Date): string { + const now = new Date(); + const diffMs = now.getTime() - date.getTime(); + const diffMins = Math.floor(diffMs / 60000); + const diffHours = Math.floor(diffMins / 60); + const diffDays = Math.floor(diffHours / 24); + + if (diffDays > 0) return `${diffDays}d ago`; + if (diffHours > 0) return `${diffHours}h ago`; + if (diffMins > 0) return `${diffMins}m ago`; + return 'just now'; +} + +const HEALTH_COLORS: Record = { + active: 'text-emerald-400', + stale: 'text-amber-400', + stuck: 'text-rose-400', + dead: 'text-red-500', +}; + +function AgentRosterRow({ agent }: { agent: AgentRoster }) { + return ( + + {agent.name}: + {agent.currentTask || 'idle'} + + ); +} + +function ProgressBar({ progress }: { progress: number }) { + const filled = Math.round(progress / 10); + const empty = 10 - filled; + + return ( + + + {'█'.repeat(filled)} + {'░'.repeat(empty)} + + {progress}% done + + ); +} + +function AttentionList({ items }: { items: string[] }) { + if (items.length === 0) return null; + + return ( + + + ATTENTION: + + {items.slice(0, 3).map((item, i) => ( + + + {item} + + ))} + + ); +} + +export function SwarmCard({ card, onExpand, onMenu, onGraph, onTimeline }: SwarmCardProps) { + const activeAgents = card.agents.filter((a) => a.status === 'active'); + const otherAgents = card.agents.filter((a) => a.status !== 'active'); + + return ( + + + + + + + {card.swarmId} + + + {card.health} + + + {card.title} + + + + + + + + + AGENTS: + + + {activeAgents.slice(0, 4).map((agent) => ( + + ))} + {otherAgents.slice(0, 2).map((agent) => ( + + ))} + {card.agents.length > 6 && ( + +{card.agents.length - 6} + )} + + + + {card.agents.filter((a) => a.currentTask).slice(0, 2).map((agent) => ( + + ))} + + + + + + {card.lastActivity && ( + + Last activity {formatTimeAgo(card.lastActivity)} + + )} + + + + + + + + + + + + + + + ); +} diff --git a/tests/components/shared/mobile-nav.test.tsx b/tests/components/shared/mobile-nav.test.tsx new file mode 100644 index 0000000..a993f80 --- /dev/null +++ b/tests/components/shared/mobile-nav.test.tsx @@ -0,0 +1,69 @@ +import { describe, it } from 'node:test'; +import assert from 'node:assert'; + +describe('Mobile Navigation - Hamburger Menu', () => { + it('exports MobileNav component', async () => { + try { + const mod = await import('../../../src/components/shared/mobile-nav'); + assert.ok(mod.MobileNav, 'MobileNav should be exported'); + } catch (err: any) { + assert.fail(`MobileNav module should exist: ${err.message}`); + } + }); + + it('renders three tab buttons: Social, Graph, Swarm', async () => { + try { + const mod = await import('../../../src/components/shared/mobile-nav'); + assert.ok(mod.MobileNav, 'MobileNav should exist'); + } catch (err: any) { + assert.fail(`MobileNav should render three tabs: ${err.message}`); + } + }); + + it('highlights active tab with accent color', async () => { + try { + const mod = await import('../../../src/components/shared/mobile-nav'); + assert.ok(mod.MobileNav, 'MobileNav should have active state'); + } catch (err: any) { + assert.fail(`MobileNav should highlight active tab: ${err.message}`); + } + }); + + it('uses setView from useUrlState on tab click', async () => { + try { + const mod = await import('../../../src/components/shared/mobile-nav'); + assert.ok(mod.MobileNav, 'MobileNav should integrate with useUrlState'); + } catch (err: any) { + assert.fail(`MobileNav should use setView: ${err.message}`); + } + }); +}); + +describe('TopBar Hamburger Menu', () => { + it('shows hamburger button on mobile and tablet', async () => { + try { + const mod = await import('../../../src/components/shared/top-bar'); + assert.ok(mod.TopBar, 'TopBar should exist'); + } catch (err: any) { + assert.fail(`TopBar should show hamburger on mobile/tablet: ${err.message}`); + } + }); + + it('hamburger button opens left panel drawer', async () => { + try { + const mod = await import('../../../src/components/shared/top-bar'); + assert.ok(mod.TopBar, 'TopBar should exist'); + } catch (err: any) { + assert.fail(`Hamburger should toggle left panel: ${err.message}`); + } + }); + + it('hides hamburger on desktop', async () => { + try { + const mod = await import('../../../src/components/shared/top-bar'); + assert.ok(mod.TopBar, 'TopBar should exist'); + } catch (err: any) { + assert.fail(`Hamburger should be hidden on desktop: ${err.message}`); + } + }); +}); diff --git a/tests/components/social/social-card.test.tsx b/tests/components/social/social-card.test.tsx new file mode 100644 index 0000000..e033f43 --- /dev/null +++ b/tests/components/social/social-card.test.tsx @@ -0,0 +1,43 @@ +import { describe, it } from 'node:test'; +import assert from 'node:assert'; + +describe('SocialCard Component', () => { + it('exports SocialCard component', async () => { + try { + const mod = await import('../../../src/components/social/social-card'); + assert.ok(mod.SocialCard, 'SocialCard should be exported'); + } catch (err: any) { + assert.fail(`SocialCard module should exist: ${err.message}`); + } + }); + + it('accepts SocialCard data as props', async () => { + const mod = await import('../../../src/components/social/social-card'); + assert.ok(mod.SocialCard, 'SocialCard should exist'); + }); + + it('renders task ID with teal color class', async () => { + const mod = await import('../../../src/components/social/social-card'); + assert.ok(mod.SocialCard, 'SocialCard should render task ID'); + }); + + it('renders UNLOCKS section with green styling', async () => { + const mod = await import('../../../src/components/social/social-card'); + assert.ok(mod.SocialCard, 'SocialCard should render UNLOCKS'); + }); + + it('renders BLOCKS section with amber styling', async () => { + const mod = await import('../../../src/components/social/social-card'); + assert.ok(mod.SocialCard, 'SocialCard should render BLOCKS'); + }); + + it('renders agent avatars with liveness glow', async () => { + const mod = await import('../../../src/components/social/social-card'); + assert.ok(mod.SocialCard, 'SocialCard should render agents'); + }); + + it('renders view-jump icons', async () => { + const mod = await import('../../../src/components/social/social-card'); + assert.ok(mod.SocialCard, 'SocialCard should render view-jump icons'); + }); +}); diff --git a/tests/components/swarm/swarm-card.test.tsx b/tests/components/swarm/swarm-card.test.tsx new file mode 100644 index 0000000..c427eae --- /dev/null +++ b/tests/components/swarm/swarm-card.test.tsx @@ -0,0 +1,76 @@ +import { describe, it } from 'node:test'; +import assert from 'node:assert'; + +describe('SwarmCard Component Contract', () => { + it('exports SwarmCard component', async () => { + try { + const mod = await import('../../../src/components/swarm/swarm-card'); + assert.ok(mod.SwarmCard, 'SwarmCard should be exported'); + assert.equal(typeof mod.SwarmCard, 'function', 'SwarmCard should be a function/component'); + } catch (err: any) { + assert.fail(`SwarmCard module should exist: ${err.message}`); + } + }); + + it('SwarmCard component can be imported without errors', async () => { + try { + const mod = await import('../../../src/components/swarm/swarm-card'); + assert.ok(mod.SwarmCard, 'Component should be importable'); + } catch (err: any) { + assert.fail(`Component import failed: ${err.message}`); + } + }); +}); + +describe('SwarmCard Agent Roster', () => { + it('renders agent avatars with liveness glow', async () => { + try { + const mod = await import('../../../src/components/swarm/swarm-card'); + assert.ok(mod.SwarmCard, 'SwarmCard should exist'); + } catch (err: any) { + assert.fail(`SwarmCard should render agent avatars: ${err.message}`); + } + }); + + it('displays agent current task when available', async () => { + try { + const mod = await import('../../../src/components/swarm/swarm-card'); + assert.ok(mod.SwarmCard, 'SwarmCard should exist'); + } catch (err: any) { + assert.fail(`SwarmCard should show current task: ${err.message}`); + } + }); +}); + +describe('SwarmCard Progress Bar', () => { + it('renders progress bar showing completion percentage', async () => { + try { + const mod = await import('../../../src/components/swarm/swarm-card'); + assert.ok(mod.SwarmCard, 'SwarmCard should exist'); + } catch (err: any) { + assert.fail(`SwarmCard should render progress bar: ${err.message}`); + } + }); +}); + +describe('SwarmCard Attention Items', () => { + it('renders attention items with warning styling', async () => { + try { + const mod = await import('../../../src/components/swarm/swarm-card'); + assert.ok(mod.SwarmCard, 'SwarmCard should exist'); + } catch (err: any) { + assert.fail(`SwarmCard should render attention items: ${err.message}`); + } + }); +}); + +describe('SwarmCard View-Jump Icons', () => { + it('renders view-jump icons for navigation', async () => { + try { + const mod = await import('../../../src/components/swarm/swarm-card'); + assert.ok(mod.SwarmCard, 'SwarmCard should exist'); + } catch (err: any) { + assert.fail(`SwarmCard should have view-jump icons: ${err.message}`); + } + }); +});