feat: branch hover card pops out in front with backdrop dim

On hover, the card:
- Grows slightly wider and floats directly over the base card
- Shows detail content (tried, result, reason, steps)
- Dims everything behind it with a fixed black/30 scrim
- Heavy drop shadow for depth (0 8px 32px rgba(0,0,0,0.5))
- Status-colored border maintained

Active branch still shows detail inline (no hover needed).
Cards below don't shift — the expanded version is position: absolute.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
chihlasm
2026-03-24 23:26:20 +00:00
parent 8180aa69b0
commit 5eb99e35c1

View File

@@ -1,4 +1,4 @@
import { useState, useRef } from 'react' import { useState } from 'react'
import { CircleDot, CheckCircle2, XCircle, Circle, RotateCcw } from 'lucide-react' import { CircleDot, CheckCircle2, XCircle, Circle, RotateCcw } from 'lucide-react'
import { cn } from '@/lib/utils' import { cn } from '@/lib/utils'
import type { BranchResponse } from '@/types/branching' import type { BranchResponse } from '@/types/branching'
@@ -60,22 +60,20 @@ interface BranchNodeProps {
export function BranchNode({ branch, depth, isActive, onClick }: BranchNodeProps) { export function BranchNode({ branch, depth, isActive, onClick }: BranchNodeProps) {
const [isHovered, setIsHovered] = useState(false) const [isHovered, setIsHovered] = useState(false)
const cardRef = useRef<HTMLDivElement>(null)
const config = STATUS_CONFIG[branch.status] const config = STATUS_CONFIG[branch.status]
const Icon = config.icon const Icon = config.icon
const hasDetail = branch.context_summary || branch.status_reason const hasDetail = branch.context_summary || branch.status_reason
const showPopover = isHovered && hasDetail && !isActive const showExpanded = isHovered && hasDetail && !isActive
return ( return (
<div <div
ref={cardRef}
style={{ marginLeft: `${depth * 12}px` }} style={{ marginLeft: `${depth * 12}px` }}
className="relative" className="relative"
onMouseEnter={() => setIsHovered(true)} onMouseEnter={() => setIsHovered(true)}
onMouseLeave={() => setIsHovered(false)} onMouseLeave={() => setIsHovered(false)}
> >
{/* Card */} {/* Base card — always visible */}
<button <button
type="button" type="button"
onClick={() => onClick(branch.id)} onClick={() => onClick(branch.id)}
@@ -86,28 +84,9 @@ export function BranchNode({ branch, depth, isActive, onClick }: BranchNodeProps
: 'bg-card/60 border-default hover:bg-card hover:border-hover', : 'bg-card/60 border-default hover:bg-card hover:border-hover',
)} )}
> >
{/* Header row */} <CardHeader icon={Icon} config={config} branch={branch} isActive={isActive} />
<div className="flex items-center gap-2">
<Icon size={14} className={cn('shrink-0', config.textClass)} />
<span
className={cn(
'flex-1 text-sm truncate',
isActive ? 'text-heading font-medium' : 'text-foreground'
)}
>
{branch.label}
</span>
<span
className={cn(
'text-[10px] font-semibold uppercase tracking-wider px-1.5 py-0.5 rounded-full shrink-0',
config.badgeClass
)}
>
{config.label}
</span>
</div>
{/* Inline detail for ACTIVE branch only */} {/* Inline detail for active branch */}
{isActive && hasDetail && ( {isActive && hasDetail && (
<div className="mt-2 pt-2 border-t border-default/50 space-y-1"> <div className="mt-2 pt-2 border-t border-default/50 space-y-1">
<BranchDetail branch={branch} /> <BranchDetail branch={branch} />
@@ -115,29 +94,74 @@ export function BranchNode({ branch, depth, isActive, onClick }: BranchNodeProps
)} )}
</button> </button>
{/* Floating popover for HOVERED non-active branches */} {/* Expanded card — floats directly on top of the base card */}
{showPopover && ( {showExpanded && (
<div <>
className={cn( {/* Scrim behind to dim everything */}
'absolute z-50 left-full top-0 ml-2 w-64', <div className="fixed inset-0 z-40 bg-black/30" />
'bg-card border rounded-lg p-3 space-y-1',
'shadow-[0_4px_20px_rgba(0,0,0,0.4)]', {/* Expanded card positioned over the original */}
'animate-in fade-in-0 zoom-in-95 duration-150', <div
config.borderClass className={cn(
)} 'absolute z-50 inset-x-0 top-0',
> 'bg-card border rounded-lg p-2.5',
<div className="flex items-center gap-1.5 mb-1.5"> 'shadow-[0_8px_32px_rgba(0,0,0,0.5)]',
<Icon size={12} className={config.textClass} /> config.borderClass,
<span className="text-xs font-medium text-heading truncate">{branch.label}</span> )}
style={{
/* Grow slightly wider than the base card */
marginLeft: -8,
marginRight: -8,
width: 'calc(100% + 16px)',
}}
>
<CardHeader icon={Icon} config={config} branch={branch} isActive={false} />
<div className="mt-2 pt-2 border-t border-default/50 space-y-1">
<BranchDetail branch={branch} />
</div>
</div> </div>
<BranchDetail branch={branch} /> </>
</div>
)} )}
</div> </div>
) )
} }
/** Shared detail content used by both inline (active) and popover (hover) */ /** Card header row — shared between base and expanded states */
function CardHeader({
icon: Icon,
config,
branch,
isActive,
}: {
icon: React.ElementType
config: StatusConfig
branch: BranchResponse
isActive: boolean
}) {
return (
<div className="flex items-center gap-2">
<Icon size={14} className={cn('shrink-0', config.textClass)} />
<span
className={cn(
'flex-1 text-sm truncate',
isActive ? 'text-heading font-medium' : 'text-foreground'
)}
>
{branch.label}
</span>
<span
className={cn(
'text-[10px] font-semibold uppercase tracking-wider px-1.5 py-0.5 rounded-full shrink-0',
config.badgeClass
)}
>
{config.label}
</span>
</div>
)
}
/** Detail content — shared between inline (active) and expanded (hover) */
function BranchDetail({ branch }: { branch: BranchResponse }) { function BranchDetail({ branch }: { branch: BranchResponse }) {
return ( return (
<> <>