diff --git a/TODO.md b/TODO.md
index a12deb3..0078fd6 100644
--- a/TODO.md
+++ b/TODO.md
@@ -114,6 +114,67 @@ All event renderers now protected with error boundaries:
- Retry button and collapsible details for debugging
- Auto-resets when event changes
+### Layout System Enhancements
+**Completed**: 2024-12-18
+**Files**: `src/lib/layout-presets.ts`, `src/components/LayoutControls.tsx`, `src/components/TabBar.tsx`, `src/core/logic.ts`, `src/core/state.ts`
+
+Quick-win improvements to window management:
+- **Balance Splits**: New action to equalize all split percentages to 50/50 after manual resizing
+ - Recursive tree traversal preserves window IDs and directions
+ - Added to Actions section in LayoutControls dropdown
+ - Smooth animation on balance operation
+- **Keyboard Workspace Switching**: Cmd+1-9 (or Ctrl+1-9) to instantly switch to workspace by number
+ - Browser-safe shortcuts (prevents default browser behavior)
+ - Significantly faster workflow for power users
+- Comprehensive test coverage for balanceLayout function
+
+## Window Management Improvements
+
+### Fullscreen Mode
+**Priority**: High | **Effort**: Medium (2-3 hours)
+**Description**: Toggle window to fill entire workspace with minimal chrome
+**Implementation**:
+- CSS-based approach (hide siblings, expand target)
+- Keep workspace tabs visible for navigation
+- Toolbar button + right-click menu to enter fullscreen
+- ESC or button click to exit
+- Add `fullscreenWindowId` to workspace state
+- Smooth animation on enter/exit
+
+**Use Case**: Reading long-form content, focused analysis of single event/profile
+
+### Move Window to Different Workspace
+**Priority**: Medium | **Effort**: High (3-4 hours)
+**Description**: Reorganize windows by moving them between workspaces
+**Implementation**:
+- Right-click window → "Move to Workspace N" submenu
+- Extract window from current layout tree
+- Insert into target workspace layout
+- Handle edge cases (last window, invalid workspace)
+
+**Use Case**: "This profile is actually relevant to workspace 2's topic"
+
+### Rotate/Mirror Layout
+**Priority**: Low | **Effort**: Medium (2 hours)
+**Description**: Swap all row↔column directions in layout tree
+**Implementation**:
+- Recursive tree traversal
+- Swap `direction: "row"` ↔ `direction: "column"`
+- Keep split percentages unchanged
+- Add to Actions section in LayoutControls
+
+**Use Case**: "This arrangement works better vertically than horizontally"
+
+### Tab Navigation Between Windows
+**Priority**: Low | **Effort**: Low (1 hour)
+**Description**: Keyboard navigation within workspace
+**Implementation**:
+- Tab/Shift+Tab to cycle focus between windows
+- Focus management via mosaic window refs
+- Visual focus indicator (border highlight)
+
+**Use Case**: Keyboard-driven workflow without mouse
+
## Planned Improvements
- **App-wide error boundary** - Splash crash screen for unhandled errors (separate from event-level boundaries)
diff --git a/src/components/LayoutControls.tsx b/src/components/LayoutControls.tsx
index a54b00d..e1113e1 100644
--- a/src/components/LayoutControls.tsx
+++ b/src/components/LayoutControls.tsx
@@ -6,6 +6,7 @@ import {
Sparkles,
SplitSquareHorizontal,
SplitSquareVertical,
+ Scale,
} from "lucide-react";
import { Button } from "./ui/button";
import { useGrimoire } from "@/core/state";
@@ -22,7 +23,8 @@ import { toast } from "sonner";
import type { LayoutConfig } from "@/types/app";
export function LayoutControls() {
- const { state, applyPresetLayout, updateLayoutConfig } = useGrimoire();
+ const { state, applyPresetLayout, balanceLayout, updateLayoutConfig } =
+ useGrimoire();
const { workspaces, activeWorkspaceId, layoutConfig } = state;
const activeWorkspace = workspaces[activeWorkspaceId];
@@ -97,6 +99,26 @@ export function LayoutControls() {
updateLayoutConfig({ splitPercentage: newValue });
};
+ const handleBalance = () => {
+ try {
+ // Enable animations for smooth transition
+ document.body.classList.add("animating-layout");
+
+ balanceLayout();
+
+ // Remove animation class after transition completes
+ setTimeout(() => {
+ document.body.classList.remove("animating-layout");
+ }, 180);
+ } catch (error) {
+ document.body.classList.remove("animating-layout");
+ toast.error(`Failed to balance layout`, {
+ description:
+ error instanceof Error ? error.message : "Unknown error occurred",
+ });
+ }
+ };
+
return (
@@ -214,6 +236,26 @@ export function LayoutControls() {
+
+
+
+ {/* Actions Section */}
+
+ Actions
+
+
+
+ Balance Splits
+ {windowCount < 2 && (
+
+ Need 2+ windows
+
+ )}
+
);
diff --git a/src/components/TabBar.tsx b/src/components/TabBar.tsx
index 3682c03..aca42a6 100644
--- a/src/components/TabBar.tsx
+++ b/src/components/TabBar.tsx
@@ -3,6 +3,7 @@ import { Button } from "./ui/button";
import { useGrimoire } from "@/core/state";
import { cn } from "@/lib/utils";
import { LayoutControls } from "./LayoutControls";
+import { useEffect } from "react";
export function TabBar() {
const { state, setActiveWorkspace, createWorkspace } = useGrimoire();
@@ -12,11 +13,30 @@ export function TabBar() {
createWorkspace();
};
- // Sort workspaces by number
+ // Sort workspaces by number (for both rendering and keyboard shortcuts)
const sortedWorkspaces = Object.values(workspaces).sort(
(a, b) => a.number - b.number,
);
+ // Keyboard shortcut: Cmd+1-9 to switch workspaces by position
+ useEffect(() => {
+ const handleKeyDown = (e: KeyboardEvent) => {
+ // Check for Cmd/Ctrl + number key (1-9)
+ if ((e.metaKey || e.ctrlKey) && e.key >= "1" && e.key <= "9") {
+ const index = Number.parseInt(e.key, 10) - 1; // Convert key to array index
+ const targetWorkspace = sortedWorkspaces[index];
+
+ if (targetWorkspace) {
+ e.preventDefault(); // Prevent browser default (like Cmd+1 = first tab)
+ setActiveWorkspace(targetWorkspace.id);
+ }
+ }
+ };
+
+ window.addEventListener("keydown", handleKeyDown);
+ return () => window.removeEventListener("keydown", handleKeyDown);
+ }, [sortedWorkspaces, setActiveWorkspace]);
+
return (
<>
diff --git a/src/core/logic.ts b/src/core/logic.ts
index a719d6a..beaff9a 100644
--- a/src/core/logic.ts
+++ b/src/core/logic.ts
@@ -2,7 +2,11 @@ import { v4 as uuidv4 } from "uuid";
import type { MosaicNode } from "react-mosaic-component";
import { GrimoireState, WindowInstance, UserRelays } from "@/types/app";
import { insertWindow } from "@/lib/layout-utils";
-import { applyPresetToLayout, type LayoutPreset } from "@/lib/layout-presets";
+import {
+ applyPresetToLayout,
+ balanceLayout,
+ type LayoutPreset,
+} from "@/lib/layout-presets";
/**
* Finds the lowest available workspace number.
@@ -394,3 +398,28 @@ export const applyPresetLayout = (
return state;
}
};
+
+/**
+ * Balances all split percentages in the active workspace to 50/50.
+ * Useful for equalizing splits after manual resizing.
+ */
+export const balanceLayoutInWorkspace = (
+ state: GrimoireState,
+): GrimoireState => {
+ const activeId = state.activeWorkspaceId;
+ const ws = state.workspaces[activeId];
+
+ // Balance the layout tree
+ const balancedLayout = balanceLayout(ws.layout);
+
+ return {
+ ...state,
+ workspaces: {
+ ...state.workspaces,
+ [activeId]: {
+ ...ws,
+ layout: balancedLayout,
+ },
+ },
+ };
+};
diff --git a/src/core/state.ts b/src/core/state.ts
index 609bfd3..667e5d5 100644
--- a/src/core/state.ts
+++ b/src/core/state.ts
@@ -241,6 +241,11 @@ export const useGrimoire = () => {
[setState],
);
+ const balanceLayout = useCallback(
+ () => setState((prev) => Logic.balanceLayoutInWorkspace(prev)),
+ [setState],
+ );
+
return {
state,
locale: state.locale || browserLocale,
@@ -256,5 +261,6 @@ export const useGrimoire = () => {
setActiveAccountRelays,
updateLayoutConfig,
applyPresetLayout,
+ balanceLayout,
};
};
diff --git a/src/lib/layout-presets.test.ts b/src/lib/layout-presets.test.ts
index beacdd2..e12aa36 100644
--- a/src/lib/layout-presets.test.ts
+++ b/src/lib/layout-presets.test.ts
@@ -2,6 +2,7 @@ import { describe, it, expect } from "vitest";
import {
collectWindowIds,
applyPresetToLayout,
+ balanceLayout,
BUILT_IN_PRESETS,
} from "./layout-presets";
import type { MosaicNode } from "react-mosaic-component";
@@ -193,6 +194,84 @@ describe("layout-presets", () => {
});
});
+ describe("balanceLayout", () => {
+ it("returns null for null layout", () => {
+ expect(balanceLayout(null)).toBeNull();
+ });
+
+ it("returns single window unchanged", () => {
+ expect(balanceLayout("w1")).toBe("w1");
+ });
+
+ it("balances a simple binary split", () => {
+ const unbalanced: MosaicNode = {
+ direction: "row",
+ first: "w1",
+ second: "w2",
+ splitPercentage: 70,
+ };
+ const balanced = balanceLayout(unbalanced);
+ expect(balanced).toEqual({
+ direction: "row",
+ first: "w1",
+ second: "w2",
+ splitPercentage: 50,
+ });
+ });
+
+ it("balances nested splits recursively", () => {
+ const unbalanced: MosaicNode = {
+ direction: "row",
+ first: {
+ direction: "column",
+ first: "w1",
+ second: "w2",
+ splitPercentage: 30,
+ },
+ second: {
+ direction: "column",
+ first: "w3",
+ second: "w4",
+ splitPercentage: 80,
+ },
+ splitPercentage: 60,
+ };
+ const balanced = balanceLayout(unbalanced);
+
+ // All splits should be 50%
+ expect(balanced).toMatchObject({
+ splitPercentage: 50,
+ first: { splitPercentage: 50 },
+ second: { splitPercentage: 50 },
+ });
+ });
+
+ it("preserves window IDs and directions", () => {
+ const original: MosaicNode = {
+ direction: "column",
+ first: "w1",
+ second: {
+ direction: "row",
+ first: "w2",
+ second: "w3",
+ splitPercentage: 75,
+ },
+ splitPercentage: 25,
+ };
+ const balanced = balanceLayout(original);
+ const windowIds = collectWindowIds(balanced);
+ expect(windowIds).toEqual(["w1", "w2", "w3"]);
+
+ // Check directions preserved
+ if (balanced && typeof balanced !== "string") {
+ expect(balanced.direction).toBe("column");
+ if (typeof balanced.second !== "string") {
+ expect(balanced.second.direction).toBe("row");
+ }
+ }
+ });
+ });
+
describe("applyPresetToLayout", () => {
it("throws error if too few windows", () => {
const layout: MosaicNode = "w1";
diff --git a/src/lib/layout-presets.ts b/src/lib/layout-presets.ts
index 247daa3..ed84ae9 100644
--- a/src/lib/layout-presets.ts
+++ b/src/lib/layout-presets.ts
@@ -203,6 +203,31 @@ export function applyPresetToLayout(
return preset.generate(windowIds);
}
+/**
+ * Balances all split percentages in a layout tree to 50/50
+ * Useful for equalizing splits after manual resizing
+ */
+export function balanceLayout(
+ layout: MosaicNode | null
+): MosaicNode | null {
+ if (layout === null) {
+ return null;
+ }
+
+ // Leaf node (window ID), return as-is
+ if (typeof layout === "string") {
+ return layout;
+ }
+
+ // Branch node, balance this split and recurse
+ return {
+ direction: layout.direction,
+ first: balanceLayout(layout.first),
+ second: balanceLayout(layout.second),
+ splitPercentage: 50,
+ };
+}
+
/**
* Get a preset by ID
*/