From 2de2abe26d1bec65712e8390905a4ec4473a197b Mon Sep 17 00:00:00 2001
From: tobomobo <57799306+tobomobo@users.noreply.github.com>
Date: Sat, 16 May 2026 22:17:05 +0200
Subject: [PATCH 1/4] Add modular overview workspace layout
---
TODO.md | 4 +
docs/plan/04-desktop-ui.md | 17 +
docs/reference/desktop.md | 9 +
ui-tauri/src/components/dashboard5.tsx | 265 ++++++++++--
ui-tauri/src/components/kb/PageWorkspace.tsx | 422 +++++++++++++++++++
ui-tauri/src/lib/workspaceLayout.test.ts | 105 +++++
ui-tauri/src/lib/workspaceLayout.ts | 323 ++++++++++++++
ui-tauri/src/store/ui.test.ts | 28 ++
ui-tauri/src/store/ui.ts | 22 +
9 files changed, 1167 insertions(+), 28 deletions(-)
create mode 100644 ui-tauri/src/components/kb/PageWorkspace.tsx
create mode 100644 ui-tauri/src/lib/workspaceLayout.test.ts
create mode 100644 ui-tauri/src/lib/workspaceLayout.ts
diff --git a/TODO.md b/TODO.md
index 93cc02e3..28bd5926 100644
--- a/TODO.md
+++ b/TODO.md
@@ -296,6 +296,10 @@ and [docs/plan/04-desktop-ui.md](docs/plan/04-desktop-ui.md).
- [x] Overview screen now uses `@shadcnblocks/dashboard5` as the first
dashboard screen, keeping Export -> Reports, Add connection modal, and
Show all transactions wiring
+- [x] Add the first configurable page workspace foundation to Overview:
+ draggable/resizable page widgets, snap-grid constraints, collision handling,
+ z-order focus, reset-to-default, a widget palette with real and placeholder
+ modules, and local per-book/page persistence
- [x] Transactions screen now uses `@shadcnblocks/dashboard2` as the
transaction dashboard, with ordered period controls, enlarged search copy,
and privacy visibility toggle in the header
diff --git a/docs/plan/04-desktop-ui.md b/docs/plan/04-desktop-ui.md
index d6d23c77..9d131791 100644
--- a/docs/plan/04-desktop-ui.md
+++ b/docs/plan/04-desktop-ui.md
@@ -75,6 +75,23 @@ malicious page could appear there.
browser, Claude in Chrome, Claude Preview MCP, and any future
AI-driven browser tool. See section 2.7 below for the concrete setup.
+### Main pages can become configurable workspaces
+
+The desktop shell should keep excellent defaults while letting serious users
+shape each main page into the workstation they need. The first implementation
+scope is per-page and additive: Overview exposes its current dashboard sections
+as page-level widgets with snap-grid movement, edge/corner resizing,
+collision handling, z-order focus, reset-to-default, and a widget palette that
+can also show planned modules. Layouts are local UI preferences keyed by the
+current book identity plus page id, so a tax-review book can have a different
+Overview layout than a wallet-monitoring book without changing accounting data
+or daemon contracts.
+
+Do not make card internals draggable. The configurable boundary is the
+page-level module frame; reports, tables, charts, and review controls inside a
+module should remain normal application UI. New configurable pages should reuse
+the shared `PageWorkspace` layer instead of creating one-off dashboard canvases.
+
### Animations honor motion preference and the display's refresh rate
The Tauri webview (WKWebView on macOS, WebView2 on Windows, webkit2gtk on
diff --git a/docs/reference/desktop.md b/docs/reference/desktop.md
index dbd86325..7b484631 100644
--- a/docs/reference/desktop.md
+++ b/docs/reference/desktop.md
@@ -52,6 +52,15 @@ prerelease and development troubleshooting: request logs include argument keys,
not argument values, while terminal daemon errors keep their structured
message, hint, and redacted details when the daemon exposes them.
+Overview is the first configurable page workspace. Its page-level modules can
+be moved, resized from edges or the lower-right corner, focused above other
+modules, removed, re-added from the widget palette, and reset to the default
+layout. The layout is a local UI preference keyed by current book identity and
+page id; it is not persisted in the accounting database and does not change
+daemon data. The widget palette contains the existing Overview modules plus
+planned placeholders so later report, source-of-funds, and wallet-monitoring
+modules can reuse the same surface.
+
Settings -> AI providers displays each provider's API-key presence plus storage
location/state. Saving provider metadata does not include the raw key in the
create/update request; when the API-key field is filled, the form sends the
diff --git a/ui-tauri/src/components/dashboard5.tsx b/ui-tauri/src/components/dashboard5.tsx
index dbd090b4..4c779979 100644
--- a/ui-tauri/src/components/dashboard5.tsx
+++ b/ui-tauri/src/components/dashboard5.tsx
@@ -43,6 +43,10 @@ import {
import { Button } from "@/components/ui/button";
import { AddConnectionDialog } from "@/components/kb/AddConnectionDialog";
+import {
+ PageWorkspace,
+ type PageWorkspaceWidget,
+} from "@/components/kb/PageWorkspace";
import { Checkbox } from "@/components/ui/checkbox";
import { CurrencyToggleText } from "@/components/kb/CurrencyToggleText";
import { type ChartConfig, ChartContainer } from "@/components/ui/chart";
@@ -73,6 +77,7 @@ import { formatBtc, useCurrency, type Currency } from "@/lib/currency";
import { screenShellClassName } from "@/lib/screen-layout";
import { cn } from "@/lib/utils";
import { useUiStore } from "@/store/ui";
+import type { WorkspaceLayoutItem } from "@/lib/workspaceLayout";
import {
MOCK_OVERVIEW,
type OverviewSnapshot,
@@ -4747,6 +4752,81 @@ const BooksHealthPanel = ({
);
};
+const overviewWorkspaceDefaultItems: WorkspaceLayoutItem[] = [
+ {
+ id: "overview-readiness",
+ widgetId: "overview-readiness",
+ x: 0,
+ y: 0,
+ w: 12,
+ h: 2,
+ z: 1,
+ },
+ {
+ id: "overview-stats",
+ widgetId: "overview-stats",
+ x: 0,
+ y: 2,
+ w: 12,
+ h: 2,
+ z: 1,
+ },
+ {
+ id: "treasury-chart",
+ widgetId: "treasury-chart",
+ x: 0,
+ y: 4,
+ w: 8,
+ h: 6,
+ z: 1,
+ },
+ {
+ id: "side-charts",
+ widgetId: "side-charts",
+ x: 8,
+ y: 4,
+ w: 4,
+ h: 5,
+ z: 1,
+ },
+ {
+ id: "recent-transactions",
+ widgetId: "recent-transactions",
+ x: 0,
+ y: 10,
+ w: 8,
+ h: 5,
+ z: 1,
+ },
+ {
+ id: "books-health",
+ widgetId: "books-health",
+ x: 8,
+ y: 9,
+ w: 4,
+ h: 4,
+ z: 1,
+ },
+];
+
+function WorkspacePlaceholder({
+ title,
+ detail,
+}: {
+ title: string;
+ detail: string;
+}) {
+ return (
+
+ );
+}
+
const Dashboard5 = ({
className,
snapshot = MOCK_OVERVIEW,
@@ -4772,35 +4852,68 @@ const Dashboard5 = ({
syncAll({ onTrustedSuccess: runJournalProcessing });
}, [isProcessingJournals, isSyncing, runJournalProcessing, syncAll]);
const isRefreshingOverview = isSyncing || isProcessingJournals;
-
- return (
-
-
setAddConnectionOpen(true)}
- />
-
-
-
-
+ const workspaceWidgets = React.useMemo
(
+ () => [
+ {
+ id: "overview-readiness",
+ title: "Readiness",
+ description: "Sync, journal, and review status for this book.",
+ minW: 6,
+ minH: 2,
+ defaultW: 12,
+ defaultH: 2,
+ render: () => (
+ setAddConnectionOpen(true)}
+ />
+ ),
+ },
+ {
+ id: "overview-stats",
+ title: "Key Metrics",
+ description: "Portfolio, review, and activity counters.",
+ minW: 6,
+ minH: 2,
+ defaultW: 12,
+ defaultH: 2,
+ render: () => (
+
+ ),
+ },
+ {
+ id: "treasury-chart",
+ title: "Treasury Timeline",
+ description: "Balance, cost basis, price, and activity markers.",
+ minW: 6,
+ minH: 4,
+ defaultW: 8,
+ defaultH: 6,
+ render: () => (
+ ),
+ },
+ {
+ id: "recent-transactions",
+ title: "Recent Transactions",
+ description: "Latest transactions with review and flow filters.",
+ minW: 5,
+ minH: 4,
+ defaultW: 8,
+ defaultH: 5,
+ render: () => (
-
-
+ ),
+ },
+ {
+ id: "side-charts",
+ title: "Holdings Breakdown",
+ description: "Source concentration and balance movement drivers.",
+ minW: 3,
+ minH: 4,
+ defaultW: 4,
+ defaultH: 5,
+ render: () => (
+ ),
+ },
+ {
+ id: "books-health",
+ title: "Book Health",
+ description: "Actionable sync, journal, and report readiness checks.",
+ minW: 3,
+ minH: 3,
+ defaultW: 4,
+ defaultH: 4,
+ render: () => (
-
-
+ ),
+ },
+ {
+ id: "report-deadlines",
+ title: "Report Deadlines",
+ description: "Planned tax-year filing and export reminders.",
+ minW: 3,
+ minH: 2,
+ defaultW: 4,
+ defaultH: 3,
+ optional: true,
+ placeholder: true,
+ render: () => (
+
+ ),
+ },
+ {
+ id: "source-funds-review",
+ title: "Source Funds Review",
+ description: "Planned source-of-funds queue and evidence status.",
+ minW: 3,
+ minH: 2,
+ defaultW: 4,
+ defaultH: 3,
+ optional: true,
+ placeholder: true,
+ render: () => (
+
+ ),
+ },
+ {
+ id: "wallet-monitor",
+ title: "Wallet Monitor",
+ description: "Planned wallet sync, balances, and backend health module.",
+ minW: 3,
+ minH: 2,
+ defaultW: 4,
+ defaultH: 3,
+ optional: true,
+ placeholder: true,
+ render: () => (
+
+ ),
+ },
+ ],
+ [
+ currency,
+ hideSensitive,
+ isProcessingJournals,
+ isRefreshingOverview,
+ refreshOverviewState,
+ runJournalProcessing,
+ snapshot,
+ transactions,
+ ],
+ );
+
+ return (
+
);
};
diff --git a/ui-tauri/src/components/kb/PageWorkspace.tsx b/ui-tauri/src/components/kb/PageWorkspace.tsx
new file mode 100644
index 00000000..4526f51d
--- /dev/null
+++ b/ui-tauri/src/components/kb/PageWorkspace.tsx
@@ -0,0 +1,422 @@
+import * as React from "react";
+import {
+ GripHorizontal,
+ Plus,
+ RotateCcw,
+ SquareDashedMousePointer,
+ X,
+} from "lucide-react";
+
+import { Button } from "@/components/ui/button";
+import {
+ DropdownMenu,
+ DropdownMenuContent,
+ DropdownMenuItem,
+ DropdownMenuLabel,
+ DropdownMenuSeparator,
+ DropdownMenuTrigger,
+} from "@/components/ui/dropdown-menu";
+import { cn } from "@/lib/utils";
+import {
+ addWorkspaceWidget,
+ createWorkspaceLayout,
+ focusWorkspaceItem,
+ moveWorkspaceItem,
+ normalizeWorkspaceLayout,
+ pageWorkspaceStorageKey,
+ removeWorkspaceItem,
+ resizeWorkspaceItem,
+ workspaceLayoutHeight,
+ WORKSPACE_LAYOUT_COLUMNS,
+ WORKSPACE_LAYOUT_ROW_HEIGHT,
+ type WorkspaceLayoutItem,
+ type WorkspacePageLayout,
+ type WorkspaceResizeEdge,
+ type WorkspaceWidgetDefinition,
+} from "@/lib/workspaceLayout";
+import { useUiStore } from "@/store/ui";
+
+export type PageWorkspaceWidget = WorkspaceWidgetDefinition & {
+ render: () => React.ReactNode;
+};
+
+interface PageWorkspaceProps {
+ pageId: string;
+ title: string;
+ widgets: PageWorkspaceWidget[];
+ defaultItems: WorkspaceLayoutItem[];
+ className?: string;
+}
+
+type WorkspacePointerOperation =
+ | {
+ type: "move";
+ pointerId: number;
+ itemId: string;
+ startClientX: number;
+ startClientY: number;
+ startItem: WorkspaceLayoutItem;
+ startLayout: WorkspacePageLayout;
+ cellWidth: number;
+ rowHeight: number;
+ }
+ | {
+ type: "resize";
+ pointerId: number;
+ itemId: string;
+ edge: WorkspaceResizeEdge;
+ startClientX: number;
+ startClientY: number;
+ startLayout: WorkspacePageLayout;
+ cellWidth: number;
+ rowHeight: number;
+ };
+
+export function PageWorkspace({
+ pageId,
+ title,
+ widgets,
+ defaultItems,
+ className,
+}: PageWorkspaceProps) {
+ const identity = useUiStore((state) => state.identity);
+ const layoutKey = React.useMemo(
+ () => pageWorkspaceStorageKey(pageId, identity),
+ [identity, pageId],
+ );
+ const storedLayout = useUiStore(
+ (state) => state.pageWorkspaceLayouts[layoutKey],
+ );
+ const setPageWorkspaceLayout = useUiStore(
+ (state) => state.setPageWorkspaceLayout,
+ );
+ const clearPageWorkspaceLayout = useUiStore(
+ (state) => state.clearPageWorkspaceLayout,
+ );
+ const containerRef = React.useRef(null);
+ const operationRef = React.useRef(null);
+ const widgetById = React.useMemo(
+ () => new Map(widgets.map((widget) => [widget.id, widget])),
+ [widgets],
+ );
+ const defaultLayout = React.useMemo(
+ () => createWorkspaceLayout(defaultItems),
+ [defaultItems],
+ );
+ const layout = React.useMemo(
+ () => normalizeWorkspaceLayout(storedLayout ?? defaultLayout, widgets),
+ [defaultLayout, storedLayout, widgets],
+ );
+ const activeWidgetIds = new Set(layout.items.map((item) => item.widgetId));
+
+ const persistLayout = React.useCallback(
+ (next: WorkspacePageLayout) => {
+ setPageWorkspaceLayout(layoutKey, normalizeWorkspaceLayout(next, widgets));
+ },
+ [layoutKey, setPageWorkspaceLayout, widgets],
+ );
+
+ const measureGrid = React.useCallback(() => {
+ const rect = containerRef.current?.getBoundingClientRect();
+ return {
+ cellWidth: rect ? rect.width / WORKSPACE_LAYOUT_COLUMNS : 96,
+ rowHeight: WORKSPACE_LAYOUT_ROW_HEIGHT,
+ };
+ }, []);
+
+ const startMove = (
+ event: React.PointerEvent,
+ item: WorkspaceLayoutItem,
+ ) => {
+ if (event.button !== 0) return;
+ const { cellWidth, rowHeight } = measureGrid();
+ operationRef.current = {
+ type: "move",
+ pointerId: event.pointerId,
+ itemId: item.id,
+ startClientX: event.clientX,
+ startClientY: event.clientY,
+ startItem: item,
+ startLayout: focusWorkspaceItem(layout, item.id),
+ cellWidth,
+ rowHeight,
+ };
+ event.currentTarget.setPointerCapture(event.pointerId);
+ persistLayout(focusWorkspaceItem(layout, item.id));
+ };
+
+ const startResize = (
+ event: React.PointerEvent,
+ item: WorkspaceLayoutItem,
+ edge: WorkspaceResizeEdge,
+ ) => {
+ if (event.button !== 0) return;
+ event.stopPropagation();
+ const { cellWidth, rowHeight } = measureGrid();
+ operationRef.current = {
+ type: "resize",
+ pointerId: event.pointerId,
+ itemId: item.id,
+ edge,
+ startClientX: event.clientX,
+ startClientY: event.clientY,
+ startLayout: focusWorkspaceItem(layout, item.id),
+ cellWidth,
+ rowHeight,
+ };
+ event.currentTarget.setPointerCapture(event.pointerId);
+ persistLayout(focusWorkspaceItem(layout, item.id));
+ };
+
+ const updatePointerOperation = (event: React.PointerEvent) => {
+ const operation = operationRef.current;
+ if (!operation || operation.pointerId !== event.pointerId) return;
+ const deltaX = Math.round(
+ (event.clientX - operation.startClientX) / operation.cellWidth,
+ );
+ const deltaY = Math.round(
+ (event.clientY - operation.startClientY) / operation.rowHeight,
+ );
+ if (operation.type === "move") {
+ persistLayout(
+ moveWorkspaceItem(
+ operation.startLayout,
+ operation.itemId,
+ operation.startItem.x + deltaX,
+ operation.startItem.y + deltaY,
+ widgets,
+ ),
+ );
+ return;
+ }
+ persistLayout(
+ resizeWorkspaceItem(
+ operation.startLayout,
+ operation.itemId,
+ operation.edge,
+ deltaX,
+ deltaY,
+ widgets,
+ ),
+ );
+ };
+
+ const finishPointerOperation = (event: React.PointerEvent) => {
+ const operation = operationRef.current;
+ if (!operation || operation.pointerId !== event.pointerId) return;
+ operationRef.current = null;
+ if (event.currentTarget.hasPointerCapture(event.pointerId)) {
+ event.currentTarget.releasePointerCapture(event.pointerId);
+ }
+ };
+
+ const addWidget = (widget: PageWorkspaceWidget) => {
+ persistLayout(addWorkspaceWidget(layout, widget));
+ };
+
+ const removeWidget = (itemId: string) => {
+ persistLayout(removeWorkspaceItem(layout, itemId));
+ };
+
+ const resetLayout = () => {
+ clearPageWorkspaceLayout(layoutKey);
+ };
+
+ const rows = workspaceLayoutHeight(layout);
+ const availableWidgets = widgets.filter((widget) => !activeWidgetIds.has(widget.id));
+
+ return (
+
+
+
+
+ {title}
+
+
+
+
+
+
+ Add widget
+
+
+
+ Widget palette
+
+ {availableWidgets.length ? (
+ availableWidgets.map((widget) => (
+ addWidget(widget)}
+ >
+ {widget.title}
+
+ {widget.description}
+
+
+ ))
+ ) : (
+ All widgets are on the page
+ )}
+
+
+
+
+ Reset
+
+
+
+
+ {layout.items.map((item) => {
+ const widget = widgetById.get(item.widgetId);
+ if (!widget) return null;
+ return (
+
+ {widget.render()}
+
+ );
+ })}
+
+
+ );
+}
+
+function WorkspaceItemFrame({
+ item,
+ title,
+ placeholder,
+ children,
+ onMoveStart,
+ onPointerMove,
+ onPointerUp,
+ onResizeStart,
+ onRemove,
+}: {
+ item: WorkspaceLayoutItem;
+ title: string;
+ placeholder?: boolean;
+ children: React.ReactNode;
+ onMoveStart: (
+ event: React.PointerEvent,
+ item: WorkspaceLayoutItem,
+ ) => void;
+ onPointerMove: (event: React.PointerEvent) => void;
+ onPointerUp: (event: React.PointerEvent) => void;
+ onResizeStart: (
+ event: React.PointerEvent,
+ item: WorkspaceLayoutItem,
+ edge: WorkspaceResizeEdge,
+ ) => void;
+ onRemove: (itemId: string) => void;
+}) {
+ const edgeClassName =
+ "absolute z-20 bg-transparent transition-colors hover:bg-primary/10";
+ return (
+
+
+ onMoveStart(event, item)}
+ onPointerMove={onPointerMove}
+ onPointerUp={onPointerUp}
+ onPointerCancel={onPointerUp}
+ >
+
+
+ {title}
+
+ event.stopPropagation()}
+ onClick={() => onRemove(item.id)}
+ >
+
+ Remove {title}
+
+
+
+ {children}
+
+ onResizeStart(event, item, "n")}
+ onPointerMove={onPointerMove}
+ onPointerUp={onPointerUp}
+ onPointerCancel={onPointerUp}
+ />
+ onResizeStart(event, item, "e")}
+ onPointerMove={onPointerMove}
+ onPointerUp={onPointerUp}
+ onPointerCancel={onPointerUp}
+ />
+ onResizeStart(event, item, "s")}
+ onPointerMove={onPointerMove}
+ onPointerUp={onPointerUp}
+ onPointerCancel={onPointerUp}
+ />
+ onResizeStart(event, item, "w")}
+ onPointerMove={onPointerMove}
+ onPointerUp={onPointerUp}
+ onPointerCancel={onPointerUp}
+ />
+ onResizeStart(event, item, "se")}
+ onPointerMove={onPointerMove}
+ onPointerUp={onPointerUp}
+ onPointerCancel={onPointerUp}
+ />
+
+
+ );
+}
diff --git a/ui-tauri/src/lib/workspaceLayout.test.ts b/ui-tauri/src/lib/workspaceLayout.test.ts
new file mode 100644
index 00000000..66f70bf1
--- /dev/null
+++ b/ui-tauri/src/lib/workspaceLayout.test.ts
@@ -0,0 +1,105 @@
+import { describe, expect, it } from "vitest";
+
+import {
+ addWorkspaceWidget,
+ createWorkspaceLayout,
+ moveWorkspaceItem,
+ pageWorkspaceStorageKey,
+ resizeWorkspaceItem,
+ workspaceItemsOverlap,
+ type WorkspaceWidgetDefinition,
+} from "./workspaceLayout";
+
+const widgets: WorkspaceWidgetDefinition[] = [
+ {
+ id: "summary",
+ title: "Summary",
+ description: "Book summary",
+ minW: 3,
+ minH: 2,
+ defaultW: 6,
+ defaultH: 2,
+ },
+ {
+ id: "chart",
+ title: "Chart",
+ description: "Balance chart",
+ minW: 4,
+ minH: 3,
+ defaultW: 8,
+ defaultH: 5,
+ },
+];
+
+describe("workspace layout", () => {
+ it("keys persisted layouts by book identity and page", () => {
+ const first = pageWorkspaceStorageKey("overview", {
+ workspace: "Holding Co",
+ profile: "Tax 2026",
+ taxCountry: "at",
+ fiatCurrency: "EUR",
+ });
+ const second = pageWorkspaceStorageKey("transactions", {
+ workspace: "Holding Co",
+ profile: "Tax 2026",
+ taxCountry: "at",
+ fiatCurrency: "EUR",
+ });
+
+ expect(first).toBe("Holding Co:Tax 2026:at:EUR::overview");
+ expect(second).toBe("Holding Co:Tax 2026:at:EUR::transactions");
+ });
+
+ it("uses imported database path as the strongest book identity", () => {
+ expect(
+ pageWorkspaceStorageKey("overview", {
+ workspace: "Renamed books",
+ profile: "Renamed profile",
+ importedProject: {
+ database: "/Users/dev/.kassiber/data/kassiber.db",
+ },
+ }),
+ ).toBe("/Users/dev/.kassiber/data/kassiber.db::overview");
+ });
+
+ it("snaps dragged widgets to the grid and pushes collisions down", () => {
+ const layout = createWorkspaceLayout([
+ { id: "a", widgetId: "summary", x: 0, y: 0, w: 6, h: 2, z: 1 },
+ { id: "b", widgetId: "chart", x: 0, y: 3, w: 6, h: 4, z: 1 },
+ ]);
+
+ const moved = moveWorkspaceItem(layout, "b", 0, 0, widgets);
+ const first = moved.items.find((item) => item.id === "a");
+ const second = moved.items.find((item) => item.id === "b");
+
+ expect(second).toMatchObject({ x: 0, y: 0, w: 6, h: 4 });
+ expect(first).toMatchObject({ x: 0, y: 4, w: 6, h: 2 });
+ expect(workspaceItemsOverlap(first!, second!)).toBe(false);
+ });
+
+ it("enforces minimum sizes while resizing from an edge", () => {
+ const layout = createWorkspaceLayout([
+ { id: "a", widgetId: "chart", x: 4, y: 2, w: 6, h: 5, z: 1 },
+ ]);
+
+ const resized = resizeWorkspaceItem(layout, "a", "w", 4, 0, widgets);
+
+ expect(resized.items[0]).toMatchObject({ x: 6, y: 2, w: 4, h: 5 });
+ });
+
+ it("adds palette widgets below the current occupied workspace", () => {
+ const layout = createWorkspaceLayout([
+ { id: "a", widgetId: "summary", x: 0, y: 0, w: 6, h: 2, z: 1 },
+ { id: "b", widgetId: "chart", x: 6, y: 0, w: 6, h: 5, z: 1 },
+ ]);
+
+ const next = addWorkspaceWidget(layout, widgets[0], "summary-copy");
+
+ expect(next.items.find((item) => item.id === "summary-copy")).toMatchObject({
+ x: 0,
+ y: 5,
+ w: 6,
+ h: 2,
+ });
+ });
+});
diff --git a/ui-tauri/src/lib/workspaceLayout.ts b/ui-tauri/src/lib/workspaceLayout.ts
new file mode 100644
index 00000000..865e50c2
--- /dev/null
+++ b/ui-tauri/src/lib/workspaceLayout.ts
@@ -0,0 +1,323 @@
+export const WORKSPACE_LAYOUT_VERSION = 1;
+export const WORKSPACE_LAYOUT_COLUMNS = 12;
+export const WORKSPACE_LAYOUT_ROW_HEIGHT = 96;
+
+export type WorkspaceResizeEdge =
+ | "n"
+ | "e"
+ | "s"
+ | "w"
+ | "ne"
+ | "nw"
+ | "se"
+ | "sw";
+
+export interface WorkspaceLayoutItem {
+ id: string;
+ widgetId: string;
+ x: number;
+ y: number;
+ w: number;
+ h: number;
+ z: number;
+}
+
+export interface WorkspacePageLayout {
+ version: typeof WORKSPACE_LAYOUT_VERSION;
+ columns: number;
+ rowHeight: number;
+ items: WorkspaceLayoutItem[];
+}
+
+export interface WorkspaceWidgetDefinition {
+ id: string;
+ title: string;
+ description: string;
+ minW: number;
+ minH: number;
+ defaultW: number;
+ defaultH: number;
+ optional?: boolean;
+ placeholder?: boolean;
+}
+
+export interface WorkspaceIdentityLike {
+ name?: string;
+ profile?: string;
+ workspace?: string;
+ taxCountry?: string;
+ fiatCurrency?: string;
+ importedProject?: {
+ dataRoot?: string;
+ database?: string;
+ stateRoot?: string;
+ };
+}
+
+export function pageWorkspaceStorageKey(
+ pageId: string,
+ identity: WorkspaceIdentityLike | null | undefined,
+) {
+ const bookKey =
+ identity?.importedProject?.database ||
+ identity?.importedProject?.dataRoot ||
+ [
+ identity?.workspace || "local-books",
+ identity?.profile || identity?.name || "default-book",
+ identity?.taxCountry || "generic",
+ identity?.fiatCurrency || "EUR",
+ ].join(":");
+
+ return `${bookKey}::${pageId}`;
+}
+
+export function createWorkspaceLayout(
+ items: WorkspaceLayoutItem[],
+): WorkspacePageLayout {
+ return normalizeWorkspaceLayout({
+ version: WORKSPACE_LAYOUT_VERSION,
+ columns: WORKSPACE_LAYOUT_COLUMNS,
+ rowHeight: WORKSPACE_LAYOUT_ROW_HEIGHT,
+ items,
+ });
+}
+
+export function normalizeWorkspaceLayout(
+ layout: WorkspacePageLayout,
+ widgets: WorkspaceWidgetDefinition[] = [],
+) {
+ const widgetById = new Map(widgets.map((widget) => [widget.id, widget]));
+ const seen = new Set();
+ const items = layout.items
+ .filter((item) => {
+ if (seen.has(item.id)) return false;
+ seen.add(item.id);
+ return widgets.length === 0 || widgetById.has(item.widgetId);
+ })
+ .map((item) =>
+ clampWorkspaceItem(item, widgetById.get(item.widgetId), {
+ columns: layout.columns || WORKSPACE_LAYOUT_COLUMNS,
+ }),
+ );
+
+ return resolveWorkspaceCollisions({
+ version: WORKSPACE_LAYOUT_VERSION,
+ columns: layout.columns || WORKSPACE_LAYOUT_COLUMNS,
+ rowHeight: layout.rowHeight || WORKSPACE_LAYOUT_ROW_HEIGHT,
+ items,
+ });
+}
+
+export function clampWorkspaceItem(
+ item: WorkspaceLayoutItem,
+ widget?: WorkspaceWidgetDefinition,
+ options: { columns?: number } = {},
+): WorkspaceLayoutItem {
+ const columns = options.columns ?? WORKSPACE_LAYOUT_COLUMNS;
+ const minW = widget?.minW ?? 2;
+ const minH = widget?.minH ?? 2;
+ const w = Math.min(columns, Math.max(minW, Math.round(item.w)));
+ const h = Math.max(minH, Math.round(item.h));
+ return {
+ ...item,
+ x: Math.min(columns - w, Math.max(0, Math.round(item.x))),
+ y: Math.max(0, Math.round(item.y)),
+ w,
+ h,
+ z: Math.max(1, Math.round(item.z || 1)),
+ };
+}
+
+export function workspaceItemsOverlap(
+ a: Pick,
+ b: Pick,
+) {
+ return (
+ a.x < b.x + b.w &&
+ a.x + a.w > b.x &&
+ a.y < b.y + b.h &&
+ a.y + a.h > b.y
+ );
+}
+
+export function resolveWorkspaceCollisions(
+ layout: WorkspacePageLayout,
+ activeItemId?: string,
+) {
+ const items = layout.items.map((item) => ({ ...item }));
+ const maxPasses = Math.max(4, items.length * items.length);
+
+ for (let pass = 0; pass < maxPasses; pass += 1) {
+ let changed = false;
+ const ordered = [...items].sort(
+ (a, b) => a.y - b.y || a.x - b.x || b.z - a.z,
+ );
+
+ for (let index = 0; index < ordered.length; index += 1) {
+ const current = ordered[index];
+ for (let nextIndex = index + 1; nextIndex < ordered.length; nextIndex += 1) {
+ const next = ordered[nextIndex];
+ if (!workspaceItemsOverlap(current, next)) continue;
+
+ const itemToPush =
+ current.id === activeItemId
+ ? next
+ : next.id === activeItemId
+ ? current
+ : current.y <= next.y
+ ? next
+ : current;
+ const blocker = itemToPush.id === current.id ? next : current;
+ const newY = blocker.y + blocker.h;
+
+ if (newY !== itemToPush.y) {
+ const stored = items.find((item) => item.id === itemToPush.id);
+ if (stored) stored.y = newY;
+ itemToPush.y = newY;
+ changed = true;
+ }
+ }
+ }
+
+ if (!changed) break;
+ }
+
+ return {
+ ...layout,
+ items: items.sort((a, b) => a.y - b.y || a.x - b.x || a.id.localeCompare(b.id)),
+ };
+}
+
+export function moveWorkspaceItem(
+ layout: WorkspacePageLayout,
+ itemId: string,
+ x: number,
+ y: number,
+ widgets: WorkspaceWidgetDefinition[] = [],
+) {
+ const widgetById = new Map(widgets.map((widget) => [widget.id, widget]));
+ const nextZ = nextWorkspaceZ(layout);
+ const items = layout.items.map((item) =>
+ item.id === itemId
+ ? clampWorkspaceItem(
+ { ...item, x, y, z: nextZ },
+ widgetById.get(item.widgetId),
+ { columns: layout.columns },
+ )
+ : item,
+ );
+ return resolveWorkspaceCollisions({ ...layout, items }, itemId);
+}
+
+export function resizeWorkspaceItem(
+ layout: WorkspacePageLayout,
+ itemId: string,
+ edge: WorkspaceResizeEdge,
+ deltaX: number,
+ deltaY: number,
+ widgets: WorkspaceWidgetDefinition[] = [],
+) {
+ const widgetById = new Map(widgets.map((widget) => [widget.id, widget]));
+ const nextZ = nextWorkspaceZ(layout);
+ const items = layout.items.map((item) => {
+ if (item.id !== itemId) return item;
+ const widget = widgetById.get(item.widgetId);
+ const minW = widget?.minW ?? 2;
+ const minH = widget?.minH ?? 2;
+ let { x, y, w, h } = item;
+
+ if (edge.includes("e")) w += deltaX;
+ if (edge.includes("s")) h += deltaY;
+ if (edge.includes("w")) {
+ x += deltaX;
+ w -= deltaX;
+ }
+ if (edge.includes("n")) {
+ y += deltaY;
+ h -= deltaY;
+ }
+
+ if (w < minW) {
+ if (edge.includes("w")) x -= minW - w;
+ w = minW;
+ }
+ if (h < minH) {
+ if (edge.includes("n")) y -= minH - h;
+ h = minH;
+ }
+
+ return clampWorkspaceItem(
+ { ...item, x, y, w, h, z: nextZ },
+ widget,
+ { columns: layout.columns },
+ );
+ });
+ return resolveWorkspaceCollisions({ ...layout, items }, itemId);
+}
+
+export function focusWorkspaceItem(layout: WorkspacePageLayout, itemId: string) {
+ const nextZ = nextWorkspaceZ(layout);
+ return {
+ ...layout,
+ items: layout.items.map((item) =>
+ item.id === itemId ? { ...item, z: nextZ } : item,
+ ),
+ };
+}
+
+export function removeWorkspaceItem(
+ layout: WorkspacePageLayout,
+ itemId: string,
+) {
+ return {
+ ...layout,
+ items: layout.items.filter((item) => item.id !== itemId),
+ };
+}
+
+export function createWorkspaceItemFromWidget(
+ layout: WorkspacePageLayout,
+ widget: WorkspaceWidgetDefinition,
+ id = `${widget.id}-${Date.now().toString(36)}`,
+) {
+ const y = layout.items.reduce(
+ (bottom, item) => Math.max(bottom, item.y + item.h),
+ 0,
+ );
+ return clampWorkspaceItem(
+ {
+ id,
+ widgetId: widget.id,
+ x: 0,
+ y,
+ w: widget.defaultW,
+ h: widget.defaultH,
+ z: nextWorkspaceZ(layout),
+ },
+ widget,
+ { columns: layout.columns },
+ );
+}
+
+export function addWorkspaceWidget(
+ layout: WorkspacePageLayout,
+ widget: WorkspaceWidgetDefinition,
+ id?: string,
+) {
+ return resolveWorkspaceCollisions({
+ ...layout,
+ items: [...layout.items, createWorkspaceItemFromWidget(layout, widget, id)],
+ });
+}
+
+export function workspaceLayoutHeight(layout: WorkspacePageLayout) {
+ const rows = layout.items.reduce(
+ (bottom, item) => Math.max(bottom, item.y + item.h),
+ 0,
+ );
+ return Math.max(4, rows);
+}
+
+function nextWorkspaceZ(layout: WorkspacePageLayout) {
+ return layout.items.reduce((max, item) => Math.max(max, item.z), 0) + 1;
+}
diff --git a/ui-tauri/src/store/ui.test.ts b/ui-tauri/src/store/ui.test.ts
index 970dd621..dc5a2a4d 100644
--- a/ui-tauri/src/store/ui.test.ts
+++ b/ui-tauri/src/store/ui.test.ts
@@ -69,6 +69,34 @@ describe("UI persistence", () => {
expect(encoded).not.toContain("logEntries");
});
+ it("persists page workspace layouts separately from transient UI state", () => {
+ const state = {
+ ...useUiStore.getState(),
+ pageWorkspaceLayouts: {
+ "My Books:Tax:at:EUR::overview": {
+ version: 1 as const,
+ columns: 12,
+ rowHeight: 96,
+ items: [
+ {
+ id: "chart",
+ widgetId: "treasury-chart",
+ x: 0,
+ y: 0,
+ w: 8,
+ h: 6,
+ z: 2,
+ },
+ ],
+ },
+ },
+ };
+
+ const persisted = uiStatePartialForStorage(state);
+
+ expect(persisted.pageWorkspaceLayouts).toEqual(state.pageWorkspaceLayouts);
+ });
+
it("normalizes persisted UI scale to the supported menu range", () => {
expect(normalizeAppScale(0.93)).toBe(0.95);
expect(normalizeAppScale(0.1)).toBe(MIN_APP_SCALE);
diff --git a/ui-tauri/src/store/ui.ts b/ui-tauri/src/store/ui.ts
index da95c0ed..9a58ebde 100644
--- a/ui-tauri/src/store/ui.ts
+++ b/ui-tauri/src/store/ui.ts
@@ -5,6 +5,7 @@ import {
DEFAULT_EXPLORER_SETTINGS,
type ExplorerSettings,
} from "@/lib/explorer";
+import type { WorkspacePageLayout } from "@/lib/workspaceLayout";
type Lang = "en" | "de";
type Currency = "btc" | "eur";
@@ -158,6 +159,7 @@ export interface UiState {
notifications: AppNotification[];
logEntries: AppLogEntry[];
sourceFundsDrafts: Record;
+ pageWorkspaceLayouts: Record;
deferredConnectionSetup: DeferredConnectionSetup | null;
setLang: (lang: Lang) => void;
setCurrency: (currency: Currency) => void;
@@ -187,6 +189,8 @@ export interface UiState {
clearLogEntries: () => void;
setSourceFundsDraft: (profileKey: string, draft: SourceFundsDraft) => void;
clearSourceFundsDraft: (profileKey: string) => void;
+ setPageWorkspaceLayout: (layoutKey: string, layout: WorkspacePageLayout) => void;
+ clearPageWorkspaceLayout: (layoutKey: string) => void;
setDeferredConnectionSetup: (intent: DeferredConnectionSetup | null) => void;
clearDeferredConnectionSetup: () => void;
}
@@ -249,6 +253,7 @@ export function uiStatePartialForStorage(state: UiState) {
daemonSession: state.daemonSession,
notifications: stripNotificationProgress(state.notifications),
sourceFundsDrafts: state.sourceFundsDrafts,
+ pageWorkspaceLayouts: state.pageWorkspaceLayouts,
};
}
@@ -270,6 +275,7 @@ export const useUiStore = create()(
notifications: [],
logEntries: [],
sourceFundsDrafts: {},
+ pageWorkspaceLayouts: {},
setLang: (lang) => set({ lang }),
setCurrency: (currency) => set({ currency }),
setDataMode: (dataMode) => set({ dataMode }),
@@ -386,6 +392,20 @@ export const useUiStore = create()(
delete next[profileKey];
return { sourceFundsDrafts: next };
}),
+ setPageWorkspaceLayout: (layoutKey, layout) =>
+ set((state) => ({
+ pageWorkspaceLayouts: {
+ ...state.pageWorkspaceLayouts,
+ [layoutKey]: layout,
+ },
+ })),
+ clearPageWorkspaceLayout: (layoutKey) =>
+ set((state) => {
+ if (!(layoutKey in state.pageWorkspaceLayouts)) return state;
+ const next = { ...state.pageWorkspaceLayouts };
+ delete next[layoutKey];
+ return { pageWorkspaceLayouts: next };
+ }),
deferredConnectionSetup: null,
setDeferredConnectionSetup: (intent) =>
set({ deferredConnectionSetup: intent }),
@@ -425,6 +445,8 @@ export const useUiStore = create()(
),
sourceFundsDrafts:
restored.sourceFundsDrafts ?? current.sourceFundsDrafts,
+ pageWorkspaceLayouts:
+ restored.pageWorkspaceLayouts ?? current.pageWorkspaceLayouts,
logEntries: current.logEntries,
};
},
From 804c004b4110e4b15f794b6f6698723f3d2f675e Mon Sep 17 00:00:00 2001
From: tobomobo <57799306+tobomobo@users.noreply.github.com>
Date: Sat, 16 May 2026 23:04:42 +0200
Subject: [PATCH 2/4] Refine workspace edit mode interactions
---
ui-tauri/src/components/dashboard5.tsx | 12 +-
ui-tauri/src/components/kb/PageWorkspace.tsx | 397 ++++++++++++-------
ui-tauri/src/lib/workspaceLayout.ts | 4 +-
ui-tauri/src/store/ui.test.ts | 2 +-
ui-tauri/src/styles/globals.css | 21 +
5 files changed, 290 insertions(+), 146 deletions(-)
diff --git a/ui-tauri/src/components/dashboard5.tsx b/ui-tauri/src/components/dashboard5.tsx
index 4c779979..12f61225 100644
--- a/ui-tauri/src/components/dashboard5.tsx
+++ b/ui-tauri/src/components/dashboard5.tsx
@@ -4859,7 +4859,7 @@ const Dashboard5 = ({
title: "Readiness",
description: "Sync, journal, and review status for this book.",
minW: 6,
- minH: 2,
+ minH: 1,
defaultW: 12,
defaultH: 2,
render: () => (
@@ -4878,7 +4878,7 @@ const Dashboard5 = ({
title: "Key Metrics",
description: "Portfolio, review, and activity counters.",
minW: 6,
- minH: 2,
+ minH: 1,
defaultW: 12,
defaultH: 2,
render: () => (
@@ -4894,7 +4894,7 @@ const Dashboard5 = ({
title: "Treasury Timeline",
description: "Balance, cost basis, price, and activity markers.",
minW: 6,
- minH: 4,
+ minH: 3,
defaultW: 8,
defaultH: 6,
render: () => (
@@ -4910,7 +4910,7 @@ const Dashboard5 = ({
title: "Recent Transactions",
description: "Latest transactions with review and flow filters.",
minW: 5,
- minH: 4,
+ minH: 3,
defaultW: 8,
defaultH: 5,
render: () => (
@@ -4928,7 +4928,7 @@ const Dashboard5 = ({
title: "Holdings Breakdown",
description: "Source concentration and balance movement drivers.",
minW: 3,
- minH: 4,
+ minH: 2,
defaultW: 4,
defaultH: 5,
render: () => (
@@ -4944,7 +4944,7 @@ const Dashboard5 = ({
title: "Book Health",
description: "Actionable sync, journal, and report readiness checks.",
minW: 3,
- minH: 3,
+ minH: 2,
defaultW: 4,
defaultH: 4,
render: () => (
diff --git a/ui-tauri/src/components/kb/PageWorkspace.tsx b/ui-tauri/src/components/kb/PageWorkspace.tsx
index 4526f51d..2c91e047 100644
--- a/ui-tauri/src/components/kb/PageWorkspace.tsx
+++ b/ui-tauri/src/components/kb/PageWorkspace.tsx
@@ -1,9 +1,10 @@
import * as React from "react";
import {
+ Check,
GripHorizontal,
Plus,
RotateCcw,
- SquareDashedMousePointer,
+ SlidersHorizontal,
X,
} from "lucide-react";
@@ -72,6 +73,11 @@ type WorkspacePointerOperation =
rowHeight: number;
};
+interface WorkspacePreview {
+ item: WorkspaceLayoutItem;
+ layout: WorkspacePageLayout;
+}
+
export function PageWorkspace({
pageId,
title,
@@ -93,6 +99,8 @@ export function PageWorkspace({
const clearPageWorkspaceLayout = useUiStore(
(state) => state.clearPageWorkspaceLayout,
);
+ const [editing, setEditing] = React.useState(false);
+ const [preview, setPreview] = React.useState(null);
const containerRef = React.useRef(null);
const operationRef = React.useRef(null);
const widgetById = React.useMemo(
@@ -107,6 +115,7 @@ export function PageWorkspace({
() => normalizeWorkspaceLayout(storedLayout ?? defaultLayout, widgets),
[defaultLayout, storedLayout, widgets],
);
+ const previewLayoutRef = React.useRef(null);
const activeWidgetIds = new Set(layout.items.map((item) => item.widgetId));
const persistLayout = React.useCallback(
@@ -124,12 +133,23 @@ export function PageWorkspace({
};
}, []);
+ const updatePreview = React.useCallback(
+ (next: WorkspacePageLayout, itemId: string) => {
+ const item = next.items.find((candidate) => candidate.id === itemId);
+ if (!item) return;
+ previewLayoutRef.current = next;
+ setPreview({ item, layout: next });
+ },
+ [],
+ );
+
const startMove = (
event: React.PointerEvent,
item: WorkspaceLayoutItem,
) => {
- if (event.button !== 0) return;
+ if (!editing || event.button !== 0) return;
const { cellWidth, rowHeight } = measureGrid();
+ const startLayout = focusWorkspaceItem(layout, item.id);
operationRef.current = {
type: "move",
pointerId: event.pointerId,
@@ -137,12 +157,13 @@ export function PageWorkspace({
startClientX: event.clientX,
startClientY: event.clientY,
startItem: item,
- startLayout: focusWorkspaceItem(layout, item.id),
+ startLayout,
cellWidth,
rowHeight,
};
+ previewLayoutRef.current = startLayout;
+ updatePreview(startLayout, item.id);
event.currentTarget.setPointerCapture(event.pointerId);
- persistLayout(focusWorkspaceItem(layout, item.id));
};
const startResize = (
@@ -150,9 +171,10 @@ export function PageWorkspace({
item: WorkspaceLayoutItem,
edge: WorkspaceResizeEdge,
) => {
- if (event.button !== 0) return;
+ if (!editing || event.button !== 0) return;
event.stopPropagation();
const { cellWidth, rowHeight } = measureGrid();
+ const startLayout = focusWorkspaceItem(layout, item.id);
operationRef.current = {
type: "resize",
pointerId: event.pointerId,
@@ -160,12 +182,13 @@ export function PageWorkspace({
edge,
startClientX: event.clientX,
startClientY: event.clientY,
- startLayout: focusWorkspaceItem(layout, item.id),
+ startLayout,
cellWidth,
rowHeight,
};
+ previewLayoutRef.current = startLayout;
+ updatePreview(startLayout, item.id);
event.currentTarget.setPointerCapture(event.pointerId);
- persistLayout(focusWorkspaceItem(layout, item.id));
};
const updatePointerOperation = (event: React.PointerEvent) => {
@@ -177,34 +200,35 @@ export function PageWorkspace({
const deltaY = Math.round(
(event.clientY - operation.startClientY) / operation.rowHeight,
);
- if (operation.type === "move") {
- persistLayout(
- moveWorkspaceItem(
- operation.startLayout,
- operation.itemId,
- operation.startItem.x + deltaX,
- operation.startItem.y + deltaY,
- widgets,
- ),
- );
- return;
- }
- persistLayout(
- resizeWorkspaceItem(
- operation.startLayout,
- operation.itemId,
- operation.edge,
- deltaX,
- deltaY,
- widgets,
- ),
- );
+ const next =
+ operation.type === "move"
+ ? moveWorkspaceItem(
+ operation.startLayout,
+ operation.itemId,
+ operation.startItem.x + deltaX,
+ operation.startItem.y + deltaY,
+ widgets,
+ )
+ : resizeWorkspaceItem(
+ operation.startLayout,
+ operation.itemId,
+ operation.edge,
+ deltaX,
+ deltaY,
+ widgets,
+ );
+ updatePreview(next, operation.itemId);
};
const finishPointerOperation = (event: React.PointerEvent) => {
const operation = operationRef.current;
if (!operation || operation.pointerId !== event.pointerId) return;
operationRef.current = null;
+ if (previewLayoutRef.current) {
+ persistLayout(previewLayoutRef.current);
+ }
+ previewLayoutRef.current = null;
+ setPreview(null);
if (event.currentTarget.hasPointerCapture(event.pointerId)) {
event.currentTarget.releasePointerCapture(event.pointerId);
}
@@ -220,57 +244,94 @@ export function PageWorkspace({
const resetLayout = () => {
clearPageWorkspaceLayout(layoutKey);
+ setPreview(null);
+ previewLayoutRef.current = null;
};
- const rows = workspaceLayoutHeight(layout);
+ const rows = Math.max(
+ workspaceLayoutHeight(layout),
+ preview ? workspaceLayoutHeight(preview.layout) : 0,
+ );
const availableWidgets = widgets.filter((widget) => !activeWidgetIds.has(widget.id));
return (
-
{title}
+ {editing && Editing layout }
-
-
-
-
- Add widget
+ {editing && (
+ <>
+
+
+
+
+ Add widget
+
+
+
+ Widget palette
+
+ {availableWidgets.length ? (
+ availableWidgets.map((widget) => (
+ addWidget(widget)}
+ >
+ {widget.title}
+
+ {widget.description}
+
+
+ ))
+ ) : (
+
+ All widgets are on the page
+
+ )}
+
+
+
+
+ Reset
-
-
- Widget palette
-
- {availableWidgets.length ? (
- availableWidgets.map((widget) => (
- addWidget(widget)}
- >
- {widget.title}
-
- {widget.description}
-
-
- ))
- ) : (
- All widgets are on the page
- )}
-
-
-
-
- Reset
+ >
+ )}
+ {
+ setEditing((value) => !value);
+ setPreview(null);
+ previewLayoutRef.current = null;
+ operationRef.current = null;
+ }}
+ >
+ {editing ? (
+
+ ) : (
+
+ )}
+ {editing ? "Done" : "Edit layout"}
{layout.items.map((item) => {
@@ -279,6 +340,7 @@ export function PageWorkspace({
return (
);
})}
+ {editing && preview && }
);
}
function WorkspaceItemFrame({
+ editing,
item,
title,
placeholder,
@@ -308,6 +372,7 @@ function WorkspaceItemFrame({
onResizeStart,
onRemove,
}: {
+ editing: boolean;
item: WorkspaceLayoutItem;
title: string;
placeholder?: boolean;
@@ -325,11 +390,9 @@ function WorkspaceItemFrame({
) => void;
onRemove: (itemId: string) => void;
}) {
- const edgeClassName =
- "absolute z-20 bg-transparent transition-colors hover:bg-primary/10";
return (
- onMoveStart(event, item)}
- onPointerMove={onPointerMove}
- onPointerUp={onPointerUp}
- onPointerCancel={onPointerUp}
- >
-
-
- {title}
-
- event.stopPropagation()}
- onClick={() => onRemove(item.id)}
- >
-
- Remove {title}
-
-
-
+
{children}
-
onResizeStart(event, item, "n")}
- onPointerMove={onPointerMove}
- onPointerUp={onPointerUp}
- onPointerCancel={onPointerUp}
- />
- onResizeStart(event, item, "e")}
- onPointerMove={onPointerMove}
- onPointerUp={onPointerUp}
- onPointerCancel={onPointerUp}
- />
- onResizeStart(event, item, "s")}
- onPointerMove={onPointerMove}
- onPointerUp={onPointerUp}
- onPointerCancel={onPointerUp}
- />
- onResizeStart(event, item, "w")}
- onPointerMove={onPointerMove}
- onPointerUp={onPointerUp}
- onPointerCancel={onPointerUp}
- />
- onResizeStart(event, item, "se")}
- onPointerMove={onPointerMove}
- onPointerUp={onPointerUp}
- onPointerCancel={onPointerUp}
- />
+ {editing && (
+ <>
+ onMoveStart(event, item)}
+ onPointerMove={onPointerMove}
+ onPointerUp={onPointerUp}
+ onPointerCancel={onPointerUp}
+ >
+
+ {title}
+
+ event.stopPropagation()}
+ onClick={() => onRemove(item.id)}
+ >
+
+ Remove {title}
+
+
+
+
+
+
+ >
+ )}
);
}
+
+function ResizeHandle({
+ edge,
+ title,
+ item,
+ onStart,
+ onMove,
+ onEnd,
+}: {
+ edge: WorkspaceResizeEdge;
+ title: string;
+ item: WorkspaceLayoutItem;
+ onStart: (
+ event: React.PointerEvent,
+ item: WorkspaceLayoutItem,
+ edge: WorkspaceResizeEdge,
+ ) => void;
+ onMove: (event: React.PointerEvent) => void;
+ onEnd: (event: React.PointerEvent) => void;
+}) {
+ const classes: Record = {
+ n: "left-4 right-4 top-0 h-2 cursor-ns-resize",
+ e: "bottom-4 right-0 top-4 w-2 cursor-ew-resize",
+ s: "bottom-0 left-4 right-4 h-2 cursor-ns-resize",
+ w: "bottom-4 left-0 top-4 w-2 cursor-ew-resize",
+ ne: "right-0 top-0 size-4 cursor-nesw-resize",
+ nw: "left-0 top-0 size-4 cursor-nwse-resize",
+ se: "bottom-0 right-0 size-5 cursor-nwse-resize",
+ sw: "bottom-0 left-0 size-4 cursor-nesw-resize",
+ };
+ return (
+ onStart(event, item, edge)}
+ onPointerMove={onMove}
+ onPointerUp={onEnd}
+ onPointerCancel={onEnd}
+ />
+ );
+}
+
+function WorkspacePreviewFrame({ item }: { item: WorkspaceLayoutItem }) {
+ return (
+
+ );
+}
diff --git a/ui-tauri/src/lib/workspaceLayout.ts b/ui-tauri/src/lib/workspaceLayout.ts
index 865e50c2..18c5653e 100644
--- a/ui-tauri/src/lib/workspaceLayout.ts
+++ b/ui-tauri/src/lib/workspaceLayout.ts
@@ -1,6 +1,6 @@
export const WORKSPACE_LAYOUT_VERSION = 1;
export const WORKSPACE_LAYOUT_COLUMNS = 12;
-export const WORKSPACE_LAYOUT_ROW_HEIGHT = 96;
+export const WORKSPACE_LAYOUT_ROW_HEIGHT = 72;
export type WorkspaceResizeEdge =
| "n"
@@ -103,7 +103,7 @@ export function normalizeWorkspaceLayout(
return resolveWorkspaceCollisions({
version: WORKSPACE_LAYOUT_VERSION,
columns: layout.columns || WORKSPACE_LAYOUT_COLUMNS,
- rowHeight: layout.rowHeight || WORKSPACE_LAYOUT_ROW_HEIGHT,
+ rowHeight: WORKSPACE_LAYOUT_ROW_HEIGHT,
items,
});
}
diff --git a/ui-tauri/src/store/ui.test.ts b/ui-tauri/src/store/ui.test.ts
index dc5a2a4d..97fd6b70 100644
--- a/ui-tauri/src/store/ui.test.ts
+++ b/ui-tauri/src/store/ui.test.ts
@@ -76,7 +76,7 @@ describe("UI persistence", () => {
"My Books:Tax:at:EUR::overview": {
version: 1 as const,
columns: 12,
- rowHeight: 96,
+ rowHeight: 72,
items: [
{
id: "chart",
diff --git a/ui-tauri/src/styles/globals.css b/ui-tauri/src/styles/globals.css
index 3a48c449..cfbaf6b7 100644
--- a/ui-tauri/src/styles/globals.css
+++ b/ui-tauri/src/styles/globals.css
@@ -147,6 +147,23 @@
}
}
+@keyframes kb-layout-wiggle {
+ 0% {
+ transform: rotate(-0.18deg);
+ }
+ 50% {
+ transform: rotate(0.18deg);
+ }
+ 100% {
+ transform: rotate(-0.18deg);
+ }
+}
+
+.kb-workspace-edit .kb-workspace-item {
+ animation: kb-layout-wiggle 260ms ease-in-out infinite;
+ transform-origin: 50% 50%;
+}
+
/*
* High refresh rate / ProMotion support: WKWebView on macOS, WebView2 on
* Windows, and webkit2gtk on Linux all sync web content to the display's
@@ -168,6 +185,10 @@
transition-duration: 0.01ms !important;
scroll-behavior: auto !important;
}
+
+ .kb-workspace-edit .kb-workspace-item {
+ animation: none !important;
+ }
}
/*
From bc470d70d946f9a91d43f1afff67af55ff5dc370 Mon Sep 17 00:00:00 2001
From: tobomobo <57799306+tobomobo@users.noreply.github.com>
Date: Sun, 17 May 2026 15:10:03 +0200
Subject: [PATCH 3/4] Remove workspace edit wiggle
---
ui-tauri/src/components/kb/PageWorkspace.tsx | 2 +-
ui-tauri/src/styles/globals.css | 21 --------------------
2 files changed, 1 insertion(+), 22 deletions(-)
diff --git a/ui-tauri/src/components/kb/PageWorkspace.tsx b/ui-tauri/src/components/kb/PageWorkspace.tsx
index 2c91e047..3c8de730 100644
--- a/ui-tauri/src/components/kb/PageWorkspace.tsx
+++ b/ui-tauri/src/components/kb/PageWorkspace.tsx
@@ -392,7 +392,7 @@ function WorkspaceItemFrame({
}) {
return (
Date: Sun, 17 May 2026 15:15:03 +0200
Subject: [PATCH 4/4] Document workspace UX guardrails
---
TODO.md | 9 +++++++++
docs/plan/04-desktop-ui.md | 31 +++++++++++++++++++++++++++++++
docs/reference/desktop.md | 19 +++++++++++++++----
3 files changed, 55 insertions(+), 4 deletions(-)
diff --git a/TODO.md b/TODO.md
index 28bd5926..940fd428 100644
--- a/TODO.md
+++ b/TODO.md
@@ -300,6 +300,15 @@ and [docs/plan/04-desktop-ui.md](docs/plan/04-desktop-ui.md).
draggable/resizable page widgets, snap-grid constraints, collision handling,
z-order focus, reset-to-default, a widget palette with real and placeholder
modules, and local per-book/page persistence
+- [ ] Prove configurable workspaces with one concrete workflow before expanding
+ beyond Overview. Candidate proof points: transaction cleanup workspace with
+ table/detail/filter/review-queue modules, or tax review workspace with
+ blockers, journal freshness, E 1kv preview, and export status. Keep the
+ long-term UX bar explicit: workflow presets, small page-specific widget
+ catalogs, reset/restore safety, no controls outside edit mode, no tiny or
+ overlapping widgets, keyboard/tile controls, and consistent behavior across
+ pages. If the only gain is movable dashboard cards, do not expand the
+ pattern.
- [x] Transactions screen now uses `@shadcnblocks/dashboard2` as the
transaction dashboard, with ordered period controls, enlarged search copy,
and privacy visibility toggle in the header
diff --git a/docs/plan/04-desktop-ui.md b/docs/plan/04-desktop-ui.md
index 9d131791..8dfdc9d4 100644
--- a/docs/plan/04-desktop-ui.md
+++ b/docs/plan/04-desktop-ui.md
@@ -92,6 +92,37 @@ page-level module frame; reports, tables, charts, and review controls inside a
module should remain normal application UI. New configurable pages should reuse
the shared `PageWorkspace` layer instead of creating one-off dashboard canvases.
+Configurable workspaces are only worth keeping if they increase task
+throughput. Normal mode must stay quiet and static: no grid, handles, palette,
+or motion outside an explicit edit-layout mode. Snapping should be shown as a
+preview outline while dragging or resizing, then committed on release, so data
+cards do not jump under the pointer.
+
+The long-term UX bar is workflow composition, not movable cards for their own
+sake:
+
+- Ship opinionated presets for real jobs such as tax review, transaction
+ cleanup, reporting/export readiness, source-of-funds review, and wallet
+ monitoring.
+- Keep each page's widget catalog small and page-specific. Add modules only
+ when they expose real state or actions for that workflow.
+- Provide reset-to-default, restore preset, and eventually duplicate/save as
+ preset affordances so experimentation is reversible.
+- Keep strong constraints: no overlapping modules, no unusably small widgets,
+ no empty decorative dashboards, and no layout controls outside edit mode.
+- Add precise non-pointer controls before expanding the pattern broadly:
+ keyboard nudging, size presets, and simple tile commands such as half-width,
+ full-width, and move-to-top.
+- Preserve cross-page consistency. If Transactions, Reports, or Source Funds
+ become configurable, they should use the same edit mode, persistence model,
+ reset behavior, and accessibility rules.
+
+Do not expand configurable workspaces to another main page until one concrete
+workflow proves the added control is useful. A good next proof is a transaction
+cleanup workstation with table, detail, filters, and review queue modules, or a
+tax review workstation with blockers, journal freshness, E 1kv preview, and
+export status.
+
### Animations honor motion preference and the display's refresh rate
The Tauri webview (WKWebView on macOS, WebView2 on Windows, webkit2gtk on
diff --git a/docs/reference/desktop.md b/docs/reference/desktop.md
index 7b484631..db3e18cd 100644
--- a/docs/reference/desktop.md
+++ b/docs/reference/desktop.md
@@ -52,15 +52,26 @@ prerelease and development troubleshooting: request logs include argument keys,
not argument values, while terminal daemon errors keep their structured
message, hint, and redacted details when the daemon exposes them.
-Overview is the first configurable page workspace. Its page-level modules can
-be moved, resized from edges or the lower-right corner, focused above other
-modules, removed, re-added from the widget palette, and reset to the default
-layout. The layout is a local UI preference keyed by current book identity and
+Overview is the first configurable page workspace. Normal mode stays static:
+there is no background grid, drag handle, resize control, remove button, or
+widget palette until the user enters Edit layout. In edit mode, page-level
+modules can be moved, resized from edges or the lower-right corner, focused
+above other modules, removed, re-added from the widget palette, and reset to the
+default layout. Drag and resize interactions show a snapped outline preview
+while the underlying module stays visually stable, then commit the layout on
+release. The layout is a local UI preference keyed by current book identity and
page id; it is not persisted in the accounting database and does not change
daemon data. The widget palette contains the existing Overview modules plus
planned placeholders so later report, source-of-funds, and wallet-monitoring
modules can reuse the same surface.
+Configurable workspaces should remain a workflow feature, not a dashboard toy.
+Future expansion needs opinionated presets for concrete jobs, a small
+page-specific widget catalog, reversible reset/restore behavior, strong minimum
+size and collision constraints, precise keyboard/tile controls, and the same
+edit-mode rules across pages. If a page cannot name the workflow it improves,
+it should keep a fixed layout.
+
Settings -> AI providers displays each provider's API-key presence plus storage
location/state. Saving provider metadata does not include the raw key in the
create/update request; when the API-key field is filled, the form sends the