fix(kanban-card): jumping hover animation & drag overlay consistency

This commit is contained in:
Illia Filippov
2025-12-30 00:51:52 +01:00
parent 57b7f92e61
commit e556521c8d
2 changed files with 100 additions and 57 deletions

View File

@@ -1,4 +1,4 @@
import React, { memo } from 'react';
import React, { memo, useLayoutEffect, useState } from 'react';
import { useSortable } from '@dnd-kit/sortable';
import { CSS } from '@dnd-kit/utilities';
import { cn } from '@/lib/utils';
@@ -10,6 +10,25 @@ import { CardContentSections } from './card-content-sections';
import { AgentInfoPanel } from './agent-info-panel';
import { CardActions } from './card-actions';
function getCardBorderStyle(enabled: boolean, opacity: number): React.CSSProperties {
if (!enabled) {
return { borderWidth: '0px', borderColor: 'transparent' };
}
if (opacity !== 100) {
return {
borderWidth: '1px',
borderColor: `color-mix(in oklch, var(--border) ${opacity}%, transparent)`,
};
}
return {};
}
function getCursorClass(isOverlay: boolean | undefined, isDraggable: boolean): string {
if (isOverlay) return 'cursor-grabbing';
if (isDraggable) return 'cursor-grab active:cursor-grabbing';
return 'cursor-default';
}
interface KanbanCardProps {
feature: Feature;
onEdit: () => void;
@@ -35,6 +54,7 @@ interface KanbanCardProps {
glassmorphism?: boolean;
cardBorderEnabled?: boolean;
cardBorderOpacity?: number;
isOverlay?: boolean;
}
export const KanbanCard = memo(function KanbanCard({
@@ -62,8 +82,18 @@ export const KanbanCard = memo(function KanbanCard({
glassmorphism = true,
cardBorderEnabled = true,
cardBorderOpacity = 100,
isOverlay,
}: KanbanCardProps) {
const { useWorktrees } = useAppStore();
const [isLifted, setIsLifted] = useState(false);
useLayoutEffect(() => {
if (isOverlay) {
requestAnimationFrame(() => {
setIsLifted(true);
});
}
}, [isOverlay]);
const isDraggable =
feature.status === 'backlog' ||
@@ -72,54 +102,45 @@ export const KanbanCard = memo(function KanbanCard({
(feature.status === 'in_progress' && !isCurrentAutoTask);
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({
id: feature.id,
disabled: !isDraggable,
disabled: !isDraggable || isOverlay,
});
const style = {
const dndStyle = {
transform: CSS.Transform.toString(transform),
transition,
opacity: isDragging ? 0.5 : undefined,
};
const borderStyle: React.CSSProperties = { ...style };
if (!cardBorderEnabled) {
(borderStyle as Record<string, string>).borderWidth = '0px';
(borderStyle as Record<string, string>).borderColor = 'transparent';
} else if (cardBorderOpacity !== 100) {
(borderStyle as Record<string, string>).borderWidth = '1px';
(borderStyle as Record<string, string>).borderColor =
`color-mix(in oklch, var(--border) ${cardBorderOpacity}%, transparent)`;
}
const cardStyle = getCardBorderStyle(cardBorderEnabled, cardBorderOpacity);
const cardElement = (
const wrapperClasses = cn(
'relative select-none outline-none touch-none',
getCursorClass(isOverlay, isDraggable),
isOverlay && isLifted && 'scale-105 rotate-1 z-50'
);
const isInteractive = !isDragging && !isOverlay;
const hasError = feature.error && !isCurrentAutoTask;
const innerCardClasses = cn(
'kanban-card-content h-full relative shadow-sm',
'transition-all duration-200 ease-out',
isInteractive && 'hover:-translate-y-0.5 hover:shadow-md hover:shadow-black/10 bg-transparent',
!glassmorphism && 'backdrop-blur-[0px]!',
!isCurrentAutoTask &&
cardBorderEnabled &&
(cardBorderOpacity === 100 ? 'border-border/50' : 'border'),
hasError && 'border-[var(--status-error)] border-2 shadow-[var(--status-error-bg)] shadow-lg'
);
const renderCardContent = () => (
<Card
ref={setNodeRef}
style={isCurrentAutoTask ? style : borderStyle}
className={cn(
'cursor-grab active:cursor-grabbing relative kanban-card-content select-none',
'transition-all duration-200 ease-out',
// Premium shadow system
'shadow-sm hover:shadow-md hover:shadow-black/10',
// Subtle lift on hover
'hover:-translate-y-0.5',
!isCurrentAutoTask && cardBorderEnabled && cardBorderOpacity === 100 && 'border-border/50',
!isCurrentAutoTask && cardBorderEnabled && cardBorderOpacity !== 100 && 'border',
!isDragging && 'bg-transparent',
!glassmorphism && 'backdrop-blur-[0px]!',
isDragging && 'scale-105 shadow-xl shadow-black/20 rotate-1',
// Error state - using CSS variable
feature.error &&
!isCurrentAutoTask &&
'border-[var(--status-error)] border-2 shadow-[var(--status-error-bg)] shadow-lg',
!isDraggable && 'cursor-default'
)}
data-testid={`kanban-card-${feature.id}`}
style={isCurrentAutoTask ? undefined : cardStyle}
className={innerCardClasses}
onDoubleClick={onEdit}
{...attributes}
{...(isDraggable ? listeners : {})}
>
{/* Background overlay with opacity */}
{!isDragging && (
{(!isDragging || isOverlay) && (
<div
className={cn(
'absolute inset-0 rounded-xl bg-card -z-10',
@@ -185,10 +206,20 @@ export const KanbanCard = memo(function KanbanCard({
</Card>
);
// Wrap with animated border when in progress
if (isCurrentAutoTask) {
return <div className="animated-border-wrapper">{cardElement}</div>;
}
return cardElement;
return (
<div
ref={setNodeRef}
style={dndStyle}
{...attributes}
{...(isDraggable ? listeners : {})}
className={wrapperClasses}
data-testid={`kanban-card-${feature.id}`}
>
{isCurrentAutoTask ? (
<div className="animated-border-wrapper">{renderCardContent()}</div>
) : (
renderCardContent()
)}
</div>
);
});

View File

@@ -1,7 +1,6 @@
import { useMemo } from 'react';
import { DndContext, DragOverlay } from '@dnd-kit/core';
import { SortableContext, verticalListSortingStrategy } from '@dnd-kit/sortable';
import { Card, CardHeader, CardTitle, CardDescription } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { HotkeyButton } from '@/components/ui/hotkey-button';
import { KanbanColumn, KanbanCard } from './components';
@@ -241,19 +240,32 @@ export function KanbanBoard({
}}
>
{activeFeature && (
<Card
className="rotate-2 shadow-2xl shadow-black/25 border-primary/50 bg-card/95 backdrop-blur-sm transition-transform"
style={{ width: `${columnWidth}px` }}
>
<CardHeader className="p-3">
<CardTitle className="text-sm font-medium line-clamp-2">
{activeFeature.description}
</CardTitle>
<CardDescription className="text-xs text-muted-foreground">
{activeFeature.category}
</CardDescription>
</CardHeader>
</Card>
<div style={{ width: `${columnWidth}px` }}>
<KanbanCard
feature={activeFeature}
isOverlay
onEdit={() => {}}
onDelete={() => {}}
onViewOutput={() => {}}
onVerify={() => {}}
onResume={() => {}}
onForceStop={() => {}}
onManualVerify={() => {}}
onMoveBackToInProgress={() => {}}
onFollowUp={() => {}}
onImplement={() => {}}
onComplete={() => {}}
onViewPlan={() => {}}
onApprovePlan={() => {}}
onSpawnTask={() => {}}
hasContext={featuresWithContext.has(activeFeature.id)}
isCurrentAutoTask={runningAutoTasks.includes(activeFeature.id)}
opacity={backgroundSettings.cardOpacity}
glassmorphism={backgroundSettings.cardGlassmorphism}
cardBorderEnabled={backgroundSettings.cardBorderEnabled}
cardBorderOpacity={backgroundSettings.cardBorderOpacity}
/>
</div>
)}
</DragOverlay>
</DndContext>