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 */}
{Icon && ( @@ -180,7 +181,7 @@ export default function KindRenderer({ kind }: { kind: number }) {
)} -
+ ); } diff --git a/src/components/KindsViewer.tsx b/src/components/KindsViewer.tsx index cbb344c..4927bb0 100644 --- a/src/components/KindsViewer.tsx +++ b/src/components/KindsViewer.tsx @@ -1,6 +1,7 @@ import { getKindInfo } from "@/constants/kinds"; import { kindRenderers } from "./nostr/kinds"; import { NIPBadge } from "./NIPBadge"; +import { CenteredContent } from "./ui/CenteredContent"; // Dynamically derive supported kinds from renderer registry const SUPPORTED_KINDS = Object.keys(kindRenderers).map(Number); @@ -14,10 +15,9 @@ export default function KindsViewer() { const sortedKinds = [...SUPPORTED_KINDS].sort((a, b) => a - b); 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 ( +
+
+ {children} +
+
+ ); +} 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"