9.7 KiB
CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
Project Overview
Grimoire is a Nostr protocol explorer and developer tool. It's a tiling window manager interface where each window is a Nostr "app" (profile viewer, event feed, NIP documentation, etc.). Commands are launched Unix-style via Cmd+K palette.
Stack: React 19 + TypeScript + Vite + TailwindCSS + Jotai + Dexie + Applesauce
Core Architecture
Dual State System
UI State (src/core/state.ts + src/core/logic.ts):
- Jotai atom persisted to localStorage
- Pure functions for all mutations:
(state, payload) => newState - Manages workspaces, windows, layout tree, active account
Nostr State (src/services/event-store.ts):
- Singleton
EventStorefrom applesauce-core - Single source of truth for all Nostr events
- Reactive: components subscribe via hooks, auto-update on new events
- Handles replaceable events automatically (profiles, contact lists, etc.)
Relay State (src/services/relay-liveness.ts):
- Singleton
RelayLivenesstracks relay health across sessions - Persisted to Dexie
relayLivenesstable - Maintains failure counts, backoff states, last success/failure times
- Prevents repeated connection attempts to dead relays
Nostr Query State Machine (src/lib/req-state-machine.ts + src/hooks/useReqTimelineEnhanced.ts):
- Accurate tracking of REQ subscriptions across multiple relays
- Distinguishes between
LIVE,LOADING,PARTIAL,OFFLINE,CLOSED, andFAILEDstates - Solves "LIVE with 0 relays" bug by tracking per-relay connection state and event counts
- Pattern: Subscribe to relays individually to detect per-relay EOSE and errors
Critical: Don't create new EventStore, RelayPool, or RelayLiveness instances - use the singletons in src/services/
Window System
Windows are rendered in a recursive binary split layout (via react-mosaic-component):
- Each window has:
id(UUID),appId(type identifier),title,props - Layout is a tree: leaf nodes are window IDs, branch nodes split space
- Never manipulate layout tree directly - use callbacks from mosaic
Workspaces are virtual desktops, each with its own layout tree.
Command System
src/types/man.ts defines all commands as Unix man pages:
- Each command has an
appId(which app to open) andargParser(CLI → props) - Parsers can be async (e.g., resolving NIP-05 addresses)
- Command pattern: user types
profile alice@example.com→ parser resolves → opens ProfileViewer with props
Global Flags (src/lib/global-flags.ts):
- Global flags work across ALL commands and are extracted before command-specific parsing
--title "Custom Title"- Override the window title (supports quotes, emoji, Unicode)- Example:
profile alice --title "👤 Alice" - Example:
req -k 1 -a npub... --title "My Feed" - Position independent: can appear before, after, or in the middle of command args
- Example:
- Tokenization uses
shell-quotelibrary for proper quote/whitespace handling - Display priority:
customTitle>dynamicTitle(from DynamicWindowTitle) >appId.toUpperCase()
Reactive Nostr Pattern
Applesauce uses RxJS observables for reactive data flow:
- Events arrive from relays → added to EventStore
- Queries/hooks subscribe to EventStore observables
- Components re-render automatically when events update
- Replaceable events (kind 0, 3, 10000-19999, 30000-39999) auto-replace older versions
Use hooks like useProfile(), useNostrEvent(), useTimeline() - they handle subscriptions.
Applesauce Helpers & Caching
Critical Performance Insight: Applesauce helpers cache computed values internally using symbols. You don't need useMemo when calling applesauce helpers.
// ❌ WRONG - Unnecessary memoization
const title = useMemo(() => getArticleTitle(event), [event]);
const text = useMemo(() => getHighlightText(event), [event]);
// ✅ CORRECT - Helpers cache internally
const title = getArticleTitle(event);
const text = getHighlightText(event);
How it works: Helpers use getOrComputeCachedValue(event, symbol, compute) to cache results on the event object. The first call computes and caches, subsequent calls return the cached value instantly.
Available Helpers (from applesauce-core/helpers):
- Tags:
getTagValue(event, name)- get single tag value (searches hidden tags first) - Article:
getArticleTitle,getArticleSummary,getArticleImage,getArticlePublished - Highlight:
getHighlightText,getHighlightSourceUrl,getHighlightSourceEventPointer,getHighlightSourceAddressPointer,getHighlightContext,getHighlightComment - Profile:
getProfileContent(event),getDisplayName(metadata, fallback) - Pointers:
parseCoordinate(aTag),getEventPointerFromETag,getAddressPointerFromATag,getProfilePointerFromPTag - Reactions:
getReactionEventPointer(event),getReactionAddressPointer(event) - Threading:
getNip10References(event)- parses NIP-10 thread tags - Filters:
isFilterEqual(a, b),matchFilter(filter, event),mergeFilters(...filters) - And 50+ more - see
node_modules/applesauce-core/dist/helpers/index.d.ts
Custom Grimoire Helpers (not in applesauce):
getTagValues(event, name)- plural version to get array of tag values (src/lib/nostr-utils.ts)resolveFilterAliases(filter, pubkey, contacts)- resolves$me/$contactsaliases (src/lib/nostr-utils.ts)getDisplayName(pubkey, metadata)- enhanced version with pubkey fallback (src/lib/nostr-utils.ts)- NIP-34 git helpers (src/lib/nip34-helpers.ts) - wraps
getTagValuefor repository, issue, patch metadata - NIP-C0 code snippet helpers (src/lib/nip-c0-helpers.ts) - wraps
getTagValuefor code metadata
When to use useMemo:
- ✅ Complex transformations not using applesauce helpers (sorting, filtering, mapping)
- ✅ Creating objects/arrays for dependency tracking (options, configurations)
- ✅ Expensive computations that don't call applesauce helpers
- ❌ Direct calls to applesauce helpers (they cache internally)
- ❌ Grimoire helpers that wrap
getTagValue(caching propagates)
Key Conventions
- Path Alias:
@/=./src/ - Styling: Tailwind + HSL CSS variables (theme tokens defined in
index.css) - Types: Prefer types from
applesauce-core, extend insrc/types/when needed - File Organization: By domain (
nostr/,ui/,services/,hooks/,lib/) - State Logic: All UI state mutations go through
src/core/logic.tspure functions
Important Patterns
Adding New Commands:
- Add entry to
manPagesinsrc/types/man.ts - Create parser in
src/lib/*-parser.tsif argument parsing needed - Create viewer component for the
appId - Wire viewer into window rendering (
WindowTitle.tsx)
Working with Nostr Data:
- Event data comes from singleton EventStore (reactive)
- Metadata cached in Dexie (
src/services/db.ts) for offline access - Active account stored in Jotai state, synced via
useAccountSynchook - Use inbox/outbox relay pattern for user relay lists
Event Rendering:
- Feed renderers:
KindRenderercomponent withrenderersregistry insrc/components/nostr/kinds/index.tsx - Detail renderers:
DetailKindRenderercomponent withdetailRenderersregistry - Registry pattern allows adding new kind renderers without modifying parent components
- Falls back to
DefaultKindRendereror feed renderer for unregistered kinds - Naming Convention: Use human-friendly names for renderers (e.g.,
LiveActivityRendererinstead ofKind30311Renderer) to make code understandable without memorizing kind numbers- Feed renderer:
[Name]Renderer.tsx(e.g.,LiveActivityRenderer.tsx) - Detail renderer:
[Name]DetailRenderer.tsx(e.g.,LiveActivityDetailRenderer.tsx)
- Feed renderer:
Mosaic Layout:
- Layout mutations via
updateLayout()callback only - Don't traverse or modify layout tree manually
- Adding/removing windows handled by
logic.tsfunctions
Error Boundaries:
- All event renderers wrapped in
EventErrorBoundarycomponent - Prevents one broken event from crashing entire feed or detail view
- Provides diagnostic UI with retry capability and error details
- Error boundaries auto-reset when event changes
Testing
Test Framework: Vitest with node environment
Running Tests:
npm test # Watch mode (auto-runs on file changes)
npm run test:ui # Visual UI for test exploration
npm run test:run # Single run (CI mode)
Test Conventions:
- Test files:
*.test.tsor*.test.tsxcolocated with source files - Focus on testing pure functions and parsing logic
- Use descriptive test names that explain behavior
- Group related tests with
describeblocks
What to Test:
- Parsers (
src/lib/*-parser.ts): All argument parsing logic, edge cases, validation - Pure functions (
src/core/logic.ts): State mutations, business logic - Utilities (
src/lib/*.ts): Helper functions, data transformations - Not UI components: React components tested manually (for now)
Example Test Structure:
describe("parseReqCommand", () => {
describe("kind flag (-k, --kind)", () => {
it("should parse single kind", () => {
const result = parseReqCommand(["-k", "1"]);
expect(result.filter.kinds).toEqual([1]);
});
it("should deduplicate kinds", () => {
const result = parseReqCommand(["-k", "1,3,1"]);
expect(result.filter.kinds).toEqual([1, 3]);
});
});
});
Critical Notes
- React 19 features in use (ensure compatibility)
- LocalStorage persistence has quota handling built-in
- Dark mode is default (controlled via HTML class)
- EventStore handles event deduplication and replaceability automatically
- Run tests before committing changes to parsers or core logic