FN-8754: adapt List task details to available width

Adapt List task detail routing to the usable content width.

- Measure the List container to choose split-pane or modal detail routing.
- Preserve phone and popup-preference routing while allowing wide tablets to use the split pane.
- Add responsive routing coverage and document the behavior.

Files changed:
 .changeset/fn-8754-responsive-list-detail.md       |   7 +
 docs/dashboard-guide.md                            |   4 +-
 packages/dashboard/app/components/ListView.css     |  25 +--
 packages/dashboard/app/components/ListView.tsx     |  47 +++++-
 .../app/components/__tests__/ListView.test.tsx     | 169 +++++++++++++++++++--
 5 files changed, 216 insertions(+), 36 deletions(-)

Fusion-Task-Id: FN-8754

Fusion-Task-Lineage: 2051e7d5-a5a9-47b8-bfc2-42586b9554cc

Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
This commit is contained in:
gsxdsm
2026-08-02 23:24:11 -07:00
parent 73fe461db5
commit d39acb5c55
5 changed files with 216 additions and 36 deletions

View File

@@ -0,0 +1,7 @@
---
"@runfusion/fusion": patch
---
summary: Make List task details adapt to available content width.
category: feature
dev: List uses measured usable width for split detail routing; popup preference still overrides it.

View File

@@ -383,10 +383,10 @@ Features:
- Bulk selection + batch model, node, and task thinking-level updates - Bulk selection + batch model, node, and task thinking-level updates
- Bulk Pause / Unpause / Archive actions from the selection toolbar (`Pause selected`, `Unpause selected`, `Archive selected`) for fast batch task state management. - Bulk Pause / Unpause / Archive actions from the selection toolbar (`Pause selected`, `Unpause selected`, `Archive selected`) for fast batch task state management.
- Bulk delete from the selection toolbar (`Delete selected`): archived selections are skipped automatically, and dependency-conflict failures can be force-deleted per task after a danger confirmation that removes dependency references. - Bulk delete from the selection toolbar (`Delete selected`): archived selections are skipped automatically, and dependency-conflict failures can be force-deleted per task after a danger confirmation that removes dependency references.
- Desktop List view keeps the two-pane table/detail split. Tablet-width and mobile viewports use the single-pane card layout so list controls and quick-add stay full-width; tapping a task opens detail instead of selecting an embedded split pane. - List measures its usable content width before routing an ordinary task open. When that surface has room for both panes, the existing table/detail split opens detail on the right; constrained surfaces use the existing task-detail modal instead. Phones remain single-pane even if a browser reports a large synthetic measurement. **Settings → Appearance → Open tasks as popups** remains an explicit override and opens the existing task popup instead of either adaptive route.
- List rows and tablet/mobile cards support the same task context menu as Board cards from right-click, keyboard context menu / Shift+F10, or touch long-press without changing ordinary row selection or tap-to-open behavior. Selecting an action applies that exact action once and dismisses the menu, including **Plan** for tasks still in Planning/ideas/hold columns and **Refine** for completed tasks; the opened refinement feedback modal stays open until the operator closes it intentionally. - List rows and tablet/mobile cards support the same task context menu as Board cards from right-click, keyboard context menu / Shift+F10, or touch long-press without changing ordinary row selection or tap-to-open behavior. Selecting an action applies that exact action once and dismisses the menu, including **Plan** for tasks still in Planning/ideas/hold columns and **Refine** for completed tasks; the opened refinement feedback modal stays open until the operator closes it intentionally.
<!-- FNXC:TaskDetailRefine 2026-07-12-00:00: List-originated Refine uses the same task-detail feedback modal as Board and must preserve the stay-open invariant across desktop and mobile activation. <!-- FNXC:TaskDetailRefine 2026-07-12-00:00: List-originated Refine uses the same task-detail feedback modal as Board and must preserve the stay-open invariant across desktop and mobile activation.
FNXC:ListView 2026-07-10-00:00: FN-7809 makes tablet List view use the same single-pane card/detail route as mobile because the desktop split sidebar clipped the primary controls and quick-add composer at 769–1024px. FNXC:ListView 2026-08-03-05:47: FN-8754 replaces the viewport-only tablet route with measured usable List width. A tablet with room for both existing panes keeps the split detail; constrained List surfaces keep the modal route, while phones remain single-pane and the popup preference takes precedence.
FNXC:ListContextMenu 2026-06-29-00:00: List context menus are alternate action entry points only; desktop left-click still selects the split-pane detail and mobile tap still opens detail while long-press suppresses the follow-up tap. FNXC:ListContextMenu 2026-06-29-00:00: List context menus are alternate action entry points only; desktop left-click still selects the split-pane detail and mobile tap still opens detail while long-press suppresses the follow-up tap.
FNXC:ListContextMenu 2026-06-30-00:20: Keyboard access is part of the Board/List context-menu contract, so docs must include the context-menu key and Shift+F10 alongside pointer and touch entry points. FNXC:ListContextMenu 2026-06-30-00:20: Keyboard access is part of the Board/List context-menu contract, so docs must include the context-menu key and Shift+F10 alongside pointer and touch entry points.
FNXC:DoneTaskRefine 2026-07-01-00:00: Completed List row/card context menus route Refine to the existing task-detail feedback modal so desktop right-click and mobile long-press share the same refinement flow. FNXC:DoneTaskRefine 2026-07-01-00:00: Completed List row/card context menus route Refine to the existing task-detail feedback modal so desktop right-click and mobile long-press share the same refinement flow.

View File

@@ -1009,8 +1009,10 @@ itself reuses the board's `.btn.btn-secondary.btn-sm` primitive so List and Boar
} }
/* === List View Single-Pane Responsive === /* === List View Single-Pane Responsive ===
FNXC:ListView 2026-07-10-00:00 (FN-7809): FNXC:ListView 2026-08-03-05:47 (FN-8754):
Tablet-width List view shares the mobile single-pane scaffolding so the full-width toolbar and QuickEntryBox are not clipped by the desktop split sidebar. Mobile behavior stays unchanged; desktop split rules remain active above the tablet tier. The component now decides split capability from measured List width. Keep these compact rules available
at tablet widths, but do not hide split chrome with a viewport-only media rule: a tablet List surface
can have sufficient room after dashboard chrome is accounted for.
*/ */
@media (max-width: 1024px) { @media (max-width: 1024px) {
.list-toolbar { .list-toolbar {
@@ -1041,21 +1043,6 @@ Tablet-width List view shares the mobile single-pane scaffolding so the full-wid
overflow-x: auto; overflow-x: auto;
} }
.list-split-layout {
display: block;
}
.list-split-sidebar,
.list-split-detail {
overflow: visible;
border: none;
padding: 0;
}
.list-split-resize-handle,
.list-split-detail {
display: none;
}
.list-table { .list-table {
min-width: auto; min-width: auto;
@@ -1077,8 +1064,8 @@ Tablet-width List view shares the mobile single-pane scaffolding so the full-wid
/* === List View Single-Pane Cards (FN-1140, FN-7809) === */ /* === List View Single-Pane Cards (FN-1140, FN-7809) === */
@media (max-width: 1024px) { @media (max-width: 1024px) {
/* Hide the table on mobile — card layout is rendered via JS */ /* The JS-measured single-pane route renders cards; a wide tablet keeps its table and split detail. */
.list-table { .list-view--single-pane .list-table {
display: none; display: none;
} }

View File

@@ -235,6 +235,14 @@ function readSidebarWidth(projectId?: string): number {
const LIST_SIDEBAR_MIN_WIDTH = 64; // FNXC:ListView 2026-06-22-00:00: The desktop task-list split sidebar minimum is 64 (was 120) so users can shrink the left panel much further; task titles wrap to two lines (.list-split-sidebar .list-cell-title) so they stay legible at narrow widths. Resize, keyboard, and ARIA paths share one clamp value. const LIST_SIDEBAR_MIN_WIDTH = 64; // FNXC:ListView 2026-06-22-00:00: The desktop task-list split sidebar minimum is 64 (was 120) so users can shrink the left panel much further; task titles wrap to two lines (.list-split-sidebar .list-cell-title) so they stay legible at narrow widths. Resize, keyboard, and ARIA paths share one clamp value.
const LIST_SIDEBAR_MAX_RATIO = 0.65; const LIST_SIDEBAR_MAX_RATIO = 0.65;
const LIST_SIDEBAR_KEYBOARD_STEP = 16; const LIST_SIDEBAR_KEYBOARD_STEP = 16;
const LIST_MINIMUM_USABLE_TASK_LIST_WIDTH = 320;
const LIST_MINIMUM_USABLE_DETAIL_WIDTH = 480;
export const LIST_MINIMUM_SPLIT_LAYOUT_WIDTH = LIST_MINIMUM_USABLE_TASK_LIST_WIDTH + LIST_MINIMUM_USABLE_DETAIL_WIDTH;
/** Returns whether the List surface can keep both its task list and embedded detail usable. */
export function canUseListSplitLayout(containerWidth: number): boolean {
return containerWidth >= LIST_MINIMUM_SPLIT_LAYOUT_WIDTH;
}
function getSidebarMaxWidth(containerWidth: number): number { function getSidebarMaxWidth(containerWidth: number): number {
return Math.max(LIST_SIDEBAR_MIN_WIDTH, containerWidth * LIST_SIDEBAR_MAX_RATIO); return Math.max(LIST_SIDEBAR_MIN_WIDTH, containerWidth * LIST_SIDEBAR_MAX_RATIO);
@@ -425,11 +433,19 @@ export function ListView({
}); });
const viewportMode = useViewportMode(); const viewportMode = useViewportMode();
const isMobile = viewportMode === "mobile"; const isMobile = viewportMode === "mobile";
const [listContainerWidth, setListContainerWidth] = useState<number | null>(null);
/* /*
FNXC:ListView 2026-07-10-00:00 (FN-7809): FNXC:ListView 2026-08-03-05:47:
Tablet-width List view must use the same single-pane layout as mobile because the desktop two-pane sidebar leaves too little horizontal room and clips the primary actions plus expanded QuickEntryBox. Keep touch-only long-press behavior on `isMobile`; this gate only controls split-vs-single-pane structure and detail routing. Available List width—not the global viewport label—owns split-versus-modal routing. A measured
surface must leave 320px for task navigation and 480px for the existing embedded detail; real
phones remain single-pane even when a synthetic measurement is large. When measurement support is
unavailable, retain the established desktop split and constrained tablet modal fallbacks.
*/ */
const useSinglePaneList = viewportMode === "mobile" || viewportMode === "tablet"; const canRenderSplitLayout = viewportMode !== "mobile"
&& (listContainerWidth !== null
? canUseListSplitLayout(listContainerWidth)
: viewportMode === "desktop");
const useSinglePaneList = !canRenderSplitLayout;
const { confirm, confirmWithChoice } = useConfirm(); const { confirm, confirmWithChoice } = useConfirm();
useEffect(() => { useEffect(() => {
@@ -493,6 +509,11 @@ export function ListView({
}); });
const [sidebarWidth, setSidebarWidth] = useState<number>(() => readSidebarWidth(projectId)); const [sidebarWidth, setSidebarWidth] = useState<number>(() => readSidebarWidth(projectId));
const splitLayoutRef = useRef<HTMLDivElement>(null); const splitLayoutRef = useRef<HTMLDivElement>(null);
const [splitLayoutContainer, setSplitLayoutContainer] = useState<HTMLDivElement | null>(null);
const setSplitLayoutRef = useCallback((node: HTMLDivElement | null) => {
splitLayoutRef.current = node;
setSplitLayoutContainer(node);
}, []);
const splitSidebarRef = useRef<HTMLDivElement>(null); const splitSidebarRef = useRef<HTMLDivElement>(null);
// FNXC:ListView 2026-06-22-18:00: Holds the active pointer-drag teardown so move/up/cancel/unmount all detach the same listeners — prevents the "window mousemove with no cleanup" leak called out by the frontend-races review. // FNXC:ListView 2026-06-22-18:00: Holds the active pointer-drag teardown so move/up/cancel/unmount all detach the same listeners — prevents the "window mousemove with no cleanup" leak called out by the frontend-races review.
const splitResizeTeardownRef = useRef<(() => void) | null>(null); const splitResizeTeardownRef = useRef<(() => void) | null>(null);
@@ -550,6 +571,24 @@ export function ListView({
}); });
}, [selectedTaskId, tasks]); }, [selectedTaskId, tasks]);
useLayoutEffect(() => {
if (!splitLayoutContainer) return;
const measureContainer = (observedWidth?: number) => {
const width = observedWidth ?? (splitLayoutContainer.getBoundingClientRect().width || splitLayoutContainer.clientWidth);
setListContainerWidth(width > 0 ? width : null);
};
measureContainer();
if (typeof ResizeObserver === "undefined") return;
const observer = new ResizeObserver((entries) => {
measureContainer(entries[0]?.contentRect.width);
});
observer.observe(splitLayoutContainer);
return () => observer.disconnect();
}, [splitLayoutContainer]);
useEffect(() => { useEffect(() => {
if (useSinglePaneList || typeof ResizeObserver === "undefined") return; if (useSinglePaneList || typeof ResizeObserver === "undefined") return;
const container = splitLayoutRef.current; const container = splitLayoutRef.current;
@@ -2949,7 +2988,7 @@ export function ListView({
)} )}
<div className="list-table-container"> <div className="list-table-container">
<div className={useSinglePaneList ? "" : "list-split-layout"} data-testid={useSinglePaneList ? undefined : "list-split-layout"} ref={splitLayoutRef}> <div className={useSinglePaneList ? "" : "list-split-layout"} data-testid={useSinglePaneList ? undefined : "list-split-layout"} ref={setSplitLayoutRef}>
<div <div
className={useSinglePaneList ? "" : "list-split-sidebar"} className={useSinglePaneList ? "" : "list-split-sidebar"}
data-testid={useSinglePaneList ? undefined : "list-split-sidebar"} data-testid={useSinglePaneList ? undefined : "list-split-sidebar"}

View File

@@ -4,7 +4,7 @@ import { describe, it, expect, vi } from "vitest";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { render, screen, fireEvent, waitFor, within, act } from "@testing-library/react"; import { render, screen, fireEvent, waitFor, within, act } from "@testing-library/react";
import userEvent from "@testing-library/user-event"; import userEvent from "@testing-library/user-event";
import { ListView } from "../ListView"; import { ListView, LIST_MINIMUM_SPLIT_LAYOUT_WIDTH } from "../ListView";
import type { Task, TaskDetail } from "@fusion/core"; import type { Task, TaskDetail } from "@fusion/core";
import { scopedKey } from "../../utils/projectStorage"; import { scopedKey } from "../../utils/projectStorage";
import { ALL_WORKFLOWS_BOARD_VIEW_ID, BOARD_WORKFLOW_SELECTION_STORAGE_KEY } from "../../utils/boardWorkflowSelection"; import { ALL_WORKFLOWS_BOARD_VIEW_ID, BOARD_WORKFLOW_SELECTION_STORAGE_KEY } from "../../utils/boardWorkflowSelection";
@@ -413,6 +413,33 @@ function mockDesktopViewport() {
})); }));
} }
function installControlledResizeObserver() {
const callbacks = new Set<ResizeObserverCallback>();
class ControlledResizeObserver {
constructor(private readonly observerCallback: ResizeObserverCallback) {
callbacks.add(observerCallback);
}
observe() {}
unobserve() {}
disconnect() {
callbacks.delete(this.observerCallback);
}
}
const OriginalResizeObserver = globalThis.ResizeObserver;
globalThis.ResizeObserver = ControlledResizeObserver as unknown as typeof ResizeObserver;
return {
resize(width: number) {
for (const callback of callbacks) {
callback([{ contentRect: { width } } as ResizeObserverEntry], {} as ResizeObserver);
}
},
restore() {
globalThis.ResizeObserver = OriginalResizeObserver;
},
};
}
/* /*
FNXC:WorkflowColumns 2026-07-28-00:00 (U12 — R9): FNXC:WorkflowColumns 2026-07-28-00:00 (U12 — R9):
The default workflow's real lane set, used both as the resolved fetch value and as The default workflow's real lane set, used both as the resolved fetch value and as
@@ -2322,6 +2349,132 @@ describe("ListView", () => {
viewportSpy.mockRestore(); viewportSpy.mockRestore();
}); });
it("uses measured List width rather than tablet viewport classification for detail routing", async () => {
const viewportSpy = mockTabletViewport();
const resizeObserver = installControlledResizeObserver();
const task = createMockTask({ id: "FN-8754", title: "Measured tablet task" });
const onOpenDetail = vi.fn();
try {
renderListView({ tasks: [task], onOpenDetail });
// The constrained control remains the existing card/modal route.
await act(async () => resizeObserver.resize(LIST_MINIMUM_SPLIT_LAYOUT_WIDTH - 1));
const constrainedCard = document.querySelector('.list-card[data-id="FN-8754"]') as HTMLElement;
fireEvent.keyDown(constrainedCard, { key: "Enter" });
expect(onOpenDetail).toHaveBeenCalledWith(task, { origin: "list-mobile" });
expect(screen.queryByTestId("list-split-detail")).toBeNull();
onOpenDetail.mockClear();
// At the named usable boundary, the same tablet surface owns the existing split detail.
await act(async () => resizeObserver.resize(LIST_MINIMUM_SPLIT_LAYOUT_WIDTH));
const boundaryRow = document.querySelector('tr[data-id="FN-8754"]') as HTMLElement;
fireEvent.keyDown(boundaryRow, { key: " " });
expect(onOpenDetail).not.toHaveBeenCalled();
expect(screen.getAllByTestId("list-split-detail-content")).toHaveLength(1);
expect(screen.getByTestId("task-detail-content")).toHaveTextContent("FN-8754");
expect(screen.getByTestId("list-split-resize-handle")).toHaveAttribute("role", "separator");
// Above the boundary pointer opens use that same single embedded host.
await act(async () => resizeObserver.resize(LIST_MINIMUM_SPLIT_LAYOUT_WIDTH + 1));
fireEvent.click(document.querySelector('tr[data-id="FN-8754"]') as HTMLElement);
expect(onOpenDetail).not.toHaveBeenCalled();
expect(screen.getAllByTestId("list-split-detail-content")).toHaveLength(1);
} finally {
resizeObserver.restore();
viewportSpy.mockRestore();
}
});
it("routes a constrained desktop List surface through the modal without split chrome", async () => {
const viewportSpy = mockDesktopViewport();
const resizeObserver = installControlledResizeObserver();
const task = createMockTask({ id: "FN-8754-desktop", title: "Constrained desktop task" });
const onOpenDetail = vi.fn();
try {
renderListView({ tasks: [task], onOpenDetail });
await act(async () => resizeObserver.resize(LIST_MINIMUM_SPLIT_LAYOUT_WIDTH - 1));
fireEvent.click(document.querySelector('.list-card[data-id="FN-8754-desktop"]') as HTMLElement);
expect(onOpenDetail).toHaveBeenCalledWith(task, { origin: "list-mobile" });
expect(screen.queryByTestId("list-split-detail")).toBeNull();
expect(screen.queryByTestId("list-split-resize-handle")).toBeNull();
} finally {
resizeObserver.restore();
viewportSpy.mockRestore();
}
});
it("keeps phones single-pane when a synthetic List measurement is wide", async () => {
const viewportSpy = mockMobileViewport();
const resizeObserver = installControlledResizeObserver();
const task = createMockTask({ id: "FN-8754-mobile", title: "Phone task" });
const onOpenDetail = vi.fn();
try {
renderListView({ tasks: [task], onOpenDetail });
await act(async () => resizeObserver.resize(LIST_MINIMUM_SPLIT_LAYOUT_WIDTH + 1));
fireEvent.click(document.querySelector('.list-card[data-id="FN-8754-mobile"]') as HTMLElement);
expect(onOpenDetail).toHaveBeenCalledWith(task, { origin: "list-mobile" });
expect(screen.queryByTestId("list-split-detail")).toBeNull();
} finally {
resizeObserver.restore();
viewportSpy.mockRestore();
}
});
it("keeps the explicit popup preference above measured tablet split routing", async () => {
const viewportSpy = mockTabletViewport();
const resizeObserver = installControlledResizeObserver();
const task = createMockTask({ id: "FN-8754-popup", title: "Popup wins" });
const onOpenDetail = vi.fn();
const onPopOut = vi.fn();
try {
renderListView({ tasks: [task], onOpenDetail, onPopOut, openMobileTasksInPopup: true });
await act(async () => resizeObserver.resize(LIST_MINIMUM_SPLIT_LAYOUT_WIDTH + 1));
fireEvent.click(document.querySelector('tr[data-id="FN-8754-popup"]') as HTMLElement);
expect(onPopOut).toHaveBeenCalledWith(task);
expect(onOpenDetail).not.toHaveBeenCalled();
expect(screen.queryByTestId("list-split-detail-content")).toBeNull();
expect(localStorage.getItem(scopedStorageKey("kb-dashboard-list-selected-task"))).toBeNull();
} finally {
resizeObserver.restore();
viewportSpy.mockRestore();
}
});
it("removes and restores split chrome across List width transitions without opening a modal", async () => {
const viewportSpy = mockTabletViewport();
const resizeObserver = installControlledResizeObserver();
const task = createMockTask({ id: "FN-8754-resize", title: "Resize task" });
const onOpenDetail = vi.fn();
try {
renderListView({ tasks: [task], onOpenDetail });
await act(async () => resizeObserver.resize(LIST_MINIMUM_SPLIT_LAYOUT_WIDTH + 1));
fireEvent.click(document.querySelector('tr[data-id="FN-8754-resize"]') as HTMLElement);
expect(screen.getByTestId("task-detail-content")).toHaveTextContent("FN-8754-resize");
await act(async () => resizeObserver.resize(LIST_MINIMUM_SPLIT_LAYOUT_WIDTH - 1));
expect(screen.queryByTestId("list-split-detail")).toBeNull();
expect(screen.queryByTestId("list-split-resize-handle")).toBeNull();
expect(onOpenDetail).not.toHaveBeenCalled();
expect(localStorage.getItem(scopedStorageKey("kb-dashboard-list-selected-task"))).toBe("FN-8754-resize");
await act(async () => resizeObserver.resize(LIST_MINIMUM_SPLIT_LAYOUT_WIDTH + 1));
expect(screen.getAllByTestId("list-split-detail-content")).toHaveLength(1);
expect(screen.getByTestId("task-detail-content")).toHaveTextContent("FN-8754-resize");
expect(onOpenDetail).not.toHaveBeenCalled();
} finally {
resizeObserver.restore();
viewportSpy.mockRestore();
}
});
it("renders tablet List view as a single full-width pane without split chrome", () => { it("renders tablet List view as a single full-width pane without split chrome", () => {
const viewportSpy = mockTabletViewport(); const viewportSpy = mockTabletViewport();
const tasks = [createMockTask({ id: "FN-001", title: "Tablet task" })]; const tasks = [createMockTask({ id: "FN-001", title: "Tablet task" })];
@@ -2978,19 +3131,13 @@ describe("ListView", () => {
expect(singlePaneToolbarRule).toContain("justify-content: center"); expect(singlePaneToolbarRule).toContain("justify-content: center");
}); });
it("loads CSS fixture rules that apply single-pane non-clipping layout at tablet while keeping desktop split rules", () => { it("keeps measured tablet split chrome visible while scoping card hiding to single-pane List", () => {
const css = loadAllAppCss(); const css = loadAllAppCss();
const splitHideRule = css.match(/@media\s*\(max-width:\s*1024px\)[\s\S]*?\.list-split-resize-handle,\s*\n\s*\.list-split-detail\s*\{[^}]*display:\s*none;[^}]*\}/)?.[0] ?? ""; const singlePaneCardRule = css.match(/\.list-view--single-pane \.list-table\s*\{[^}]*display:\s*none;[^}]*\}/)?.[0] ?? "";
const cardRule = css.match(/@media\s*\(max-width:\s*1024px\)[\s\S]*?\.list-table\s*\{[^}]*display:\s*none;[^}]*\}[\s\S]*?\.list-cards\s*\{[^}]*width:\s*100%;[^}]*\}/)?.[0] ?? "";
const desktopSplitRule = css.match(/\.list-split-layout\s*\{[^}]*grid-template-columns:\s*auto 0 minmax\(0, 1fr\);[^}]*\}/)?.[0] ?? ""; const desktopSplitRule = css.match(/\.list-split-layout\s*\{[^}]*grid-template-columns:\s*auto 0 minmax\(0, 1fr\);[^}]*\}/)?.[0] ?? "";
expect(splitHideRule).toContain("max-width: 1024px"); expect(css).not.toMatch(/\.list-split-resize-handle,\s*\n\s*\.list-split-detail\s*\{[^}]*display:\s*none/);
expect(splitHideRule).toContain(".list-split-resize-handle"); expect(singlePaneCardRule).toContain("display: none");
expect(splitHideRule).toContain("display: none");
expect(cardRule).toContain(".list-table");
expect(cardRule).toContain("display: none");
expect(cardRule).toContain(".list-cards");
expect(cardRule).toContain("width: 100%");
expect(desktopSplitRule).toContain("grid-template-columns: auto 0 minmax(0, 1fr)"); expect(desktopSplitRule).toContain("grid-template-columns: auto 0 minmax(0, 1fr)");
}); });