feat(ui): complete shell layout components (bb-ui2.6, .7, .8, .9, .27)

STORY:
Phase 1 of the Unified UX epic required a complete 3-panel shell layout
with responsive behavior across mobile, tablet, and desktop breakpoints.
The existing page structure was fragmented - we needed a cohesive shell.

COLLABORATION:
Three agents (bb-5am, bb-dwz, bb-3dv) worked in parallel on:
- TopBar: View tabs (Social/Graph/Swarm) with active states, filter input
- LeftPanel: Channel tree navigation with epic filtering, responsive collapse
- RightPanel: Detail strip with sidebar (desktop) / drawer (tablet/mobile) modes

We encountered a hydration mismatch error on mobile/tablet because
useResponsive was returning different values on server vs client.
Fixed by defaulting to desktop on server and only updating after mount.

Mobile navigation (bb-ui2.27) added:
- Hamburger menu for left panel access on mobile/tablet
- Bottom tab bar for thumb-friendly view switching

DELIVERABLES:
- src/components/shared/top-bar.tsx: TopBar with view tabs + hamburger
- src/components/shared/left-panel.tsx: Epic tree with expand/collapse
- src/components/shared/right-panel.tsx: Responsive sidebar/drawer
- src/components/shared/unified-shell.tsx: Main 3-panel grid layout
- src/components/shared/mobile-nav.tsx: Bottom tab bar for mobile
- src/hooks/use-responsive.ts: Breakpoint detection (mobile/tablet/desktop)
- Tests for all components

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

CLOSES: bb-ui2.6, bb-ui2.7, bb-ui2.8, bb-ui2.9, bb-ui2.27
This commit is contained in:
zenchantlive 2026-02-15 23:19:52 -08:00
parent 539e6e7021
commit ce8fdd0d4c
9 changed files with 855 additions and 0 deletions

View file

@ -0,0 +1,238 @@
'use client';
import { useState, useMemo } from 'react';
import type { BeadIssue } from '../../lib/types';
import { useResponsive } from '../../hooks/use-responsive';
import { cn } from '../../lib/utils';
export interface LeftPanelProps {
issues: BeadIssue[];
selectedEpicId?: string | null;
onEpicSelect?: (epicId: string | null) => void;
}
interface EpicNode {
epic: BeadIssue;
children: BeadIssue[];
}
function buildEpicTree(issues: BeadIssue[]): EpicNode[] {
const epics = issues.filter(issue => issue.issue_type === 'epic');
const epicMap = new Map<string, EpicNode>();
for (const epic of epics) {
epicMap.set(epic.id, { epic, children: [] });
}
for (const issue of issues) {
if (issue.issue_type === 'epic') continue;
const parentDep = issue.dependencies.find(dep => dep.type === 'parent');
if (parentDep && epicMap.has(parentDep.target)) {
epicMap.get(parentDep.target)!.children.push(issue);
}
}
return Array.from(epicMap.values()).sort((a, b) =>
a.epic.id.localeCompare(b.epic.id)
);
}
export function LeftPanel({
issues,
selectedEpicId,
onEpicSelect,
}: LeftPanelProps) {
const [expandedEpics, setExpandedEpics] = useState<Set<string>>(new Set());
const { isDesktop, isTablet } = useResponsive();
const epicTree = useMemo(() => buildEpicTree(issues), [issues]);
const toggleEpic = (epicId: string) => {
setExpandedEpics(prev => {
const next = new Set(prev);
if (next.has(epicId)) {
next.delete(epicId);
} else {
next.add(epicId);
}
return next;
});
};
const handleEpicClick = (epicId: string) => {
onEpicSelect?.(epicId);
toggleEpic(epicId);
};
if (isTablet) {
return (
<div
className="w-12 overflow-y-auto flex flex-col items-center py-3 gap-2"
style={{
backgroundColor: 'var(--color-bg-card)',
borderRight: '1px solid rgba(255, 255, 255, 0.1)',
}}
data-testid="left-panel"
>
{epicTree.map(({ epic }) => (
<button
key={epic.id}
type="button"
onClick={() => handleEpicClick(epic.id)}
className={cn(
'w-9 h-9 rounded flex items-center justify-center text-xs font-medium transition-colors',
selectedEpicId === epic.id
? 'bg-[var(--color-accent-green)]/20 text-[var(--color-accent-green)]'
: 'hover:bg-white/5'
)}
style={{ color: selectedEpicId === epic.id ? undefined : 'var(--color-text-muted-dark)' }}
title={epic.id}
>
{epic.id.slice(0, 2).toUpperCase()}
</button>
))}
</div>
);
}
return (
<div
className={cn(
'flex flex-col h-full overflow-hidden',
!isDesktop && 'hidden lg:flex'
)}
style={{
width: '13rem',
backgroundColor: 'var(--color-bg-card)',
borderRight: '1px solid rgba(255, 255, 255, 0.1)',
}}
data-testid="left-panel"
>
<div className="p-3 border-b border-white/10">
<span
className="text-xs font-medium uppercase tracking-wider"
style={{ color: 'var(--color-text-muted-dark)' }}
>
Channels
</span>
</div>
<div className="flex-1 overflow-y-auto custom-scrollbar">
{epicTree.map(({ epic, children }) => {
const isExpanded = expandedEpics.has(epic.id);
const isSelected = selectedEpicId === epic.id;
const childCount = children.length;
return (
<div key={epic.id} className="select-none">
<button
type="button"
onClick={() => handleEpicClick(epic.id)}
className={cn(
'w-full flex items-center gap-2 px-3 py-2 text-left transition-colors',
'hover:bg-white/5 focus:outline-none focus:bg-white/5'
)}
style={{
color: isSelected
? 'var(--color-accent-green)'
: 'var(--color-text-secondary)',
}}
data-testid={`epic-${epic.id}`}
>
<span
className="text-xs transition-transform inline-block"
style={{
transform: isExpanded ? 'rotate(90deg)' : 'rotate(0deg)',
}}
>
</span>
<span className="flex-1 truncate text-sm">
{epic.id}
</span>
{childCount > 0 && (
<span
className="text-xs px-1.5 py-0.5 rounded"
style={{
backgroundColor: 'rgba(255,255,255,0.08)',
color: 'var(--color-text-muted-dark)',
}}
>
{childCount}
</span>
)}
</button>
{isExpanded && childCount > 0 && (
<div className="pl-6">
{children.map(child => {
const childSelected = selectedEpicId === child.id;
return (
<button
key={child.id}
type="button"
onClick={() => onEpicSelect?.(child.id)}
className={cn(
'w-full flex items-center gap-2 px-3 py-1.5 text-left transition-colors',
'hover:bg-white/5 focus:outline-none focus:bg-white/5'
)}
style={{
color: childSelected
? 'var(--color-accent-green)'
: 'var(--color-text-muted-dark)',
}}
data-testid={`bead-${child.id}`}
>
<span className="text-xs opacity-60"></span>
<span className="flex-1 truncate text-xs">
{child.id}
</span>
</button>
);
})}
</div>
)}
</div>
);
})}
{epicTree.length === 0 && (
<div
className="p-4 text-sm text-center"
style={{ color: 'var(--color-text-muted-dark)' }}
>
No epics found
</div>
)}
</div>
<div
className="border-t border-white/10 p-3"
style={{ backgroundColor: 'var(--color-bg-base)' }}
>
<span
className="text-xs font-medium uppercase tracking-wider"
style={{ color: 'var(--color-text-muted-dark)' }}
>
Scope
</span>
<div className="mt-2 flex flex-col gap-1.5">
<label
className="flex items-center gap-2 cursor-pointer"
style={{ color: 'var(--color-text-secondary)' }}
>
<input
type="checkbox"
defaultChecked
className="rounded border-white/20 accent-[var(--color-accent-green)]"
/>
<span className="text-xs">All Projects</span>
</label>
</div>
</div>
</div>
);
}
export default LeftPanel;