Frontend Development Guide¶
This document covers the React + TypeScript frontend architecture, component patterns, state management, styling conventions, and development workflow for Stratum.
Architecture Overview¶
Stratum's frontend is a React 19 single-page application built with TypeScript 6 (strict mode), bundled with Vite, and styled with MUI v6 (Material UI) using the sx prop — no Tailwind CSS, no CSS-in-JS libraries beyond MUI's built-in system.
Tech Stack¶
| Layer | Technology | Notes |
|---|---|---|
| Framework | React 19 | Concurrent features, use() hook |
| Language | TypeScript 6 | Strict mode, no as any, no @ts-ignore |
| Bundler | Vite 6 | HMR, lazy route splitting |
| UI Library | MUI v6 (@mui/material) |
sx prop only, no styled() API |
| State | Zustand 5 | Lightweight, no boilerplate |
| Routing | React Router v7 | File-adjacent route definitions |
| Icons | MUI Icons (@mui/icons-material) |
Material Design icons |
| Editor | BlockNote (@blocknote/react) |
ProseMirror-based block editor |
| Desktop Bridge | Tauri v2 (@tauri-apps/api) |
invoke() for Rust commands |
| Testing | Vitest + React Testing Library | Component + hook tests |
File Layout¶
src/
├── main.tsx # Bootstrap, settings load, React root
├── App.tsx # Root layout, routes, close handler
├── global.css # CSS variables, safe-area, overrides
├── lib/
│ ├── types.ts # TypeScript DTOs (mirror Rust structs)
│ ├── commands.ts # 64+ Tauri invoke() wrappers
│ ├── theme.ts # CSS variable generation (--primary-* shades)
│ ├── muiTheme.ts # MUI Theme creation from config
│ ├── wikiLinks.ts # Wiki-link parsing/serialization
│ ├── libraryStore.ts # Module-level library JSON cache
│ ├── useCtrlHeld.ts # Hook: Ctrl/Meta key tracking
│ ├── useMathInline.tsx # Hook: ProseMirror inline KaTeX plugin
│ └── hooks/
│ ├── useAsyncData.ts
│ ├── useDebounce.ts
│ ├── useAutoSave.ts
│ ├── useResponsive.ts
│ └── index.ts
├── stores/
│ ├── appStore.ts # Core Zustand store
│ ├── settingsStore.ts # Theme + AI + sync config
│ ├── graphStore.ts # Graph data + settings
│ └── syncStore.ts # Sync status + commits
├── components/
│ ├── ui/ # Reusable UI primitives
│ ├── Sidebar/
│ ├── PageView.tsx
│ └── ... (panel components)
└── test/ # Test utilities
Data Flow¶
All data operations follow a strict unidirectional flow:
Component
→ src/lib/commands.ts (typed invoke wrapper)
→ Tauri IPC (serialized as JSON)
→ Rust command handler (src-tauri/src/commands/)
→ Crate business logic (pkm-block, pkm-index, pkm-sync, etc.)
→ SQLite / filesystem / Tantivy
Components never call invoke() directly — always through commands.ts. Zustand stores wrap commands.ts calls and expose loading/error states.
Component Hierarchy¶
Panel Components (route-mapped)¶
| Component | Route | Purpose |
|---|---|---|
PagesHome |
/ |
Page list with block counts |
JournalPanel |
/journal |
Calendar + daily journal creation |
PageView |
/page/:pagePath |
Block editor + backlinks + connections |
SearchPanel |
/search |
Full-text + tag search |
QueryPanel |
/query |
Datalog query input + results table |
GraphPanel |
/graph |
3D force-directed graph |
TemplatesPanel |
/templates |
Template list + apply with variables |
FlashcardsPanel |
/flashcards |
SRS card review (SM-2) |
KanbanPanel |
/kanban |
Drag-and-drop Kanban board |
WhiteboardPanel |
/whiteboards |
Excalidraw spatial canvas |
SettingsPage |
/settings |
6-tab app configuration |
Editor Sub-components¶
| Component | Parent | Purpose |
|---|---|---|
OutlinerEditor |
PageView |
BlockNote-based outliner with auto-save, markers, wiki-links |
BacklinksPanel |
PageView |
Linked references + unlinked mentions + hover preview |
SuggestedConnectionsPanel |
PageView |
AI-suggested wiki-link connections |
MermaidBlock |
OutlinerEditor |
Custom BlockNote block for Mermaid diagrams |
AISlashMenu |
OutlinerEditor |
Slash menu with AI actions |
AIFormattingToolbar |
OutlinerEditor |
Formatting toolbar with AI buttons |
AutocompletePopup |
OutlinerEditor |
Popover for wiki-link autocomplete |
LinkPreviewPopup |
OutlinerEditor |
Hover preview for wiki-links |
MathEditorModal |
OutlinerEditor |
LaTeX editor with live KaTeX preview |
MathSymbolPalette |
MathEditorModal |
Tabbed symbol palette |
MarkerBadge |
OutlinerEditor |
Colored chip for task markers |
KanbanEditDialog |
KanbanPanel |
Edit card content, marker, priority |
Navigation & Utility¶
| Component | Purpose |
|---|---|
Sidebar |
Collapsible navigation + page tree + create/delete/export |
VaultPicker |
Landing page when no vault is configured |
StratumIcon |
App icon SVG renderer |
State Management¶
Zustand Stores¶
Stratum uses four domain-specific Zustand stores, each in src/stores/:
appStore — Core Application State¶
// src/stores/appStore.ts
import { create } from 'zustand';
import type { VaultInfo, PageDto } from '../lib/types';
import * as api from '../lib/commands';
interface AppState {
vault: VaultInfo | null;
pages: PageDto[];
currentPage: PageDto | null;
loading: boolean;
error: string | null;
loadVault: () => Promise<void>;
loadPages: () => Promise<void>;
openPage: (path: string) => Promise<void>;
createPage: (path: string, title?: string) => Promise<void>;
deletePage: (path: string) => Promise<void>;
}
export const useStore = create<AppState>((set, get) => ({
vault: null,
pages: [],
currentPage: null,
loading: false,
error: null,
loadVault: async () => {
try {
set({ loading: true, error: null });
const vault = await api.getVaultInfo();
set({ vault });
} catch (e) {
set({ error: String(e) });
} finally {
set({ loading: false });
}
},
openPage: async (path: string) => {
try {
set({ loading: true });
const page = await api.openPage(path);
set({ currentPage: page });
} catch (e) {
set({ error: String(e) });
} finally {
set({ loading: false });
}
},
// ...
}));
Usage in a component:
// src/components/PageView.tsx
import { useStore } from '../stores/appStore';
export default function PageView() {
const { currentPage, openPage, deletePage } = useStore();
useEffect(() => {
if (pagePath) {
openPage(decodeURIComponent(pagePath));
}
}, [pagePath, openPage]);
// ...
}
settingsStore — Theme, AI, and Sync Configuration¶
Manages user preferences including theme colors, AI provider settings, research configuration, and sync intervals. Persisted to disk via Tauri commands.
graphStore — Graph Data and View Settings¶
Holds the node/edge data for the force-directed graph, connected components, orphans, and interactive settings (repulsion, link distance, visibility toggles).
syncStore — Git Sync Status¶
Tracks sync state (idle/syncing/error), commit log, and conflict resolution state.
Async Data Flow Pattern¶
Every async operation in a store follows the same pattern:
actionName: async () => {
try {
set({ loading: true, error: null });
const result = await api.someCommand();
set({ data: result });
} catch (e) {
set({ error: String(e) });
} finally {
set({ loading: false });
}
}
Components then use Zustand selectors to subscribe to the specific slice they need:
const { vault, loading, error } = useStore(state => ({
vault: state.vault,
loading: state.loading,
error: state.error,
}));
UI Primitives (src/components/ui/)¶
The ui/ directory holds reusable, presentation-only components. They have no business logic and accept all behavior through props.
Available Components¶
| Component | Props | Purpose |
|---|---|---|
LoadingOverlay |
message?, overlay? |
Centered spinner, absolute overlay or inline |
AILoadingOverlay |
loading: boolean, message? |
Fullscreen portal overlay for AI operations |
ErrorAlert |
message, onDismiss? |
Dismissable error Alert |
EmptyState |
icon?, message, description?, actionLabel?, onAction? |
Centered empty state with optional CTA |
PageHeader |
title, actions?, onBack? |
Consistent header bar |
ConfirmDialog |
open, title, message, onConfirm, onCancel |
Confirmation dialog |
SliderRow |
label, value, min, max, onChange |
Label + slider + display value |
PassphraseModal |
— | SSH key passphrase input |
ConflictModal |
— | Git conflict resolution |
Usage Examples¶
LoadingOverlay — wraps any section that needs a loading state:
import LoadingOverlay from '../ui/LoadingOverlay';
function MyPanel() {
const [loading, setLoading] = useState(true);
return (
<Box sx={{ position: 'relative', height: 200 }}>
{loading && <LoadingOverlay message="Loading data..." />}
{/* content */}
</Box>
);
}
EmptyState — shown when a panel has no content:
import EmptyState from '../ui/EmptyState';
function SearchPanel() {
if (results.length === 0 && !searching) {
return (
<EmptyState
icon={<SearchIcon />}
message="No results found"
description="Try a different search term"
/>
);
}
// ...
}
ConfirmDialog — destructive or important actions:
import ConfirmDialog from '../ui/ConfirmDialog';
function PageView() {
const [deleteOpen, setDeleteOpen] = useState(false);
return (
<>
<ConfirmDialog
open={deleteOpen}
title="Delete Page"
message={`Delete "${currentPage.title}"? This cannot be undone.`}
confirmLabel="Delete"
confirmColor="error"
onConfirm={handleDelete}
onCancel={() => setDeleteOpen(false)}
/>
</>
);
}
AILoadingOverlay — fullscreen portal for AI operations (replaces duplicated inline portal code):
import AILoadingOverlay from '../ui/AILoadingOverlay';
function AIFeature() {
const [busy, setBusy] = useState<string | null>(null);
return (
<>
<AILoadingOverlay loading={!!busy} message={busy ? `AI ${busy}...` : undefined} />
<button onClick={() => setBusy('rewrite')}>Rewrite</button>
</>
);
}
Adding a New UI Primitive¶
- Create the component in
src/components/ui/(keep it < 50 lines) - Every component must define an
interface Propsat the top - Export it from
src/components/ui/index.ts - Add it to the component table in
AGENTS.md
Custom Hooks¶
All hooks live in src/lib/hooks/ and use named exports (not default exports).
useAsyncData<T>¶
Generic async fetcher with loading/error/data states. Ideal for one-shot data fetching.
// src/lib/hooks/useAsyncData.ts
export function useAsyncData<T>(
fetcher: () => Promise<T>,
deps: unknown[] = [],
): { data: T | null; loading: boolean; error: string | null; refresh: () => Promise<void> }
Usage:
import { useAsyncData } from '../lib/hooks/useAsyncData';
function MyPanel() {
const { data, loading, error, refresh } = useAsyncData(
() => api.listPages(),
[], // re-fetch when these change
);
if (loading) return <LoadingOverlay />;
if (error) return <ErrorAlert message={error} />;
if (!data) return <EmptyState message="No pages" />;
return <List>{data.map(page => <ListItem key={page.path}>{page.title}</ListItem>)}</List>;
}
useDebounce<T>¶
Debounces a value by a given delay. Useful for search-as-you-type.
Usage:
import { useDebounce } from '../lib/hooks/useDebounce';
function SearchPanel() {
const [query, setQuery] = useState('');
const debouncedQuery = useDebounce(query, 300);
useEffect(() => {
if (debouncedQuery.trim()) {
api.searchBlocks(debouncedQuery, 20);
}
}, [debouncedQuery]);
// ...
}
useAutoSave¶
Debounced save with manual flush support.
// src/lib/hooks/useAutoSave.ts
export function useAutoSave(
saveFn: () => Promise<void>,
delay?: number,
): { scheduleSave: () => void; flush: () => Promise<void>; saving: boolean }
Usage:
import { useAutoSave } from '../lib/hooks/useAutoSave';
function Editor({ content, pagePath }: { content: string; pagePath: string }) {
const { scheduleSave, flush, saving } = useAutoSave(async () => {
await api.savePage(pagePath, content);
}, 500);
useEffect(() => {
// Flush pending saves before navigation
return () => { flush(); };
}, [flush]);
return (
<>
{saving && <Typography variant="caption">Saving...</Typography>}
<textarea value={content} onChange={scheduleSave} />
</>
);
}
useResponsive¶
Breakpoint detection for mobile vs desktop layouts.
// src/lib/hooks/useResponsive.ts
export function useResponsive(): { isMobile: boolean; isDesktop: boolean; width: number }
The breakpoint is 768px (tablet portrait width).
Usage:
import { useResponsive } from '../lib/hooks/useResponsive';
function MyPanel() {
const { isMobile, isDesktop } = useResponsive();
if (isMobile) return <MobileVariant />;
return <DesktopVariant />;
}
useCtrlHeld¶
Tracks whether Ctrl (or Meta on macOS) is currently held. Used for preview-on-hover behavior.
// src/lib/useCtrlHeld.ts
import { useEffect, useRef } from 'react';
export function useCtrlHeld() {
const ctrlHeld = useRef(false);
useEffect(() => {
const down = (e: KeyboardEvent) => {
if (e.key === 'Control' || e.key === 'Meta') ctrlHeld.current = true;
};
const up = (e: KeyboardEvent) => {
if (e.key === 'Control' || e.key === 'Meta') ctrlHeld.current = false;
};
const blur = () => { ctrlHeld.current = false; };
window.addEventListener('keydown', down);
window.addEventListener('keyup', up);
window.addEventListener('blur', blur);
return () => {
window.removeEventListener('keydown', down);
window.removeEventListener('keyup', up);
window.removeEventListener('blur', blur);
};
}, []);
return ctrlHeld;
}
Usage (from BacklinksPanel):
import { useCtrlHeld } from '../lib/useCtrlHeld';
function BacklinksPanel() {
const ctrlHeld = useCtrlHeld();
const handleMouseEnter = (item, e) => {
setTimeout(async () => {
if (!ctrlHeld.current) return; // Only preview when Ctrl is held
const page = await api.openPage(item.source_page);
// show preview popover
}, 200);
};
// ...
}
useMathInline¶
ProseMirror plugin for inline KaTeX rendering within the BlockNote editor.
Component Sizing Rules¶
These rules are enforced by convention — no file or component should grow past these limits:
| Metric | Limit | Action |
|---|---|---|
| File lines | < 500 | Split into sub-modules |
| Component JSX | < 50 lines | Extract sub-components |
| Inline function | < 20 lines | Extract to module-level |
| Same logic in 2+ files | 0 duplicates | Extract to src/lib/ or hooks/ |
| Props interface | Required | Every component must define interface Props |
Duplicate Detection Workflow¶
When you see identical logic in two or more files:
- Extract the shared logic into a reusable hook or utility in
src/lib/hooks/orsrc/lib/ - If it's a presentational element with no business logic, add it to
src/components/ui/ - If it's a larger component (50+ lines of JSX), promote it to a shared component
Real example — AI loading overlays were duplicated in both AISlashMenu.tsx and AIFormattingToolbar.tsx:
// Before: inline createPortal in both files (duplicated ~8 lines each)
{loading && createPortal(
<Box sx={{ position: 'fixed', inset: 0, zIndex: 9999, ... }}>
<CircularProgress size={20} />
<Typography variant="body2">{message}</Typography>
</Box>,
document.body,
)}
// After: extracted to src/components/ui/AILoadingOverlay.tsx
<AILoadingOverlay loading={!!loading} message={message} />
Desktop + Mobile Pattern¶
Stratum components that need platform-specific implementations follow this folder convention:
src/components/FeaturePanel/
├── index.tsx # Desktop/web implementation
├── FeaturePanel.mobile.tsx # Mobile variant
├── FeaturePanel.shared.tsx # Shared logic, hooks, types
└── FeaturePanel.test.tsx # Tests
The index.tsx uses useResponsive to conditionally render:
import { useResponsive } from '../../lib/hooks/useResponsive';
import { FeaturePanelDesktop } from './index';
import { FeaturePanelMobile } from './FeaturePanel.mobile';
export default function FeaturePanel() {
const { isMobile } = useResponsive();
if (isMobile) return <FeaturePanelMobile />;
return <FeaturePanelDesktop />;
}
The .shared.tsx file holds:
- Type definitions
- Shared hooks and data transformations
- Pure rendering helpers that don't depend on layout
This keeps mobile-specific code from bloating the desktop bundle and prevents responsive conditionals from spreading through component logic.
Adding a New Feature¶
Follow this step-by-step process when adding a new panel or feature:
1. Create the Component¶
// src/components/MyFeature.tsx
import Box from '@mui/material/Box';
import Typography from '@mui/material/Typography';
import { useStore } from '../stores/appStore';
interface Props {
/* document all props */
}
export default function MyFeature() {
const { vault } = useStore();
return (
<Box sx={{ p: 2 }}>
<Typography variant="h5">My Feature</Typography>
</Box>
);
}
Rules:
- Define interface Props at the top of every component (even if empty initially)
- Keep JSX under 50 lines — extract sub-components early
- Use MUI sx prop exclusively for styling — no CSS modules, no styled-components
- Use useStore selectors to subscribe to Zustand state
2. Add the Route¶
// src/App.tsx
import MyFeature from './components/MyFeature';
// Inside <Routes>
<Route path="/my-feature" element={<MyFeature />} />
Add the route link to the Sidebar component.
3. Create the Store (if needed)¶
// src/stores/myFeatureStore.ts
import { create } from 'zustand';
import * as api from '../lib/commands';
interface MyFeatureState {
data: MyType[];
loading: boolean;
fetchData: () => Promise<void>;
}
export const useMyFeatureStore = create<MyFeatureState>((set) => ({
data: [],
loading: false,
fetchData: async () => {
set({ loading: true });
try {
const result = await api.someCommand();
set({ data: result });
} finally {
set({ loading: false });
}
},
}));
4. Register Tauri Commands (if needed)¶
Add a typed wrapper in src/lib/commands.ts:
// src/lib/commands.ts
export async function myFeatureCommand(param: string): Promise<MyResult> {
return invoke('my_feature_command', { param });
}
Then implement the Rust command in src-tauri/src/commands/.
5. Add the User Guide¶
Create docs/guide/my-feature.md documenting the feature from the user's perspective.
6. Update AGENTS.md¶
Add the new component to the Frontend Components table and crate to the Crate Dependency Graph if applicable.
7. Add Tests¶
// src/components/MyFeature.test.tsx
import { describe, it, expect } from 'vitest';
import { render, screen } from '@testing-library/react';
import MyFeature from './MyFeature';
describe('MyFeature', () => {
it('renders the feature title', () => {
render(<MyFeature />);
expect(screen.getByText('My Feature')).toBeDefined();
});
});
Code Style¶
TypeScript Strictness¶
tsconfig.jsonenablesstrict: truewithnoUncheckedIndexedAccess- No
as any— use proper type narrowing orunknown+ type guards - No
@ts-ignoreor@ts-expect-error— fix the type instead - Named exports for hooks and utilities, default exports for components
Import Order¶
// 1. React / framework
import { useState, useEffect } from 'react';
import { useNavigate } from 'react-router-dom';
// 2. Third-party libraries
import Box from '@mui/material/Box';
import { create } from 'zustand';
// 3. Internal modules (sorted by depth)
import { useStore } from '../stores/appStore';
import * as api from '../lib/commands';
import type { PageDto } from '../lib/types';
import LoadingOverlay from './ui/LoadingOverlay';
Styling with MUI sx¶
Use the sx prop for all styles. Avoid styled() API or CSS modules entirely.
// ✅ Correct
<Box sx={{ display: 'flex', gap: 1.5, p: 2, bgcolor: 'background.paper' }}>
// ❌ Avoid
const StyledBox = styled(Box)(({ theme }) => ({
display: 'flex',
gap: theme.spacing(1.5),
}));
The sx prop accepts all MUI theme tokens (background.paper, text.secondary, divider, etc.) and supports shorthand properties (p: 2 = padding 16px, gap: 1.5 = gap 12px).
Component Patterns¶
Props interface (required at top of every component):
Conditional rendering:
// Loading state
if (loading) return <LoadingOverlay message="Loading..." />;
// Error state
if (error) return <ErrorAlert message={error} onDismiss={clearError} />;
// Empty state
if (data.length === 0) return <EmptyState message="No items found" />;
// Happy path
return <List>{data.map(item => <ListItem key={item.id}>{item.name}</ListItem>)}</List>;
Event handlers:
const handleClick = useCallback(async () => {
try {
setLoading(true);
await api.someCommand();
} catch (e) {
console.error('[MyFeature] action failed:', e);
} finally {
setLoading(false);
}
}, [deps]);
Always wrap async event handlers in try/catch/finally and log errors with a [ComponentName] prefix.
Testing¶
Setup¶
Tests use Vitest with React Testing Library. Test files co-locate with components:
src/components/FeaturePanel/
├── FeaturePanel.tsx
├── FeaturePanel.mobile.tsx
├── FeaturePanel.shared.tsx
└── FeaturePanel.test.tsx
Writing Tests¶
Component tests:
import { describe, it, expect, vi } from 'vitest';
import { render, screen, fireEvent } from '@testing-library/react';
import MyComponent from './MyComponent';
describe('MyComponent', () => {
it('renders the title', () => {
render(<MyComponent title="Hello" />);
expect(screen.getByText('Hello')).toBeDefined();
});
it('calls onClick when button is pressed', () => {
const onClick = vi.fn();
render(<MyComponent onClick={onClick} />);
fireEvent.click(screen.getByRole('button'));
expect(onClick).toHaveBeenCalledOnce();
});
});
Hook tests:
import { describe, it, expect } from 'vitest';
import { renderHook, act } from '@testing-library/react';
import { useDebounce } from './useDebounce';
describe('useDebounce', () => {
it('returns initial value immediately', () => {
const { result } = renderHook(() => useDebounce('hello', 300));
expect(result.current).toBe('hello');
});
});
Running Tests¶
npm run test # Run all tests
npm run test:watch # Watch mode
npm run test:coverage # With coverage report
Vite Configuration¶
The Vite config (vite.config.ts) handles:
- React plugin with Fast Refresh
- Path aliases (
@/maps tosrc/) - Build splitting for route-based lazy loading
- Environment variables via
import.meta.env.VITE_*
// vite.config.ts (simplified)
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
import path from 'path';
export default defineConfig({
plugins: [react()],
resolve: {
alias: { '@': path.resolve(__dirname, 'src') },
},
build: {
rollupOptions: {
output: {
manualChunks: {
vendor: ['react', 'react-dom', '@mui/material'],
editor: ['@blocknote/react', '@blocknote/core'],
graph: ['react-force-graph-2d', 'd3-force'],
},
},
},
},
});
Performance Guidelines¶
| Metric | Target |
|---|---|
| Component render | < 10ms (DevTools Profiler) |
| Re-render scope | Minimize via Zustand selectors, React.memo sparingly |
| Bundle contribution | Each panel < 50KB gzipped |
| Images/Assets | Lazy-loaded, WebP format |
| Event handlers | Debounce at 300ms (search), 500ms (save) |
Zustand Selector Best Practices¶
// ✅ Good — subscribes only to vault
const vault = useStore(state => state.vault);
// ✅ Good — subscribes to two specific values
const { loading, error } = useStore(state => ({
loading: state.loading,
error: state.error,
}));
// ❌ Avoid — subscribes to entire store, re-renders on every change
const store = useStore();
Avoiding Unnecessary Re-renders¶
- Extract
useCallbackwrappers for function props passed to children - Use
startTransitionfor non-urgent state updates:
- Memoize expensive computations with
useMemo - Keep context values narrow — avoid putting entire state objects in context