2025-12-01 13:33:00 -08:00

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

  1. Platform Agnostic - This package works on Tauri, Web, and React Native
  2. Clean Separation - UI components here, state in @sd/ts-client, primitives in @sd/ui
  3. Type Safety First - Use auto-generated types, no any, strict TypeScript
  4. Performance Matters - Virtual scrolling, code splitting, memoization when needed
  5. Accessible - Radix primitives, proper ARIA labels, keyboard navigation
  6. 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.tsx for components, camelCase.ts for utilities
  • Components: PascalCase functions
  • Hooks: useCamelCase pattern
  • Constants: SCREAMING_SNAKE_CASE
  • CSS classes: Semantic names only (bg-sidebar, not bg-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:

  1. Layout (flex, grid, w-full, h-screen)
  2. Spacing (p-4, m-2, gap-2)
  3. Typography (text-sm, font-medium)
  4. Colors (bg-sidebar, text-ink)
  5. Borders (border, border-sidebar-line, rounded-lg)
  6. Effects (shadow-sm, backdrop-blur)
  7. States (hover:bg-app-hover, focus:ring-accent)
  8. Transitions (transition-colors)

Rounding (V2 Style)

V2 is more rounded than V1. Use:

  • rounded-lg for most containers (8px)
  • rounded-md for smaller elements (6px)
  • rounded-full for pills/badges
  • rounded-[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:

  1. Primitive has minimal/no styling
  2. All visual styling applied via className prop
  3. Business logic (filtering, selecting) in parent component
  4. 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 (use unknown with 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-motion for 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

  1. Check if primitive exists in @sd/ui
  2. Check if types are auto-generated (they probably are)
  3. Plan component composition (primitive + styling)
  4. Use semantic color classes

When Adding Features

  1. Create minimal primitive in @sd/ui if needed
  2. Use primitive in @sd/interface with styling
  3. Use type-safe queries/mutations
  4. Add to this document if architectural decision made

When Styling

  1. Use semantic colors (bg-sidebar, not bg-gray-900)
  2. Follow V2 rounded style
  3. Use opacity modifiers (bg-accent/10)
  4. 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 condition callback
  • Smart labels that update based on state
  • Supports icons, keybinds, variants, submenus, separators
  • Use useLibraryMutation for actions, not client.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:

  1. Update colors: bg-gray-900bg-app, text-gray-400text-ink-dull
  2. Use primitives: Extract reusable parts to @sd/ui
  3. Remove state: Move to @sd/ts-client if global, use local state if component-specific
  4. Update queries: Use new type-safe hooks
  5. Add rounding: V1 used rounded-md, V2 uses rounded-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 any types
  • 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-900bg-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:

  1. Is this a primitive? → Should it be in @sd/ui?
  2. Is this state global? → Should it be in @sd/ts-client?
  3. Are the types auto-generated? → Don't duplicate them!
  4. Can I use a semantic color? → Yes, always!
  5. 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!