FN-7258: restore mobile board scroll on return

Preserve the board lane position when mobile users return from full-panel task detail.

- Capture and restore board, column, project-content, and document scroll offsets for board detail navigation.
- Retry board scroll restoration across bounded animation frames after remount and hydration.
- Cover mobile and desktop Back to board behavior with focused tests and document the mobile scroll contract.
- Add a patch changeset for the published CLI package.

Files changed:
 .changeset/fn-7258-mobile-board-scroll.md          |   7 ++
 docs/dashboard-guide.md                            |   2 +
 .../__tests__/navigation-history.test.tsx          | 111 ++++++++++++++++++---
 .../hooks/__tests__/useBoardScrollRestore.test.ts  |  43 +++++++-
 .../dashboard/app/hooks/useBoardScrollRestore.ts   |  45 ++++++---
 .../utils/__tests__/boardScrollSnapshot.test.ts    |  91 +++++++++++++++--
 .../dashboard/app/utils/boardScrollSnapshot.ts     |  41 +++++++-
 7 files changed, 305 insertions(+), 35 deletions(-)

Fusion-Task-Id: FN-7258

Fusion-Task-Lineage: 97faf031-4244-4117-9948-45491c364134

Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
This commit is contained in:
gsxdsm
2026-06-29 21:02:34 -07:00
parent 9fb706944e
commit d05ca2154f
7 changed files with 305 additions and 35 deletions

View File

@@ -0,0 +1,7 @@
---
"@runfusion/fusion": patch
---
summary: Preserve mobile board scroll after returning from task detail.
category: fix
dev: Restores the mobile board/card scroll snapshot after Back to board remounts the board.

View File

@@ -17,6 +17,8 @@ The installed mobile/PWA home-screen icons are generated from `packages/dashboar
The dashboard now handles browser back navigation consistently on desktop and mobile. The dashboard now handles browser back navigation consistently on desktop and mobile.
Using Back will first dismiss open modals and then step back through in-app view changes before leaving the app. Using Back will first dismiss open modals and then step back through in-app view changes before leaving the app.
When task detail is open from a board card, mobile list row, right-dock/activity/onboarding link, deep link, or another task detail link, one browser/Android Back action closes the current detail first and restores the prior dashboard context (for example, nested task detail → previous task detail, or task detail → board). When task detail is open from a board card, mobile list row, right-dock/activity/onboarding link, deep link, or another task detail link, one browser/Android Back action closes the current detail first and restores the prior dashboard context (for example, nested task detail → previous task detail, or task detail → board).
On mobile board-card detail, **Back to board** also restores the prior board/card scroll position so the same lane context remains visible.
<!-- FNXC:BoardNavigationDocs 2026-06-29-20:45: Mobile full-panel task detail temporarily replaces the board, so the user-facing navigation guide must document that Back to board restores the board/card scroll context instead of returning to the top of the board. -->
This behavior used to be mobile-only, and now applies across all viewports. This behavior used to be mobile-only, and now applies across all viewports.
Task Detail modal opens from onboarding, activity log, and task-to-task navigation now all register navigation history entries, so Android back swipe/button dismisses them consistently. Task Detail modal opens from onboarding, activity log, and task-to-task navigation now all register navigation history entries, so Android back swipe/button dismisses them consistently.

View File

@@ -155,20 +155,24 @@ vi.mock("../../components/Board", () => ({
onOpenDetail: (task: Task) => void; onOpenDetail: (task: Task) => void;
onOpenDetailWithTab?: (task: Task, initialTab: "changes" | "retries" | "workflow") => void; onOpenDetailWithTab?: (task: Task, initialTab: "changes" | "retries" | "workflow") => void;
}) => ( }) => (
<div data-testid="board-view"> <main id="board" className="board" data-testid="board-view">
{tasks.map((task) => ( <section className="column" data-column="todo">
<div key={task.id}> <div className="column-body" data-testid="todo-column-body">
<button type="button" data-testid={`open-task-${task.id}`} onClick={() => onOpenDetail(task)}> {tasks.map((task) => (
{task.title} <div key={task.id}>
</button> <button type="button" data-testid={`open-task-${task.id}`} onClick={() => onOpenDetail(task)}>
{task.modifiedFiles && task.modifiedFiles.length > 0 ? ( {task.title}
<button type="button" data-testid={`open-task-changes-${task.id}`} onClick={() => onOpenDetailWithTab?.(task, "changes")}> </button>
Files changed {task.modifiedFiles && task.modifiedFiles.length > 0 ? (
</button> <button type="button" data-testid={`open-task-changes-${task.id}`} onClick={() => onOpenDetailWithTab?.(task, "changes")}>
) : null} Files changed
</button>
) : null}
</div>
))}
</div> </div>
))} </section>
</div> </main>
), ),
})); }));
@@ -674,6 +678,87 @@ describe("Navigation history integration", () => {
}); });
}); });
it("returns desktop board-detail Back to board to the board without breaking history", async () => {
mockUseViewportMode.mockReturnValue("desktop");
const task = makeTask("FN-1", "Desktop Detail Card");
mockUseTasks.mockImplementation(() => ({
tasks: [task],
createTask: mockCreateTask,
moveTask: vi.fn(),
deleteTask: vi.fn(),
mergeTask: vi.fn(),
retryTask: vi.fn(),
updateTask: vi.fn(),
duplicateTask: vi.fn(),
archiveTask: vi.fn(),
unarchiveTask: vi.fn(),
archiveAllDone: vi.fn(),
refreshTasks: vi.fn(),
}));
await renderAppAndWait();
const pushCallsBefore = (window.history.pushState as any).mock.calls.length;
fireEvent.click(screen.getByTestId("open-task-FN-1"));
await waitFor(() => {
expect(screen.getByTestId("task-detail-main-panel-content")).toBeTruthy();
expect(screen.queryByTestId("board-view")).toBeNull();
});
expect((window.history.pushState as any).mock.calls.length).toBeGreaterThan(pushCallsBefore);
// FNXC:BoardNavigation 2026-06-29-20:45: Desktop board-card detail keeps the same full-panel Back-to-board history contract while mobile adds scroll restoration coverage.
fireEvent.click(screen.getByRole("button", { name: "Back to board" }));
dispatchPopState({ navIndex: 0 });
await waitFor(() => {
expect(screen.queryByTestId("task-detail-main-panel-content")).toBeNull();
expect(screen.getByTestId("board-view")).toBeTruthy();
});
});
it("restores mobile board scroll after Back to board", async () => {
mockUseViewportMode.mockReturnValue("mobile");
const task = makeTask("FN-1", "Scrolled Mobile Card");
mockUseTasks.mockImplementation(() => ({
tasks: [task],
createTask: mockCreateTask,
moveTask: vi.fn(),
deleteTask: vi.fn(),
mergeTask: vi.fn(),
retryTask: vi.fn(),
updateTask: vi.fn(),
duplicateTask: vi.fn(),
archiveTask: vi.fn(),
unarchiveTask: vi.fn(),
archiveAllDone: vi.fn(),
refreshTasks: vi.fn(),
}));
await renderMobileAppAndWait();
const board = screen.getByTestId("board-view");
const todoBody = screen.getByTestId("todo-column-body");
board.scrollLeft = 240;
todoBody.scrollTop = 380;
// FNXC:BoardNavigation 2026-06-29-20:45: Mobile Back-to-board must return to the clicked card's horizontal board offset and vertical lane offset after the full-panel detail replaces the board.
fireEvent.click(screen.getByTestId("open-task-FN-1"));
await waitFor(() => {
expect(screen.getByTestId("task-detail-main-panel-content")).toBeTruthy();
});
fireEvent.click(screen.getByRole("button", { name: "Back to board" }));
dispatchPopState({ navIndex: 0 });
await waitFor(() => {
expect(screen.queryByTestId("task-detail-main-panel-content")).toBeNull();
expect(screen.getByTestId("board-view").scrollLeft).toBe(240);
expect(screen.getByTestId("todo-column-body").scrollTop).toBe(380);
});
});
it("opens board files-changed actions inline on the changes tab instead of in a modal", async () => { it("opens board files-changed actions inline on the changes tab instead of in a modal", async () => {
const task = { const task = {
...makeTask("FN-1", "Inline Changes Detail"), ...makeTask("FN-1", "Inline Changes Detail"),

View File

@@ -34,7 +34,15 @@ describe("useBoardScrollRestore", () => {
}); });
it("restores the captured snapshot after returning to the board view", () => { it("restores the captured snapshot after returning to the board view", () => {
const sentinel = { boardLeft: 42, boardTop: 7, columnTops: { c1: 3 } }; const sentinel = {
boardLeft: 42,
boardTop: 7,
columnTops: { c1: 3 },
projectContentLeft: 0,
projectContentTop: 0,
documentLeft: 0,
documentTop: 0,
};
mockedCapture.mockReturnValue(sentinel); mockedCapture.mockReturnValue(sentinel);
// Make the double requestAnimationFrame fire synchronously so the restore // Make the double requestAnimationFrame fire synchronously so the restore
@@ -67,4 +75,37 @@ describe("useBoardScrollRestore", () => {
expect(mockedRestore).toHaveBeenCalledTimes(1); expect(mockedRestore).toHaveBeenCalledTimes(1);
expect(mockedRestore).toHaveBeenCalledWith(sentinel); expect(mockedRestore).toHaveBeenCalledWith(sentinel);
}); });
it("retries while the board is not yet ready after returning to the board view", () => {
const sentinel = {
boardLeft: 240,
boardTop: 0,
columnTops: { todo: 380 },
projectContentLeft: 0,
projectContentTop: 0,
documentLeft: 0,
documentTop: 0,
};
mockedCapture.mockReturnValue(sentinel);
mockedRestore.mockReturnValueOnce(false).mockReturnValueOnce(true);
vi.spyOn(window, "requestAnimationFrame").mockImplementation((cb: FrameRequestCallback) => {
cb(0);
return 0;
});
const { result, rerender } = renderHook(
({ taskView }: { taskView: TaskView }) => useBoardScrollRestore(taskView),
{ initialProps: { taskView: "task-detail" } },
);
act(() => {
result.current.capture();
result.current.requestRestore();
rerender({ taskView: "board" });
});
expect(mockedRestore).toHaveBeenCalledTimes(2);
expect(mockedRestore).toHaveBeenLastCalledWith(sentinel);
});
}); });

View File

@@ -1,6 +1,9 @@
/* /*
FNXC:BoardNavigation 2026-06-24-00:00: FNXC:BoardNavigation 2026-06-24-00:00:
Preserves horizontal board scroll and per-column vertical scroll across a board → task-detail → back-to-board round trip. capture() snapshots before opening detail; requestRestore() schedules a restore that fires (double requestAnimationFrame, after the board remounts) once the view returns to "board". Extracted from AppInner. Preserves horizontal board scroll and per-column vertical scroll across a board → task-detail → back-to-board round trip. capture() snapshots before opening detail; requestRestore() schedules a restore that fires (double requestAnimationFrame, after the board remounts) once the view returns to "board". Extracted from AppInner.
FNXC:BoardNavigation 2026-06-29-20:45:
Mobile Back-to-board must restore the clicked-card board position after the full-panel detail unmounts. Retry the restore for a bounded sequence of animation frames because mobile board layout stabilization and workflow-board hydration can temporarily leave #board unavailable or reset its offsets after the first post-return frame.
*/ */
import { useCallback, useEffect, useRef } from "react"; import { useCallback, useEffect, useRef } from "react";
@@ -11,6 +14,8 @@ import {
} from "../utils/boardScrollSnapshot"; } from "../utils/boardScrollSnapshot";
import type { TaskView } from "./useViewState"; import type { TaskView } from "./useViewState";
const MAX_RESTORE_ATTEMPTS = 6;
export interface UseBoardScrollRestoreResult { export interface UseBoardScrollRestoreResult {
capture: () => void; capture: () => void;
requestRestore: () => void; requestRestore: () => void;
@@ -20,12 +25,6 @@ export function useBoardScrollRestore(taskView: TaskView): UseBoardScrollRestore
const boardScrollSnapshotRef = useRef<BoardScrollSnapshot | null>(null); const boardScrollSnapshotRef = useRef<BoardScrollSnapshot | null>(null);
const pendingBoardScrollRestoreRef = useRef(false); const pendingBoardScrollRestoreRef = useRef(false);
const restore = useCallback(() => {
if (restoreBoardScrollSnapshot(boardScrollSnapshotRef.current)) {
pendingBoardScrollRestoreRef.current = false;
}
}, []);
const capture = useCallback(() => { const capture = useCallback(() => {
boardScrollSnapshotRef.current = captureBoardScrollSnapshot(); boardScrollSnapshotRef.current = captureBoardScrollSnapshot();
}, []); }, []);
@@ -42,16 +41,36 @@ export function useBoardScrollRestore(taskView: TaskView): UseBoardScrollRestore
const cancelFrame = typeof window.cancelAnimationFrame === "function" const cancelFrame = typeof window.cancelAnimationFrame === "function"
? window.cancelAnimationFrame.bind(window) ? window.cancelAnimationFrame.bind(window)
: window.clearTimeout.bind(window); : window.clearTimeout.bind(window);
let firstFrame = 0; const frameIds: number[] = [];
let secondFrame = 0; let attempts = 0;
firstFrame = scheduleFrame(() => { let cancelled = false;
secondFrame = scheduleFrame(restore);
const schedule = (callback: FrameRequestCallback) => {
const id = scheduleFrame(callback);
frameIds.push(id);
};
const attemptRestore = () => {
if (cancelled || !pendingBoardScrollRestoreRef.current) return;
attempts += 1;
const restored = restoreBoardScrollSnapshot(boardScrollSnapshotRef.current);
if (restored) {
pendingBoardScrollRestoreRef.current = false;
return;
}
if (attempts < MAX_RESTORE_ATTEMPTS) {
schedule(attemptRestore);
}
};
schedule(() => {
schedule(attemptRestore);
}); });
return () => { return () => {
cancelFrame(firstFrame); cancelled = true;
cancelFrame(secondFrame); frameIds.forEach(cancelFrame);
}; };
}, [restore, taskView]); }, [taskView]);
return { capture, requestRestore }; return { capture, requestRestore };
} }

View File

@@ -1,18 +1,31 @@
import { describe, expect, it } from "vitest"; import { afterEach, describe, expect, it, vi } from "vitest";
import { captureBoardScrollSnapshot, restoreBoardScrollSnapshot } from "../boardScrollSnapshot"; import { captureBoardScrollSnapshot, restoreBoardScrollSnapshot } from "../boardScrollSnapshot";
describe("boardScrollSnapshot", () => { describe("boardScrollSnapshot", () => {
afterEach(() => {
vi.restoreAllMocks();
delete (document as Document & { scrollingElement?: Element | null }).scrollingElement;
Object.defineProperty(window, "scrollX", { configurable: true, writable: true, value: 0 });
Object.defineProperty(window, "scrollY", { configurable: true, writable: true, value: 0 });
document.body.innerHTML = "";
});
it("round-trips board horizontal scroll and per-column vertical scroll", () => { it("round-trips board horizontal scroll and per-column vertical scroll", () => {
document.body.innerHTML = ` document.body.innerHTML = `
<main id="board"> <div class="project-content">
<section class="column" data-column="todo"><div class="column-body"></div></section> <main id="board">
<section class="column" data-column="in-progress"><div class="column-body"></div></section> <section class="column" data-column="todo"><div class="column-body"></div></section>
</main> <section class="column" data-column="in-progress"><div class="column-body"></div></section>
</main>
</div>
`; `;
const projectContent = document.querySelector(".project-content") as HTMLElement;
const board = document.getElementById("board") as HTMLElement; const board = document.getElementById("board") as HTMLElement;
const todoBody = document.querySelector('[data-column="todo"] .column-body') as HTMLElement; const todoBody = document.querySelector('[data-column="todo"] .column-body') as HTMLElement;
const activeBody = document.querySelector('[data-column="in-progress"] .column-body') as HTMLElement; const activeBody = document.querySelector('[data-column="in-progress"] .column-body') as HTMLElement;
projectContent.scrollLeft = 4;
projectContent.scrollTop = 22;
board.scrollLeft = 240; board.scrollLeft = 240;
board.scrollTop = 12; board.scrollTop = 12;
todoBody.scrollTop = 380; todoBody.scrollTop = 380;
@@ -20,22 +33,88 @@ describe("boardScrollSnapshot", () => {
const snapshot = captureBoardScrollSnapshot(); const snapshot = captureBoardScrollSnapshot();
projectContent.scrollLeft = 0;
projectContent.scrollTop = 0;
board.scrollLeft = 0; board.scrollLeft = 0;
board.scrollTop = 0; board.scrollTop = 0;
todoBody.scrollTop = 0; todoBody.scrollTop = 0;
activeBody.scrollTop = 0; activeBody.scrollTop = 0;
expect(restoreBoardScrollSnapshot(snapshot)).toBe(true); expect(restoreBoardScrollSnapshot(snapshot)).toBe(true);
expect(projectContent.scrollLeft).toBe(4);
expect(projectContent.scrollTop).toBe(22);
expect(board.scrollLeft).toBe(240); expect(board.scrollLeft).toBe(240);
expect(board.scrollTop).toBe(12); expect(board.scrollTop).toBe(12);
expect(todoBody.scrollTop).toBe(380); expect(todoBody.scrollTop).toBe(380);
expect(activeBody.scrollTop).toBe(95); expect(activeBody.scrollTop).toBe(95);
}); });
it("round-trips document scroll without requiring the project-content shell", () => {
document.body.innerHTML = `
<main id="board">
<section class="column" data-column="todo"><div class="column-body"></div></section>
</main>
`;
Object.defineProperty(document, "scrollingElement", {
configurable: true,
value: document.documentElement,
});
Object.defineProperty(window, "scrollX", { configurable: true, writable: true, value: 0 });
Object.defineProperty(window, "scrollY", { configurable: true, writable: true, value: 0 });
const scrollTo = vi.spyOn(window, "scrollTo").mockImplementation((leftOrOptions?: number | ScrollToOptions, top?: number) => {
if (typeof leftOrOptions === "number") {
Object.defineProperty(window, "scrollX", { configurable: true, writable: true, value: leftOrOptions });
Object.defineProperty(window, "scrollY", { configurable: true, writable: true, value: top ?? window.scrollY });
return;
}
Object.defineProperty(window, "scrollX", { configurable: true, writable: true, value: leftOrOptions?.left ?? window.scrollX });
Object.defineProperty(window, "scrollY", { configurable: true, writable: true, value: leftOrOptions?.top ?? window.scrollY });
});
const board = document.getElementById("board") as HTMLElement;
board.scrollLeft = 40;
board.scrollTop = 8;
document.documentElement.scrollLeft = 13;
document.documentElement.scrollTop = 144;
const snapshot = captureBoardScrollSnapshot();
expect(snapshot).toMatchObject({
boardLeft: 40,
boardTop: 8,
projectContentLeft: 0,
projectContentTop: 0,
documentLeft: 13,
documentTop: 144,
});
board.scrollLeft = 0;
board.scrollTop = 0;
document.documentElement.scrollLeft = 0;
document.documentElement.scrollTop = 0;
Object.defineProperty(window, "scrollX", { configurable: true, writable: true, value: 0 });
Object.defineProperty(window, "scrollY", { configurable: true, writable: true, value: 0 });
expect(restoreBoardScrollSnapshot(snapshot)).toBe(true);
expect(document.documentElement.scrollLeft).toBe(13);
expect(document.documentElement.scrollTop).toBe(144);
expect(board.scrollLeft).toBe(40);
expect(board.scrollTop).toBe(8);
expect(scrollTo).toHaveBeenCalledWith(13, 144);
});
it("returns false when the board is not mounted", () => { it("returns false when the board is not mounted", () => {
document.body.innerHTML = ""; document.body.innerHTML = "";
expect(captureBoardScrollSnapshot()).toBeNull(); expect(captureBoardScrollSnapshot()).toBeNull();
expect(restoreBoardScrollSnapshot({ boardLeft: 10, boardTop: 0, columnTops: {} })).toBe(false); expect(restoreBoardScrollSnapshot({
boardLeft: 10,
boardTop: 0,
columnTops: {},
projectContentLeft: 0,
projectContentTop: 0,
documentLeft: 0,
documentTop: 0,
})).toBe(false);
}); });
}); });

View File

@@ -2,6 +2,10 @@ export interface BoardScrollSnapshot {
boardLeft: number; boardLeft: number;
boardTop: number; boardTop: number;
columnTops: Record<string, number>; columnTops: Record<string, number>;
projectContentLeft: number;
projectContentTop: number;
documentLeft: number;
documentTop: number;
} }
function getBoardDocument(doc?: Document): Document | null { function getBoardDocument(doc?: Document): Document | null {
@@ -12,12 +16,19 @@ function getBoardDocument(doc?: Document): Document | null {
/* /*
FNXC:BoardNavigation 2026-06-22-20:15: FNXC:BoardNavigation 2026-06-22-20:15:
Board-card task detail replaces the board instead of overlaying it. Capture horizontal board scroll and per-column vertical scroll before opening detail, then restore after Back to board remounts the board so users return to the same lane/card context. Board-card task detail replaces the board instead of overlaying it. Capture horizontal board scroll and per-column vertical scroll before opening detail, then restore after Back to board remounts the board so users return to the same lane/card context.
FNXC:BoardNavigation 2026-06-29-20:45:
Mobile Back-to-board must restore the clicked-card board position even when the browser parks scroll on the project-content/document shell during the full-panel task-detail transition. Snapshot the shell offsets alongside #board and .column-body; CSS keeps #board as the horizontal scroller and .column-body as the vertical lane scroller, but restoring the shell defensively prevents mobile viewport drift from hiding the clicked card after return.
*/ */
export function captureBoardScrollSnapshot(doc?: Document): BoardScrollSnapshot | null { export function captureBoardScrollSnapshot(doc?: Document): BoardScrollSnapshot | null {
const ownerDocument = getBoardDocument(doc); const ownerDocument = getBoardDocument(doc);
const board = ownerDocument?.getElementById("board") as HTMLElement | null; if (!ownerDocument) return null;
const board = ownerDocument.getElementById("board") as HTMLElement | null;
if (!board) return null; if (!board) return null;
const projectContent = ownerDocument.querySelector<HTMLElement>(".project-content");
const scrollingElement = ownerDocument.scrollingElement as HTMLElement | null;
const defaultView = ownerDocument.defaultView;
const columnTops: Record<string, number> = {}; const columnTops: Record<string, number> = {};
board.querySelectorAll<HTMLElement>(".column[data-column]").forEach((column) => { board.querySelectorAll<HTMLElement>(".column[data-column]").forEach((column) => {
const columnId = column.dataset.column; const columnId = column.dataset.column;
@@ -31,15 +42,41 @@ export function captureBoardScrollSnapshot(doc?: Document): BoardScrollSnapshot
boardLeft: board.scrollLeft, boardLeft: board.scrollLeft,
boardTop: board.scrollTop, boardTop: board.scrollTop,
columnTops, columnTops,
projectContentLeft: projectContent?.scrollLeft ?? 0,
projectContentTop: projectContent?.scrollTop ?? 0,
documentLeft: scrollingElement?.scrollLeft ?? defaultView?.scrollX ?? 0,
documentTop: scrollingElement?.scrollTop ?? defaultView?.scrollY ?? 0,
}; };
} }
export function restoreBoardScrollSnapshot(snapshot: BoardScrollSnapshot | null, doc?: Document): boolean { export function restoreBoardScrollSnapshot(snapshot: BoardScrollSnapshot | null, doc?: Document): boolean {
if (!snapshot) return false; if (!snapshot) return false;
const ownerDocument = getBoardDocument(doc); const ownerDocument = getBoardDocument(doc);
const board = ownerDocument?.getElementById("board") as HTMLElement | null; if (!ownerDocument) return false;
const board = ownerDocument.getElementById("board") as HTMLElement | null;
if (!board) return false; if (!board) return false;
const projectContent = ownerDocument.querySelector<HTMLElement>(".project-content");
const scrollingElement = ownerDocument.scrollingElement as HTMLElement | null;
const defaultView = ownerDocument.defaultView;
if (projectContent) {
projectContent.scrollLeft = snapshot.projectContentLeft ?? 0;
projectContent.scrollTop = snapshot.projectContentTop ?? 0;
}
if (scrollingElement) {
scrollingElement.scrollLeft = snapshot.documentLeft ?? 0;
scrollingElement.scrollTop = snapshot.documentTop ?? 0;
}
const documentLeft = snapshot.documentLeft ?? 0;
const documentTop = snapshot.documentTop ?? 0;
if (defaultView && (defaultView.scrollX !== documentLeft || defaultView.scrollY !== documentTop)) {
try {
defaultView.scrollTo(documentLeft, documentTop);
} catch {
// Test DOMs may expose scrollTo without implementing it; element offsets above still cover the restore contract.
}
}
board.scrollLeft = snapshot.boardLeft; board.scrollLeft = snapshot.boardLeft;
board.scrollTop = snapshot.boardTop; board.scrollTop = snapshot.boardTop;
board.querySelectorAll<HTMLElement>(".column[data-column]").forEach((column) => { board.querySelectorAll<HTMLElement>(".column[data-column]").forEach((column) => {