feat: kanban-style drag feedback with a real drop gap

The old feedback was a card faded to 0.4 opacity and a 3px line above the
target. The fade read as "disabled" rather than "held", and a thin line is
a weak signal for where something lands.

The dragged card lifts now: full opacity, accent border, raised shadow, a
small scale and tilt. Cards it passes slide by the space it vacated, which
opens a real gap at the destination. The gap can't disagree with where the
card lands, because it is where the card lands.

Also fixes something found while building it. targetIndexFor measured live
rects, but those rows carry the shift transforms the drag applies, so the
midpoints used to pick the target moved as a result of picking it and the
choice oscillated. Slot geometry is captured once at drag start, relative
to the list so scrolling mid-drag is fine.

The displacement rule moves to src/lib/drag.js with tests. Its up/down
boundaries are where off-by-ones live.
This commit is contained in:
Aculix Technologies
2026-08-16 01:27:44 +05:30
parent 143fcc205d
commit 00d1e211fe
4 changed files with 171 additions and 20 deletions
+25 -14
View File
@@ -273,34 +273,45 @@ body {
cursor: grabbing;
}
/* The lifted card. It stays fully opaque and sits above the list — the old
ghosted 0.4 opacity read as "this card is disabled" rather than "you are
holding this card". The gap that opens beneath it is the drop indicator. */
.task-item.dragging {
opacity: 0.4;
cursor: grabbing;
/* Only while a drag is actually in flight, so an ordinary touch on a row
still scrolls the page. */
z-index: 20;
box-shadow:
0 12px 28px rgba(0, 0, 0, 0.18),
0 4px 8px rgba(0, 0, 0, 0.1);
border-color: var(--accent);
/* Only while a drag is in flight, so an ordinary touch still scrolls. */
touch-action: none;
user-select: none;
}
.task-item.drag-over::before {
content: '';
position: absolute;
top: -6px;
left: 0;
right: 0;
height: 3px;
background-color: var(--accent);
border-radius: 2px;
box-shadow: 0 0 8px rgba(96, 122, 251, 0.4);
z-index: 10;
html.dark .task-item.dragging {
box-shadow:
0 12px 28px rgba(0, 0, 0, 0.5),
0 4px 8px rgba(0, 0, 0, 0.35);
}
/* Cards sliding aside to open the gap. */
.task-item.drag-settling {
transition: transform 200ms cubic-bezier(0.2, 0, 0, 1);
}
/* A drag is in progress: suppress hover lift on the cards being displaced,
which would otherwise fight the transform that opens the gap. */
.task-item:hover {
background-color: var(--hover);
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
}
.task-items:has(.dragging) .task-item:not(.dragging):hover {
background-color: var(--bg-surface);
box-shadow: none;
}
.task-item.completed {
background-color: var(--completed-bg);
opacity: 0.8;