mirror of
https://github.com/crabwise-ai/crabwalk.git
synced 2026-08-14 00:57:52 +00:00
feat: introduce Crabwalk Design System and enhance layout components
- Added a comprehensive design system document outlining aesthetic direction, core principles, color palette, typography, and component patterns. - Implemented AppHeader and various UI components (StatusPill, StatBlock, IconButton, etc.) to support the new design system. - Updated SettingsPanel to conditionally hide the trigger button for improved flexibility. - Enhanced FileTree component with refined class names for better styling consistency.
This commit is contained in:
@@ -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
|
||||
<span className="w-1.5 h-1.5 rounded-full bg-neon-mint animate-pulse" />
|
||||
```
|
||||
|
||||
### 5. Traffic Light Decorations
|
||||
Decorative dots that reference classic window controls, add personality:
|
||||
```tsx
|
||||
<div className="flex items-center gap-1.5">
|
||||
<div className="w-2 h-2 rounded-full bg-crab-500/80" />
|
||||
<div className="w-2 h-2 rounded-full bg-neon-peach/80" />
|
||||
<div className="w-2 h-2 rounded-full bg-neon-mint/80" />
|
||||
</div>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 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
|
||||
<div className={`flex items-center gap-2 px-3 py-1.5 rounded-lg border ${
|
||||
active
|
||||
? 'bg-neon-mint/10 border-neon-mint/30'
|
||||
: 'bg-shell-800/50 border-shell-700'
|
||||
}`}>
|
||||
<span className={`w-1.5 h-1.5 rounded-full ${
|
||||
active ? 'bg-neon-mint animate-pulse' : 'bg-shell-600'
|
||||
}`} />
|
||||
<span className={`font-console text-xs ${
|
||||
active ? 'text-neon-mint' : 'text-shell-500'
|
||||
}`}>
|
||||
{active ? 'CONNECTED' : 'DISCONNECTED'}
|
||||
</span>
|
||||
</div>
|
||||
```
|
||||
|
||||
### Stat Block
|
||||
```tsx
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-console text-[10px] text-shell-500 uppercase">
|
||||
Sessions
|
||||
</span>
|
||||
<span className="font-console text-sm text-neon-mint">
|
||||
{count}
|
||||
</span>
|
||||
</div>
|
||||
```
|
||||
|
||||
### Keyboard Shortcut Badge
|
||||
```tsx
|
||||
<kbd className="px-1.5 py-0.5 bg-shell-800 border border-shell-700 rounded text-[9px] font-console text-shell-500">
|
||||
ALT
|
||||
</kbd>
|
||||
```
|
||||
|
||||
### Section Divider (Vertical)
|
||||
```tsx
|
||||
<div className="w-px h-4 bg-shell-700" />
|
||||
```
|
||||
|
||||
### Icon Container
|
||||
```tsx
|
||||
<div className="p-1.5 rounded-md bg-shell-800 text-shell-500">
|
||||
<Icon size={14} />
|
||||
</div>
|
||||
```
|
||||
|
||||
### Input Field
|
||||
```tsx
|
||||
<div className="relative">
|
||||
<Icon className="absolute left-3 top-1/2 -translate-y-1/2 text-shell-500" size={16} />
|
||||
<input 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" />
|
||||
</div>
|
||||
```
|
||||
|
||||
### Action Button (Primary)
|
||||
```tsx
|
||||
<button className="px-3 py-1.5 bg-crab-600 hover:bg-crab-500 text-white text-sm font-console rounded-lg transition-colors">
|
||||
Action
|
||||
</button>
|
||||
```
|
||||
|
||||
### Action Button (Ghost)
|
||||
```tsx
|
||||
<button className="p-2 hover:bg-shell-800 rounded-lg transition-colors border border-transparent hover:border-shell-600 group">
|
||||
<Icon size={16} className="text-shell-400 group-hover:text-crab-400" />
|
||||
</button>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 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
|
||||
<div className="relative w-16 h-16">
|
||||
<motion.div
|
||||
animate={{ rotate: 360 }}
|
||||
transition={{ duration: 2, repeat: Infinity, ease: 'linear' }}
|
||||
className="absolute inset-0 border-2 border-shell-700 border-t-crab-500 rounded-lg"
|
||||
/>
|
||||
<div className="absolute inset-2 bg-shell-900 rounded flex items-center justify-center">
|
||||
<ContextIcon size={20} className="text-crab-400" />
|
||||
</div>
|
||||
</div>
|
||||
```
|
||||
|
||||
### Inline Loading
|
||||
```tsx
|
||||
<RefreshCw size={14} className="animate-spin text-shell-500" />
|
||||
```
|
||||
|
||||
### Connection Retry
|
||||
```tsx
|
||||
<div className="flex items-center gap-2">
|
||||
<Loader2 size={14} className="animate-spin text-neon-peach" />
|
||||
<span className="font-console text-xs text-shell-400">
|
||||
retrying ({count}/{max})...
|
||||
</span>
|
||||
</div>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 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
|
||||
```
|
||||
@@ -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 (
|
||||
<header
|
||||
className={`${hiddenOnMobile ? 'hidden sm:flex' : 'flex'} items-center justify-between px-4 py-3 bg-shell-900/80 backdrop-blur-sm relative z-30`}
|
||||
>
|
||||
{/* Gradient accent line */}
|
||||
<div className="absolute inset-x-0 bottom-0 h-px bg-linear-to-r from-transparent via-shell-700/50 to-transparent" />
|
||||
|
||||
{/* Left section */}
|
||||
<div className="relative flex items-center gap-4">
|
||||
{/* Spacer for nav button (accounts for fixed position nav at top-4 left-4) */}
|
||||
<div className="w-56 sm:w-64" />
|
||||
{left}
|
||||
</div>
|
||||
|
||||
{/* Center section */}
|
||||
{center && <div className="flex-1 flex justify-center max-w-2xl mx-4">{center}</div>}
|
||||
|
||||
{/* Right section */}
|
||||
{right && <div className="relative flex items-center gap-3">{right}</div>}
|
||||
</header>
|
||||
)
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// 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 (
|
||||
<div className={`flex items-center gap-2 px-3 py-1.5 rounded-lg border ${c.bg}`}>
|
||||
<span className={`w-1.5 h-1.5 rounded-full ${c.dot}`} />
|
||||
<span className={`font-console text-[10px] tracking-wider ${c.text}`}>
|
||||
{label || c.defaultLabel}
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/** 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 (
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-console text-[10px] text-shell-500 uppercase tracking-wider">
|
||||
{label}
|
||||
</span>
|
||||
<span className={`font-console text-sm ${colorClass}`}>{value}</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/** Stats container with dividers */
|
||||
export function StatsGroup({ children }: { children: ReactNode }) {
|
||||
return (
|
||||
<div className="hidden sm:flex items-center gap-3 px-3 py-1.5 bg-shell-800/50 border border-shell-700/50 rounded-lg">
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/** Vertical divider for stats */
|
||||
export function StatsDivider() {
|
||||
return <div className="w-px h-4 bg-shell-700" />
|
||||
}
|
||||
|
||||
/** 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 (
|
||||
<button
|
||||
onClick={onClick}
|
||||
disabled={disabled || loading}
|
||||
title={title}
|
||||
className={`${baseStyles} ${variantStyles[variant]} disabled:opacity-50 disabled:cursor-not-allowed`}
|
||||
>
|
||||
{loading ? (
|
||||
<Loader2 size={16} className="animate-spin text-shell-400" />
|
||||
) : (
|
||||
<div
|
||||
className={`text-shell-400 group-hover:text-crab-400 transition-colors ${
|
||||
active ? 'text-crab-400' : ''
|
||||
}`}
|
||||
>
|
||||
{icon}
|
||||
</div>
|
||||
)}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
/** 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 (
|
||||
<button
|
||||
onClick={onClick}
|
||||
className="flex items-center gap-2 px-3 py-1.5 rounded-lg transition-all bg-shell-800/50 hover:bg-crab-900/50 hover:border-crab-700/50 border border-shell-700/50 group"
|
||||
title={title}
|
||||
>
|
||||
<div className="text-shell-400 group-hover:text-crab-400 transition-colors">{icon}</div>
|
||||
<span className="font-console text-xs text-shell-400 group-hover:text-crab-400 transition-colors">
|
||||
{count}
|
||||
</span>
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
/** Service indicator (like persistence) */
|
||||
interface ServiceIndicatorProps {
|
||||
active: boolean
|
||||
icon: ReactNode
|
||||
onClick?: () => void
|
||||
title?: string
|
||||
}
|
||||
|
||||
export function ServiceIndicator({ active, icon, onClick, title }: ServiceIndicatorProps) {
|
||||
return (
|
||||
<button
|
||||
onClick={onClick}
|
||||
className={`flex items-center gap-2 px-3 py-1.5 rounded-lg transition-all border ${
|
||||
active
|
||||
? 'bg-neon-mint/10 border-neon-mint/30 hover:bg-neon-mint/20'
|
||||
: 'bg-shell-800/50 border-shell-700/50 hover:bg-shell-700/50'
|
||||
}`}
|
||||
title={title}
|
||||
>
|
||||
<div className={active ? 'text-neon-mint' : 'text-shell-500'}>{icon}</div>
|
||||
{active && <span className="w-1.5 h-1.5 rounded-full bg-neon-mint animate-pulse" />}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
/** Retry indicator for connection attempts */
|
||||
interface RetryIndicatorProps {
|
||||
retryCount: number
|
||||
maxRetries: number
|
||||
}
|
||||
|
||||
export function RetryIndicator({ retryCount, maxRetries }: RetryIndicatorProps) {
|
||||
return (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, x: -10 }}
|
||||
animate={{ opacity: 1, x: 0 }}
|
||||
exit={{ opacity: 0, x: -10 }}
|
||||
className="flex items-center gap-2"
|
||||
>
|
||||
<Loader2 size={14} className="animate-spin text-neon-peach" />
|
||||
<span className="font-console text-[10px] text-shell-400 tracking-wider">
|
||||
RETRY {retryCount}/{maxRetries}
|
||||
</span>
|
||||
</motion.div>
|
||||
)
|
||||
}
|
||||
|
||||
/** 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 (
|
||||
<div className="flex items-center gap-2 flex-1 relative">
|
||||
<div className="flex-1 relative">
|
||||
<div className="absolute left-3 top-1/2 -translate-y-1/2 text-shell-500 pointer-events-none">
|
||||
{icon}
|
||||
</div>
|
||||
<input
|
||||
type="text"
|
||||
value={value}
|
||||
onChange={(e) => 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"
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
onClick={onSubmit}
|
||||
disabled={submitDisabled}
|
||||
className={`px-4 py-2 text-sm font-console tracking-wider rounded-lg transition-colors shrink-0 ${
|
||||
submitDisabled
|
||||
? 'bg-shell-800 text-shell-500 cursor-default border border-shell-700'
|
||||
: 'bg-crab-600 hover:bg-crab-500 text-white border border-crab-500'
|
||||
}`}
|
||||
>
|
||||
{submitLabel}
|
||||
</button>
|
||||
|
||||
{error && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: -5 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
className="absolute top-full left-0 right-0 mt-2 px-3 py-2 bg-crab-900/95 border border-crab-700 rounded-lg flex items-center gap-2 z-50 backdrop-blur-sm"
|
||||
>
|
||||
<div className="w-1.5 h-1.5 rounded-full bg-crab-400" />
|
||||
<span className="text-xs text-crab-200 font-console">{error}</span>
|
||||
</motion.div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
export {
|
||||
AppHeader,
|
||||
StatusPill,
|
||||
StatBlock,
|
||||
StatsGroup,
|
||||
StatsDivider,
|
||||
IconButton,
|
||||
BadgeCounter,
|
||||
ServiceIndicator,
|
||||
RetryIndicator,
|
||||
PathInput,
|
||||
} from './AppHeader'
|
||||
@@ -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 (
|
||||
<>
|
||||
<button
|
||||
onClick={() => onOpenChange(true)}
|
||||
className="p-2 bg-shell-800 hover:bg-shell-700 rounded-lg transition-all group"
|
||||
>
|
||||
<Settings size={14} className="text-gray-400 group-hover:text-crab-400 transition-colors" />
|
||||
</button>
|
||||
{!hideTrigger && (
|
||||
<button
|
||||
onClick={() => onOpenChange(true)}
|
||||
className="p-2 bg-shell-800 hover:bg-shell-700 rounded-lg transition-all group"
|
||||
>
|
||||
<Settings size={14} className="text-gray-400 group-hover:text-crab-400 transition-colors" />
|
||||
</button>
|
||||
)}
|
||||
|
||||
<AnimatePresence>
|
||||
{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 */}
|
||||
<div className="absolute inset-0 texture-scanlines pointer-events-none opacity-30" />
|
||||
|
||||
@@ -124,14 +124,14 @@ function FileTreeItem({ entry, selectedPath, onSelect, onLoadDirectory, level }:
|
||||
{/* Icon */}
|
||||
{isDirectory ? (
|
||||
expanded ? (
|
||||
<FolderOpen size={16} className="text-neon-mint flex-shrink-0" />
|
||||
<FolderOpen size={16} className="text-neon-mint shrink-0" />
|
||||
) : (
|
||||
<Folder size={16} className="text-neon-mint flex-shrink-0" />
|
||||
<Folder size={16} className="text-neon-mint shrink-0" />
|
||||
)
|
||||
) : (
|
||||
<FileText
|
||||
size={16}
|
||||
className={`flex-shrink-0 ${
|
||||
className={`shrink-0 ${
|
||||
entry.extension === '.md' ? 'text-crab-400' : 'text-shell-500'
|
||||
}`}
|
||||
/>
|
||||
@@ -148,7 +148,7 @@ function FileTreeItem({ entry, selectedPath, onSelect, onLoadDirectory, level }:
|
||||
|
||||
{/* Metadata for files */}
|
||||
{!isDirectory && (
|
||||
<span className="font-console text-[10px] text-shell-600 flex-shrink-0">
|
||||
<span className="font-console text-[10px] text-shell-600 shrink-0">
|
||||
{entry.size !== undefined && formatFileSize(entry.size)}
|
||||
</span>
|
||||
)}
|
||||
|
||||
+84
-113
@@ -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() {
|
||||
<CommandNav />
|
||||
|
||||
{/* Header */}
|
||||
<header className="flex items-center justify-between px-4 py-3 bg-shell-900/80 backdrop-blur-sm relative z-30">
|
||||
{/* Gradient accent */}
|
||||
<div className="absolute inset-0 bg-linear-to-r from-crab-950/20 via-transparent to-transparent pointer-events-none" />
|
||||
|
||||
{/* Spacer for nav + connection status */}
|
||||
<div className="relative flex items-center gap-4">
|
||||
<div className="w-48" />
|
||||
|
||||
{/* Connection status pill */}
|
||||
<div className={`flex items-center gap-2 px-3 py-1.5 rounded-lg border ${
|
||||
connected
|
||||
? 'bg-neon-mint/10 border-neon-mint/30'
|
||||
: connecting
|
||||
? 'bg-neon-peach/10 border-neon-peach/30'
|
||||
: 'bg-shell-800/50 border-shell-700'
|
||||
}`}>
|
||||
<StatusIndicator status={connecting ? 'thinking' : connected ? 'active' : 'idle'} />
|
||||
<span className={`font-console text-xs ${
|
||||
connected ? 'text-neon-mint' : connecting ? 'text-neon-peach' : 'text-shell-500'
|
||||
}`}>
|
||||
{connected ? 'CONNECTED' : connecting ? 'CONNECTING' : 'DISCONNECTED'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="relative flex items-center gap-4">
|
||||
{connecting && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
className="flex items-center gap-2"
|
||||
>
|
||||
<Loader2 size={14} className="animate-spin text-neon-peach" />
|
||||
<span className="font-console text-xs text-shell-400">
|
||||
{retryCount > 0 ? `retrying (${retryCount}/${MAX_RETRIES})...` : 'connecting...'}
|
||||
</span>
|
||||
</motion.div>
|
||||
)}
|
||||
|
||||
{/* Clear Completed button */}
|
||||
{completedCount > 0 && (
|
||||
<button
|
||||
<AppHeader
|
||||
hiddenOnMobile={false}
|
||||
left={
|
||||
<>
|
||||
<StatusPill
|
||||
status={connected ? 'connected' : connecting ? 'connecting' : 'disconnected'}
|
||||
/>
|
||||
<AnimatePresence>
|
||||
{connecting && retryCount > 0 && (
|
||||
<RetryIndicator retryCount={retryCount} maxRetries={MAX_RETRIES} />
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</>
|
||||
}
|
||||
right={
|
||||
<>
|
||||
{/* Clear completed */}
|
||||
<BadgeCounter
|
||||
count={completedCount}
|
||||
icon={<Trash2 size={14} />}
|
||||
onClick={handleClearCompleted}
|
||||
className="flex items-center gap-2 px-3 py-1.5 rounded-lg transition-all bg-shell-800/50 hover:bg-crab-900/50 hover:border-crab-700/50 border border-transparent group"
|
||||
title={`Clear ${completedCount} completed item${completedCount !== 1 ? 's' : ''}`}
|
||||
>
|
||||
<Trash2
|
||||
size={14}
|
||||
className="text-shell-400 group-hover:text-crab-400 transition-colors"
|
||||
/>
|
||||
<span className="font-console text-xs text-shell-400 group-hover:text-crab-400 transition-colors">
|
||||
{completedCount}
|
||||
</span>
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Persistence indicator */}
|
||||
<button
|
||||
onClick={() => setSettingsOpen(true)}
|
||||
className={`flex items-center gap-2 px-3 py-1.5 rounded-lg transition-all ${
|
||||
persistenceEnabled
|
||||
? 'bg-neon-mint/10 hover:bg-neon-mint/20'
|
||||
: 'bg-shell-800/50 hover:bg-shell-700'
|
||||
}`}
|
||||
title={persistenceEnabled ? 'Background service running' : 'Background service stopped'}
|
||||
>
|
||||
<HardDrive
|
||||
size={14}
|
||||
className={persistenceEnabled ? 'text-neon-mint' : 'text-shell-500'}
|
||||
title={`Clear ${completedCount} completed`}
|
||||
/>
|
||||
{persistenceEnabled && (
|
||||
<span className="w-1.5 h-1.5 rounded-full bg-neon-mint animate-pulse" />
|
||||
)}
|
||||
</button>
|
||||
|
||||
{/* Stats display */}
|
||||
<div className="hidden sm:flex items-center gap-3 px-3 py-1.5 bg-shell-800/50 rounded-lg">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-console text-[10px] text-shell-500 uppercase">Sessions</span>
|
||||
<span className="font-display text-sm text-neon-mint">{sessions.length}</span>
|
||||
</div>
|
||||
<div className="w-px h-4 bg-shell-700" />
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-console text-[10px] text-shell-500 uppercase">Actions</span>
|
||||
<span className="font-display text-sm text-neon-peach">{actions.length}</span>
|
||||
</div>
|
||||
</div>
|
||||
{/* Persistence service */}
|
||||
<ServiceIndicator
|
||||
active={persistenceEnabled}
|
||||
icon={<HardDrive size={14} />}
|
||||
onClick={() => setSettingsOpen(true)}
|
||||
title={persistenceEnabled ? 'Background service running' : 'Service stopped'}
|
||||
/>
|
||||
|
||||
<SettingsPanel
|
||||
connected={connected}
|
||||
historicalMode={historicalMode}
|
||||
debugMode={debugMode}
|
||||
logCollection={logCollection}
|
||||
logCount={logCount}
|
||||
persistenceEnabled={persistenceEnabled}
|
||||
persistenceStartedAt={persistenceStartedAt}
|
||||
persistenceSessionCount={persistenceSessionCount}
|
||||
persistenceActionCount={persistenceActionCount}
|
||||
open={settingsOpen}
|
||||
onOpenChange={setSettingsOpen}
|
||||
onHistoricalModeChange={handleHistoricalModeChange}
|
||||
onDebugModeChange={handleDebugModeChange}
|
||||
onLogCollectionChange={handleLogCollectionChange}
|
||||
onDownloadLogs={handleDownloadLogs}
|
||||
onClearLogs={handleClearLogs}
|
||||
onConnect={handleConnect}
|
||||
onDisconnect={handleDisconnect}
|
||||
onRefresh={handleRefresh}
|
||||
onPersistenceStart={handlePersistenceStart}
|
||||
onPersistenceStop={handlePersistenceStop}
|
||||
onPersistenceClear={handlePersistenceClear}
|
||||
/>
|
||||
</div>
|
||||
</header>
|
||||
{/* Stats */}
|
||||
<StatsGroup>
|
||||
<StatBlock label="Sessions" value={sessions.length} color="mint" />
|
||||
<StatsDivider />
|
||||
<StatBlock label="Actions" value={actions.length} color="peach" />
|
||||
</StatsGroup>
|
||||
|
||||
{/* Settings trigger */}
|
||||
<button
|
||||
onClick={() => setSettingsOpen(true)}
|
||||
className="p-2 bg-shell-800/50 hover:bg-shell-700/50 border border-shell-700/50 hover:border-shell-600 rounded-lg transition-all group"
|
||||
>
|
||||
<Settings size={14} className="text-gray-400 group-hover:text-crab-400 transition-colors" />
|
||||
</button>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
|
||||
{/* Main content */}
|
||||
<div className="flex-1 flex overflow-hidden">
|
||||
@@ -511,6 +455,33 @@ function MonitorPage() {
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Settings panel - rendered at root level to avoid z-index clipping */}
|
||||
<SettingsPanel
|
||||
connected={connected}
|
||||
historicalMode={historicalMode}
|
||||
debugMode={debugMode}
|
||||
logCollection={logCollection}
|
||||
logCount={logCount}
|
||||
persistenceEnabled={persistenceEnabled}
|
||||
persistenceStartedAt={persistenceStartedAt}
|
||||
persistenceSessionCount={persistenceSessionCount}
|
||||
persistenceActionCount={persistenceActionCount}
|
||||
open={settingsOpen}
|
||||
onOpenChange={setSettingsOpen}
|
||||
onHistoricalModeChange={handleHistoricalModeChange}
|
||||
onDebugModeChange={handleDebugModeChange}
|
||||
onLogCollectionChange={handleLogCollectionChange}
|
||||
onDownloadLogs={handleDownloadLogs}
|
||||
onClearLogs={handleClearLogs}
|
||||
onConnect={handleConnect}
|
||||
onDisconnect={handleDisconnect}
|
||||
onRefresh={handleRefresh}
|
||||
onPersistenceStart={handlePersistenceStart}
|
||||
onPersistenceStop={handlePersistenceStop}
|
||||
onPersistenceClear={handlePersistenceClear}
|
||||
hideTrigger
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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 */}
|
||||
<CommandNav />
|
||||
|
||||
{/* Header - path controls */}
|
||||
<header className="hidden sm:flex items-center justify-between px-4 py-3 bg-shell-900/80 backdrop-blur-sm relative z-30">
|
||||
{/* Gradient accent */}
|
||||
<div className="absolute inset-0 bg-linear-to-r from-crab-950/20 via-transparent to-transparent pointer-events-none" />
|
||||
|
||||
{/* Spacer for nav button */}
|
||||
<div className="w-48" />
|
||||
|
||||
{/* Path input - centered */}
|
||||
<div className="flex relative items-center gap-2 flex-1 max-w-2xl">
|
||||
<div className="flex-1 relative">
|
||||
<FolderOpen size={16} className="absolute left-3 top-1/2 -translate-y-1/2 text-shell-500 pointer-events-none" />
|
||||
<input
|
||||
type="text"
|
||||
value={workspacePathInput}
|
||||
onChange={(e) => 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"
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
onClick={validateAndSetPath}
|
||||
disabled={pathValid && workspacePathInput === workspacePath}
|
||||
className={`px-3 py-1.5 text-sm font-display rounded-lg transition-colors shrink-0 ${
|
||||
pathValid && workspacePathInput === workspacePath
|
||||
? 'bg-shell-800 text-shell-500 cursor-default'
|
||||
: 'bg-crab-600 hover:bg-crab-500 text-white'
|
||||
}`}
|
||||
>
|
||||
Open
|
||||
</button>
|
||||
|
||||
{pathError && (
|
||||
<div className="absolute top-full left-0 right-0 mt-2 px-3 py-2 bg-crab-900/90 border border-crab-700 rounded-lg flex items-center gap-2 z-50">
|
||||
<AlertCircle size={14} className="text-crab-400" />
|
||||
<span className="text-xs text-crab-200 font-console">{pathError}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Refresh button */}
|
||||
<div className="flex relative items-center gap-3 ml-4">
|
||||
<button
|
||||
{/* Header */}
|
||||
<AppHeader
|
||||
left={
|
||||
<StatusPill
|
||||
status={pathValid ? 'active' : 'inactive'}
|
||||
label={pathValid ? 'PATH SET' : 'NO PATH'}
|
||||
/>
|
||||
}
|
||||
center={
|
||||
<PathInput
|
||||
value={workspacePathInput}
|
||||
onChange={setWorkspacePathInput}
|
||||
onSubmit={validateAndSetPath}
|
||||
placeholder="Enter workspace path..."
|
||||
icon={<FolderOpen size={16} />}
|
||||
submitDisabled={pathValid && workspacePathInput === workspacePath}
|
||||
error={pathError}
|
||||
/>
|
||||
}
|
||||
right={
|
||||
<IconButton
|
||||
icon={<RefreshCw size={16} className={loading ? 'animate-spin' : ''} />}
|
||||
onClick={handleRefresh}
|
||||
disabled={!pathValid || loading}
|
||||
className="p-2 hover:bg-shell-800 rounded-lg transition-all border border-transparent hover:border-shell-600 disabled:opacity-50 disabled:cursor-not-allowed group"
|
||||
title="Refresh"
|
||||
>
|
||||
<RefreshCw
|
||||
size={18}
|
||||
className={`text-gray-400 group-hover:text-crab-400 ${loading ? 'animate-spin' : ''}`}
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
title="Refresh workspace"
|
||||
/>
|
||||
}
|
||||
/>
|
||||
|
||||
{/* Main content */}
|
||||
<div className="flex-1 flex overflow-hidden">
|
||||
@@ -518,7 +484,7 @@ function WorkspacePage() {
|
||||
>
|
||||
<FileText
|
||||
size={14}
|
||||
className={`flex-shrink-0 ${
|
||||
className={`shrink-0 ${
|
||||
ext === '.md' ? 'text-crab-400' : 'text-shell-500'
|
||||
}`}
|
||||
/>
|
||||
@@ -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"
|
||||
>
|
||||
<Star size={14} fill="currentColor" />
|
||||
@@ -547,7 +513,7 @@ function WorkspacePage() {
|
||||
{!sidebarCollapsed && (
|
||||
<div className="flex-1 overflow-auto py-2">
|
||||
{pathValid ? (
|
||||
<FileTree
|
||||
<FileTreeComponent
|
||||
entries={rootEntries}
|
||||
selectedPath={selectedPath}
|
||||
onSelect={handleSelect}
|
||||
|
||||
Reference in New Issue
Block a user