diff --git a/src/components/KindRenderer.tsx b/src/components/KindRenderer.tsx
index 15de044..4682c76 100644
--- a/src/components/KindRenderer.tsx
+++ b/src/components/KindRenderer.tsx
@@ -9,6 +9,7 @@ import {
parseTagStructure,
getContentTypeDescription,
} from "@/lib/nostr-schema";
+import { CenteredContent } from "./ui/CenteredContent";
// NIP-01 Kind ranges
const REPLACEABLE_START = 10000;
@@ -44,7 +45,7 @@ export default function KindRenderer({ kind }: { kind: number }) {
}
return (
-
-
- {/* Header */}
-
+
+ {/* Header */}
+
Supported Event Kinds ({sortedKinds.length})
@@ -70,7 +70,6 @@ export default function KindsViewer() {
);
})}
-
-
+
);
}
diff --git a/src/components/ManPage.tsx b/src/components/ManPage.tsx
index 0f7c7e9..d17a92d 100644
--- a/src/components/ManPage.tsx
+++ b/src/components/ManPage.tsx
@@ -1,5 +1,6 @@
import { manPages } from "@/types/man";
import { useGrimoire } from "@/core/state";
+import { CenteredContent } from "./ui/CenteredContent";
interface ManPageProps {
cmd: string;
@@ -48,17 +49,17 @@ export default function ManPage({ cmd }: ManPageProps) {
if (!page) {
return (
-
+
No manual entry for {cmd}
Use 'help' to see available commands.
-
+
);
}
return (
-
+
{/* Header */}
{page.name.toUpperCase()}
@@ -161,6 +162,6 @@ export default function ManPage({ cmd }: ManPageProps) {
Grimoire 1.0.0 {new Date().getFullYear()}
-
+
);
}
diff --git a/src/components/NipRenderer.tsx b/src/components/NipRenderer.tsx
index 7051b4e..c819eb0 100644
--- a/src/components/NipRenderer.tsx
+++ b/src/components/NipRenderer.tsx
@@ -2,6 +2,8 @@ import { useNip } from "@/hooks/useNip";
import { MarkdownContent } from "./nostr/MarkdownContent";
import { KindBadge } from "./KindBadge";
import { getKindsForNip } from "@/lib/nip-kinds";
+import { CenteredContent } from "./ui/CenteredContent";
+import { cn } from "@/lib/utils";
interface NipRendererProps {
nipId: string;
@@ -14,21 +16,17 @@ export function NipRenderer({ nipId, className = "" }: NipRendererProps) {
if (loading) {
return (
-
-
- Loading NIP-{nipId}...
-
-
+
+ Loading NIP-{nipId}...
+
);
}
if (error) {
return (
-
-
- Error loading NIP-{nipId}: {error.message}
-
-
+
+ Error loading NIP-{nipId}: {error.message}
+
);
}
@@ -37,7 +35,7 @@ export function NipRenderer({ nipId, className = "" }: NipRendererProps) {
}
return (
-
+
{kinds.length > 0 && (
@@ -52,6 +50,6 @@ export function NipRenderer({ nipId, className = "" }: NipRendererProps) {
)}
-
+
);
}
diff --git a/src/components/NipsViewer.tsx b/src/components/NipsViewer.tsx
index b1b54f3..15e4c6b 100644
--- a/src/components/NipsViewer.tsx
+++ b/src/components/NipsViewer.tsx
@@ -5,6 +5,7 @@ import { Input } from "@/components/ui/input";
import { Button } from "@/components/ui/button";
import { NIPBadge } from "./NIPBadge";
import { useGrimoire } from "@/core/state";
+import { CenteredContent } from "./ui/CenteredContent";
/**
* NipsViewer - Documentation introspection command
@@ -64,10 +65,9 @@ export default function NipsViewer() {
};
return (
-
-
- {/* Header */}
-
+
+ {/* Header */}
+
{search
? `Showing ${filteredNips.length} of ${sortedNips.length} NIPs`
@@ -122,7 +122,6 @@ export default function NipsViewer() {
Try searching for a different term
)}
-
-
+
);
}
diff --git a/src/components/ui/CenteredContent.tsx b/src/components/ui/CenteredContent.tsx
new file mode 100644
index 0000000..ffcf591
--- /dev/null
+++ b/src/components/ui/CenteredContent.tsx
@@ -0,0 +1,108 @@
+import { ReactNode } from "react";
+import { cn } from "@/lib/utils";
+
+interface CenteredContentProps {
+ children: ReactNode;
+ /**
+ * Maximum width of the centered content
+ * @default '3xl' (48rem / 768px)
+ */
+ maxWidth?: "sm" | "md" | "lg" | "xl" | "2xl" | "3xl" | "4xl" | "5xl" | "6xl" | "full";
+ /**
+ * Vertical spacing between child elements
+ * @default '6' (1.5rem)
+ */
+ spacing?: "0" | "1" | "2" | "3" | "4" | "5" | "6" | "8" | "10" | "12";
+ /**
+ * Padding around the content
+ * @default '6' (1.5rem)
+ */
+ padding?: "0" | "2" | "4" | "6" | "8";
+ /**
+ * Additional CSS classes for the inner content container
+ */
+ className?: string;
+}
+
+const maxWidthClasses = {
+ sm: "max-w-sm", // 24rem / 384px
+ md: "max-w-md", // 28rem / 448px
+ lg: "max-w-lg", // 32rem / 512px
+ xl: "max-w-xl", // 36rem / 576px
+ "2xl": "max-w-2xl", // 42rem / 672px
+ "3xl": "max-w-3xl", // 48rem / 768px - DEFAULT
+ "4xl": "max-w-4xl", // 56rem / 896px
+ "5xl": "max-w-5xl", // 64rem / 1024px
+ "6xl": "max-w-6xl", // 72rem / 1152px
+ full: "max-w-full", // No limit
+} as const;
+
+const spacingClasses = {
+ "0": "space-y-0",
+ "1": "space-y-1",
+ "2": "space-y-2",
+ "3": "space-y-3",
+ "4": "space-y-4",
+ "5": "space-y-5",
+ "6": "space-y-6", // DEFAULT
+ "8": "space-y-8",
+ "10": "space-y-10",
+ "12": "space-y-12",
+} as const;
+
+const paddingClasses = {
+ "0": "p-0",
+ "2": "p-2",
+ "4": "p-4",
+ "6": "p-6", // DEFAULT
+ "8": "p-8",
+} as const;
+
+/**
+ * CenteredContent - Reusable container for centered, max-width content
+ *
+ * Provides consistent layout pattern for documentation-style pages:
+ * - Centered content with configurable max-width
+ * - Consistent padding and spacing
+ * - Works with WindowRenderer's scroll container
+ *
+ * @example
+ * // Default (3xl width, 6 spacing, 6 padding)
+ *
+ * {content}
+ *
+ *
+ * @example
+ * // Man pages (wider, tighter spacing)
+ *
+ * {content}
+ *
+ *
+ * @example
+ * // No padding (rare)
+ *
+ * {content}
+ *
+ */
+export function CenteredContent({
+ children,
+ maxWidth = "3xl",
+ spacing = "6",
+ padding = "6",
+ className,
+}: CenteredContentProps) {
+ return (
+
+ );
+}
diff --git a/src/lib/layout-utils.test.ts b/src/lib/layout-utils.test.ts
index a9e0879..b12daab 100644
--- a/src/lib/layout-utils.test.ts
+++ b/src/lib/layout-utils.test.ts
@@ -2,7 +2,6 @@ import { describe, it, expect } from "vitest";
import type { MosaicNode } from "react-mosaic-component";
import {
analyzeLayoutStats,
- calculateSmartDirection,
insertWindow,
type LayoutStats,
} from "./layout-utils";
@@ -212,125 +211,6 @@ describe("analyzeLayoutStats", () => {
});
});
-describe("calculateSmartDirection", () => {
- describe("null and empty layouts", () => {
- it("should default to row for null layout", () => {
- const result = calculateSmartDirection(null);
- expect(result).toBe("row");
- });
-
- it("should default to row for single window", () => {
- const result = calculateSmartDirection("window-1");
- expect(result).toBe("row");
- });
- });
-
- describe("balanced layouts", () => {
- it("should return row when splits are equal", () => {
- // 1 row split, 1 column split - equal, default to row
- const layout: MosaicNode
= {
- direction: "row",
- first: {
- direction: "column",
- first: "window-1",
- second: "window-2",
- splitPercentage: 50,
- },
- second: "window-3",
- splitPercentage: 50,
- };
- const result = calculateSmartDirection(layout);
- expect(result).toBe("row");
- });
-
- it("should return row when no splits exist yet", () => {
- // Just two windows with one split
- const layout: MosaicNode = {
- direction: "row",
- first: "window-1",
- second: "window-2",
- splitPercentage: 50,
- };
- const result = calculateSmartDirection(layout);
- // 1 row split, 0 column splits -> row > column, should favor column
- expect(result).toBe("column");
- });
- });
-
- describe("unbalanced layouts", () => {
- it("should return column when more horizontal splits exist", () => {
- // 2 row splits, 0 column splits
- const layout: MosaicNode = {
- direction: "row",
- first: {
- direction: "row",
- first: "window-1",
- second: "window-2",
- splitPercentage: 50,
- },
- second: "window-3",
- splitPercentage: 50,
- };
- const result = calculateSmartDirection(layout);
- expect(result).toBe("column");
- });
-
- it("should return row when more vertical splits exist", () => {
- // 0 row splits, 2 column splits
- const layout: MosaicNode = {
- direction: "column",
- first: {
- direction: "column",
- first: "window-1",
- second: "window-2",
- splitPercentage: 50,
- },
- second: "window-3",
- splitPercentage: 50,
- };
- const result = calculateSmartDirection(layout);
- expect(result).toBe("row");
- });
-
- it("should favor column when significantly more horizontal splits", () => {
- // 5 row splits, 1 column split
- const layout: MosaicNode = {
- direction: "row",
- first: {
- direction: "row",
- first: {
- direction: "row",
- first: {
- direction: "row",
- first: {
- direction: "row",
- first: "w1",
- second: "w2",
- splitPercentage: 50,
- },
- second: "w3",
- splitPercentage: 50,
- },
- second: "w4",
- splitPercentage: 50,
- },
- second: {
- direction: "column",
- first: "w5",
- second: "w6",
- splitPercentage: 50,
- },
- splitPercentage: 50,
- },
- second: "w7",
- splitPercentage: 50,
- };
- const result = calculateSmartDirection(layout);
- expect(result).toBe("column");
- });
- });
-});
-
describe("insertWindow", () => {
describe("first window insertion", () => {
it("should return window ID for null layout", () => {
@@ -473,8 +353,16 @@ describe("insertWindow", () => {
insertionPosition: "second",
};
const result = insertWindow(existingLayout, "window-4", config);
- expect(result).toHaveProperty("direction", "column");
- expect(result).toHaveProperty("second", "window-4");
+
+ // NEW IMPROVED BEHAVIOR: Splits shallowest leaf (window-3 at depth 1)
+ // Parent direction is row, so rotates to column for the split
+ expect(result).toHaveProperty("direction", "row");
+
+ // window-3 should be replaced with [column: window-3 | window-4]
+ const secondNode = (result as any).second;
+ expect(secondNode).toHaveProperty("direction", "column");
+ expect(secondNode).toHaveProperty("first", "window-3");
+ expect(secondNode).toHaveProperty("second", "window-4");
});
it("should balance vertical splits by adding horizontal", () => {
@@ -496,8 +384,16 @@ describe("insertWindow", () => {
insertionPosition: "second",
};
const result = insertWindow(existingLayout, "window-4", config);
- expect(result).toHaveProperty("direction", "row");
- expect(result).toHaveProperty("second", "window-4");
+
+ // NEW IMPROVED BEHAVIOR: Splits shallowest leaf (window-3 at depth 1)
+ // Parent direction is column, so rotates to row for the split
+ expect(result).toHaveProperty("direction", "column");
+
+ // window-3 should be replaced with [row: window-3 | window-4]
+ const secondNode = (result as any).second;
+ expect(secondNode).toHaveProperty("direction", "row");
+ expect(secondNode).toHaveProperty("first", "window-3");
+ expect(secondNode).toHaveProperty("second", "window-4");
});
});
@@ -526,10 +422,20 @@ describe("insertWindow", () => {
insertionPosition: "second",
};
const result = insertWindow(quadLayout, "window-5", config);
- // More column splits than row, should add row
+
+ // NEW IMPROVED BEHAVIOR: All 4 windows at depth 2 (equal depth)
+ // Algorithm picks first shallowest leaf (window-1)
+ // Parent is column, so rotates to row for the split
expect(result).toHaveProperty("direction", "row");
- expect(result).toHaveProperty("first", quadLayout);
- expect(result).toHaveProperty("second", "window-5");
+
+ const firstNode = (result as any).first;
+ expect(firstNode).toHaveProperty("direction", "column");
+
+ // window-1 should be replaced with [row: window-1 | window-5]
+ const window1Split = firstNode.first;
+ expect(window1Split).toHaveProperty("direction", "row");
+ expect(window1Split).toHaveProperty("first", "window-1");
+ expect(window1Split).toHaveProperty("second", "window-5");
});
});
diff --git a/src/lib/layout-utils.ts b/src/lib/layout-utils.ts
index 5d9de07..450d89f 100644
--- a/src/lib/layout-utils.ts
+++ b/src/lib/layout-utils.ts
@@ -15,6 +15,18 @@ export interface LayoutStats {
windowCount: number;
}
+/**
+ * Information about a leaf node in the layout tree
+ */
+export interface LeafInfo {
+ /** The window ID (leaf node value) */
+ leafId: string;
+ /** Depth of this leaf in the tree (root = 0) */
+ depth: number;
+ /** Direction of the parent split (null if no parent) */
+ parentDirection: "row" | "column" | null;
+}
+
/**
* Analyzes the layout tree and returns statistics
* Used by smart direction algorithm to balance splits
@@ -50,31 +62,139 @@ export function analyzeLayoutStats(
}
/**
- * Calculates the optimal split direction to balance the layout tree
- * Returns 'column' if there are more horizontal splits (to balance)
- * Returns 'row' if there are more vertical splits or equal (default to horizontal)
+ * Finds all leaf nodes in the tree with their depth and parent direction
*/
-export function calculateSmartDirection(
- layout: MosaicNode | null
+export function findAllLeaves(
+ node: MosaicNode | null,
+ depth: number = 0,
+ parentDirection: "row" | "column" | null = null
+): LeafInfo[] {
+ if (node === null) {
+ return [];
+ }
+
+ // Leaf node - return info
+ if (typeof node === "string") {
+ return [{ leafId: node, depth, parentDirection }];
+ }
+
+ // Branch node - recursively find leaves in children
+ const leftLeaves = findAllLeaves(node.first, depth + 1, node.direction);
+ const rightLeaves = findAllLeaves(node.second, depth + 1, node.direction);
+
+ return [...leftLeaves, ...rightLeaves];
+}
+
+/**
+ * Finds the shallowest leaf in the tree (closest to root = largest screen space)
+ * If multiple leaves at same depth, returns first one encountered
+ */
+export function findShallowstLeaf(
+ node: MosaicNode | null
+): LeafInfo | null {
+ const leaves = findAllLeaves(node);
+
+ if (leaves.length === 0) return null;
+
+ // Find minimum depth
+ const minDepth = Math.min(...leaves.map((leaf) => leaf.depth));
+
+ // Return first leaf at minimum depth
+ return leaves.find((leaf) => leaf.depth === minDepth) || null;
+}
+
+/**
+ * Replaces a specific leaf node with a split containing the leaf + new window
+ *
+ * @param node - Current node in tree traversal
+ * @param targetLeafId - ID of leaf to replace
+ * @param newWindowId - ID of new window to insert
+ * @param direction - Direction of the new split
+ * @param splitPercentage - Split percentage for new split
+ * @param position - Where to place new window ('first' or 'second')
+ * @returns New tree with split at target leaf, or original tree if leaf not found
+ */
+export function replaceLeafWithSplit(
+ node: MosaicNode | null,
+ targetLeafId: string,
+ newWindowId: string,
+ direction: "row" | "column",
+ splitPercentage: number,
+ position: "first" | "second" = "second"
+): MosaicNode | null {
+ if (node === null) return null;
+
+ // Leaf node - check if this is our target
+ if (typeof node === "string") {
+ if (node === targetLeafId) {
+ // Found target! Replace with split
+ const [first, second] =
+ position === "first"
+ ? [newWindowId, targetLeafId] // New window first
+ : [targetLeafId, newWindowId]; // New window second (default)
+
+ return {
+ direction,
+ first,
+ second,
+ splitPercentage,
+ };
+ }
+ // Not our target, return unchanged
+ return node;
+ }
+
+ // Branch node - recursively check children
+ const newFirst = replaceLeafWithSplit(
+ node.first,
+ targetLeafId,
+ newWindowId,
+ direction,
+ splitPercentage,
+ position
+ );
+ const newSecond = replaceLeafWithSplit(
+ node.second,
+ targetLeafId,
+ newWindowId,
+ direction,
+ splitPercentage,
+ position
+ );
+
+ // Return new branch with potentially updated children
+ return {
+ direction: node.direction,
+ first: newFirst!,
+ second: newSecond!,
+ splitPercentage: node.splitPercentage,
+ };
+}
+
+/**
+ * Calculates split direction for balanced insertion
+ * Rotates parent direction 90° (row→column, column→row)
+ * This creates a checkerboard pattern for more balanced layouts
+ */
+export function calculateBalancedDirection(
+ parentDirection: "row" | "column" | null
): "row" | "column" {
- if (layout === null) {
+ if (parentDirection === null) {
return "row"; // Default to horizontal for first split
}
- const stats = analyzeLayoutStats(layout);
-
- // If more horizontal splits, add vertical to balance
- if (stats.rowSplits > stats.columnSplits) {
- return "column";
- }
-
- // Otherwise, default to horizontal (including when equal)
- return "row";
+ // Rotate 90 degrees
+ return parentDirection === "row" ? "column" : "row";
}
/**
* Inserts a new window into the layout tree according to the layout configuration
*
+ * Smart mode uses shallowest-leaf algorithm for balanced trees:
+ * - Finds the leaf node at minimum depth (approximates largest visual space)
+ * - Splits that leaf with direction rotated from parent (row→column, column→row)
+ * - Creates more balanced layouts than root-level wrapping
+ *
* @param currentLayout - The current layout tree (null if no windows yet)
* @param newWindowId - The ID of the new window to insert
* @param config - Layout configuration specifying how to insert the window
@@ -90,18 +210,46 @@ export function insertWindow(
return newWindowId;
}
- // Determine split direction based on insertion mode
- let direction: "row" | "column";
+ // Smart mode: Use improved shallowest-leaf algorithm
+ if (config.insertionMode === "smart") {
+ // Find shallowest leaf to split (largest screen space)
+ const leafInfo = findShallowstLeaf(currentLayout);
- if (config.insertionMode === "row") {
- direction = "row";
- } else if (config.insertionMode === "column") {
- direction = "column";
- } else {
- // smart mode - calculate balanced direction
- direction = calculateSmartDirection(currentLayout);
+ if (!leafInfo) {
+ // Shouldn't happen, but fallback to simple root insertion
+ console.warn("[Layout] No leaf found, falling back to root insertion");
+ return {
+ direction: "row",
+ first: currentLayout,
+ second: newWindowId,
+ splitPercentage: config.splitPercentage,
+ };
+ }
+
+ // Determine split direction by rotating parent's direction
+ const direction = calculateBalancedDirection(leafInfo.parentDirection);
+
+ // Replace that leaf with a split
+ const newLayout = replaceLeafWithSplit(
+ currentLayout,
+ leafInfo.leafId,
+ newWindowId,
+ direction,
+ config.splitPercentage,
+ config.insertionPosition
+ );
+
+ return newLayout || newWindowId; // Fallback if replacement failed
}
+ // Row/Column modes: Simple root-level wrapping (old behavior)
+ const direction =
+ config.insertionMode === "row"
+ ? "row"
+ : config.insertionMode === "column"
+ ? "column"
+ : "row";
+
// Determine which side gets the new window
const [firstNode, secondNode] =
config.insertionPosition === "first"