fix(kanban): compact multiple workflow swimlane heights (#3604)

This commit is contained in:
Carlos Florêncio
2026-09-12 20:41:33 +01:00
committed by GitHub
parent e27f888876
commit 3e557f0729
18 changed files with 1079 additions and 19 deletions
+29 -5
View File
@@ -1,6 +1,7 @@
"use client";
import { memo, useMemo } from "react";
import { memo, useCallback, useMemo, type Ref } from "react";
import { useColumnNaturalHeight } from "@/hooks/domains/kanban/use-column-natural-height";
import { useDroppable } from "@dnd-kit/core";
import { Task, type KanbanPresentation } from "./kanban-card";
import { Badge } from "@kandev/ui/badge";
@@ -26,6 +27,7 @@ export interface WorkflowStep {
}
interface KanbanColumnProps {
onNaturalHeightChange?: (stepId: string, height: number) => void;
step: WorkflowStep;
tasks: Task[];
presentation?: KanbanPresentation;
@@ -48,7 +50,15 @@ interface KanbanColumnProps {
externalLinkAvailability: KanbanExternalLinkAvailability;
}
function ColumnHeader({ step, tasks }: { step: WorkflowStep; tasks: Task[] }) {
function ColumnHeader({
step,
tasks,
headerRef,
}: {
step: WorkflowStep;
tasks: Task[];
headerRef: Ref<HTMLDivElement>;
}) {
const { t } = useTranslation();
const admittedTaskCount = countAdmittedTasks(tasks);
const overWipLimit = isOverWipLimit(admittedTaskCount, step.wip_limit);
@@ -56,7 +66,7 @@ function ColumnHeader({ step, tasks }: { step: WorkflowStep; tasks: Task[] }) {
const queuedCount = partitionWipTasks(tasks, step.id).queued.length;
return (
<div className="flex items-center justify-between pb-2 mb-3 px-1">
<div ref={headerRef} className="flex shrink-0 items-center justify-between pb-2 mb-3 px-1">
<div className="flex items-center gap-2">
<div className={cn("w-2 h-2 rounded-full", step.color)} />
<h2 className="font-semibold text-sm">{step.title}</h2>
@@ -102,6 +112,7 @@ function externalLinkAvailabilityEqual(
function columnCallbacksEqual(previous: KanbanColumnProps, next: KanbanColumnProps): boolean {
return (
previous.onNaturalHeightChange === next.onNaturalHeightChange &&
previous.onPreviewTask === next.onPreviewTask &&
previous.onOpenTask === next.onOpenTask &&
previous.onEditTask === next.onEditTask &&
@@ -137,6 +148,7 @@ function kanbanColumnPropsEqual(previous: KanbanColumnProps, next: KanbanColumnP
}
export const KanbanColumn = memo(function KanbanColumn({
onNaturalHeightChange,
step,
tasks,
presentation = "desktop",
@@ -160,6 +172,17 @@ export const KanbanColumn = memo(function KanbanColumn({
const { setNodeRef, isOver } = useDroppable({
id: step.id,
});
const { columnRef, headerRef, onContentHeightChange } = useColumnNaturalHeight(
step.id,
onNaturalHeightChange,
);
const setColumnRef = useCallback(
(element: HTMLDivElement | null) => {
columnRef.current = element;
setNodeRef(element);
},
[columnRef, setNodeRef],
);
const activeWorkspaceId = useAppStore((state) => state.workspaces.activeId);
// Access repositories from store to pass repository names to cards
@@ -176,7 +199,7 @@ export const KanbanColumn = memo(function KanbanColumn({
return (
<div
ref={setNodeRef}
ref={setColumnRef}
data-testid={`kanban-column-${step.id}`}
className={cn(
"flex flex-col flex-1 h-full min-w-0 px-3 py-2 sm:min-h-[200px]",
@@ -185,9 +208,10 @@ export const KanbanColumn = memo(function KanbanColumn({
)}
>
{/* Column Header */}
{!hideHeader && <ColumnHeader step={step} tasks={tasks} />}
{!hideHeader && <ColumnHeader step={step} tasks={tasks} headerRef={headerRef} />}
<VirtualizedColumnTaskList
onContentHeightChange={onContentHeightChange}
orderedTasks={orderedTasks}
queuedStartIndex={admitted.length}
queuedCount={queued.length}
@@ -5,6 +5,7 @@ import type { WorkflowStep } from "@/components/kanban-column";
import { getKanbanColumnGridTemplate, KANBAN_COLUMN_MIN_PX } from "./kanban-grid-template";
type AdaptiveDesktopKanbanProps = {
columnHeight?: string;
steps: WorkflowStep[];
isDragging?: boolean;
renderColumn: (step: WorkflowStep) => ReactNode;
@@ -34,6 +35,7 @@ type PanStart = {
};
export function AdaptiveDesktopKanban({
columnHeight,
steps,
isDragging = false,
renderColumn,
@@ -81,7 +83,11 @@ export function AdaptiveDesktopKanban({
};
return (
<div data-testid="desktop-kanban-layout" className="h-full min-h-0 min-w-0">
<div
data-testid="desktop-kanban-layout"
className="h-full min-h-0 min-w-0"
style={{ height: columnHeight ? "auto" : undefined }}
>
<div
ref={scrollWindowRef}
data-testid="desktop-kanban-scroll-window"
@@ -89,6 +95,7 @@ export function AdaptiveDesktopKanban({
isPanCandidate ? "cursor-grabbing" : ""
} ${isPanning ? "select-none" : ""} ${isDragging ? "scrollbar-hide" : ""}`}
style={{
height: columnHeight ? "auto" : undefined,
containerType: "inline-size",
scrollSnapType: isPanning ? "none" : undefined,
}}
@@ -100,6 +107,7 @@ export function AdaptiveDesktopKanban({
<div
className="flex h-full min-h-0"
style={{
height: columnHeight,
width: `calc(max(100cqw, ${steps.length * KANBAN_COLUMN_MIN_PX}px) + ${
isDragging ? KANBAN_DRAG_END_RESERVE : "0px"
})`,
@@ -123,20 +131,24 @@ export function AdaptiveDesktopKanban({
</div>
))}
</div>
{isDragging && (
<div
data-testid="desktop-kanban-drag-end-reserve"
aria-hidden="true"
className="h-full flex-none"
style={{ width: KANBAN_DRAG_END_RESERVE }}
/>
)}
{isDragging && <DragEndReserve />}
</div>
</div>
</div>
);
}
function DragEndReserve() {
return (
<div
data-testid="desktop-kanban-drag-end-reserve"
aria-hidden="true"
className="h-full flex-none"
style={{ width: KANBAN_DRAG_END_RESERVE }}
/>
);
}
function isInteractiveTarget(target: EventTarget | null, boundary: HTMLElement): boolean {
if (!(target instanceof Element)) return true;
@@ -140,6 +140,7 @@ type WorkflowItemProps = {
isMultiSelectMode?: boolean;
onToggleMultiSelect?: () => void;
fillHeight?: boolean;
compactHeight?: boolean;
mobileWorkflowNavigation?: MobileWorkflowNavigation;
onToggleStepVisibility: (workflowId: string, stepId: string) => void;
onToggleAutoHideEmpty: (workflowId: string) => void;
@@ -359,6 +360,7 @@ type WorkflowItemsProps = {
ViewComponent: ComponentType<ViewContentProps>;
hideHeaders: boolean;
fillHeight: boolean;
compactHeight: boolean;
isMobileKanban: boolean;
onToggleStepVisibility: (workflowId: string, stepId: string) => void;
onToggleAutoHideEmpty: (workflowId: string) => void;
@@ -378,6 +380,7 @@ function WorkflowItems({
ViewComponent,
hideHeaders,
fillHeight,
compactHeight,
isMobileKanban,
onToggleStepVisibility,
onToggleAutoHideEmpty,
@@ -400,6 +403,7 @@ function WorkflowItems({
ViewComponent={ViewComponent}
hideHeader={hideHeaders}
fillHeight={fillHeight && !collapsed}
compactHeight={compactHeight}
isSortable={canSortWorkflows && !isMobileKanban}
isCollapsed={collapsed}
toggleCollapse={toggleCollapse}
@@ -580,7 +584,8 @@ export function SwimlaneContainer(containerProps: SwimlaneContainerProps) {
hideHeaders={hideHeaders}
onToggleStepVisibility={onToggleStepVisibility}
onToggleAutoHideEmpty={onToggleAutoHideEmpty}
fillHeight={view.id === "kanban"}
fillHeight={view.id === "kanban" && renderedWorkflows.length === 1}
compactHeight={view.id === "kanban" && !isMobile && renderedWorkflows.length > 1}
isMobileKanban={isMobileKanban}
canSortWorkflows={canSortWorkflows}
isCollapsed={isCollapsed}
@@ -49,11 +49,13 @@ import { cn } from "@kandev/ui/lib/utils";
import { useKanbanExternalLinkAvailability } from "@/components/kanban-external-link-availability";
import { useTranslation } from "react-i18next";
import { t } from "@/lib/i18n";
import { useCompactSwimlaneHeight } from "@/hooks/domains/kanban/use-compact-swimlane-height";
const TASK_POINTER_SENSOR_OPTIONS = { activationConstraint: { distance: 8 } };
const TASK_TOUCH_SENSOR_OPTIONS = { activationConstraint: { delay: 250, tolerance: 5 } };
export type SwimlaneKanbanContentProps = {
compactHeight?: boolean;
workflowId: string;
steps: WorkflowStep[];
moveTargetSteps: WorkflowStep[];
@@ -342,6 +344,8 @@ function MobileKanbanLayout({
}
function TabletKanbanLayout({
columnHeight,
onNaturalHeightChange,
steps,
moveTargetSteps,
tasks,
@@ -367,6 +371,7 @@ function TabletKanbanLayout({
<div
className="flex h-full min-h-0 gap-2 overflow-x-auto snap-x snap-mandatory scrollbar-hide"
data-testid="tablet-kanban-layout"
style={{ height: columnHeight }}
>
{steps.map((step) => (
<div
@@ -378,6 +383,7 @@ function TabletKanbanLayout({
)}
>
<KanbanColumn
onNaturalHeightChange={onNaturalHeightChange}
step={step}
tasks={getTasksForStep(step.id)}
presentation="desktop"
@@ -404,6 +410,8 @@ function TabletKanbanLayout({
}
function DesktopKanbanLayout({
columnHeight,
onNaturalHeightChange,
steps,
moveTargetSteps,
tasks,
@@ -428,11 +436,13 @@ function DesktopKanbanLayout({
return (
<AdaptiveDesktopKanban
columnHeight={columnHeight}
steps={steps}
isDragging={isDragging}
renderColumn={(step) => (
<div className={cn("h-full", temporaryStepIds.has(step.id) && "opacity-70")}>
<KanbanColumn
onNaturalHeightChange={onNaturalHeightChange}
step={step}
tasks={getTasksForStep(step.id)}
presentation="desktop"
@@ -497,6 +507,7 @@ function renderKanbanLayout({
}
export function SwimlaneKanbanContent({
compactHeight = false,
workflowId,
steps,
moveTargetSteps,
@@ -535,7 +546,13 @@ export function SwimlaneKanbanContent({
moveTargetSteps,
isMobile,
});
const sizing = useCompactSwimlaneHeight(
compactHeight && !isMobile,
displaySteps,
!!drag.activeTask,
);
const sharedProps = useSharedKanbanLayoutProps({
...sizing,
drag,
moveTargetSteps,
displayTasks,
@@ -1,6 +1,6 @@
"use client";
import { memo, useCallback, useMemo, useRef } from "react";
import { memo, useCallback, useLayoutEffect, useMemo, useRef } from "react";
import { useVirtualizer } from "@tanstack/react-virtual";
import { useTranslation } from "react-i18next";
import {
@@ -14,6 +14,7 @@ import type { WorkflowStep } from "../kanban-column";
import type { KanbanExternalLinkAvailability } from "../kanban-external-link-availability";
type VirtualizedColumnTaskListProps = {
onContentHeightChange?: (height: number, element: HTMLDivElement) => void;
orderedTasks: Task[];
queuedStartIndex: number;
queuedCount: number;
@@ -132,6 +133,7 @@ function useStableExternalLinkAvailability(
}
export function VirtualizedColumnTaskList({
onContentHeightChange,
orderedTasks,
queuedStartIndex,
queuedCount,
@@ -167,6 +169,10 @@ export function VirtualizedColumnTaskList({
getItemKey: (index) => orderedTasks[index]?.id ?? index,
overscan: 5,
});
const totalHeight = virtualizer.getTotalSize();
useLayoutEffect(() => {
if (scrollRef.current) onContentHeightChange?.(totalHeight, scrollRef.current);
}, [onContentHeightChange, totalHeight]);
return (
<div
@@ -1,6 +1,64 @@
import { test, expect } from "../../fixtures/test-base";
import { MobileKanbanPage } from "../../pages/mobile-kanban-page";
import { missingGitHealth } from "./health-fixtures";
import {
withHeightWorkflows,
expectCompactColumn,
expectNoDocumentOverflow,
} from "./swimlane-height-helpers";
test("multiple workflows keep a full-height focused phone board across the tablet boundary", async ({
testPage,
apiClient,
seedData,
}, testInfo) => {
// @covers AC-UI-ADAPTIVE-KANBAN-002.6
await withHeightWorkflows(apiClient, seedData, async (second) => {
const mobile = new MobileKanbanPage(testPage);
await mobile.goto();
const phoneSize = testPage.viewportSize()!;
for (const width of [phoneSize.width, 767]) {
await testPage.setViewportSize({ width, height: phoneSize.height });
await expect(mobile.mobileKanbanLayout()).toHaveCount(1);
await expect(mobile.mobileKanbanLayout()).toHaveCSS("display", "flex");
await expect(mobile.mobileKanbanLayout()).toHaveCSS("overflow-y", "hidden");
await expect
.poll(async () => (await mobile.mobileKanbanLayout().boundingBox())!.height)
.toBeGreaterThan(400);
const boardBox = (await mobile.swimlaneContainer.boundingBox())!;
const layoutBox = (await mobile.mobileKanbanLayout().boundingBox())!;
expect(layoutBox.y + layoutBox.height).toBeLessThanOrEqual(boardBox.y + boardBox.height);
await expectNoDocumentOverflow(testPage);
}
await testPage.setViewportSize(phoneSize);
await mobile.boardNavigator.tap();
await mobile.workflowItem(second.workflowId).tap();
const drawer = testPage.getByTestId("mobile-board-navigator-drawer");
await expect(drawer).toBeVisible();
await drawer.getByTestId(`column-tab-${second.stepIndex}`).tap();
await expect(drawer).not.toBeVisible();
await expect(mobile.taskCard(second.taskId)).toBeInViewport();
await testInfo.attach("focused-phone", {
body: await testPage.screenshot({ path: testInfo.outputPath("focused-phone.png") }),
contentType: "image/png",
});
await mobile.taskCard(second.taskId).tap();
await expect(testPage).toHaveURL(new RegExp(`/t/${second.taskId}`));
await apiClient.saveUserSettings({ workflow_filter_id: "" });
await testPage.setViewportSize({ width: 768, height: phoneSize.height });
await testPage.goto("/");
await expect(testPage.getByTestId("mobile-kanban-layout")).toHaveCount(0);
await expect(testPage.getByTestId("tablet-kanban-layout")).toHaveCount(2);
await expectCompactColumn(testPage.getByTestId(`kanban-column-${second.stepId}`));
await expectNoDocumentOverflow(testPage);
await testPage.setViewportSize(phoneSize);
await expect(mobile.mobileKanbanLayout()).toHaveCount(1);
await expect
.poll(async () => (await mobile.mobileKanbanLayout().boundingBox())!.height)
.toBeGreaterThan(400);
expect((await apiClient.getUserSettings()).settings.kanban_view_mode ?? "").toBe("");
});
});
test.describe("Mobile kanban view", () => {
test.afterEach(async ({ apiClient }) => {
@@ -0,0 +1,72 @@
import { expect, type Locator, type Page } from "@playwright/test";
import type { ApiClient } from "../../helpers/api-client";
import type { SeedData } from "../../fixtures/test-base";
export async function withHeightWorkflows(
api: ApiClient,
seed: SeedData,
run: (second: {
workflowId: string;
stepId: string;
taskId: string;
seedWorkflowTaskId: string;
stepIndex: number;
}) => Promise<void>,
) {
const { settings } = await api.getUserSettings();
const workflow = await api.createWorkflow(seed.workspaceId, "Compact second", "simple");
try {
const { steps } = await api.listWorkflowSteps(workflow.id);
const step = steps.find((item) => item.is_start_step) ?? steps[0];
const first = await api.createTask(seed.workspaceId, "Sparse first card", {
workflow_id: seed.workflowId,
workflow_step_id: seed.startStepId,
});
const second = await api.createTask(seed.workspaceId, "Sparse second card", {
workflow_id: workflow.id,
workflow_step_id: step.id,
});
await api.saveUserSettings({ workflow_filter_id: "", repository_ids: [] });
await run({
workflowId: workflow.id,
stepId: step.id,
taskId: second.id,
seedWorkflowTaskId: first.id,
stepIndex: steps.findIndex((item) => item.id === step.id),
});
} finally {
await api.deleteWorkflow(workflow.id);
const restored = await api.rawRequest("PATCH", "/api/v1/user/settings", {
workflow_filter_id: settings.workflow_filter_id ?? "",
repository_ids: settings.repository_ids ?? [],
enable_preview_on_click: settings.enable_preview_on_click ?? false,
kanban_hidden_step_ids: settings.kanban_hidden_step_ids ?? {},
workflow_ids_with_auto_hide_empty_steps:
settings.workflow_ids_with_auto_hide_empty_steps ?? [],
kanban_view_mode: settings.kanban_view_mode ?? "",
});
expect.soft(restored.ok, "Restore user settings after swimlane height test").toBe(true);
}
}
export async function expectCompactColumn(column: Locator) {
await expect
.poll(async () => (await column.boundingBox())?.height ?? 0)
.toBeGreaterThanOrEqual(200);
await expect.poll(async () => (await column.boundingBox())!.height).toBeLessThanOrEqual(400);
}
export async function selectHeightWorkflow(page: Page, name: string) {
await page.getByTestId("display-button").click();
await page.getByTestId("display-workflow-filter").click();
await page.getByRole("listbox").getByRole("option", { name, exact: true }).click();
await page.keyboard.press("Escape");
}
export async function expectNoDocumentOverflow(page: Page) {
expect(
await page.evaluate(
() => document.documentElement.scrollWidth - document.documentElement.clientWidth,
),
).toBe(0);
}
@@ -0,0 +1,238 @@
import { test, expect } from "../../fixtures/test-base";
import { KanbanPage } from "../../pages/kanban-page";
import {
withHeightWorkflows,
expectCompactColumn,
selectHeightWorkflow,
expectNoDocumentOverflow,
} from "./swimlane-height-helpers";
import {
seedLargeColumnTasks,
expectBoundedMountedCards,
scrollColumnToBottom,
taskCards,
} from "./large-column-virtualization-helpers";
test("two sparse workflows fit without scrolling the board", async ({
testPage,
apiClient,
seedData,
}, testInfo) => {
// @covers AC-UI-ADAPTIVE-KANBAN-002.1, AC-UI-ADAPTIVE-KANBAN-002.2
await testPage.setViewportSize({ width: 1440, height: 900 });
const { settings } = await apiClient.getUserSettings();
const workflow = await apiClient.createWorkflow(seedData.workspaceId, "Compact second", "simple");
try {
const { steps } = await apiClient.listWorkflowSteps(workflow.id);
const step = steps.find((item) => item.is_start_step) ?? steps[0];
await apiClient.createTask(seedData.workspaceId, "Sparse first card", {
workflow_id: seedData.workflowId,
workflow_step_id: seedData.startStepId,
});
const second = await apiClient.createTask(seedData.workspaceId, "Sparse second card", {
workflow_id: workflow.id,
workflow_step_id: step.id,
});
await apiClient.saveUserSettings({ workflow_filter_id: "", repository_ids: [] });
const kanban = new KanbanPage(testPage);
await kanban.goto();
await expect(kanban.taskCard(second.id)).toBeVisible();
const board = testPage.getByTestId("swimlane-container");
await testInfo.attach("sparse-lane-geometry", {
body: JSON.stringify({
board: await board.boundingBox(),
headers: await testPage
.getByTestId("swimlane-header")
.evaluateAll((elements) =>
elements.map((element) => element.getBoundingClientRect().toJSON()),
),
secondCard: await kanban.taskCard(second.id).boundingBox(),
scrollTop: await board.evaluate((element) => element.scrollTop),
}),
contentType: "application/json",
});
await testInfo.attach("sparse-workflows", {
body: await testPage.screenshot({ path: testInfo.outputPath("sparse-workflows.png") }),
contentType: "image/png",
});
await expect
.poll(async () => {
const bounds = await board.boundingBox();
const card = await kanban.taskCard(second.id).boundingBox();
return card!.y + card!.height <= bounds!.y + bounds!.height;
})
.toBe(true);
expect(await board.evaluate((element) => element.scrollTop)).toBe(0);
const column = kanban.columnByStepId(seedData.startStepId);
const height = (await column.boundingBox())!.height;
expect(height).toBeGreaterThanOrEqual(200);
expect(height).toBeLessThanOrEqual(400);
} finally {
await apiClient.deleteWorkflow(workflow.id);
await apiClient.saveUserSettings({
workflow_filter_id: settings.workflow_filter_id ?? "",
repository_ids: (settings.repository_ids as string[]) ?? [],
});
}
});
test("compact lanes survive collapse, workflow filters and preview resizing", async ({
testPage,
apiClient,
seedData,
}, testInfo) => {
// @covers AC-UI-ADAPTIVE-KANBAN-002.3, AC-UI-ADAPTIVE-KANBAN-002.4, AC-UI-ADAPTIVE-KANBAN-002.5
await testPage.setViewportSize({ width: 1440, height: 900 });
await withHeightWorkflows(apiClient, seedData, async (second) => {
await apiClient.saveUserSettings({ enable_preview_on_click: true });
const kanban = new KanbanPage(testPage);
await kanban.goto();
const firstColumn = kanban.columnByStepId(seedData.startStepId);
const secondColumn = kanban.columnByStepId(second.stepId);
await expectCompactColumn(firstColumn);
const toggle = testPage
.getByTestId("swimlane-header")
.getByRole("button", { name: "Compact second 1", exact: true });
await toggle.click();
await expect(secondColumn).toHaveCount(0);
await expectCompactColumn(firstColumn);
await toggle.click();
await expectCompactColumn(secondColumn);
await selectHeightWorkflow(testPage, "E2E Workflow");
await expect(secondColumn).toHaveCount(0);
await expect.poll(async () => (await firstColumn.boundingBox())!.height).toBeGreaterThan(400);
await testPage.reload();
await expect.poll(async () => (await firstColumn.boundingBox())!.height).toBeGreaterThan(400);
await selectHeightWorkflow(testPage, "All Workflows");
await expectCompactColumn(secondColumn);
await kanban.taskCard(second.seedWorkflowTaskId).click();
await expect(testPage.getByTestId("task-preview-panel")).toBeVisible();
await expectCompactColumn(firstColumn);
await expectCompactColumn(secondColumn);
await expectNoDocumentOverflow(testPage);
await testInfo.attach("compact-preview", {
body: await testPage.screenshot({ path: testInfo.outputPath("compact-preview.png") }),
contentType: "image/png",
});
await testPage.keyboard.press("Escape");
await expectCompactColumn(firstColumn);
});
});
test("dense and sparse workflows size independently and keep the final task reachable", async ({
testPage,
apiClient,
seedData,
}) => {
// @covers AC-UI-ADAPTIVE-KANBAN-002.2, AC-UI-ADAPTIVE-KANBAN-002.7, AC-UI-ADAPTIVE-KANBAN-001.7
test.setTimeout(120_000);
await testPage.setViewportSize({ width: 1440, height: 900 });
await withHeightWorkflows(apiClient, seedData, async (second) => {
await seedLargeColumnTasks(apiClient, seedData, "Dense height", 439);
const kanban = new KanbanPage(testPage);
await kanban.goto();
const dense = kanban.columnByStepId(seedData.startStepId);
const sparse = kanban.columnByStepId(second.stepId);
await expect.poll(async () => (await dense.boundingBox())?.height).toBe(400);
await expect.poll(async () => (await sparse.boundingBox())?.height).toBe(200);
await expectBoundedMountedCards(dense);
await scrollColumnToBottom(dense.getByTestId("kanban-column-scroll"));
const oldest = dense.getByTestId(`task-card-${second.seedWorkflowTaskId}`);
await expect(oldest).toBeInViewport();
await expectBoundedMountedCards(dense);
expect(await taskCards(dense).count()).toBeLessThan(50);
await expectNoDocumentOverflow(testPage);
await oldest.click();
await expect(testPage).toHaveURL(new RegExp(`/t/${second.seedWorkflowTaskId}`));
});
});
test("live content grows the lane after drag cancellation and shrinks after removal", async ({
testPage,
apiClient,
seedData,
}) => {
// @covers AC-UI-ADAPTIVE-KANBAN-002.5, AC-UI-ADAPTIVE-KANBAN-002.7
await testPage.setViewportSize({ width: 1440, height: 900 });
await withHeightWorkflows(apiClient, seedData, async (second) => {
const kanban = new KanbanPage(testPage);
await kanban.goto();
const column = kanban.columnByStepId(second.stepId);
await expect.poll(async () => (await column.boundingBox())?.height).toBe(200);
const cardBox = (await kanban.taskCard(second.taskId).boundingBox())!;
await testPage.mouse.move(cardBox.x + 80, cardBox.y + 30);
await testPage.mouse.down();
await testPage.mouse.move(cardBox.x + 100, cardBox.y + 30, { steps: 4 });
await expect(testPage.getByTestId("desktop-kanban-drag-end-reserve")).toHaveCount(1);
const added = await Promise.all(
["Second", "Third", "Fourth", "Fifth"].map((title) =>
apiClient.createTask(seedData.workspaceId, title, {
workflow_id: second.workflowId,
workflow_step_id: second.stepId,
}),
),
);
for (const task of added) await expect(kanban.taskCard(task.id)).toBeAttached();
expect((await column.boundingBox())!.height).toBe(200);
await testPage.keyboard.press("Escape");
await testPage.mouse.up();
await expect.poll(async () => (await column.boundingBox())!.height).toBeGreaterThan(200);
expect((await column.boundingBox())!.height).toBeLessThan(400);
const lane = testPage.getByTestId("desktop-kanban-lane-grid").filter({ has: column });
const heights = await lane
.locator("[data-kanban-step-id]")
.evaluateAll((elements) => elements.map((element) => element.getBoundingClientRect().height));
expect(Math.max(...heights) - Math.min(...heights)).toBeLessThanOrEqual(1);
const scroll = column.getByTestId("kanban-column-scroll");
expect(
await scroll.evaluate((element) => element.scrollHeight - element.clientHeight),
).toBeLessThanOrEqual(1);
for (const task of added) await apiClient.deleteTask(task.id);
await expect.poll(async () => (await column.boundingBox())!.height).toBe(200);
});
});
test("a retained empty workflow keeps compact recovery controls", async ({
testPage,
apiClient,
seedData,
}) => {
// @covers AC-UI-ADAPTIVE-KANBAN-002.5
await withHeightWorkflows(apiClient, seedData, async (second) => {
await apiClient.deleteTask(second.taskId);
await apiClient.saveUserSettings({
kanban_hidden_step_ids: { [second.workflowId]: [second.stepId] },
});
const kanban = new KanbanPage(testPage);
await kanban.goto();
await expectCompactColumn(kanban.columnByStepId(seedData.startStepId));
await testPage.getByTestId(`columns-menu-${second.workflowId}`).click();
await testPage.getByTestId(`columns-menu-step-${second.stepId}`).click();
await testPage.keyboard.press("Escape");
await expect(testPage.getByTestId(`columns-menu-${second.workflowId}`)).toHaveCount(0);
await expect
.poll(async () => (await kanban.columnByStepId(seedData.startStepId).boundingBox())!.height)
.toBeGreaterThan(400);
});
});
test("tablet workflows retain two-column snapping inside compact lanes", async ({
tabletTestPage,
apiClient,
seedData,
}, testInfo) => {
// @covers AC-UI-ADAPTIVE-KANBAN-002.1, AC-UI-ADAPTIVE-KANBAN-002.7
await withHeightWorkflows(apiClient, seedData, async (second) => {
const kanban = new KanbanPage(tabletTestPage);
await kanban.goto();
await expect(tabletTestPage.getByTestId("tablet-kanban-layout")).toHaveCount(2);
await expectCompactColumn(kanban.columnByStepId(seedData.startStepId));
await expectCompactColumn(kanban.columnByStepId(second.stepId));
await expect(kanban.taskCard(second.taskId)).toBeInViewport();
await expectNoDocumentOverflow(tabletTestPage);
await testInfo.attach("compact-tablet", {
body: await tabletTestPage.screenshot({ path: testInfo.outputPath("compact-tablet.png") }),
contentType: "image/png",
});
});
});
@@ -0,0 +1,57 @@
import { act, renderHook } from "@testing-library/react";
import { afterEach, expect, it, vi } from "vitest";
import { useColumnNaturalHeight } from "./use-column-natural-height";
afterEach(() => vi.unstubAllGlobals());
it("reports logical rows plus chrome, remeasures chrome and disconnects outside compact mode", () => {
const disconnect = vi.fn();
let resize = () => {};
const observer = vi.fn(
class {
constructor(callback: () => void) {
resize = callback;
}
observe = vi.fn();
disconnect = disconnect;
},
);
vi.stubGlobal("ResizeObserver", observer);
const report = vi.fn();
const { result, rerender, unmount } = renderHook(
({ enabled }) => useColumnNaturalHeight("step", enabled ? report : undefined),
{ initialProps: { enabled: false } },
);
expect(observer).not.toHaveBeenCalled();
expect(result.current.onContentHeightChange).toBeUndefined();
const column = document.createElement("div");
column.style.padding = "8px";
column.style.height = "400px";
const header = document.createElement("div");
header.style.marginBottom = "12px";
let headerHeight = 28;
vi.spyOn(header, "getBoundingClientRect").mockImplementation(
() => ({ height: headerHeight }) as DOMRect,
);
const scroll = document.createElement("div");
scroll.style.paddingTop = "4px";
scroll.style.paddingBottom = "0px";
column.append(header, scroll);
document.body.append(column);
result.current.columnRef.current = column;
result.current.headerRef.current = header;
rerender({ enabled: true });
act(() => result.current.onContentHeightChange!(190, scroll));
expect(report).toHaveBeenLastCalledWith("step", 250);
headerHeight = 40;
act(() => resize());
expect(report).toHaveBeenLastCalledWith("step", 262);
act(() => result.current.onContentHeightChange!(90, scroll));
expect(report).toHaveBeenLastCalledWith("step", 162);
rerender({ enabled: false });
expect(disconnect).toHaveBeenCalledTimes(1);
rerender({ enabled: true });
unmount();
expect(disconnect).toHaveBeenCalledTimes(2);
column.remove();
});
@@ -0,0 +1,46 @@
import { useCallback, useLayoutEffect, useRef } from "react";
export function useColumnNaturalHeight(
stepId: string,
onNaturalHeightChange?: (stepId: string, height: number) => void,
) {
const columnRef = useRef<HTMLDivElement | null>(null);
const headerRef = useRef<HTMLDivElement | null>(null);
const contentRef = useRef<{ height: number; element: HTMLDivElement } | null>(null);
const report = useCallback(() => {
const column = columnRef.current;
const content = contentRef.current;
if (!column || !content || !onNaturalHeightChange) return;
const columnStyle = getComputedStyle(column);
const scrollStyle = getComputedStyle(content.element);
const header = headerRef.current;
const headerHeight = header
? header.getBoundingClientRect().height + parseFloat(getComputedStyle(header).marginBottom)
: 0;
const padding = [columnStyle, scrollStyle].reduce(
(total, style) => total + parseFloat(style.paddingTop) + parseFloat(style.paddingBottom),
0,
);
onNaturalHeightChange(stepId, Math.ceil(content.height + headerHeight + padding));
}, [onNaturalHeightChange, stepId]);
const onContentHeightChange = useCallback(
(height: number, element: HTMLDivElement) => {
contentRef.current = { height, element };
report();
},
[report],
);
useLayoutEffect(() => {
if (!onNaturalHeightChange) return;
const observer = new ResizeObserver(report);
if (columnRef.current) observer.observe(columnRef.current);
if (headerRef.current) observer.observe(headerRef.current);
report();
return () => observer.disconnect();
}, [onNaturalHeightChange, report]);
return {
columnRef,
headerRef,
onContentHeightChange: onNaturalHeightChange ? onContentHeightChange : undefined,
};
}
@@ -0,0 +1,58 @@
import { act, renderHook } from "@testing-library/react";
import { describe, expect, it } from "vitest";
import { useCompactSwimlaneHeight } from "./use-compact-swimlane-height";
const steps = ["a", "b"].map((id) => ({ id, title: id, color: "" }));
describe("compact swimlane sizing", () => {
// @covers AC-UI-ADAPTIVE-KANBAN-002.1, AC-UI-ADAPTIVE-KANBAN-002.5
it("uses the tallest current column and discards removed measurements", () => {
const { result, rerender } = renderHook(
({ visible }) => useCompactSwimlaneHeight(true, visible, false),
{ initialProps: { visible: steps } },
);
expect(result.current.columnHeight).toBe("clamp(12.5rem, 0px, 25rem)");
const report = result.current.onNaturalHeightChange!;
act(() => {
report("a", 270);
report("b", 600);
});
expect(result.current.columnHeight).toBe("clamp(12.5rem, 600px, 25rem)");
expect(result.current.onNaturalHeightChange).toBe(report);
rerender({ visible: [steps[0]] });
expect(result.current.columnHeight).toBe("clamp(12.5rem, 270px, 25rem)");
act(() => report("b", 600));
rerender({ visible: steps });
expect(result.current.columnHeight).toBe("clamp(12.5rem, 270px, 25rem)");
act(() => report("a", 220));
expect(result.current.columnHeight).toBe("clamp(12.5rem, 220px, 25rem)");
});
it("freezes during drag and applies pending heights after cancellation", () => {
const { result, rerender } = renderHook(
({ dragging }) => useCompactSwimlaneHeight(true, steps, dragging),
{ initialProps: { dragging: false } },
);
act(() => result.current.onNaturalHeightChange!("a", 270));
rerender({ dragging: true });
act(() => result.current.onNaturalHeightChange!("a", 350));
expect(result.current.columnHeight).toBe("clamp(12.5rem, 270px, 25rem)");
rerender({ dragging: false });
expect(result.current.columnHeight).toBe("clamp(12.5rem, 350px, 25rem)");
});
it("disables reporting and clears measurements outside compact mode", () => {
const { result, rerender } = renderHook(
({ enabled }) => useCompactSwimlaneHeight(enabled, steps, false),
{ initialProps: { enabled: true } },
);
act(() => result.current.onNaturalHeightChange!("a", 350));
const lateReport = result.current.onNaturalHeightChange!;
rerender({ enabled: false });
expect(result.current.columnHeight).toBeUndefined();
expect(result.current.onNaturalHeightChange).toBeUndefined();
act(() => lateReport("a", 350));
rerender({ enabled: true });
expect(result.current.columnHeight).toBe("clamp(12.5rem, 0px, 25rem)");
});
});
@@ -0,0 +1,40 @@
import { useCallback, useLayoutEffect, useRef, useState } from "react";
import type { WorkflowStep } from "@/components/kanban-column";
export function useCompactSwimlaneHeight(
enabled: boolean,
steps: WorkflowStep[],
isDragging: boolean,
) {
const [heights, setHeights] = useState<Record<string, number>>({});
const scopeRef = useRef({ enabled, steps });
scopeRef.current = { enabled, steps };
const onNaturalHeightChange = useCallback((stepId: string, height: number) => {
const scope = scopeRef.current;
if (!scope.enabled || !scope.steps.some((step) => step.id === stepId)) return;
setHeights((previous) =>
previous[stepId] === height ? previous : { ...previous, [stepId]: height },
);
}, []);
useLayoutEffect(() => {
setHeights((previous) => {
const entries = Object.entries(previous).filter(
([id]) => enabled && steps.some((step) => step.id === id),
);
return entries.length === Object.keys(previous).length
? previous
: Object.fromEntries(entries);
});
}, [enabled, steps]);
const naturalHeight = Math.max(0, ...steps.map((step) => heights[step.id] ?? 0));
const nextHeight = `clamp(12.5rem, ${naturalHeight}px, 25rem)`;
const settledHeight = useRef(nextHeight);
useLayoutEffect(() => {
if (!isDragging) settledHeight.current = nextHeight;
}, [isDragging, nextHeight]);
const columnHeight = isDragging ? settledHeight.current : nextHeight;
return {
columnHeight: enabled ? columnHeight : undefined,
onNaturalHeightChange: enabled ? onNaturalHeightChange : undefined,
};
}
@@ -4,6 +4,8 @@ import type { WorkflowStep } from "@/components/kanban-column";
import type { KanbanExternalLinkAvailability } from "@/components/kanban-external-link-availability";
export type SharedKanbanLayoutProps = {
columnHeight?: string;
onNaturalHeightChange?: (stepId: string, height: number) => void;
steps: WorkflowStep[];
moveTargetSteps: WorkflowStep[];
tasks: Task[];
@@ -46,6 +48,8 @@ export function useSharedKanbanLayoutProps(
const { drag, displayTasks, externalLinkAvailability } = options;
return useMemo(
() => ({
columnHeight: options.columnHeight,
onNaturalHeightChange: options.onNaturalHeightChange,
steps: drag.renderedSteps,
moveTargetSteps: options.moveTargetSteps,
tasks: displayTasks,
@@ -67,6 +71,8 @@ export function useSharedKanbanLayoutProps(
isDragging: !!drag.activeTask,
}),
[
options.columnHeight,
options.onNaturalHeightChange,
drag.renderedSteps,
drag.moveTaskToStep,
drag.temporaryStepIds,
+1
View File
@@ -7,6 +7,7 @@ import type { WorkflowStep } from "@/components/kanban-column";
import type { MoveTaskError } from "@/hooks/use-drag-and-drop";
export type ViewContentProps = {
compactHeight?: boolean;
workflowId: string;
steps: WorkflowStep[];
moveTargetSteps: WorkflowStep[];
+153
View File
@@ -0,0 +1,153 @@
---
created: 2026-09-11
status: complete
requirements:
- REQ-UI-ADAPTIVE-KANBAN-001
- REQ-UI-ADAPTIVE-KANBAN-002
system_design:
- ../../specs/ui/system-design/adaptive-kanban.md
legacy_specs: []
---
# Fix plan: Compact workflow swimlanes
## Overview
Give sparse workflows compact heights while preserving bounded virtualized columns. One sequential work order owns the regression, correction, and focused verification.
The UI system owns this presentation contract. Task and workspace state remain under their existing owners.
## Evidence and root cause
The user screenshot shows a sparse first workflow occupying most of the board, with another workflow header at the bottom.
The source trace identifies the height allocation responsible for this geometry:
1. `SwimlaneContainer` passes `fillHeight={view.id === "kanban"}` for all Kanban workflows.
2. `WorkflowItems` enables that value for each expanded workflow.
3. `SortableWorkflowItem` applies `h-full min-h-0 flex-1` inside a desktop block container.
4. `SwimlaneSection` propagates the definite parent height through its content.
Thus every expanded lane receives a board-sized area regardless of its task count.
The separate `sm:min-h-[200px]` in `KanbanColumn` is not the cause of viewport-sized blank space.
Commit `6a4ba4cbc8` introduced the desktop full-height path for large-column virtualization.
Removing the entire height chain risks unbounded virtualized content or an empty viewport.
Evidence is a read-only source/history trace plus the supplied screenshot. No live browser reproduction ran during package preparation.
The minimal browser reproduction uses All Workflows, two expanded workflows, and one short card in each workflow.
At 1440 by 900, the second header and its first card must fit without outer scrolling after the correction.
## Requirement conformance
The existing Adaptive Kanban contract covers width and virtualization but lacks a multi-workflow height rule.
`REQ-UI-ADAPTIVE-KANBAN-002` adds that missing rule in the existing requirement document.
The implementation uses the approved 400px maximum. The 200px minimum preserves the existing column floor.
The paired design is current after implementation and rendered verification.
## Scope
### In scope
- Content-sensitive compact heights for multiple desktop and tablet lanes.
- Full-height single-workflow and phone views.
- Collapsed and retained-empty lane recovery.
- Bounded virtualization and unchanged card interactions.
### Out of scope
- Manual lane resizing or a saved height preference.
- Pipeline, List, or task lifecycle redesign.
- Backend, dependency, localization, and public documentation changes.
- Commits and publication.
## Technical approach
Use the mode and measurement boundaries in the [system design](../../specs/ui/system-design/adaptive-kanban.md#workflow-height-allocation).
Keep all sizing state local. Reuse virtualizer totals and actual column chrome measurements.
Keep the lane height fixed during active task drag. Recompute after drag completion.
Preserve callback identity and update the existing column memo comparator for new props.
## ASCII UI preview
UI-01: Desktop Home, All Workflows, two sparse expanded lanes.
```text
Before After
[Workflow A / Columns] [Workflow A / Columns]
[Todo] [Plan] [Done] [Todo] [Plan] [Done]
[card] [card]
| | compact column area
| full board height [Workflow B / Columns]
| [Todo] [Review] [Done]
| [card]
[Workflow B / Columns]
```
UI-02: Desktop mixed state and phone Home.
```text
Desktop Phone
[Workflow A > / Columns] [Workflow / Step v]
[Workflow B v / Columns] [card]
[Todo] [Plan] [Done] [card]
[card] | focused column scroll
| dense column scroll | fills available height
| bounded column area [Create task]
```
Header order, compact multiple lanes, and focused phone composition are structural requirements.
Spacing and labels are illustrative. UI-01 covers 002.1-002.2. UI-02 covers 002.3 and 002.6.
An empty retained lane keeps its header and existing empty guidance. A collapsed lane contains only its header.
Tablet keeps its existing two-column horizontal snap view inside each compact lane.
## Tests
The primary RED test is browser geometry, not class-name presence.
A small measurement reducer or height helper needs unit coverage only if implementation extracts one.
Existing render-isolation tests protect stable lane and card updates.
## E2E tests
| Criteria | File and scenario |
| --- | --- |
| 002.1-002.2 | New `swimlane-height.spec.ts`: two sparse lanes fit, mixed dense lane caps, last task reachable. |
| 002.3-002.5 | Same file: collapse/expand, filter to one lane, clear filter, retained-empty recovery, preview resize. |
| 002.6 | Existing `mobile-kanban.spec.ts`: add full-height focused-lane geometry with multiple workflows and breakpoint transitions. |
| 002.1, 002.7 | New `swimlane-height.spec.ts`: coarse-pointer tablet case through `tabletTestPage`. |
| 001.5-001.7, 002.7 | Existing desktop/tablet/mobile large-column suites plus a 440-task lane beside a sparse lane. |
| 001.4, 001.9-001.10, 002.7 | Existing board, auto-hide, WIP, and workflow-sorting suites. |
All named files live under `apps/web/e2e/tests/kanban/`, except `workflow/workflow-sorting.spec.ts`.
Desktop and tablet use the `chromium` project. Phone uses `mobile-chrome`.
The new phone assertions use the canonical device, then 767px and 768px with the same pointer mode.
They prove branch selection and preserve the saved desktop view.
## Companion packages
The completed adaptive-kanban, kanban-large-column-virtualization, and kanban-drag-column-width-stability packages remain historical delivery records.
This package adds height coverage and retains their width, drag, and virtualization acceptance conditions.
Their recorded test counts are historical, not verification results for this repair.
## Work orders
- [x] [Task 01: Compact workflow heights](task-01-compact-workflow-heights.md)
## Verification results
Implemented and verified on 2026-09-11.
- The sparse-workflow geometry test failed before production changes and passed after the correction.
- The final managed build passed for the backend, Vite assets, and fixture plugin.
- All 22 targeted Chromium tests and 29 mobile tests passed without retries on the final build.
- All 14 focused hook and render-stability tests passed.
- Typecheck, targeted ESLint, Prettier, and the i18n ratchet passed.
- All 36 specification-linter tests, full specification lint, and `git diff --check` passed.
- Desktop, tablet, and phone captures match the structural previews. No unresolved visual differences were identified.
Task 01 records the commands, intermediate failures, and verification scope.
No dependency, backend source, public guide, or saved preference changed. No commit or publication was performed.
## Risks
- Measuring constrained boxes can create a height feedback loop. Natural content totals avoid this dependency.
- Dynamic card metadata changes height. Tests must allow measurement settlement without fixed sleeps.
- Compact lanes still require both outer workflow scrolling and internal task scrolling.
- A CSS-only removal of `h-full` can break virtualization. The definite column area is required.
@@ -0,0 +1,187 @@
---
id: "01-compact-workflow-heights"
title: "Compact workflow heights"
status: done
wave: 1
depends_on: []
plan: "plan.md"
requirements:
- REQ-UI-ADAPTIVE-KANBAN-001
- REQ-UI-ADAPTIVE-KANBAN-002
acceptance_criteria:
- AC-UI-ADAPTIVE-KANBAN-001.4
- AC-UI-ADAPTIVE-KANBAN-001.5
- AC-UI-ADAPTIVE-KANBAN-001.6
- AC-UI-ADAPTIVE-KANBAN-001.7
- AC-UI-ADAPTIVE-KANBAN-001.9
- AC-UI-ADAPTIVE-KANBAN-001.10
- AC-UI-ADAPTIVE-KANBAN-002.1
- AC-UI-ADAPTIVE-KANBAN-002.2
- AC-UI-ADAPTIVE-KANBAN-002.3
- AC-UI-ADAPTIVE-KANBAN-002.4
- AC-UI-ADAPTIVE-KANBAN-002.5
- AC-UI-ADAPTIVE-KANBAN-002.6
- AC-UI-ADAPTIVE-KANBAN-002.7
system_design:
- ../../specs/ui/system-design/adaptive-kanban.md
---
# Task 01: Compact workflow heights
## Summary
Replace repeated full-board heights with bounded content heights for multiple visible workflows.
Preserve the definite viewport required by virtualized columns.
## In scope
- Add the browser geometry regression before production changes.
- Implement compact-mode selection, natural-height reports, and local measurement cleanup.
- Preserve full-height single-workflow and phone behavior.
- Cover collapse, empty recovery, filters, previews, tablet, and virtualized task access.
## Out of scope
Backend contracts, new settings, dependencies, Pipeline changes, and publication.
## Acceptance
- The sparse two-workflow browser assertion fails on the original full-height behavior and passes after the correction.
- All compact sizing and mode-transition scenarios satisfy 002.1-002.6, including the mixed dense/sparse case and retained-empty controls.
- Focused interaction and virtualization suites pass, with no stale measurements, document overflow, or saved-preference changes.
## ASCII UI preview
UI-01 and UI-02 excerpt from the [full preview](plan.md#ascii-ui-preview):
```text
Desktop Phone
[Workflow A / Columns] [Workflow / Step v]
[Todo] [Plan] [Done] [card]
[card] | focused column scroll
[Workflow B / Columns] | fills available height
[Todo] [Review] [Done] [Create task]
[card]
```
Multiple desktop/tablet lanes remain compact even when another visible lane is collapsed (002.1-002.3).
Phone retains the existing navigator and full-height focused column (002.6).
## Implementation sequence
1. Install workspace dependencies if absent.
2. Add `swimlane-height.spec.ts` with the two sparse workflows at 1440 by 900.
3. Run the targeted RED command. Record the second-header/card bounding boxes and outer scroll position.
4. Implement the sizing path in the paired design.
5. Add the remaining scenario matrix from the plan.
6. Run the targeted checks and inspect desktop, tablet, and phone captures.
7. Record results and promote the paired design only after all work passes.
Use `testPage` before seeding settings. Use disposable workflows and restore baseline settings in cleanup.
Scope selectors by workflow and step IDs. Add stable lane selectors only when existing selectors cannot identify the geometry.
Compare bounding boxes and scroll reachability. Visibility assertions alone do not prove compact sizing.
Wait for finite animations and stable measurements, without fixed sleeps.
## Verification
Run from the repository root. Each command starts from an independent directory context.
```bash
(cd apps && pnpm install --frozen-lockfile)
(cd apps/web && pnpm e2e:run --project chromium tests/kanban/swimlane-height.spec.ts)
```
The first browser run must fail for excessive lane height before production changes.
After implementation, run:
```bash
(cd apps/web && pnpm test -- --run components/kanban/swimlane-container.render-stability.test.tsx components/kanban/swimlane-kanban-content.render-stability.test.tsx components/kanban/virtualized-column-task-list.render-stability.test.tsx)
(cd apps/web && pnpm test -- --run hooks/domains/kanban/use-compact-swimlane-height.test.ts hooks/domains/kanban/use-column-natural-height.test.ts)
(cd apps/web && pnpm exec eslint components/kanban/swimlane-container.tsx components/kanban/swimlane-section.tsx components/kanban/swimlane-kanban-content.tsx components/kanban/adaptive-desktop-kanban.tsx components/kanban/virtualized-column-task-list.tsx components/kanban-column.tsx lib/kanban/view-registry.ts e2e/tests/kanban/swimlane-height.spec.ts e2e/tests/kanban/mobile-kanban.spec.ts)
(cd apps/web && pnpm run typecheck)
(cd apps/web && pnpm exec eslint hooks/domains/kanban/use-compact-swimlane-height.ts hooks/domains/kanban/use-column-natural-height.ts hooks/domains/kanban/use-shared-kanban-layout-props.ts hooks/domains/kanban/use-compact-swimlane-height.test.ts hooks/domains/kanban/use-column-natural-height.test.ts e2e/tests/kanban/swimlane-height-helpers.ts)
(cd apps/web && pnpm run i18n:ratchet)
(cd apps/web && pnpm e2e:run --project chromium tests/kanban/swimlane-height.spec.ts tests/kanban/large-column-virtualization.spec.ts tests/kanban/tablet-large-column-virtualization.spec.ts tests/kanban/kanban-board.spec.ts tests/kanban/auto-hide-empty-columns.spec.ts tests/kanban/wip-overflow-queue.spec.ts tests/workflow/workflow-sorting.spec.ts)
(cd apps/web && pnpm e2e:run --project mobile-chrome tests/kanban/mobile-kanban.spec.ts tests/kanban/mobile-large-column-virtualization.spec.ts)
python3 scripts/lint-spec-files.test.py
python3 scripts/lint-spec-files.py --all
git diff --check
```
Use managed builds for current assets. Run suites sequentially with the runner's resource limits.
If helper extraction adds a unit suite, add its exact path to this block and record its result.
## Files likely touched
- `apps/web/components/kanban/swimlane-container.tsx`
- `apps/web/components/kanban/swimlane-section.tsx`
- `apps/web/components/kanban/swimlane-kanban-content.tsx`
- `apps/web/components/kanban/adaptive-desktop-kanban.tsx`
- `apps/web/components/kanban/virtualized-column-task-list.tsx`
- `apps/web/components/kanban-column.tsx`
- `apps/web/lib/kanban/view-registry.ts`
- `apps/web/e2e/tests/kanban/swimlane-height.spec.ts` (new)
- `apps/web/e2e/tests/kanban/mobile-kanban.spec.ts`
- This plan, work order, and paired system design for completion results.
## Dependencies
None. Existing virtualization and adaptive width packages are complete.
## Risks
Natural-height reports must exclude allocated viewport height. New callback props must not bypass the memo comparator or trigger unrelated lane renders.
Long columns must remain scrollable after a lane shrinks, expands, or returns from collapse.
## Parallelism
`sequential`
## Inputs
- [Requirements](../../specs/ui/requirements/adaptive-kanban.md), especially REQ-UI-ADAPTIVE-KANBAN-002.
- [System design](../../specs/ui/system-design/adaptive-kanban.md), workflow allocation and height measurement.
- Existing `workflow-filter.spec.ts` for two-workflow setup and cleanup.
- Existing large-column suites and `tabletTestPage` for virtualization and coarse-pointer evidence.
- Existing render-isolation suites for stable lane and card updates.
## Results
Implementation and final browser verification are complete (2026-09-11).
- The original browser test failed because the second card extended below the board at outer scroll position zero.
- The same geometry assertion passed after compact sizing replaced repeated full-board heights.
- Fourteen hook and render-stability tests pass. They cover height aggregation, drag freeze, observer cleanup, and late reports from hidden columns.
- Typecheck, targeted ESLint, formatting, and the i18n ratchet pass.
- The managed runner rebuilt the backend, Vite assets, and fixture plugin after the final source change.
- The final mobile command in Verification passed all 29 tests. The final Chromium matrix passed all 22 tests.
- The Chromium matrix used `--no-build` to reuse the same freshly built assets after the mobile run. Both runs used one worker and zero retries.
- Specification validation passed all 36 linter tests, full specification lint, and the final diff check.
- Desktop and tablet captures match UI-01. The phone capture matches UI-02, with one focused column and its existing navigator.
- Public guides do not specify lane dimensions. No public documentation change is needed.
Intermediate test corrections included an ambiguous collapse selector, short-card fixture sizing, the phone-only navigation helper, and response cleanup.
One earlier browser run failed during shared fixture setup with a workspace-update 404, before rendering the board.
The affected drag tests then passed without retries. No backend code changed.
The final unit regression also rejected late reports after a column was hidden or compact mode was disabled.
The first version failed both cases. The completed hook ignores reports outside the current visible-column scope.
No acceptance criteria remain open. The implementation phase did not create a commit, publication, or additional task.
### PR review remediation
PR review clarified the helper field as `seedWorkflowTaskId` and changed the settings-restoration assertion to a named soft assertion.
The cleanup failure still fails the test, but it does not replace an error from the test body.
These changes affect test diagnostics only. Product behavior, screenshots, and durable requirements remain unchanged.
The following commands passed after the helper changes (2026-09-11):
```bash
(cd apps/web && pnpm e2e:run --host --no-build --project chromium tests/kanban/swimlane-height.spec.ts)
(cd apps/web && pnpm e2e:run --host --no-build --project mobile-chrome tests/kanban/mobile-kanban.spec.ts -- --grep 'multiple workflows keep a full-height')
(cd apps/web && pnpm exec eslint e2e/tests/kanban/swimlane-height-helpers.ts e2e/tests/kanban/swimlane-height.spec.ts)
```
All six Chromium scenarios and the affected phone scenario passed with zero retries.
The runs reused the unchanged production assets. Remote CI and review completion remain pending until validation of the final PR head.
+19 -1
View File
@@ -2,7 +2,7 @@
status: active
system: ui
created: 2026-07-27
updated: 2026-09-05
updated: 2026-09-11
owners:
- kandev
---
@@ -31,6 +31,22 @@ People use Kandev in portrait desktop windows, beside a task preview, and with a
- **AC-UI-ADAPTIVE-KANBAN-001.9:** When a pointer drag starts or ends without a change to the rendered workflow steps, each desktop column SHALL retain its computed width.
- **AC-UI-ADAPTIVE-KANBAN-001.10:** A desktop pointer drag MAY extend the workflow's internal scroll range for drag anchoring, but it SHALL NOT widen the document or show a transient horizontal scrollbar solely for that reserve.
### REQ-UI-ADAPTIVE-KANBAN-002: Compact workflow swimlanes
**Intent:** Users can scan several workflows without a viewport of blank space between sparse lanes.
The column area excludes the workflow header and the horizontal scrollbar. Pixel values assume the standard root font.
#### Acceptance criteria
- **AC-UI-ADAPTIVE-KANBAN-002.1:** When several workflow lanes are visible on desktop or tablet, each expanded column area shall fit its tallest column between 200px and 400px.
- **AC-UI-ADAPTIVE-KANBAN-002.2:** Columns in one lane shall share a height. Content beyond the height limit shall remain reachable through each column's internal scroll.
- **AC-UI-ADAPTIVE-KANBAN-002.3:** A collapsed lane shall occupy only its header. Other visible lanes shall retain compact sizing, even when only one remains expanded.
- **AC-UI-ADAPTIVE-KANBAN-002.4:** When exactly one workflow lane is visible, its expanded board shall fill the available height. Filters shall determine visibility before sizing.
- **AC-UI-ADAPTIVE-KANBAN-002.5:** Task changes, column visibility changes, and preview resizing shall update compact heights. Empty retained lanes shall keep their header and existing recovery controls.
- **AC-UI-ADAPTIVE-KANBAN-002.6:** Phone Kanban shall retain one focused workflow and column, its navigator, internal scroll, and direct task navigation.
- **AC-UI-ADAPTIVE-KANBAN-002.7:** Height changes shall preserve virtualization, task order, WIP boundaries, drag actions, and saved display preferences. Workflow overflow shall remain inside the board.
## Migrated source detail
## Why
@@ -129,3 +145,5 @@ desktop.
[Adaptive Kanban implementation plan](../../../plans/adaptive-kanban/plan.md)
[Large-column virtualization repair plan](../../../plans/kanban-large-column-virtualization/plan.md)
[Compact workflow swimlane repair plan](../../../plans/kanban-swimlane-height/plan.md)
+64 -2
View File
@@ -3,7 +3,9 @@ status: current
system: ui
requirements:
- REQ-UI-ADAPTIVE-KANBAN-001
- REQ-UI-ADAPTIVE-KANBAN-002
created: 2026-09-05
updated: 2026-09-11
owners:
- kandev
---
@@ -13,13 +15,16 @@ owners:
The UI system owns the desktop Kanban grid, its contained horizontal overflow, and its drag-time geometry.
This design covers desktop column sizing and drag scroll anchoring. It does not change workflow data, move permissions, or task persistence.
This design covers column sizing, workflow heights, and drag scroll anchoring. It does not change workflow data, move permissions, or task persistence.
Workflow-height allocation, horizontal sizing, and drag behavior are implemented and covered by focused browser tests.
## Requirement mapping
| Requirement | Design sections |
| --- | --- |
| `REQ-UI-ADAPTIVE-KANBAN-001` | [Components and responsibilities](#components-and-responsibilities), [Drag control flow](#drag-control-flow), [Responsive boundaries](#responsive-boundaries) |
| `REQ-UI-ADAPTIVE-KANBAN-002` | [Workflow height allocation](#workflow-height-allocation), [Height measurement](#height-measurement), [Responsive boundaries](#responsive-boundaries) |
## Components and responsibilities
@@ -57,14 +62,71 @@ If the source step no longer exists, the hook keeps the current scroll position.
## Responsive boundaries
The desktop layout uses this design. The tablet layout keeps its two-column snap-scrolling surface.
The desktop layout uses this design. The tablet layout keeps its two-column snap-scrolling surface and uses the same workflow height allocation.
The phone layout keeps one focused column and separate touch drop targets. This correction does not change mobile composition or touch behavior.
The existing mobile auto-hide E2E scenario covers the nearest mobile surface. It proves touch destinations and document-width containment.
## Workflow height allocation
`SwimlaneContainer` selects the sizing mode after `useRenderedWorkflowLayout` resolves visible workflows.
- Phone Kanban retains its existing full-height focused workflow.
- A single rendered desktop or tablet workflow retains full-height columns.
- Several rendered workflows use compact heights. Collapsed workflows still count toward this condition.
- Pipeline retains its existing content height.
The sortable wrapper must not assign the parent height to every compact lane. `SwimlaneSection` retains its header outside the collapse guard.
An optional presentation prop on `ViewContentProps` carries the compact mode into `SwimlaneKanbanContent`.
That component owns a definite column-area height between 200px and 400px. Constants use root-font-scaled units equivalent to these values.
The workflow header and native horizontal scrollbar add their own measured height outside this column area.
The tablet layout receives the definite height directly. The desktop inner track receives it through `AdaptiveDesktopKanban`.
The desktop outer scroll window uses intrinsic height so its native horizontal scrollbar stays outside the column area.
`KanbanDragSurface` keeps the existing wrapper, while descendant columns resolve `h-full` against the bounded track.
The outer swimlane container owns vertical travel between workflows. Each column owns vertical travel through its tasks.
The desktop scroll window retains horizontal overflow. The document never becomes a board scroll owner.
On a short viewport, the outer container scrolls instead of compressing lanes below the minimum.
## Height measurement
`VirtualizedColumnTaskList` already obtains its logical content height from `virtualizer.getTotalSize()`.
An optional callback reports this total after layout, including measured rows and the WIP divider.
`KanbanColumn` adds its measured header, spacing, and padding before reporting its natural height to the workflow.
The total must not come from the constrained column box or its viewport height.
The workflow computes `clamp(max(column natural heights), minimum, maximum)` over the current display steps.
The empty set uses the minimum only when an actual column area renders. Existing no-column empty states retain intrinsic height.
Initial unmeasured columns use the minimum. The virtualizer then mounts its first window and supplies estimates plus measured row heights.
Callbacks and measurements remain local to the lane. Unchanged values cause no state update.
`useCompactSwimlaneHeight` owns the lane measurements and drag freeze. `useColumnNaturalHeight` observes column chrome and adds logical row totals.
Removed steps discard their measurements. Width changes remeasure mounted rows and header chrome.
Observers disconnect on unmount and remain disabled outside compact mode.
During a task drag, the lane retains its last settled height so temporary destinations do not move the target vertically.
After drop or cancellation, the current projection determines the height again.
New callbacks must participate in `KanbanColumn` memo comparisons and remain stable to preserve render isolation.
No setting, API, dependency, migration, or new user-facing copy is required.
This extends the existing layout boundary and requires no new ADR.
## Mobile design contract
Home remains the entry point. `MobileColumnTabs` is the shipped navigator exemplar.
The hierarchy remains workflow, step, then tasks. The temporary drawer selects context, while the focused column owns task scrolling.
Task taps navigate directly. Existing dynamic viewport sizing, safe-area clearance, and touch controls remain in place.
The compact mode never applies below the canonical 768px phone boundary.
Responsive changes do not overwrite saved desktop preferences.
## Test strategy
- A desktop geometry regression seeds two sparse workflows and asserts that both headers and first cards fit at 1440 by 900.
- Mixed sparse and dense lanes prove independent heights, bounded mounts, and access to the last task.
- Collapse, filters, empty retained lanes, and preview resizing prove mode changes and measurement cleanup.
- Tablet coverage uses `tabletTestPage`. Phone coverage uses the configured Pixel 5 and widths adjacent to 768px.
- The implementation plan names the exact suites and acceptance mappings.
- Component tests assert that drag state uses a trailing spacer and does not use end padding.
- The Chromium Kanban E2E scenario compares every rendered desktop column before and during drag.
- The same Chromium scenario covers a board whose minimum track width exceeds the viewport and verifies the added scroll range.