diff --git a/design-system.md b/design-system.md new file mode 100644 index 0000000..94b8985 --- /dev/null +++ b/design-system.md @@ -0,0 +1,323 @@ +# Crabwalk Design System + +## Aesthetic Direction: **Terminal Control Panel** + +A command-line inspired interface that feels like operating a sophisticated monitoring system. Clean, precise, information-dense but not cluttered. The aesthetic borrows from terminal emulators, mission control panels, and developer tools—functional beauty. + +--- + +## Core Principles + +### 1. Information Hierarchy Through Typography +- **Labels**: Tiny, uppercase, muted (`text-[10px] text-shell-500 uppercase tracking-widest`) +- **Values**: Slightly larger, brighter (`text-xs text-gray-300`) +- **Key Metrics**: Accent colors with the display font (`text-sm text-neon-mint`) +- **Actions**: Console font, tracking-wider (`font-console tracking-wider`) + +### 2. Contained Modules +Every piece of information lives in a clearly bounded container: +- `bg-shell-900/95` or `bg-shell-800/50` backgrounds +- `border border-shell-700/80` borders +- `rounded-lg` corners (8px) +- `backdrop-blur-sm` for layered elements + +### 3. Status Through Color +| State | Color | CSS Class | +|-------|-------|-----------| +| Active/Connected | Mint | `text-neon-mint`, `bg-neon-mint/10` | +| Warning/Processing | Peach | `text-neon-peach`, `bg-neon-peach/10` | +| Error/Primary Action | Crab Red | `text-crab-400`, `bg-crab-500/15` | +| Inactive/Muted | Shell Gray | `text-shell-500`, `bg-shell-800` | + +### 4. Pulse Indicators +Small animated dots communicate live status: +```tsx + +``` + +### 5. Traffic Light Decorations +Decorative dots that reference classic window controls, add personality: +```tsx +
+
+
+
+
+``` + +--- + +## Color Palette + +### Primary: Crab Reds +```css +--color-crab-400: #f87171; /* Text accents */ +--color-crab-500: #ef4444; /* Primary actions */ +--color-crab-600: #dc2626; /* Buttons */ +--color-crab-900: #7f1d1d; /* Backgrounds */ +``` + +### Backgrounds: Shell Darks +```css +--color-shell-950: #0a0a0f; /* Page background */ +--color-shell-900: #12121a; /* Card backgrounds */ +--color-shell-800: #1a1a26; /* Input backgrounds */ +--color-shell-700: #252535; /* Borders */ +--color-shell-500: #52526e; /* Muted text */ +``` + +### Accents: Neon Status +```css +--color-neon-mint: #98ffc8; /* Success, active, connected */ +--color-neon-peach: #ffb088; /* Warning, processing */ +--color-neon-coral: #ff6b6b; /* Attention */ +--color-neon-cyan: #00ffff; /* Special highlights */ +``` + +--- + +## Typography + +### Font Stack +```css +--font-arcade: 'Press Start 2P'; /* Headlines only, sparingly */ +--font-console: 'JetBrains Mono'; /* Primary UI font */ +``` + +### Type Scale +| Use Case | Size | Weight | Tracking | +|----------|------|--------|----------| +| Micro labels | `text-[9px]` | Regular | `tracking-widest` | +| Labels | `text-[10px]` | Regular | `tracking-widest` | +| Body/UI | `text-xs` (12px) | Regular | `tracking-wider` | +| Values | `text-sm` (14px) | Medium | Default | +| Headers | `text-base` (16px) | Semibold | Default | + +--- + +## Component Patterns + +### Status Pill +```tsx +
+ + + {active ? 'CONNECTED' : 'DISCONNECTED'} + +
+``` + +### Stat Block +```tsx +
+ + Sessions + + + {count} + +
+``` + +### Keyboard Shortcut Badge +```tsx + + ALT + +``` + +### Section Divider (Vertical) +```tsx +
+``` + +### Icon Container +```tsx +
+ +
+``` + +### Input Field +```tsx +
+ + +
+``` + +### Action Button (Primary) +```tsx + +``` + +### Action Button (Ghost) +```tsx + +``` + +--- + +## Animation Guidelines + +### Timing Functions +- **Snappy interactions**: `duration-150` +- **Smooth transitions**: `duration-200` +- **Entrance animations**: `duration-300` + +### Motion Presets +```tsx +// Panel entrance +initial={{ opacity: 0, y: -10, scale: 0.95 }} +animate={{ opacity: 1, y: 0, scale: 1 }} +transition={{ duration: 0.2, ease: [0.23, 1, 0.32, 1] }} + +// Fade backdrop +initial={{ opacity: 0 }} +animate={{ opacity: 1 }} +transition={{ duration: 0.15 }} + +// Staggered list items +transition={{ duration: 0.2, delay: index * 0.05 }} + +// Rotating loader +animate={{ rotate: 360 }} +transition={{ duration: 2, repeat: Infinity, ease: 'linear' }} +``` + +--- + +## Layout Patterns + +### Header Structure +``` +┌─────────────────────────────────────────────────────────────┐ +│ [Nav Trigger] [Context Controls] [Stats] [Actions] │ +└─────────────────────────────────────────────────────────────┘ +``` + +- Nav trigger: Fixed position overlay (z-50) +- Header: Sticky, semi-transparent (`bg-shell-900/80 backdrop-blur-sm`) +- Left side: Spacer for nav + page-specific context +- Right side: Stats, status indicators, action buttons + +### Z-Index Layers +| Layer | Z-Index | Use | +|-------|---------|-----| +| Base content | 0 | Main content area | +| In-page controls | 10 | Graph controls, sticky headers | +| Header | 30 | App header bar | +| Toolbar | 40 | Mobile bottom toolbar | +| Navigation backdrop | 40 | CommandNav backdrop | +| Navigation/drawers | 50 | CommandNav panel, mobile drawers | +| Settings backdrop | 60 | Settings panel backdrop | +| Settings panel | 70 | Settings panel (top layer) | + +--- + +## Iconography + +Use **Lucide React** icons exclusively. Preferred sizes: +- Inline with text: `14px` +- Standalone buttons: `16-18px` +- Feature icons: `20px` +- Loading states: `20px` + +Icon style: Stroke-based, 2px stroke width (Lucide default). + +--- + +## Responsive Behavior + +### Breakpoints +- `sm:` (640px) - Mobile/desktop split +- Headers hide on mobile, replaced by bottom toolbar +- Stats/secondary info hidden on mobile + +### Mobile Patterns +- Bottom fixed toolbar for primary actions +- Slide-up sheets for forms +- Slide-in drawers for navigation lists +- Larger touch targets (min 44x44px) + +--- + +## Loading States + +### Page Loading +Geometric spinner with context icon: +```tsx +
+ +
+ +
+
+``` + +### Inline Loading +```tsx + +``` + +### Connection Retry +```tsx +
+ + + retrying ({count}/{max})... + +
+``` + +--- + +## Do's and Don'ts + +### Do +- Use monospace font for all UI text +- Keep labels uppercase and tiny +- Use color to indicate state, not decoration +- Add subtle borders to define boundaries +- Include keyboard shortcuts for power users +- Use backdrop blur for layered elements + +### Don't +- Use large, bold headers +- Add decorative gradients without purpose +- Use more than 2-3 accent colors per view +- Animate everything—be selective +- Use rounded-full except for status dots +- Mix different font families + +--- + +## File Organization + +``` +src/ +├── components/ +│ ├── navigation/ # Global nav system +│ ├── monitor/ # Monitor-specific components +│ ├── workspace/ # Workspace-specific components +│ └── ui/ # Shared UI primitives (future) +├── hooks/ +│ └── useIsMobile.ts # Responsive detection +└── styles.css # Global styles & CSS variables +``` diff --git a/src/components/layout/AppHeader.tsx b/src/components/layout/AppHeader.tsx new file mode 100644 index 0000000..49c57c2 --- /dev/null +++ b/src/components/layout/AppHeader.tsx @@ -0,0 +1,326 @@ +import { motion } from 'framer-motion' +import { Loader2 } from 'lucide-react' +import { ReactNode } from 'react' + +interface AppHeaderProps { + /** Left side content - appears after nav spacer */ + left?: ReactNode + /** Center content - typically page-specific controls */ + center?: ReactNode + /** Right side content - stats, actions, settings */ + right?: ReactNode + /** Whether header is visible (hidden on mobile) */ + hiddenOnMobile?: boolean +} + +export function AppHeader({ left, center, right, hiddenOnMobile = true }: AppHeaderProps) { + return ( +
+ {/* Gradient accent line */} +
+ + {/* Left section */} +
+ {/* Spacer for nav button (accounts for fixed position nav at top-4 left-4) */} +
+ {left} +
+ + {/* Center section */} + {center &&
{center}
} + + {/* Right section */} + {right &&
{right}
} +
+ ) +} + +// ============================================================================ +// Header Building Blocks +// ============================================================================ + +/** Status pill showing connection or active state */ +interface StatusPillProps { + status: 'connected' | 'connecting' | 'disconnected' | 'active' | 'inactive' + label?: string +} + +export function StatusPill({ status, label }: StatusPillProps) { + const config = { + connected: { + bg: 'bg-neon-mint/10 border-neon-mint/30', + dot: 'bg-neon-mint animate-pulse', + text: 'text-neon-mint', + defaultLabel: 'CONNECTED', + }, + active: { + bg: 'bg-neon-mint/10 border-neon-mint/30', + dot: 'bg-neon-mint animate-pulse', + text: 'text-neon-mint', + defaultLabel: 'ACTIVE', + }, + connecting: { + bg: 'bg-neon-peach/10 border-neon-peach/30', + dot: 'bg-neon-peach animate-pulse', + text: 'text-neon-peach', + defaultLabel: 'CONNECTING', + }, + disconnected: { + bg: 'bg-shell-800/50 border-shell-700', + dot: 'bg-shell-600', + text: 'text-shell-500', + defaultLabel: 'DISCONNECTED', + }, + inactive: { + bg: 'bg-shell-800/50 border-shell-700', + dot: 'bg-shell-600', + text: 'text-shell-500', + defaultLabel: 'INACTIVE', + }, + } + + const c = config[status] + + return ( +
+ + + {label || c.defaultLabel} + +
+ ) +} + +/** Stat block showing a label and value */ +interface StatBlockProps { + label: string + value: string | number + color?: 'mint' | 'peach' | 'coral' | 'default' +} + +export function StatBlock({ label, value, color = 'default' }: StatBlockProps) { + const colorClass = { + mint: 'text-neon-mint', + peach: 'text-neon-peach', + coral: 'text-neon-coral', + default: 'text-gray-300', + }[color] + + return ( +
+ + {label} + + {value} +
+ ) +} + +/** Stats container with dividers */ +export function StatsGroup({ children }: { children: ReactNode }) { + return ( +
+ {children} +
+ ) +} + +/** Vertical divider for stats */ +export function StatsDivider() { + return
+} + +/** Icon button with hover states */ +interface IconButtonProps { + icon: ReactNode + onClick?: () => void + disabled?: boolean + loading?: boolean + title?: string + active?: boolean + variant?: 'ghost' | 'subtle' +} + +export function IconButton({ + icon, + onClick, + disabled, + loading, + title, + active, + variant = 'ghost', +}: IconButtonProps) { + const baseStyles = 'p-2 rounded-lg transition-all group' + const variantStyles = { + ghost: `hover:bg-shell-800 border border-transparent hover:border-shell-600 ${ + active ? 'bg-shell-800 border-shell-600' : '' + }`, + subtle: `bg-shell-800/50 hover:bg-shell-700/50 border border-shell-700/50 hover:border-shell-600 ${ + active ? 'bg-crab-500/10 border-crab-500/30' : '' + }`, + } + + return ( + + ) +} + +/** Badge counter for notifications/counts */ +interface BadgeCounterProps { + count: number + onClick?: () => void + icon: ReactNode + title?: string +} + +export function BadgeCounter({ count, onClick, icon, title }: BadgeCounterProps) { + if (count === 0) return null + + return ( + + ) +} + +/** Service indicator (like persistence) */ +interface ServiceIndicatorProps { + active: boolean + icon: ReactNode + onClick?: () => void + title?: string +} + +export function ServiceIndicator({ active, icon, onClick, title }: ServiceIndicatorProps) { + return ( + + ) +} + +/** Retry indicator for connection attempts */ +interface RetryIndicatorProps { + retryCount: number + maxRetries: number +} + +export function RetryIndicator({ retryCount, maxRetries }: RetryIndicatorProps) { + return ( + + + + RETRY {retryCount}/{maxRetries} + + + ) +} + +/** Path input field styled for the header */ +interface PathInputProps { + value: string + onChange: (value: string) => void + onSubmit: () => void + placeholder?: string + icon: ReactNode + submitLabel?: string + submitDisabled?: boolean + error?: string | null +} + +export function PathInput({ + value, + onChange, + onSubmit, + placeholder, + icon, + submitLabel = 'Open', + submitDisabled, + error, +}: PathInputProps) { + const handleKeyDown = (e: React.KeyboardEvent) => { + if (e.key === 'Enter') { + onSubmit() + } + } + + return ( +
+
+
+ {icon} +
+ onChange(e.target.value)} + onKeyDown={handleKeyDown} + placeholder={placeholder} + className="w-full bg-shell-800 border border-shell-700 rounded-lg pl-9 pr-3 py-2 text-sm font-console text-gray-200 placeholder-shell-500 focus:outline-none focus:border-crab-500 focus:ring-1 focus:ring-crab-500/20 transition-colors" + /> +
+ + + {error && ( + +
+ {error} + + )} +
+ ) +} diff --git a/src/components/layout/index.ts b/src/components/layout/index.ts new file mode 100644 index 0000000..190180e --- /dev/null +++ b/src/components/layout/index.ts @@ -0,0 +1,12 @@ +export { + AppHeader, + StatusPill, + StatBlock, + StatsGroup, + StatsDivider, + IconButton, + BadgeCounter, + ServiceIndicator, + RetryIndicator, + PathInput, +} from './AppHeader' diff --git a/src/components/monitor/SettingsPanel.tsx b/src/components/monitor/SettingsPanel.tsx index fee7808..cb59d1c 100644 --- a/src/components/monitor/SettingsPanel.tsx +++ b/src/components/monitor/SettingsPanel.tsx @@ -25,6 +25,8 @@ interface SettingsPanelProps { onPersistenceStart: () => void onPersistenceStop: () => void onPersistenceClear: () => void + /** Hide the built-in trigger button (use when providing external trigger) */ + hideTrigger?: boolean } export function SettingsPanel({ @@ -50,16 +52,19 @@ export function SettingsPanel({ onPersistenceStart, onPersistenceStop, onPersistenceClear, + hideTrigger, }: SettingsPanelProps) { return ( <> - + {!hideTrigger && ( + + )} {open && ( @@ -70,7 +75,7 @@ export function SettingsPanel({ animate={{ opacity: 1 }} exit={{ opacity: 0 }} onClick={() => onOpenChange(false)} - className="fixed inset-0 bg-black/60 backdrop-blur-sm z-40" + className="fixed inset-0 bg-black/60 backdrop-blur-sm z-[60]" /> {/* Panel */} @@ -79,7 +84,7 @@ export function SettingsPanel({ animate={{ opacity: 1, x: 0 }} exit={{ opacity: 0, x: 300 }} transition={{ type: 'spring', damping: 30, stiffness: 300 }} - className="fixed right-0 top-0 h-full w-80 bg-shell-900 z-50 p-5 overflow-y-auto" + className="fixed right-0 top-0 h-full w-80 bg-shell-900 z-[70] p-5 overflow-y-auto" > {/* Texture overlay */}
diff --git a/src/components/workspace/FileTree.tsx b/src/components/workspace/FileTree.tsx index 7ff6fa6..fde2bb2 100644 --- a/src/components/workspace/FileTree.tsx +++ b/src/components/workspace/FileTree.tsx @@ -124,14 +124,14 @@ function FileTreeItem({ entry, selectedPath, onSelect, onLoadDirectory, level }: {/* Icon */} {isDirectory ? ( expanded ? ( - + ) : ( - + ) ) : ( @@ -148,7 +148,7 @@ function FileTreeItem({ entry, selectedPath, onSelect, onLoadDirectory, level }: {/* Metadata for files */} {!isDirectory && ( - + {entry.size !== undefined && formatFileSize(entry.size)} )} diff --git a/src/routes/monitor/index.tsx b/src/routes/monitor/index.tsx index 6f58364..dda4edf 100644 --- a/src/routes/monitor/index.tsx +++ b/src/routes/monitor/index.tsx @@ -1,10 +1,20 @@ import { useState, useEffect, useCallback, useMemo } from 'react' import { createFileRoute } from '@tanstack/react-router' import { useLiveQuery } from '@tanstack/react-db' -import { motion } from 'framer-motion' -import { Activity, Loader2, HardDrive, Trash2 } from 'lucide-react' +import { motion, AnimatePresence } from 'framer-motion' +import { Activity, HardDrive, Trash2, Settings } from 'lucide-react' import { trpc } from '~/integrations/trpc/client' import { CommandNav } from '~/components/navigation' +import { + AppHeader, + StatusPill, + StatBlock, + StatsGroup, + StatsDivider, + BadgeCounter, + ServiceIndicator, + RetryIndicator, +} from '~/components/layout' import { sessionsCollection, actionsCollection, @@ -21,7 +31,6 @@ import { ActionGraph, SessionList, SettingsPanel, - StatusIndicator, } from '~/components/monitor' export const Route = createFileRoute('/monitor/')({ @@ -374,120 +383,55 @@ function MonitorPage() { {/* Header */} -
- {/* Gradient accent */} -
- - {/* Spacer for nav + connection status */} -
-
- - {/* Connection status pill */} -
- - - {connected ? 'CONNECTED' : connecting ? 'CONNECTING' : 'DISCONNECTED'} - -
-
- -
- {connecting && ( - - - - {retryCount > 0 ? `retrying (${retryCount}/${MAX_RETRIES})...` : 'connecting...'} - - - )} - - {/* Clear Completed button */} - {completedCount > 0 && ( - - )} - - {/* Persistence indicator */} - - {/* Stats display */} -
-
- Sessions - {sessions.length} -
-
-
- Actions - {actions.length} -
-
+ {/* Persistence service */} + } + onClick={() => setSettingsOpen(true)} + title={persistenceEnabled ? 'Background service running' : 'Service stopped'} + /> - -
-
+ {/* Stats */} + + + + + + + {/* Settings trigger */} + + + } + /> {/* Main content */}
@@ -511,6 +455,33 @@ function MonitorPage() { />
+ + {/* Settings panel - rendered at root level to avoid z-index clipping */} +
) } diff --git a/src/routes/workspace/index.tsx b/src/routes/workspace/index.tsx index d39a4c3..b188345 100644 --- a/src/routes/workspace/index.tsx +++ b/src/routes/workspace/index.tsx @@ -5,7 +5,6 @@ import { FolderOpen, FolderTree, RefreshCw, - AlertCircle, PanelLeft, PanelLeftClose, Star, @@ -13,8 +12,9 @@ import { } from 'lucide-react' import { trpc } from '~/integrations/trpc/client' import { CommandNav } from '~/components/navigation' +import { AppHeader, StatusPill, IconButton, PathInput } from '~/components/layout' import { - FileTree, + FileTree as FileTreeComponent, MarkdownViewer, MobileBottomToolbar, MobileFileDrawer, @@ -340,12 +340,6 @@ function WorkspacePage() { } }, [workspacePath, pathValid, selectedPath, loadFile]) - const handleKeyDown = (e: React.KeyboardEvent) => { - if (e.key === 'Enter') { - validateAndSetPath() - } - } - // Handle starring/unstarring files const handleStar = useCallback((filePath: string) => { setStarredPaths((prev) => { @@ -368,62 +362,34 @@ function WorkspacePage() { {/* Global navigation */} - {/* Header - path controls */} -
- {/* Gradient accent */} -
- - {/* Spacer for nav button */} -
- - {/* Path input - centered */} -
-
- - setWorkspacePathInput(e.target.value)} - onKeyDown={handleKeyDown} - placeholder="Enter workspace path..." - className="w-full bg-shell-800 border border-shell-700 rounded-lg pl-9 pr-3 py-1.5 text-sm font-console text-gray-200 placeholder-shell-500 focus:outline-none focus:border-crab-500 focus:ring-1 focus:ring-crab-500/20" - /> -
- - - {pathError && ( -
- - {pathError} -
- )} -
- - {/* Refresh button */} -
- -
-
+ title="Refresh workspace" + /> + } + /> {/* Main content */}
@@ -518,7 +484,7 @@ function WorkspacePage() { > @@ -530,7 +496,7 @@ function WorkspacePage() { e.stopPropagation() handleStar(filePath) }} - className="text-yellow-400 hover:text-yellow-300 flex-shrink-0" + className="text-yellow-400 hover:text-yellow-300 shrink-0" title="Unstar file" > @@ -547,7 +513,7 @@ function WorkspacePage() { {!sidebarCollapsed && (
{pathValid ? ( -