From 044d858231535a5461941b42399606041e31cb43 Mon Sep 17 00:00:00 2001 From: Sandro Date: Mon, 2 Nov 2026 20:45:10 +0100 Subject: [PATCH] feat(tasks): Kanban columns keep one height across rows, wheel moves row by row On the desktop every column, in every row, is as high as the room from the board to the bottom of the scroll area (at least about three cards), so the first row fits on opening and further rows fill one screen; the first row no longer grows when a column wraps into a second row. With several rows one wheel step moves to the next row or back (own logic: the browser's snapping returned to the nearest point and never got past the first row). Card lists that can scroll go first; Ctrl + wheel over the board scrolls freely without zooming. Co-Authored-By: Claude Opus 5.5 --- frontend/src/features/triage/TasksView.tsx | 86 +++++++++++++++++++--- 1 file changed, 75 insertions(+), 11 deletions(-) diff --git a/frontend/src/features/triage/TasksView.tsx b/frontend/src/features/triage/TasksView.tsx index 82f1022..b47f2a4 100644 --- a/frontend/src/features/triage/TasksView.tsx +++ b/frontend/src/features/triage/TasksView.tsx @@ -41,6 +41,7 @@ import { ManageColumnsModal, DEFAULT_KANBAN_COLUMNS, columnDotClass } from './Ma import { AvatarStack, AvatarPerson } from '../../components/AvatarStack'; import { CountBadge } from '../../components/CountBadge'; import { ListSwitcher } from '../../components/ListSwitcher'; +import { isPhoneWidth } from '../../lib/breakpoints'; import { LivePill } from '../../components/LivePill'; import { FormSheet } from '../../components/FormSheet'; import { EmojiIconButton } from '../../components/EmojiIconButton'; @@ -74,6 +75,8 @@ interface TasksViewProps { } const KANBAN_WIDE_KEY = 'mindsift:kanban_wide'; +// Desktop Kanban columns never get lower than this: room for the header and about three cards +const KANBAN_MIN_COL_HEIGHT = 340; // Wide Kanban: a column grows to at most this width, the board stays centred const KANBAN_COL_MAX = 380; const KANBAN_GAP = 12; // gap-3 @@ -538,38 +541,99 @@ export const TasksView: React.FC = ({ [ownColumnOf, boardIdOf] ); - // Desktop Kanban in one row: the columns fill the screen height and scroll on their own, so the page - // itself does not scroll as well (like the workspace). Several rows of columns keep the page scroll. + // Desktop Kanban: every column, in every row, is as high as the room from the board's top to the bottom of + // the scroll area, so the first row fits completely on opening and each further row fills one screen; + // more cards scroll inside the column. At least room for about three cards (the page scrolls below that). + // One row: the page itself does not scroll (like the workspace). Several rows: the wheel moves row by row + // (below). const kanbanBoardRef = useRef(null); const [kanbanColHeight, setKanbanColHeight] = useState(null); - const kanbanOneRow = viewMode === 'kanban' && (isKanbanWide || boardColumns.length <= 4); + const [kanbanMultiRow, setKanbanMultiRow] = useState(false); + const isKanban = viewMode === 'kanban'; useEffect(() => { - if (!kanbanOneRow) { + if (!isKanban) { setKanbanColHeight(null); + setKanbanMultiRow(false); return; } const fit = () => { const el = kanbanBoardRef.current; - if (!el || window.innerWidth < 1024) { + if (!el || isPhoneWidth()) { setKanbanColHeight(null); + setKanbanMultiRow(false); return; } - // From the board's top to the bottom of the app's scroll area (the desktop window itself does not - // scroll); then take back what still overflows (paddings) const area = el.closest('[data-app-scroll]'); const areaTop = area ? area.getBoundingClientRect().top - area.scrollTop : -window.scrollY; const areaHeight = area ? area.clientHeight : window.innerHeight; const top = el.getBoundingClientRect().top - areaTop; - setKanbanColHeight(Math.max(320, areaHeight - top - 16)); + setKanbanColHeight(Math.max(KANBAN_MIN_COL_HEIGHT, areaHeight - top - 16)); requestAnimationFrame(() => { + const tops = new Set( + Array.from(el.querySelectorAll('[data-kanban-col]')).map((c) => c.offsetTop) + ); + const multi = tops.size > 1; + setKanbanMultiRow(multi); + if (multi) return; + // One row: take back what still overflows (paddings), so the page does not scroll const over = area ? area.scrollHeight - area.clientHeight : document.documentElement.scrollHeight - window.innerHeight; - if (over > 0) setKanbanColHeight((h) => (h ? Math.max(320, h - over) : h)); + if (over > 0) setKanbanColHeight((h) => (h ? Math.max(KANBAN_MIN_COL_HEIGHT, h - over) : h)); }); }; fit(); window.addEventListener('resize', fit); return () => window.removeEventListener('resize', fit); - }, [kanbanOneRow, selectedListId, loading]); + }, [isKanban, isKanbanWide, boardColumns.length, selectedListId, loading]); + + // Several rows: one wheel step moves to the next row (or back up to the previous row / the page top), + // smoothly. The browser's own snapping goes back to the nearest point, so a wheel step shorter than a row + // never got past the first one. A column whose cards can still scroll that way scrolls first; Ctrl + + // wheel over the board scrolls freely (and does not zoom there); scrollbar and keys scroll freely. + useEffect(() => { + const board = kanbanBoardRef.current; + const area = board?.closest('[data-app-scroll]'); + if (!kanbanMultiRow || !board || !area) return; + let pending = 0; // trackpads send many small steps: move only once they add up + let lockedUntil = 0; + const snapPoints = () => { + const areaTop = area.getBoundingClientRect().top - area.scrollTop; + const rows = new Set([0]); + board.querySelectorAll('[data-kanban-col]').forEach((c) => { + rows.add(Math.max(0, Math.round(c.getBoundingClientRect().top - areaTop - 12))); + }); + return Array.from(rows).sort((a, b) => a - b); + }; + const onWheel = (e: WheelEvent) => { + const dy = e.deltaMode === 1 ? e.deltaY * 16 : e.deltaY; + if (e.ctrlKey) { + if (!board.contains(e.target as Node)) return; // zooming elsewhere stays as it is + e.preventDefault(); + area.scrollBy({ top: dy }); + return; + } + if (dy === 0 || e.shiftKey) return; + const list = (e.target as HTMLElement).closest('[data-kanban-list]'); + if (list && (dy > 0 ? list.scrollTop + list.clientHeight < list.scrollHeight - 1 : list.scrollTop > 0)) return; + e.preventDefault(); + if (Date.now() < lockedUntil) return; + pending += dy; + if (Math.abs(pending) < 40) return; + const down = pending > 0; + pending = 0; + const cur = area.scrollTop; + const points = snapPoints(); + const next = down ? points.find((p) => p > cur + 2) : [...points].reverse().find((p) => p < cur - 2); + if (next === undefined) { + // Past the last row (e.g. a very low window): plain scrolling + area.scrollBy({ top: dy }); + return; + } + lockedUntil = Date.now() + 450; + area.scrollTo({ top: next, behavior: 'smooth' }); + }; + area.addEventListener('wheel', onWheel, { passive: false }); + return () => area.removeEventListener('wheel', onWheel); + }, [kanbanMultiRow]); const safeTasks = useMemo(() => { if (!Array.isArray(tasks)) return []; @@ -2782,7 +2846,7 @@ export const TasksView: React.FC = ({ {/* Tasks List in Column */} -
+
{colTasks.map((task) => { const isDragging = draggingTaskId === task.id; const readOnly = isTaskReadOnly(task);