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
│ ├── recentsEvents.ts # Backend "pages changed" → recents refresh bridge
│ ├── 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
│ ├── recentsStore.ts # Sidebar "Recent" list + debounced refresh
│ └── onboardingStore.ts # First-run wizard state + install orchestration
├── 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 panel: visual filter builder + raw Datalog editor |
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 |
8-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 (legacy, unused — superseded by WikiLinkAutocomplete) |
WikiLinkAutocomplete |
OutlinerEditor |
BlockNote SuggestionMenuController wiki-link autocomplete ([[ → search pages → insert slug/path link; path-qualified for duplicate slugs) |
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 |
EditorLoadingState / EditorErrorState |
OutlinerEditor (desktop & mobile) |
Shared loading / error placeholders (file EditorStates.tsx) |
SaveStatusChip |
OutlinerEditor (desktop & mobile) |
Floating save-status chip (saving / saved-at label) |
MarkerBadgeRow |
OutlinerEditor (desktop & mobile) |
Page marker badge strip (task markers on the page) |
DeadLinkCreatePopup |
OutlinerEditor (desktop & mobile) |
"Create page" popover for unresolved [[wiki-links]] |
DesktopBlockContextMenu |
OutlinerEditor (desktop) |
Right-click block menu (read aloud / set-change-clear reminder) |
MobileInsertMenu |
OutlinerEditor (mobile) |
Floating insert menu (paragraph/headings/code/mermaid/math) |
MobileMarkerMenu |
OutlinerEditor (mobile) |
Floating marker menu (TODO..CANCELLED) |
MobileBlockActionsMenu |
OutlinerEditor (mobile) |
Long-press block menu (cut/copy/read-aloud/delete) |
KanbanEditDialog |
KanbanPanel |
Edit card content, marker, priority |
Navigation & Utility¶
| Component | Purpose |
|---|---|
Sidebar |
Collapsible navigation + page tree + create/delete/export |
PageTree |
Page list with create/delete and new-page form (flat list, default) |
PageTreeGrouped |
Folder-tree variant of the page list when pages_view = "tree" (sidebar + PagesHome) |
MovePageDialog |
Rename/move a page by path (sidebar row action / PageView menu); calls move_page, backend rewrites inbound links |
VaultPicker |
Landing page when no vault is configured |
StratumIcon |
App icon SVG renderer |
State Management¶
Zustand Stores¶
Stratum uses six 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, mobile 3D opt-in).
3D capability detection¶
src/lib/graph3d.ts provides detectGraph3dSupport(), which reports whether the current device can render the 3D graph (WebGL availability and RAM/core constraints). It is used by the mobile graph variant to fall back to the 2D force layout on constrained devices. See docs/guide/graph-view.md.
syncStore — Git Sync Status¶
Tracks sync state (idle/syncing/error), commit log, and conflict resolution state.
recentsStore — Sidebar "Recent" List¶
Holds the recent-pages list shown in the sidebar and exposes a single debounced refresh() entry point. refresh() reloads from the canonical source (list_pages, which orders by modified_at DESC), drops the journals/ namespace, and only publishes a new list when the derived data actually changed — so event sources can call it freely without causing churn or disturbing user scroll/selection. Rapid successive triggers coalesce into a single reload within RECENTS_DEBOUNCE_MS (500ms trailing).
// event source (autosave, page ops, watcher)
import { useRecentsStore } from '../stores/recentsStore';
useRecentsStore.getState().refresh();
Derivation is exposed as pure functions (deriveRecents, isRecentPage, sameRecents) for unit testing without IPC.
Event wiring. The store is kept current by the event sources listed below; no manual refresh is needed:
- Editor autosave/edit —
OutlinerEditor's save path callsuseRecentsStore.getState().refresh(). - Page create/open/delete — the
appStoreactions (createPage,openPage,deletePage) callrefresh()after the command succeeds, and the two dead-link "create page" entry points in the editors do the same. - Watcher / external changes — the backend file watcher emits a
pages-changedTauri event after it syncs an external.mdchange into the block store;src/lib/recentsEvents.tsbridges that event intorefresh()(subscribed at app level inApp.tsxwhen a vault is present).
All paths converge on the store's debounced refresh(), so bursts (a git pull hitting many files, rapid typing) collapse into a single reload.
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, onClose? |
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 |
SettingsSection |
title?, description?, divider?, variant |
Settings section wrapper (desktop/mobile parity) |
SettingsTab |
value, onChange, tabs |
Settings tab bar (desktop SettingsPage) |
SettingsAccordion |
summary, expanded, onChange |
Collapsible settings accordion (mobile) |
SettingsRow |
label, description?, control? / checked + onCheckedChange |
Labeled settings row (toggle or generic control) |
ThemeColorEditor |
label, value, presets, onChange, variant? |
Theme color presets + custom picker + hex field |
ThemeFontSizeSlider |
value, onChange, variant? |
Theme font-size slider with scale captions |
PassphraseModal |
— | SSH key passphrase input |
ConflictModal |
— | Git conflict resolution |
Settings primitives (src/components/ui/settings/)¶
The settings/ sub-folder holds the shared settings primitives that the
desktop SettingsPage tabs and the mobile settings sections both build on, so
the two surfaces stay in visual and behavioral parity.
Components available: SettingsSection, SettingsTab, SettingsAccordion,
SettingsRow, ThemeColorEditor, and ThemeFontSizeSlider. All theme-related
sizing (swatch width/height, color-input width, slider max width, presets, and
spacing) lives in themeConstants.ts — the single source of truth shared by
the desktop and mobile theme editors. Do not hardcode a swatch size or preset
elsewhere; add it to themeConstants.ts instead.
All desktop SettingsPage tabs are built on these primitives, and the AI tab is
decomposed into AiConfigSection / SttConfigSection / TtsConfigSection.
Usage example:
import { SettingsSection, SettingsRow, SETTINGS_LOCALES } from '../ui/settings';
<SettingsSection title={t('settings.theme')} variant="desktop">
<SettingsRow
label={t('settings.dark_mode')}
checked={theme.dark_mode}
onCheckedChange={v => onThemeChange({ dark_mode: v })}
/>
</SettingsSection>
The mobile settings page composes SettingsSection + SettingsAccordion for
EVERY section (Vault, Theme, AI, Research, Developer, Sync, Tray,
Notifications) — one accordion per desktop tab. The AI accordion reuses the
shared desktop AI sections (AiConfigSection / SttConfigSection /
TtsConfigSection / VoiceCaptureSection) so mobile and desktop cannot drift.
Each section is a collapsible accordion whose summary toggles between a
settings.configure_* ("Configure …") and settings.hide_*_config ("Hide …
configuration") label. The Vault accordion is open by default (defaultOpen
prop); all other accordions start collapsed. Accordion children are kept in the
DOM (no unmountOnExit) so the section contents remain queryable and testable
while collapsed.
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.
useBacklinkNavigation / backlink navigation state¶
Modifier-click navigation from backlinks. useBacklinkNavigation() (in src/lib/backlinkNavigation.ts)
returns a handler that:
- on a plain click, behaves exactly like the previous behavior (routes to the target note),
- on a Ctrl/Cmd+click, captures the current editor scroll offset into
src/stores/navigationStore.ts before navigating, so the source note can be restored.
useRestoreSourceScroll(pagePath) (same module) restores the captured scroll when the user
returns to the source note. The capture/restore mechanism walks up from the editor container
(.blocknote-editor-container) to the nearest scrollable ancestor, since the editor itself does
not expose a stable scroll element.
// src/lib/backlinkNavigation.ts
export function useBacklinkNavigation(): (
sourcePage: string,
e?: { ctrlKey?: boolean; metaKey?: boolean },
) => void;
export function useRestoreSourceScroll(pagePath: string | null): () => void;
Usage (from BacklinksPanel):
import { useBacklinkNavigation } from '../../lib/backlinkNavigation';
function BacklinksPanel() {
const navigateBacklink = useBacklinkNavigation();
// ...
<ListItemButton onClick={(e) => navigateBacklink(item.source_page, e)}>
{item.source_page}
</ListItemButton>;
}
useEditorReminders¶
Reminder dialog state, the applyDue metadata mutation, and the reminder-chip click
delegation for the Outliner editor. Used by both the desktop and mobile variants.
// src/components/OutlinerEditor/useEditorReminders.ts
export function useEditorReminders(args: {
editor: EditorData['editor'];
blockMetaRef: MutableRefObject<Map<string, BlockMeta>>;
persistBlocks: (blocks: any[]) => void;
containerRef: RefObject<HTMLDivElement | null>;
status: string;
}): {
dialogOpen: boolean;
dialogDue: string | null;
openReminderForBlock: (blockId: string) => void;
handleReminderSave: (isoUtc: string | null) => void;
closeDialog: () => void;
applyDue: (blockId: string, isoUtc: string | null) => void;
};
Usage (from OutlinerEditor.mobile):
const { dialogOpen, dialogDue, handleReminderSave, closeDialog } = useEditorReminders({
editor,
blockMetaRef,
persistBlocks,
containerRef,
status,
});
useDesktopBlockMenu¶
Desktop right-click block menu plus read-aloud and reminder actions for the Outliner
editor. Composes useEditorReminders and adds the context-menu state, read-aloud, and
reminder open/clear helpers.
// src/components/OutlinerEditor/useDesktopBlockMenu.ts
export function useDesktopBlockMenu(args: {
editor: EditorData['editor'];
blockMetaRef: MutableRefObject<Map<string, BlockMeta>>;
persistBlocks: (blocks: any[]) => void;
containerRef: RefObject<HTMLDivElement | null>;
status: string;
});
useMobileBlockContextMenu¶
Mobile long-press block context menu (cut / copy / speak / delete) for the Outliner editor. Owns the long-press detection and the action handler.
// src/components/OutlinerEditor/useMobileBlockContextMenu.ts
export function useMobileBlockContextMenu({ editor }: { editor: EditorData['editor'] });
useEditorAutoSave¶
Debounced auto-save with the ED-07 no-op skip and flush-on-unmount for the Outliner editor. Owns the pending-save ref, the debounce timer, the deterministic-serialization skip, and the flush-on-unmount safety net.
// src/components/OutlinerEditor/useEditorData/save.ts
export function useEditorAutoSave(args: {
pagePath: string;
editor: EditorData['editor'];
blockMetaRef: MutableRefObject<Map<string, BlockMeta>>;
loadedSnapshotRef: MutableRefObject<string | null>;
status: string;
isProcessingRef: MutableRefObject<boolean>;
onPersistScheduled: () => void;
});
useWikiLinkInteractions¶
Dead-link detection, Ctrl/Cmd-hover preview, and hover/click event delegation for the
Outliner editor. Highlights unresolved [[wiki-links]], drives the preview popup, and
prevents browser navigation on link clicks.
// src/components/OutlinerEditor/useEditorData/wikiLinks.ts
export function useWikiLinkInteractions(args: {
containerRef: RefObject<HTMLDivElement | null>;
status: string;
pagePath: string;
ctrlHeld: MutableRefObject<boolean>;
setPreview: Dispatch<SetStateAction<PreviewState>>;
});
useBlockLoading¶
Backend block load, DTO conversion, and marker extraction for the Outliner editor.
Suppresses the programmatic onChange fired by replaceBlocks (ED-07) and moves the
editor to the ready state.
// src/components/OutlinerEditor/useEditorData/loadBlocks.ts
export function useBlockLoading(args: {
pagePath: string;
editor: EditorData['editor'];
blockMetaRef: MutableRefObject<Map<string, BlockMeta>>;
loadedSnapshotRef: MutableRefObject<string | null>;
isProcessingRef: MutableRefObject<boolean>;
setStatus: Dispatch<SetStateAction<string>>;
setError: Dispatch<SetStateAction<string | null>>;
setPageMarkers: Dispatch<SetStateAction<string[]>>;
onLoaded: () => void;
});
Internationalization (i18n)¶
All user-facing strings MUST go through t() (useTranslation from
react-i18next) — never hardcode English copy. The i18n layer is initialized in
src/main.tsx via initI18n() (best-effort — a failure never blocks boot) and
re-targeted at runtime with setLocale() when the user changes the language in
Settings → Appearance. The active locale is persisted through the normal
settings pipeline (getSettings/saveSettings → config.locale).
Resources live in src/i18n/locales/<lang>.json:
en is the source of truth; de/it/fr/es are fully translated;
zh/ja/el are placeholders mirroring en.
Rules:
- Wrap every user-facing literal:
t('common.save'), never"Save". - Key names use snake_case under a component/feature section (e.g.
graph.refresh_graph), mirroring the section structure ofen.json. - Dynamic values use interpolation:
t('graph.showing', { count, total, cap }). - Never add a key to one locale file only — add it to all 8 files in the
same commit (or the
localeParitygate fails). - Use the
Intlhelpers fromsrc/i18n(formatDate,formatNumber,localizedMonthNames,localizedDayHeaders) instead of ad-hoc formatting so dates/numbers follow the active locale.
Two automated gates protect this in the test suite (npm run test):
src/i18n/i18nGate.test.ts— static AST scan oversrc/that FAILS if a user-facing string is hardcoded as JSX text or alabel/placeholder/title/aria-labelliteral outsidet(). Allowlist covers only non-UI literals (CSS, imports, enum tokens, units). Seedocs/guide/localization.mdfor details.src/i18n/localeParity.test.ts— verifies identical flattened key sets across all 8 locales, no extra keys, placeholders mirroren, and full locales contain no untranslated English beyond the reviewed allowlist.
Accessibility¶
Accessibility was swept in tandem with the i18n work (same component-touch scope). Every component touched by the string sweep must remain reachable and labeled:
- aria-labels: interactive elements without visible text (icon-only
buttons,
IconButton) MUST have anaria-label— always throught(). - Focus management: modals/dialogs move focus on open and restore it on
close;
ConfirmDialog,PassphraseModal,ConflictModal, and theResponsiveDialogvariants follow this pattern. - Keyboard reachability: all actions available via mouse must also be
reachable via keyboard (MUI
ListItemButton/IconButton/ToggleButtonprovide this; custom clickable divs must addrole="button"+tabIndex+ key handling). - Contrast: text meets WCAG AA against its background;
text.secondaryand disabled states were checked in the theme sweep.
When adding a new component, include the same three checks (label, focus,
keyboard) in the same PR — the i18n gate flags missing aria-label literals as
hardcoded strings, so an unlabeled icon button will fail npm run test.
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