23 KiB
Spacedrive Interface Development Rules
Status: Living Document - Update as architectural decisions are made Purpose: Ensure consistent, clean, and maintainable code across the interface package Audience: AI assistants and developers working on @sd/interface
Core Principles
- Platform Agnostic - This package works on Tauri, Web, and React Native
- Clean Separation - UI components here, state in @sd/ts-client, primitives in @sd/ui
- Type Safety First - Use auto-generated types, no
any, strict TypeScript - Performance Matters - Virtual scrolling, code splitting, memoization when needed
- Accessible - Radix primitives, proper ARIA labels, keyboard navigation
- Consistent Styling - Semantic color system, no arbitrary values
Package Architecture
What Lives Where
@sd/interface (this package):
- Route components and layouts
- Feature components (Explorer, Settings, etc.)
- React Query hook wrappers
- UI composition and interactivity
- NO state management (use @sd/ts-client)
- NO primitive components (use @sd/ui)
- NO platform APIs (use platform prop)
@sd/ts-client:
- Client implementation
- Transport layer
- Auto-generated types from Rust
- State stores (if needed)
@sd/ui:
- Primitive components (Button, Input, DropdownMenu, etc.)
- Reusable, unstyled or minimally styled
- No business logic
- No API calls
Current Structure
packages/interface/
├── src/
│ ├── Explorer.tsx # Main explorer window
│ ├── DemoWindow.tsx # Demo/testing window
│ ├── FloatingControls.tsx # Floating controls UI
│ ├── components/ # Feature components
│ │ └── TrafficLights.tsx # macOS window controls
│ ├── hooks/ # React hooks
│ │ ├── useLibraries.ts
│ │ └── useEvent.ts
│ ├── hooks-client/ # TanStack Query hooks
│ │ ├── useClient.tsx
│ │ ├── useQuery.ts
│ │ └── useMutation.ts
│ ├── context.tsx # Re-exports from hooks-client
│ ├── styles.css # Global CSS variables
│ └── index.tsx # Public exports
Code Style Rules
React 19 Standards
Critical: You Might Not Need an Effect
Effects are an escape hatch - only use them to sync with external systems (network, DOM, browser APIs).
DON'T use Effects for:
- Transforming data for rendering (calculate during render instead)
- Handling user events (use event handlers)
- Updating state based on props (calculate during render or use
key) - Chains of state updates (do in event handler)
- Initializing app (use module-level code)
- Notifying parent of changes (pass callback, call in event handler)
DO use Effects for:
- Subscribing to external systems (WebSocket, browser events)
- Syncing with non-React widgets
- Network requests with proper cleanup
Examples
Wrong - Don't use Effect to transform data:
function TodoList({ todos, filter }) {
const [visibleTodos, setVisibleTodos] = useState([]);
useEffect(() => {
setVisibleTodos(getFilteredTodos(todos, filter));
}, [todos, filter]);
// Extra render pass!
}
Correct - Calculate during render:
function TodoList({ todos, filter }) {
const visibleTodos = getFilteredTodos(todos, filter);
// Or use useMemo if expensive:
const visibleTodos = useMemo(
() => getFilteredTodos(todos, filter),
[todos, filter]
);
}
Wrong - Don't use Effect for user events:
function ProductPage({ product, addToCart }) {
useEffect(() => {
if (product.isInCart) {
showNotification('Added to cart!');
}
}, [product]);
}
Correct - Use event handler:
function ProductPage({ product, addToCart }) {
function buyProduct() {
addToCart(product);
showNotification('Added to cart!');
}
}
Wrong - Don't use Effect to update parent:
function Toggle({ onChange }) {
const [isOn, setIsOn] = useState(false);
useEffect(() => {
onChange(isOn); // Too late! Extra render.
}, [isOn, onChange]);
}
Correct - Call in event handler:
function Toggle({ onChange }) {
const [isOn, setIsOn] = useState(false);
function updateToggle(nextIsOn) {
setIsOn(nextIsOn);
onChange(nextIsOn); // Same render pass!
}
}
Wrong - Don't chain Effects:
useEffect(() => {
if (card.gold) setGoldCardCount(c => c + 1);
}, [card]);
useEffect(() => {
if (goldCardCount > 3) setRound(r => r + 1);
}, [goldCardCount]);
// Multiple render passes!
Correct - Calculate in event handler:
function handlePlaceCard(nextCard) {
setCard(nextCard);
if (nextCard.gold) {
if (goldCardCount < 3) {
setGoldCardCount(goldCardCount + 1);
} else {
setGoldCardCount(0);
setRound(round + 1);
}
}
// Single render pass!
}
Function components only:
// Correct
function Component({ name }: { name: string }) {
return <div>{name}</div>;
}
// Wrong
const Component: React.FC<{ name: string }> = ({ name }) => {
return <div>{name}</div>;
};
Hooks must follow rules:
// Correct - proper cleanup
useEffect(() => {
const subscription = subscribe();
return () => subscription.unsubscribe();
}, [dependency]);
// Wrong - missing cleanup
useEffect(() => {
subscribe();
}, []);
Use TypeScript strictly:
// Correct - explicit types
interface ButtonProps {
label: string;
onClick: () => void;
}
function Button({ label, onClick }: ButtonProps) { }
// Wrong - implicit any
function Button(props) { }
Color System Rules
CRITICAL: Always Use Semantic Tailwind Classes
Never use var() syntax directly. Always use Tailwind's semantic color classes.
WRONG:
className="bg-[var(--color-sidebar)]"
className="text-[var(--color-sidebar-ink)]"
className="border-[var(--color-accent)]"
CORRECT:
className="bg-sidebar"
className="text-sidebar-ink"
className="border-accent"
IMPORTANT: CSS variables must be defined as comma-separated HSL values (not wrapped in hsl()):
/* CORRECT - bare values for Tailwind */
--color-sidebar: 235, 15%, 7%;
/* WRONG - wrapped in hsl() */
--color-sidebar: hsl(235, 15%, 7%);
This is because Tailwind uses hsla(var(--color-sidebar), <alpha-value>) which becomes hsla(235, 15%, 7%, 0.5) for opacity support.
Color Categories
Accent: accent, accent-faint, accent-deep
- Use for: Primary actions, selections, focus states
Text (Ink): ink, ink-dull, ink-faint
- Use for: Text hierarchy (primary, secondary, tertiary)
Sidebar: sidebar, sidebar-box, sidebar-line, sidebar-ink, sidebar-selected, etc.
- Use for: Sidebar-specific elements
App: app, app-box, app-line, app-hover, app-selected, etc.
- Use for: Main content area elements
Menu: menu, menu-line, menu-hover, menu-ink, etc.
- Use for: Dropdowns, context menus
Opacity Modifiers
// Use Tailwind opacity
className="bg-accent/10"
className="bg-sidebar/65"
// Don't use manual alpha
className="bg-[var(--color-accent)]/10"
Component Rules
Primitive vs Feature Components
Primitives (@sd/ui):
- Generic, reusable
- Minimal styling (or unstyled)
- No business logic
- Example:
DropdownMenu,Button,Input
Feature Components (@sd/interface):
- Specific to Spacedrive features
- Uses primitives
- Can have business logic
- Example:
Explorer,Sidebar,LibrariesDropdown
Component Structure
// Correct structure
import { Primitive } from '@sd/ui';
import { useSomeQuery } from '../context';
interface ComponentProps {
// Props interface
}
function Component({ prop }: ComponentProps) {
// Hooks first
const data = useSomeQuery();
// Logic
const derived = useMemo(() => transform(data), [data]);
// Render
return (
<Primitive className="semantic-colors">
{/* Content */}
</Primitive>
);
}
export { Component };
Naming Conventions
- Files:
PascalCase.tsxfor components,camelCase.tsfor utilities - Components:
PascalCasefunctions - Hooks:
useCamelCasepattern - Constants:
SCREAMING_SNAKE_CASE - CSS classes: Semantic names only (
bg-sidebar, notbg-gray-900)
Styling Rules
CRITICAL: Never Use Style Tags
NEVER use <style>, <style jsx>, or any inline style tags. Always use Tailwind utility classes.
WRONG:
<style jsx>{`
.slider::-webkit-slider-thumb {
background: var(--color-accent);
}
`}</style>
CORRECT:
className="[&::-webkit-slider-thumb]:bg-accent [&::-webkit-slider-thumb]:rounded-full"
Use Tailwind's arbitrary variant syntax for pseudo-elements and other edge cases.
Tailwind Class Order
Follow this order for readability:
- Layout (
flex,grid,w-full,h-screen) - Spacing (
p-4,m-2,gap-2) - Typography (
text-sm,font-medium) - Colors (
bg-sidebar,text-ink) - Borders (
border,border-sidebar-line,rounded-lg) - Effects (
shadow-sm,backdrop-blur) - States (
hover:bg-app-hover,focus:ring-accent) - Transitions (
transition-colors)
Rounding (V2 Style)
V2 is more rounded than V1. Use:
rounded-lgfor most containers (8px)rounded-mdfor smaller elements (6px)rounded-fullfor pills/badgesrounded-[10px]for window frame
Animation
Use framer-motion for complex animations:
import { motion, AnimatePresence } from 'framer-motion';
<AnimatePresence>
{isOpen && (
<motion.div
initial={{ height: 0, opacity: 0 }}
animate={{ height: 'auto', opacity: 1 }}
exit={{ height: 0, opacity: 0 }}
transition={{ duration: 0.15, ease: [0.25, 1, 0.5, 1] }}
>
{content}
</motion.div>
)}
</AnimatePresence>
Data Fetching Rules
Use Type-Safe Hooks
Core queries (no library required):
import { useCoreQuery } from '../context';
const { data: libraries } = useCoreQuery({
type: 'libraries.list',
input: { include_stats: false },
});
Library queries (requires library context):
import { useLibraryQuery } from '../context';
const { data: files } = useLibraryQuery({
type: 'files.directory_listing',
input: { path: '/' },
});
Mutations:
import { useCoreMutation, useLibraryMutation } from '../context';
const createLib = useCoreMutation('libraries.create');
const applyTags = useLibraryMutation('tags.apply');
const copyFiles = useLibraryMutation('files.copy');
const deleteFiles = useLibraryMutation('files.delete');
// Use mutations, not client.execute()
createLib.mutate({ name: 'New Library', path: null });
await copyFiles.mutateAsync({
sources: { paths: [path1, path2] },
destination: destPath,
overwrite: false,
verify_checksum: false,
preserve_timestamps: true,
move_files: false,
copy_method: "Auto"
});
Never Fetch Manually
Wrong:
const [data, setData] = useState();
useEffect(() => {
fetchData().then(setData);
}, []);
Correct:
const { data } = useCoreQuery({ type: 'operation', input: {} });
Performance Rules
Virtual Scrolling
Use for lists > 100 items:
import { useVirtualizer } from '@tanstack/react-virtual';
const virtualizer = useVirtualizer({
count: items.length,
getScrollElement: () => parentRef.current,
estimateSize: () => 50,
});
Code Splitting
Lazy load routes:
const SettingsPage = lazy(() => import('./Settings'));
<Suspense fallback={<Spinner />}>
<SettingsPage />
</Suspense>
Memoization
Only when actually needed:
// Expensive computation
const sorted = useMemo(
() => items.sort(expensiveCompare),
[items]
);
// Premature optimization
const greeting = useMemo(() => `Hello ${name}`, [name]);
Component Composition Rules
Dropdown Example (Current Implementation)
The DropdownMenu primitive provides minimal base functionality. Explorer customizes it:
// Primitive (in @sd/ui/DropdownMenu.tsx)
export const DropdownMenu = {
Root: ({ trigger, children, className }) => (
// Minimal expanding container with motion
),
Item: ({ children, onClick, className }) => (
// Basic button with flex layout
),
Separator: ({ className }) => (
// Simple divider
),
};
// Usage (in Explorer.tsx)
<DropdownMenu.Root
trigger={
<button className="w-full bg-sidebar-box border-sidebar-line rounded-lg">
{currentLibrary?.name}
</button>
}
className="bg-sidebar-box border-sidebar-line rounded-lg"
>
<DropdownMenu.Item
className="px-2 py-1 rounded-md hover:bg-sidebar-selected"
onClick={() => switchLibrary(lib.id)}
>
{lib.name}
</DropdownMenu.Item>
</DropdownMenu.Root>
Key principles:
- Primitive has minimal/no styling
- All visual styling applied via className prop
- Business logic (filtering, selecting) in parent component
- Semantic color classes only
Type Safety Rules
Use Generated Types
All types are auto-generated from Rust:
import type { LibraryInfo, CoreQuery, LibraryAction } from '@sd/ts-client';
Never:
- Define manual type interfaces that duplicate Rust types
- Use
any(useunknownwith type guards if needed) - Ignore TypeScript errors
Query Type Safety
The hooks automatically infer types:
// TypeScript knows data is LibraryInfo[]
const { data } = useCoreQuery({
type: 'libraries.list',
input: { include_stats: false },
});
// data is automatically typed based on the operation!
File Organization Rules
Component Co-location
Explorer/
├── index.tsx # Main component
├── Sidebar.tsx # Sub-component
├── TopBar.tsx # Sub-component
└── hooks/
└── useExplorer.ts # Feature-specific hooks
Exports
Only export what's needed:
// index.tsx
export { Explorer } from './Explorer';
export { DemoWindow } from './DemoWindow';
// Don't export everything
macOS-Specific Rules
Native Traffic Lights
The window uses native macOS traffic lights positioned by Swift code:
- Traffic lights are real, functional native controls
- Content must have
pt-[52px]to avoid overlap - No fake CSS traffic lights
- Transparent titlebar + invisible toolbar trick (see sd-desktop-macos crate)
Window Styling
// Correct - accounts for native traffic lights
<nav className="pt-[52px] ...">
{/* Content starts below traffic lights */}
</nav>
// Correct - window frame with rounded corners
<div className="rounded-[10px] border-transparent frame">
{/* App content */}
</div>
Blur Effects
Use backdrop blur for macOS native feel:
className="backdrop-blur-lg bg-sidebar/65"
Current Architectural Decisions
1. Expanding Dropdowns (Not Overlays)
Decision: Dropdowns should expand inline and push content down, not overlay it.
Implementation:
- Use
framer-motionfor smooth height animation - No Radix Portal (renders inline in DOM)
- Pushes surrounding content naturally
2. Library Switcher Logic
Decision: Show/hide current library based on count.
Rules:
- 1 library: Hide current from dropdown (no point showing it)
- 2+ libraries: Show all including current (with highlight)
- Always show "New Library" and "Library Settings"
3. Color System
Decision: Use Tailwind semantic classes, never var() directly.
// Correct
className="bg-sidebar-box text-sidebar-ink border-sidebar-line"
// Wrong
className="bg-[var(--color-sidebar-box)]"
4. Rounded Style (V2)
Decision: V2 is more rounded than V1.
- Containers:
rounded-lg(8px) - Small elements:
rounded-md(6px) - Window:
rounded-[10px] - Pills/badges:
rounded-full
Development Workflow
Before Writing Code
- Check if primitive exists in @sd/ui
- Check if types are auto-generated (they probably are)
- Plan component composition (primitive + styling)
- Use semantic color classes
When Adding Features
- Create minimal primitive in @sd/ui if needed
- Use primitive in @sd/interface with styling
- Use type-safe queries/mutations
- Add to this document if architectural decision made
When Styling
- Use semantic colors (
bg-sidebar, notbg-gray-900) - Follow V2 rounded style
- Use opacity modifiers (
bg-accent/10) - Maintain color context (sidebar colors in sidebar, app colors in main area)
Common Patterns
Library Switcher Pattern
const client = useSpacedriveClient();
const { data: libraries } = useLibraries();
const [currentLibraryId, setCurrentLibraryId] = useState<string | null>(null);
// Auto-select first library
useEffect(() => {
if (libraries && libraries.length > 0 && !currentLibraryId) {
client.setCurrentLibrary(libraries[0].id);
setCurrentLibraryId(libraries[0].id);
}
}, [libraries, currentLibraryId, client]);
// Switch library
const handleSwitch = (id: string) => {
client.setCurrentLibrary(id);
setCurrentLibraryId(id);
};
Sidebar Item Pattern
function SidebarItem({ icon: Icon, label, active }: Props) {
return (
<button
className={clsx(
"flex items-center gap-2 px-2 py-1 rounded-md text-sm font-medium",
active
? "bg-sidebar-selected text-sidebar-ink"
: "text-sidebar-inkDull hover:text-sidebar-ink"
)}
>
<Icon className="size-4" weight={active ? "fill" : "bold"} />
<span className="truncate">{label}</span>
</button>
);
}
Dropdown Pattern
<DropdownMenu.Root
trigger={<button className="...">Trigger</button>}
className="bg-sidebar-box border-sidebar-line rounded-lg"
>
<DropdownMenu.Item
className="px-2 py-1 hover:bg-sidebar-selected"
onClick={() => action()}
>
Item content
</DropdownMenu.Item>
<DropdownMenu.Separator className="border-sidebar-line" />
</DropdownMenu.Root>
Context Menu Pattern
Use useContextMenu hook for platform-agnostic context menus:
import { useContextMenu } from '../hooks/useContextMenu';
import { Copy, Trash } from '@phosphor-icons/react';
const { selectedFiles } = useExplorer();
const copyFiles = useLibraryMutation('files.copy');
const deleteFiles = useLibraryMutation('files.delete');
const contextMenu = useContextMenu({
items: [
{
icon: Copy,
label: selectedFiles.length > 1 ? `Copy ${selectedFiles.length} items` : "Copy",
onClick: async () => {
await copyFiles.mutateAsync({
sources: { paths: selectedFiles.map(f => f.sd_path) },
destination: currentPath,
overwrite: false,
verify_checksum: false,
preserve_timestamps: true,
move_files: false,
copy_method: "Auto"
});
},
keybind: "⌘C",
condition: () => selectedFiles.length > 0, // Only show if files selected
},
{ type: "separator" },
{
icon: Trash,
label: "Delete",
onClick: async () => {
await deleteFiles.mutateAsync({
targets: { paths: selectedFiles.map(f => f.sd_path) },
permanent: false,
recursive: true
});
},
keybind: "⌘⌫",
variant: "danger"
}
]
});
return <div onContextMenu={contextMenu.show}>Content</div>;
Key features:
- Platform-agnostic (native on Tauri, Radix on web)
- Conditional items via
conditioncallback - Smart labels that update based on state
- Supports icons, keybinds, variants, submenus, separators
- Use
useLibraryMutationfor actions, notclient.execute()
Type-Safe Query Pattern
Query Keys
Use descriptive, hierarchical keys:
// Good
queryKey: ['libraries', 'list']
queryKey: ['files', 'directory', libraryId, path]
// Bad
queryKey: ['getLibraries']
queryKey: ['data']
Using Queries
const { data, isLoading, error } = useCoreQuery({
type: 'libraries.list',
input: { include_stats: true },
});
// data is automatically typed as LibraryInfo[]!
Testing Requirements
Critical Paths Must Be Tested
- Explorer file operations
- Library switching
- Settings mutations
- Search functionality
Test Pattern
import { render, screen } from '@testing-library/react';
import { Explorer } from './Explorer';
test('switches libraries', async () => {
const user = userEvent.setup();
render(<Explorer client={mockClient} />);
await user.click(screen.getByText('Switch Library'));
// ...
});
Migration from V1
When porting V1 components:
- Update colors:
bg-gray-900→bg-app,text-gray-400→text-ink-dull - Use primitives: Extract reusable parts to @sd/ui
- Remove state: Move to @sd/ts-client if global, use local state if component-specific
- Update queries: Use new type-safe hooks
- Add rounding: V1 used
rounded-md, V2 usesrounded-lg
Checklist Before PR
- All colors use semantic classes (no
var()directly) - Component uses primitives from @sd/ui where applicable
- Type-safe queries/mutations (no manual fetch)
- Follows V2 rounded style
- No
anytypes - Proper cleanup in useEffect
- Accessible (keyboard nav, ARIA labels)
- Tested critical paths
Quick Reference
Import Order
// 1. External libraries
import { useState } from 'react';
import { motion } from 'framer-motion';
// 2. @sd packages
import { Button, DropdownMenu } from '@sd/ui';
import { useCoreQuery } from '@sd/ts-client';
// 3. Local imports
import { useLibraries } from './hooks/useLibraries';
import clsx from 'clsx';
Common Mistakes
<style> or <style jsx> tags → Use Tailwind arbitrary variants
className="bg-[var(--color-sidebar)]" → className="bg-sidebar"
bg-gray-900 → bg-app
rounded-md everywhere → rounded-lg for V2
Manual fetch → Use type-safe hooks
State in component → Use @sd/ts-client or local state
Questions to Ask
Before writing code:
- Is this a primitive? → Should it be in @sd/ui?
- Is this state global? → Should it be in @sd/ts-client?
- Are the types auto-generated? → Don't duplicate them!
- Can I use a semantic color? → Yes, always!
- Is this accessible? → Keyboard nav? ARIA labels?
Resources
- Type Generation:
cargo run --bin generate_typescript_types - Color System:
/docs/react/ui/colors.mdx - Workbench Docs:
/workbench/interface/ - V1 Reference:
/Users/jamespine/Projects/spacedrive_v1
Status: Current Implementation
Complete:
- Type-safe client with auto-generated types
- Native macOS traffic lights
- V1 color system as CSS variables
- Expanding dropdown (DropdownMenu primitive)
- Explorer with sidebar and library switcher
- TanStack Query integration
In Progress:
- Port remaining V1 components
- Build complete Explorer (file grid/list views)
- Settings pages
- Multi-window system
Remember: This is a living document. Update it when architectural decisions are made. This is our rulebook for building a world-class file manager interface!