refactor: implement icon rail sidebar for Design System v4

72px icon rail with hover flyouts, pin-to-expand toggle (260px),
keyboard accessible, mobile hamburger overlay. Flat TopBar styling
(no blur/glass). New BrandLogo mark (gradient square + lightning bolt).
BrandWordmark uses solid text color instead of gradient.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Michael Chihlas
2026-03-22 00:26:19 -04:00
parent 0499284679
commit 8efc443949
7 changed files with 477 additions and 237 deletions

View File

@@ -5,37 +5,36 @@ interface BrandLogoProps {
className?: string
}
/**
* Brand logo mark: gradient cyan square with rounded corners
* containing a white lightning bolt.
*/
export function BrandLogo({ size = 'sm', className }: BrandLogoProps) {
const sizeClasses = size === 'sm' ? 'h-8 w-8' : 'h-20 w-20'
const strokeBase = size === 'sm' ? 1 : 2
const strokeThick = size === 'sm' ? 1.25 : 2.5
const dashArray = size === 'sm' ? '1 1.5' : '2 3'
const nodeR = size === 'sm' ? { outer: 2.5, inner: 2.75 } : { outer: 5, inner: 5.5 }
const hubR = size === 'sm' ? { glow: 5, solid: 3.5 } : { glow: 10, solid: 7 }
const vb = size === 'sm' ? '0 0 40 40' : '0 0 80 80'
const s = size === 'sm' ? 1 : 2
const gradId = size === 'sm' ? 'logoGradSm' : 'logoGradLg'
const gradEnd = String(40 * (size === 'sm' ? 1 : 2))
const dim = size === 'sm' ? 30 : 64
return (
<svg viewBox={vb} fill="none" className={cn(sizeClasses, className)}>
<defs>
<linearGradient id={gradId} x1="0" y1="0" x2={gradEnd} y2={gradEnd} gradientUnits="userSpaceOnUse">
<stop offset="0%" stopColor="#06b6d4" />
<stop offset="100%" stopColor="#22d3ee" />
</linearGradient>
</defs>
<circle cx={5 * s} cy={7 * s} r={nodeR.outer} fill={`url(#${gradId})`} opacity="0.5" />
<circle cx={5 * s} cy={15 * s} r={nodeR.inner} fill={`url(#${gradId})`} opacity="0.7" />
<circle cx={5 * s} cy={25 * s} r={nodeR.inner} fill={`url(#${gradId})`} opacity="0.7" />
<circle cx={5 * s} cy={33 * s} r={nodeR.outer} fill={`url(#${gradId})`} opacity="0.5" />
<path d={`M${7.5 * s} ${7 * s}L${14 * s} ${17 * s}`} stroke={`url(#${gradId})`} strokeWidth={strokeBase} strokeLinecap="round" strokeDasharray={dashArray} opacity="0.4" />
<path d={`M${7.75 * s} ${15 * s}L${14 * s} ${19 * s}`} stroke={`url(#${gradId})`} strokeWidth={strokeBase} strokeLinecap="round" opacity="0.5" />
<path d={`M${7.75 * s} ${25 * s}L${14 * s} ${21 * s}`} stroke={`url(#${gradId})`} strokeWidth={strokeBase} strokeLinecap="round" opacity="0.5" />
<path d={`M${7.5 * s} ${33 * s}L${14 * s} ${23 * s}`} stroke={`url(#${gradId})`} strokeWidth={strokeBase} strokeLinecap="round" strokeDasharray={dashArray} opacity="0.4" />
<circle cx={18 * s} cy={20 * s} r={hubR.glow} fill={`url(#${gradId})`} opacity="0.15" />
<circle cx={18 * s} cy={20 * s} r={hubR.solid} fill={`url(#${gradId})`} opacity="0.9" />
<path d={`M${21.5 * s} ${20 * s}H${35 * s}M${35 * s} ${20 * s}L${30 * s} ${15 * s}M${35 * s} ${20 * s}L${30 * s} ${25 * s}`} stroke={`url(#${gradId})`} strokeWidth={strokeThick} strokeLinecap="round" strokeLinejoin="round" />
</svg>
<div
className={cn('shrink-0 flex items-center justify-center', className)}
style={{
width: dim,
height: dim,
borderRadius: size === 'sm' ? 8 : 14,
background: 'linear-gradient(135deg, #06b6d4, #22d3ee)',
}}
>
<svg
viewBox="0 0 24 24"
fill="none"
style={{ width: dim * 0.5, height: dim * 0.5 }}
>
<path
d="M13 2L4.5 13.5H12L11 22L19.5 10.5H12L13 2Z"
fill="white"
stroke="white"
strokeWidth="0.5"
strokeLinejoin="round"
/>
</svg>
</div>
)
}

View File

@@ -9,13 +9,12 @@ export function BrandWordmark({ size = 'sm', className }: BrandWordmarkProps) {
return (
<span
className={cn(
'font-heading font-bold tracking-tight',
'font-heading font-bold tracking-tight text-[#f0f2f5]',
size === 'sm' ? 'text-xl' : 'text-3xl',
className
)}
>
<span className="text-foreground">Resolution</span>
<span className="text-gradient-brand">Flow</span>
ResolutionFlow
</span>
)
}

View File

@@ -1,6 +1,6 @@
import { useEffect, useState, useCallback } from 'react'
import { useLocation, useNavigate, Link } from 'react-router-dom'
import { Menu, X, LayoutGrid, Clock, Network, AlertTriangle, Code2, Wand2, BarChart3, Settings, LogOut, Shield, Library } from 'lucide-react'
import { Menu, X, LayoutGrid, Clock, AlertTriangle, GitBranch, Code2, Wand2, BarChart3, Settings, LogOut, Shield, Layers } from 'lucide-react'
import { useAuthStore } from '@/store/authStore'
import { usePermissions } from '@/hooks/usePermissions'
import { useUserPreferencesStore } from '@/store/userPreferencesStore'
@@ -16,7 +16,7 @@ export function AppLayout() {
const navigate = useNavigate()
const { user, logout } = useAuthStore()
const { effectiveRole } = usePermissions()
const sidebarCollapsed = useUserPreferencesStore(s => s.sidebarCollapsed)
const sidebarPinned = useUserPreferencesStore(s => s.sidebarPinned)
const [mobileMenuOpen, setMobileMenuOpen] = useState(false)
// Close mobile menu on route change
@@ -54,8 +54,8 @@ export function AppLayout() {
{ path: '/', label: 'Dashboard', icon: LayoutGrid },
{ path: '/sessions', label: 'Active Sessions', icon: Clock },
{ path: '/escalations', label: 'Escalations', icon: AlertTriangle },
{ path: '/trees', label: 'Flows', icon: Network },
{ path: '/step-library', label: 'Step Library', icon: Library },
{ path: '/trees', label: 'Flows', icon: GitBranch },
{ path: '/step-library', label: 'Step Library', icon: Layers },
{ path: '/scripts', label: 'Scripts', icon: Code2 },
{ path: '/script-builder', label: 'Script Builder', icon: Wand2 },
{ path: '/analytics', label: 'Analytics', icon: BarChart3 },
@@ -64,34 +64,8 @@ export function AppLayout() {
return (
<>
{/* Atmosphere orbs — ambient light behind glass */}
<div
className="pointer-events-none fixed z-0"
style={{
top: '-120px',
right: '-80px',
width: '600px',
height: '600px',
borderRadius: '50%',
background: 'radial-gradient(circle, rgba(6, 182, 212, 0.15) 0%, rgba(6, 182, 212, 0.04) 40%, transparent 70%)',
filter: 'blur(60px)',
}}
/>
<div
className="pointer-events-none fixed z-0"
style={{
bottom: '-100px',
left: '-60px',
width: '500px',
height: '500px',
borderRadius: '50%',
background: 'radial-gradient(circle, rgba(99, 102, 241, 0.08) 0%, rgba(99, 102, 241, 0.02) 40%, transparent 70%)',
filter: 'blur(50px)',
}}
/>
<div
className={cn('app-shell relative z-1', sidebarCollapsed && 'app-shell--collapsed')}
className={cn('app-shell relative z-1', sidebarPinned && 'app-shell--pinned')}
data-testid="app-shell"
>
{/* Top Bar - spans full width */}
@@ -104,8 +78,9 @@ export function AppLayout() {
{/* Mobile hamburger - overlaid on topbar */}
<button
type="button"
onClick={() => setMobileMenuOpen(true)}
className="fixed left-4 top-3.5 z-50 rounded-lg p-2 text-muted-foreground hover:bg-card hover:text-foreground transition-colors md:hidden"
className="fixed left-4 top-3.5 z-50 rounded-lg p-2 text-[#848b9b] hover:bg-[#14161d] hover:text-[#e2e5eb] transition-colors md:hidden"
aria-label="Open menu"
>
<Menu size={20} />
@@ -119,17 +94,19 @@ export function AppLayout() {
onClick={() => setMobileMenuOpen(false)}
aria-hidden="true"
/>
<nav className="absolute inset-y-0 left-0 w-72 border-r border-border bg-[var(--sidebar-bg)] shadow-2xl animate-slide-in-left">
<div className="flex h-14 items-center justify-between border-b border-border px-4">
<nav
className="absolute inset-y-0 left-0 w-72 shadow-2xl animate-slide-in-left"
style={{ background: '#0f1118', borderRight: '1px solid #1e2130' }}
>
<div className="flex h-14 items-center justify-between px-4" style={{ borderBottom: '1px solid #1e2130' }}>
<Link to="/" className="flex items-center gap-2.5">
<div className="flex h-8 w-8 items-center justify-center rounded-lg bg-gradient-brand">
<BrandLogo size="sm" className="h-4 w-4" />
</div>
<span className="text-sm font-heading font-bold">ResolutionFlow</span>
<BrandLogo size="sm" />
<span className="text-sm font-heading font-bold text-[#f0f2f5]">ResolutionFlow</span>
</Link>
<button
type="button"
onClick={() => setMobileMenuOpen(false)}
className="rounded-lg p-2 text-muted-foreground hover:bg-card hover:text-foreground"
className="rounded-lg p-2 text-[#848b9b] hover:bg-[#14161d] hover:text-[#e2e5eb]"
aria-label="Close menu"
>
<X size={18} />
@@ -138,10 +115,10 @@ export function AppLayout() {
<div className="flex flex-col p-3">
{/* User info */}
<div className="mb-3 border-b border-border pb-3 px-3">
<p className="text-sm font-medium text-foreground">{user?.name || user?.email}</p>
<div className="mb-3 pb-3 px-3" style={{ borderBottom: '1px solid #1e2130' }}>
<p className="text-sm font-medium text-[#e2e5eb]">{user?.name || user?.email}</p>
{effectiveRole && effectiveRole !== 'engineer' && (
<span className="mt-1 inline-flex items-center gap-1 text-xs text-muted-foreground">
<span className="mt-1 inline-flex items-center gap-1 text-xs text-[#848b9b]">
<Shield size={10} />
{effectiveRole === 'super_admin' ? 'Super Admin' : effectiveRole === 'owner' ? 'Owner' : 'Viewer'}
</span>
@@ -162,8 +139,8 @@ export function AppLayout() {
className={cn(
'flex items-center gap-3 rounded-lg px-3 py-2.5 text-sm font-medium transition-colors',
isActive
? 'bg-[var(--sidebar-active)] text-foreground'
: 'text-muted-foreground hover:bg-[var(--sidebar-hover)] hover:text-foreground'
? 'bg-[rgba(34,211,238,0.10)] text-[#e2e5eb]'
: 'text-[#848b9b] hover:bg-[#191c25] hover:text-[#e2e5eb]'
)}
>
<Icon size={18} />
@@ -174,10 +151,11 @@ export function AppLayout() {
</div>
{/* Logout */}
<div className="mt-3 border-t border-border pt-3">
<div className="mt-3 pt-3" style={{ borderTop: '1px solid #1e2130' }}>
<button
type="button"
onClick={handleLogout}
className="flex w-full items-center gap-3 rounded-lg px-3 py-2.5 text-sm font-medium text-muted-foreground hover:bg-[var(--sidebar-hover)] hover:text-foreground transition-colors"
className="flex w-full items-center gap-3 rounded-lg px-3 py-2.5 text-sm font-medium text-[#848b9b] hover:bg-[#191c25] hover:text-[#e2e5eb] transition-colors"
>
<LogOut size={18} />
Logout

View File

@@ -1,173 +1,420 @@
import { useCallback, useEffect, useState } from 'react'
import { useLocation } from 'react-router-dom'
import { useCallback, useEffect, useRef, useState } from 'react'
import { Link, useLocation } from 'react-router-dom'
import type { LucideIcon } from 'lucide-react'
import {
LayoutGrid, Network, Clock, FileOutput, BarChart3, TrendingUp,
Settings, PanelLeftClose, PanelLeftOpen, MessageSquareText, ListChecks,
BookOpen, Code2, Library, AlertTriangle, Wand2,
LayoutGrid, Clock, AlertTriangle, GitBranch, Layers, Code2, Wand2,
ListChecks, Download, BarChart3, Rocket, BookOpen, MessageSquare,
Settings, Pin, PinOff,
} from 'lucide-react'
import { cn } from '@/lib/utils'
import { useUserPreferencesStore } from '@/store/userPreferencesStore'
import { sidebarApi } from '@/api'
import type { SidebarStatsResponse } from '@/api/sidebar'
import { NavItem } from './NavItem'
import { prefetchForRoute } from '@/lib/routePrefetch'
// Semantic icon colors — each nav item gets a unique color for visual landmarks
const NAV_COLORS = {
dashboard: '#22d3ee', // cyan-400
flows: '#a78bfa', // violet-400
sessions: '#34d399', // emerald-400
exports: '#60a5fa', // blue-400
stepLib: '#fb923c', // orange-400
scripts: '#2dd4bf', // teal-400
scriptBuilder: '#e879f9', // fuchsia-400
analytics: '#38bdf8', // sky-400
guides: '#a3e635', // lime-400
feedback: '#818cf8', // indigo-400
} as const
/* ── Types ──────────────────────────────────────────── */
interface NavSubItem {
href: string
label: string
count?: number
}
interface NavEntry {
href: string
icon: LucideIcon
label: string
shortLabel: string
badge?: number
matchPaths?: string[]
children?: NavSubItem[]
}
interface NavSection {
title: string
items: NavEntry[]
}
/* ── Sidebar component ──────────────────────────────── */
export function Sidebar() {
const sidebarCollapsed = useUserPreferencesStore(s => s.sidebarCollapsed)
const toggleSidebar = useUserPreferencesStore(s => s.toggleSidebar)
const location = useLocation()
const sidebarPinned = useUserPreferencesStore(s => s.sidebarPinned)
const toggleSidebarPinned = useUserPreferencesStore(s => s.toggleSidebarPinned)
const [stats, setStats] = useState<SidebarStatsResponse | null>(null)
const [flyoutIndex, setFlyoutIndex] = useState<string | null>(null)
const flyoutTimeout = useRef<ReturnType<typeof setTimeout> | null>(null)
const sidebarRef = useRef<HTMLElement>(null)
/* ── Stats fetching ───────────────────────────────── */
const refreshStats = useCallback(() => {
sidebarApi.getStats().then(setStats).catch(() => {})
}, [])
// Fetch sidebar stats — refreshes on navigation
useEffect(() => {
refreshStats()
}, [location.pathname, refreshStats])
useEffect(() => { refreshStats() }, [location.pathname, refreshStats])
// Refresh when sessions are created or completed
useEffect(() => {
window.addEventListener('session-changed', refreshStats)
return () => window.removeEventListener('session-changed', refreshStats)
}, [refreshStats])
const handleSidebarWheel = (e: React.WheelEvent<HTMLElement>) => {
const sidebar = e.currentTarget
const canSidebarScroll = sidebar.scrollHeight > sidebar.clientHeight
const atTop = sidebar.scrollTop <= 0
const atBottom = sidebar.scrollTop + sidebar.clientHeight >= sidebar.scrollHeight - 1
/* ── Navigation data ──────────────────────────────── */
// If sidebar can't consume wheel movement, forward it to main content scroller.
if (!canSidebarScroll || (e.deltaY < 0 && atTop) || (e.deltaY > 0 && atBottom)) {
const sections: NavSection[] = [
{
title: 'RESOLVE',
items: [
{ href: '/', icon: LayoutGrid, label: 'Dashboard', shortLabel: 'Dash' },
{ href: '/sessions', icon: Clock, label: 'Active Sessions', shortLabel: 'Sessions', badge: stats?.active_count || undefined, matchPaths: ['/sessions'] },
{ href: '/escalations', icon: AlertTriangle, label: 'Escalations', shortLabel: 'Escal', badge: stats?.escalation_count || undefined },
],
},
{
title: 'KNOWLEDGE',
items: [
{
href: '/trees', icon: GitBranch, label: 'Flows', shortLabel: 'Flows',
badge: stats?.tree_counts.total || undefined,
matchPaths: ['/trees', '/flows', '/my-trees'],
children: [
{ href: '/trees', label: 'All Flows' },
{ href: '/trees?type=troubleshooting', label: 'Troubleshooting', count: stats?.tree_counts.troubleshooting || undefined },
{ href: '/trees?type=procedural', label: 'Projects', count: stats?.tree_counts.procedural || undefined },
{ href: '/trees?type=maintenance', label: 'Maintenance', count: stats?.tree_counts.maintenance || undefined },
],
},
{ href: '/step-library', icon: Layers, label: 'Step Library', shortLabel: 'Steps' },
{ href: '/scripts', icon: Code2, label: 'Scripts', shortLabel: 'Scripts' },
{ href: '/script-builder', icon: Wand2, label: 'Script Builder', shortLabel: 'Builder' },
{ href: '/review-queue', icon: ListChecks, label: 'Review Queue', shortLabel: 'Review' },
],
},
{
title: 'INSIGHTS',
items: [
{ href: '/shares', icon: Download, label: 'Exports', shortLabel: 'Export' },
{ href: '/analytics', icon: BarChart3, label: 'Analytics', shortLabel: 'Stats' },
{ href: '/analytics/flowpilot', icon: Rocket, label: 'FlowPilot Analytics', shortLabel: 'FPilot' },
],
},
]
const footerItems: NavEntry[] = [
{ href: '/guides', icon: BookOpen, label: 'User Guides', shortLabel: 'Guides' },
{ href: '/feedback', icon: MessageSquare, label: 'Feedback', shortLabel: 'Feedbk' },
{ href: '/account', icon: Settings, label: 'Account', shortLabel: 'Acct' },
]
/* ── Active detection ─────────────────────────────── */
const isActive = (item: NavEntry) => {
if (item.matchPaths) return item.matchPaths.some(p => location.pathname.startsWith(p))
if (item.href === '/') return location.pathname === '/'
return location.pathname.startsWith(item.href)
}
const isChildActive = (child: NavSubItem) => {
const fullPath = location.pathname + location.search
return fullPath === child.href || fullPath.startsWith(child.href + '&')
}
/* ── Flyout management ────────────────────────────── */
const openFlyout = (key: string) => {
if (flyoutTimeout.current) clearTimeout(flyoutTimeout.current)
setFlyoutIndex(key)
}
const closeFlyout = () => {
flyoutTimeout.current = setTimeout(() => setFlyoutIndex(null), 120)
}
const keepFlyout = () => {
if (flyoutTimeout.current) clearTimeout(flyoutTimeout.current)
}
/* Close flyout on Escape */
useEffect(() => {
const handleKey = (e: KeyboardEvent) => {
if (e.key === 'Escape') setFlyoutIndex(null)
}
document.addEventListener('keydown', handleKey)
return () => document.removeEventListener('keydown', handleKey)
}, [])
/* ── Wheel forwarding (when sidebar can't scroll) ── */
const handleWheel = (e: React.WheelEvent<HTMLElement>) => {
const el = e.currentTarget
const canScroll = el.scrollHeight > el.clientHeight
const atTop = el.scrollTop <= 0
const atBottom = el.scrollTop + el.clientHeight >= el.scrollHeight - 1
if (!canScroll || (e.deltaY < 0 && atTop) || (e.deltaY > 0 && atBottom)) {
const main = document.querySelector('.main-content') as HTMLElement | null
if (main) {
main.scrollTop += e.deltaY
e.preventDefault()
}
if (main) { main.scrollTop += e.deltaY; e.preventDefault() }
}
}
/* ── Render helpers ───────────────────────────────── */
const renderRailItem = (item: NavEntry, key: string) => {
const active = isActive(item)
const Icon = item.icon
const hasChildren = item.children && item.children.length > 0
return (
<div
key={key}
className="relative"
onMouseEnter={() => hasChildren && !sidebarPinned ? openFlyout(key) : undefined}
onMouseLeave={() => hasChildren && !sidebarPinned ? closeFlyout() : undefined}
>
<Link
to={item.href}
onMouseEnter={() => prefetchForRoute(item.href)}
onFocus={() => hasChildren && !sidebarPinned ? openFlyout(key) : undefined}
onBlur={() => hasChildren && !sidebarPinned ? closeFlyout() : undefined}
className={cn(
'group relative flex flex-col items-center justify-center rounded-lg px-1 py-2 transition-all duration-150',
active
? 'bg-[rgba(34,211,238,0.10)] text-[#67e8f9]'
: 'text-[#6b7280] hover:text-[#848b9b]'
)}
title={item.label}
>
<span className="relative">
<Icon size={20} className={active ? 'opacity-100' : 'opacity-60 group-hover:opacity-85'} />
{item.badge !== undefined && item.badge > 0 && (
<span className="absolute -right-1.5 -top-1.5 flex h-4 min-w-[16px] items-center justify-center rounded-full bg-[#22d3ee] px-1 text-[0.5rem] font-bold text-[#0c0d10]">
{item.badge > 99 ? '99+' : item.badge}
</span>
)}
</span>
<span className="mt-1 text-[0.5625rem] font-mono leading-tight truncate max-w-[60px]">
{item.shortLabel}
</span>
</Link>
{/* Flyout panel (icon rail only) */}
{hasChildren && !sidebarPinned && flyoutIndex === key && (
<div
className="fixed z-50 ml-1"
style={{
left: '72px',
top: sidebarRef.current
? (() => {
const itemEl = sidebarRef.current.querySelector(`[data-flyout-key="${key}"]`)
if (itemEl) {
const rect = itemEl.getBoundingClientRect()
return `${rect.top}px`
}
return '0px'
})()
: '0px',
maxHeight: '70vh',
overflowY: 'auto',
}}
onMouseEnter={keepFlyout}
onMouseLeave={closeFlyout}
>
<div
className="w-[220px] rounded-lg p-2 animate-fade-in"
style={{
background: '#14161d',
border: '1px solid #1e2130',
boxShadow: '0 4px 12px rgba(0,0,0,0.3)',
}}
>
{item.children!.map(child => (
<Link
key={child.href}
to={child.href}
className={cn(
'flex items-center justify-between rounded-md px-3 py-2 text-[0.8125rem] transition-colors',
isChildActive(child)
? 'bg-[rgba(34,211,238,0.10)] text-[#67e8f9]'
: 'text-[#848b9b] hover:bg-[#191c25] hover:text-[#e2e5eb]'
)}
>
<span>{child.label}</span>
{child.count !== undefined && (
<span className="text-[0.6875rem] font-mono text-[#4f5666]">{child.count}</span>
)}
</Link>
))}
</div>
</div>
)}
</div>
)
}
const renderPinnedItem = (item: NavEntry, key: string) => {
const active = isActive(item)
const Icon = item.icon
const fullPath = location.pathname + location.search
const activeChild = item.children?.find(c => fullPath === c.href || fullPath.startsWith(c.href + '&'))
const isParentDimmed = !!activeChild && active
return (
<div key={key} className="group/nav">
<Link
to={item.href}
onMouseEnter={() => prefetchForRoute(item.href)}
className={cn(
'group relative flex items-center gap-3 rounded-lg px-3 py-2 text-[0.8125rem] font-medium transition-all duration-150',
active
? isParentDimmed
? 'bg-[rgba(34,211,238,0.05)] text-[#e2e5eb]/70'
: 'bg-[rgba(34,211,238,0.10)] text-[#e2e5eb]'
: 'text-[#848b9b] hover:bg-[#191c25] hover:text-[#e2e5eb]'
)}
>
{active && !isParentDimmed && (
<div
className="absolute left-0 top-1/2 h-6 w-[3px] -translate-y-1/2"
style={{ background: '#22d3ee', borderRadius: '0 3px 3px 0' }}
/>
)}
<Icon size={18} className={cn('shrink-0', active ? 'opacity-100' : 'opacity-70')} />
<span className="truncate">{item.label}</span>
{item.badge !== undefined && item.badge > 0 && (
<span className="ml-auto shrink-0 rounded-full px-2 text-[0.6875rem] font-mono text-[#4f5666]"
style={{ background: '#14161d', border: '1px solid #1e2130' }}>
{item.badge}
</span>
)}
</Link>
{/* Sub-items for pinned mode */}
{item.children && item.children.length > 0 && (
<div className={cn(
'mt-0.5 space-y-0.5 overflow-hidden transition-all duration-200',
active || activeChild
? 'max-h-40 opacity-100'
: 'max-h-0 opacity-0 group-hover/nav:max-h-40 group-hover/nav:opacity-100'
)}>
{item.children.map(child => {
const childActive = isChildActive(child)
return (
<Link
key={child.href}
to={child.href}
className={cn(
'flex items-center gap-2 rounded-lg pl-9 pr-3 py-1.5 text-[0.8125rem] font-medium transition-colors',
childActive
? 'bg-[rgba(34,211,238,0.10)] text-[#e2e5eb]'
: 'text-[#848b9b] hover:bg-[#191c25] hover:text-[#e2e5eb]'
)}
>
<span className="truncate">{child.label}</span>
{child.count !== undefined && (
<span className="ml-auto shrink-0 rounded-full px-2 text-[0.6875rem] font-mono text-[#4f5666]"
style={{ background: '#14161d', border: '1px solid #1e2130' }}>
{child.count}
</span>
)}
</Link>
)
})}
</div>
)}
</div>
)
}
/* ── Flyout positioning: use data attribute for lookup ── */
const renderRailItemWithRef = (item: NavEntry, key: string) => {
return (
<div key={key} data-flyout-key={key}>
{renderRailItem(item, key + '-inner')}
</div>
)
}
/* ── Main render ──────────────────────────────────── */
if (sidebarPinned) {
return (
<nav
ref={sidebarRef}
className="sidebar flex flex-col"
style={{ background: '#0f1118', borderRight: '1px solid #1e2130' }}
onWheel={handleWheel}
>
{/* Pinned sidebar content */}
<div className="px-3 py-2 space-y-0.5">
{sections.map((section, si) => (
<div key={section.title}>
{si > 0 && (
<div className="font-mono text-[0.5625rem] uppercase tracking-[0.12em] text-[#4f5666] px-3 pt-3 pb-1">
{section.title}
</div>
)}
{section.items.map((item, ii) => renderPinnedItem(item, `${si}-${ii}`))}
</div>
))}
</div>
<div className="flex-1" />
{/* Footer */}
<div className="px-3 py-2 space-y-0.5" style={{ borderTop: '1px solid #1e2130' }}>
{footerItems.map((item, i) => renderPinnedItem(item, `footer-${i}`))}
<button
type="button"
onClick={toggleSidebarPinned}
className="flex w-full items-center gap-3 rounded-lg px-3 py-2 text-[0.8125rem] font-medium text-[#848b9b] hover:bg-[#191c25] hover:text-[#e2e5eb] transition-colors"
title="Unpin sidebar"
>
<PinOff size={18} className="shrink-0" />
<span>Unpin</span>
</button>
</div>
</nav>
)
}
/* Icon Rail (default) */
return (
<nav
className="sidebar flex flex-col border-r"
style={{
background: 'rgba(16, 17, 20, 0.5)',
backdropFilter: 'var(--glass-blur-light)',
WebkitBackdropFilter: 'var(--glass-blur-light)',
borderColor: 'var(--glass-border)',
}}
onWheel={handleSidebarWheel}
ref={sidebarRef}
className="sidebar flex flex-col items-center"
style={{ background: '#0f1118', borderRight: '1px solid #1e2130' }}
onWheel={handleWheel}
>
{sidebarCollapsed ? (
<>
{/* Collapsed: icon-only nav */}
<div className="flex flex-col items-center px-1.5 py-3 space-y-1">
<NavItem href="/" icon={LayoutGrid} label="Dashboard" iconColor={NAV_COLORS.dashboard} collapsed />
<NavItem href="/sessions" icon={Clock} label="Active Sessions" badge={stats?.active_count || undefined} iconColor={NAV_COLORS.sessions} collapsed />
<NavItem href="/escalations" icon={AlertTriangle} label="Escalations" badge={stats?.escalation_count || undefined} iconColor="#fbbf24" collapsed />
<NavItem href="/trees" icon={Network} label="Flows" matchPaths={['/trees', '/flows', '/my-trees']} iconColor={NAV_COLORS.flows} collapsed />
<NavItem href="/step-library" icon={Library} label="Step Library" iconColor={NAV_COLORS.stepLib} collapsed />
<NavItem href="/scripts" icon={Code2} label="Scripts" iconColor={NAV_COLORS.scripts} collapsed />
<NavItem href="/script-builder" icon={Wand2} label="Script Builder" iconColor={NAV_COLORS.scriptBuilder} collapsed />
<NavItem href="/review-queue" icon={ListChecks} label="Review Queue" iconColor="#fbbf24" collapsed />
<NavItem href="/shares" icon={FileOutput} label="Exports" iconColor={NAV_COLORS.exports} collapsed />
<NavItem href="/analytics" icon={BarChart3} label="Analytics" iconColor={NAV_COLORS.analytics} collapsed />
<NavItem href="/analytics/flowpilot" icon={TrendingUp} label="FlowPilot Analytics" iconColor="#2dd4bf" collapsed />
<NavItem href="/guides" icon={BookOpen} label="User Guides" iconColor={NAV_COLORS.guides} collapsed />
<NavItem href="/feedback" icon={MessageSquareText} label="Feedback" iconColor={NAV_COLORS.feedback} collapsed />
{/* Nav sections */}
<div className="flex flex-col items-center w-full px-1.5 py-2 space-y-0.5">
{sections.map((section, si) => (
<div key={section.title} className="w-full">
{si > 0 && (
<div className="mx-3 my-2" style={{ borderTop: '1px solid #1e2130' }} />
)}
{section.items.map((item, ii) => renderRailItemWithRef(item, `${si}-${ii}`))}
</div>
</>
) : (
<>
{/* Navigation */}
<div className="px-3 py-2 space-y-0.5">
{/* Dashboard */}
<NavItem href="/" icon={LayoutGrid} label="Dashboard" iconColor={NAV_COLORS.dashboard} />
))}
</div>
{/* Resolve */}
<div className="font-label text-[0.5625rem] uppercase tracking-[0.12em] text-[#5a6170] px-3 pt-3 pb-1">
Resolve
</div>
<NavItem href="/sessions" icon={Clock} label="Active Sessions" badge={stats?.active_count || undefined} iconColor={NAV_COLORS.sessions} matchPaths={['/sessions']} />
<NavItem href="/escalations" icon={AlertTriangle} label="Escalations" badge={stats?.escalation_count || undefined} iconColor="#fbbf24" />
{/* Knowledge */}
<div className="font-label text-[0.5625rem] uppercase tracking-[0.12em] text-[#5a6170] px-3 pt-3 pb-1">
Knowledge
</div>
<NavItem
href="/trees"
icon={Network}
label="Flows"
badge={stats?.tree_counts.total || undefined}
iconColor={NAV_COLORS.flows}
matchPaths={['/trees', '/flows', '/my-trees']}
children={[
{ href: '/trees?type=troubleshooting', label: 'Troubleshooting', count: stats?.tree_counts.troubleshooting || undefined },
{ href: '/trees?type=procedural', label: 'Projects', count: stats?.tree_counts.procedural || undefined },
{ href: '/trees?type=maintenance', label: 'Maintenance', count: stats?.tree_counts.maintenance || undefined },
]}
/>
<NavItem href="/step-library" icon={Library} label="Step Library" iconColor={NAV_COLORS.stepLib} />
<NavItem href="/scripts" icon={Code2} label="Scripts" iconColor={NAV_COLORS.scripts} />
<NavItem href="/script-builder" icon={Wand2} label="Script Builder" iconColor={NAV_COLORS.scriptBuilder} />
<NavItem href="/review-queue" icon={ListChecks} label="Review Queue" iconColor="#fbbf24" />
{/* Insights */}
<div className="font-label text-[0.5625rem] uppercase tracking-[0.12em] text-[#5a6170] px-3 pt-3 pb-1">
Insights
</div>
<NavItem href="/shares" icon={FileOutput} label="Exports" iconColor={NAV_COLORS.exports} />
<NavItem href="/analytics" icon={BarChart3} label="Analytics" iconColor={NAV_COLORS.analytics} />
<NavItem href="/analytics/flowpilot" icon={TrendingUp} label="FlowPilot Analytics" iconColor="#2dd4bf" />
</div>
</>
)}
{/* Spacer — pushes footer to bottom */}
<div className="flex-1" />
{/* Footer */}
<div
className={cn(
"border-t",
sidebarCollapsed ? "px-1.5 py-2 flex flex-col items-center" : "px-3 py-2 space-y-0.5"
)}
style={{ borderColor: 'var(--glass-border)' }}
>
{!sidebarCollapsed && (
<>
<NavItem href="/guides" icon={BookOpen} label="User Guides" iconColor={NAV_COLORS.guides} />
<NavItem href="/feedback" icon={MessageSquareText} label="Feedback" iconColor={NAV_COLORS.feedback} />
<NavItem href="/account" icon={Settings} label="Account" />
</>
)}
<div className="flex flex-col items-center w-full px-1.5 py-2 space-y-0.5" style={{ borderTop: '1px solid #1e2130' }}>
{footerItems.map((item, i) => (
<div key={`footer-${i}`} data-flyout-key={`footer-${i}`}>
{renderRailItem(item, `footer-${i}-inner`)}
</div>
))}
<button
onClick={toggleSidebar}
className={cn(
"flex w-full items-center rounded-lg text-[0.8125rem] font-medium text-muted-foreground hover:bg-[var(--sidebar-hover)] hover:text-foreground transition-colors",
sidebarCollapsed ? "justify-center p-2.5" : "gap-3 px-3 py-2"
)}
title={sidebarCollapsed ? 'Expand sidebar' : 'Collapse sidebar'}
type="button"
onClick={toggleSidebarPinned}
className="flex flex-col items-center justify-center rounded-lg px-1 py-2 text-[#6b7280] hover:text-[#848b9b] transition-colors"
title="Pin sidebar"
>
{sidebarCollapsed ? <PanelLeftOpen size={20} /> : <PanelLeftClose size={18} />}
{!sidebarCollapsed && <span>Collapse</span>}
<Pin size={18} className="opacity-60 hover:opacity-85" />
<span className="mt-1 text-[0.5625rem] font-mono leading-tight">Pin</span>
</button>
</div>
</nav>

View File

@@ -35,7 +35,7 @@ export function TopBar() {
return () => document.removeEventListener('mousedown', handleClickOutside)
}, [userMenuOpen])
// K / Ctrl+K global shortcut
// Cmd+K / Ctrl+K global shortcut
const handleGlobalKeyDown = useCallback((e: KeyboardEvent) => {
if ((e.metaKey || e.ctrlKey) && e.key === 'k') {
e.preventDefault()
@@ -55,12 +55,10 @@ export function TopBar() {
return (
<>
<header
className="topbar relative z-10 flex items-center gap-4 border-b px-4"
className="topbar relative z-10 flex items-center gap-4 px-4"
style={{
background: 'rgba(16, 17, 20, 0.6)',
backdropFilter: 'var(--glass-blur-strong)',
WebkitBackdropFilter: 'var(--glass-blur-strong)',
borderColor: 'var(--glass-border)',
background: '#0f1118',
borderBottom: '1px solid #1e2130',
}}
>
{/* Logo area */}
@@ -68,10 +66,9 @@ export function TopBar() {
to="/"
className="flex items-center gap-2.5 pr-4 transition-all duration-200"
>
<BrandLogo size="sm" className="h-7 w-7 shrink-0" />
<span className="text-sm font-heading font-bold tracking-tight whitespace-nowrap">
<span className="text-foreground">Resolution</span>
<span className="text-gradient-brand">Flow</span>
<BrandLogo size="sm" />
<span className="text-sm font-heading font-bold tracking-tight whitespace-nowrap text-[#f0f2f5]">
ResolutionFlow
</span>
</Link>
@@ -84,17 +81,28 @@ export function TopBar() {
className="hidden sm:relative sm:block w-full text-left"
style={{ maxWidth: '480px' }}
>
<Search size={16} className="absolute left-3 top-1/2 -translate-y-1/2 text-muted-foreground" />
<div className="w-full rounded-lg border border-border bg-card py-2 pl-9 pr-14 text-[0.8125rem] text-muted-foreground cursor-pointer hover:border-primary/30 transition-colors">
Search flows, sessions, tags
<Search size={16} className="absolute left-3 top-1/2 -translate-y-1/2 text-[#848b9b]" />
<div
className="w-full rounded-md py-2 pl-9 pr-14 text-[0.8125rem] text-[#848b9b] cursor-pointer transition-colors"
style={{
background: '#14161d',
border: '1px solid #1e2130',
}}
onMouseEnter={(e) => { (e.currentTarget as HTMLElement).style.borderColor = '#2a2f3d' }}
onMouseLeave={(e) => { (e.currentTarget as HTMLElement).style.borderColor = '#1e2130' }}
>
Search flows, sessions, tags...
</div>
<span className="absolute right-3 top-1/2 -translate-y-1/2 rounded border border-border bg-background px-1.5 py-0.5 font-label text-[0.625rem] text-muted-foreground">
{navigator.platform?.toLowerCase().includes('mac') ? '⌘K' : 'Ctrl+K'}
<span
className="absolute right-3 top-1/2 -translate-y-1/2 rounded px-1.5 py-0.5 font-mono text-[0.625rem] text-[#4f5666]"
style={{ background: '#0c0d10', border: '1px solid #1e2130' }}
>
{navigator.platform?.toLowerCase().includes('mac') ? '\u2318K' : 'Ctrl+K'}
</span>
</button>
<button
onClick={() => setCommandPaletteOpen(true)}
className="sm:hidden rounded-lg p-2 text-muted-foreground hover:bg-card hover:text-foreground transition-colors"
className="sm:hidden rounded-lg p-2 text-[#848b9b] hover:text-[#e2e5eb] transition-colors"
title="Search"
>
<Search size={18} />
@@ -107,14 +115,14 @@ export function TopBar() {
<div className="flex items-center gap-1">
<button
onClick={() => setQuickLaunchOpen(true)}
className="rounded-lg p-2 text-muted-foreground hover:bg-card hover:text-foreground transition-colors"
className="rounded-lg p-2 text-[#848b9b] hover:bg-[#14161d] hover:text-[#e2e5eb] transition-colors"
title="Quick Launch"
>
<Zap size={18} />
</button>
<Link
to="/guides"
className="rounded-lg p-2 text-muted-foreground hover:bg-card hover:text-foreground transition-colors"
className="rounded-lg p-2 text-[#848b9b] hover:bg-[#14161d] hover:text-[#e2e5eb] transition-colors"
title="User Guides"
>
<HelpCircle size={18} />
@@ -125,18 +133,22 @@ export function TopBar() {
<div className="relative ml-2" ref={menuRef}>
<button
onClick={() => setUserMenuOpen(!userMenuOpen)}
className="flex h-8 w-8 items-center justify-center rounded-[10px] bg-gradient-brand text-xs font-heading font-bold text-primary-foreground hover:opacity-90 transition-opacity"
className="flex h-8 w-8 items-center justify-center rounded-[10px] text-xs font-heading font-bold text-white hover:opacity-90 transition-opacity"
style={{ background: 'linear-gradient(135deg, #06b6d4, #22d3ee)' }}
title={user?.name || user?.email || 'User'}
>
{initials}
</button>
{userMenuOpen && (
<div className="absolute right-0 z-50 mt-2 w-56 rounded-lg border border-border bg-card p-1 shadow-xl animate-scale-in">
<div className="border-b border-border px-3 py-2.5 mb-1">
<p className="text-sm font-medium text-foreground truncate">{user?.name || user?.email}</p>
<div
className="absolute right-0 z-50 mt-2 w-56 rounded-lg p-1 shadow-xl animate-scale-in"
style={{ background: '#14161d', border: '1px solid #1e2130' }}
>
<div className="px-3 py-2.5 mb-1" style={{ borderBottom: '1px solid #1e2130' }}>
<p className="text-sm font-medium text-[#e2e5eb] truncate">{user?.name || user?.email}</p>
{effectiveRole && effectiveRole !== 'engineer' && (
<span className="mt-1 inline-flex items-center gap-1 text-xs text-muted-foreground">
<span className="mt-1 inline-flex items-center gap-1 text-xs text-[#848b9b]">
<Shield size={10} />
{effectiveRole === 'super_admin' ? 'Super Admin' : effectiveRole === 'owner' ? 'Owner' : 'Viewer'}
</span>
@@ -145,7 +157,7 @@ export function TopBar() {
<Link
to="/account"
onClick={() => setUserMenuOpen(false)}
className="flex items-center gap-2 rounded-md px-3 py-2 text-sm text-muted-foreground hover:bg-accent hover:text-foreground"
className="flex items-center gap-2 rounded-md px-3 py-2 text-sm text-[#848b9b] hover:bg-[#191c25] hover:text-[#e2e5eb]"
>
<Settings size={14} />
Account
@@ -154,18 +166,18 @@ export function TopBar() {
<Link
to="/admin"
onClick={() => setUserMenuOpen(false)}
className="flex items-center gap-2 rounded-md px-3 py-2 text-sm text-muted-foreground hover:bg-accent hover:text-foreground"
className="flex items-center gap-2 rounded-md px-3 py-2 text-sm text-[#848b9b] hover:bg-[#191c25] hover:text-[#e2e5eb]"
>
<Shield size={14} />
Admin Panel
</Link>
)}
<div className="border-t border-border mt-1 pt-1">
<div className="mt-1 pt-1" style={{ borderTop: '1px solid #1e2130' }}>
<button
onClick={handleLogout}
className={cn(
'flex w-full items-center gap-2 rounded-md px-3 py-2 text-sm',
'text-muted-foreground hover:bg-accent hover:text-foreground'
'text-[#848b9b] hover:bg-[#191c25] hover:text-[#e2e5eb]'
)}
>
<LogOut size={14} />

View File

@@ -261,6 +261,7 @@
--sidebar-w: 260px;
}
/* Legacy collapsed class — kept as alias for pinned inverse */
.app-shell--collapsed {
grid-template-columns: 56px 1fr;
}

View File

@@ -17,6 +17,8 @@ interface UserPreferencesState {
setPreferredEditorMode: (mode: EditorMode) => void
sidebarCollapsed: boolean
toggleSidebar: () => void
sidebarPinned: boolean
toggleSidebarPinned: () => void
dashboardMyFlowsView: TreeLibraryView
setDashboardMyFlowsView: (view: TreeLibraryView) => void
}
@@ -34,6 +36,8 @@ export const useUserPreferencesStore = create<UserPreferencesState>()(
setPreferredEditorMode: (mode) => set({ preferredEditorMode: mode }),
sidebarCollapsed: false,
toggleSidebar: () => set({ sidebarCollapsed: !get().sidebarCollapsed }),
sidebarPinned: false,
toggleSidebarPinned: () => set({ sidebarPinned: !get().sidebarPinned }),
dashboardMyFlowsView: 'grid',
setDashboardMyFlowsView: (view) => set({ dashboardMyFlowsView: view }),
}),