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:
luccast
2026-02-01 21:31:27 -05:00
parent 306a3b9c10
commit 98319d0b8d
7 changed files with 793 additions and 190 deletions
+323
View File
@@ -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
```
+326
View File
@@ -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>
)
}
+12
View File
@@ -0,0 +1,12 @@
export {
AppHeader,
StatusPill,
StatBlock,
StatsGroup,
StatsDivider,
IconButton,
BadgeCounter,
ServiceIndicator,
RetryIndicator,
PathInput,
} from './AppHeader'
+13 -8
View File
@@ -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" />
+4 -4
View File
@@ -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
View File
@@ -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>
)
}
+31 -65
View File
@@ -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}