chore: remove old docs

This commit is contained in:
Alejandro Gómez
2026-04-01 09:13:37 +02:00
parent 4e694945b3
commit 799538e5f6
9 changed files with 0 additions and 4541 deletions

View File

@@ -1,456 +0,0 @@
# Accessibility Improvement Plan
This document outlines the accessibility improvements planned for Grimoire to achieve WCAG 2.1 AA compliance.
## Current State Assessment
**Current Coverage**: ~16% of components have ARIA attributes
| Category | Status | Details |
|----------|--------|---------|
| Keyboard Navigation | ⚠️ Partial | Cmd+K works, limited elsewhere |
| Screen Reader Support | ⚠️ Partial | Basic labels, missing live regions |
| Focus Management | ✅ Good | Visible focus rings |
| Color Contrast | ⚠️ Unchecked | No WCAG verification |
| Loading States | ✅ Good | Skeletons with aria-busy |
| Error Handling | ⚠️ Partial | Errors not announced |
---
## Phase 1: Foundation (Priority: High)
### 1.1 Keyboard Navigation Improvements
**Files to update**: `CommandLauncher.tsx`, `Home.tsx`, `TabBar.tsx`
```typescript
// Add keyboard shortcuts help modal (Cmd+?)
const KEYBOARD_SHORTCUTS = [
{ keys: ['⌘', 'K'], description: 'Open command palette' },
{ keys: ['⌘', '1-9'], description: 'Switch workspace' },
{ keys: ['Escape'], description: 'Close dialog/modal' },
{ keys: ['↑', '↓'], description: 'Navigate list items' },
{ keys: ['Enter'], description: 'Select/confirm' },
];
```
**Tasks**:
- [ ] Create `KeyboardShortcutsDialog` component
- [ ] Add `Cmd+?` shortcut to show help
- [ ] Add keyboard navigation to window tiles (focus, close, resize)
- [ ] Implement roving tabindex for command list
- [ ] Add skip links for main content areas
### 1.2 Focus Management
**Files to update**: `components/ui/dialog.tsx`, `GlobalAuthPrompt.tsx`
```typescript
// Focus trap for modals
import { FocusTrap } from '@radix-ui/react-focus-trap';
// Return focus after dialog close
const previousFocusRef = useRef<HTMLElement | null>(null);
useEffect(() => {
if (open) {
previousFocusRef.current = document.activeElement as HTMLElement;
} else {
previousFocusRef.current?.focus();
}
}, [open]);
```
**Tasks**:
- [ ] Verify all dialogs trap focus properly
- [ ] Return focus to trigger element on close
- [ ] Add `autoFocus` to first interactive element in dialogs
- [ ] Prevent focus from leaving modal while open
### 1.3 Screen Reader Announcements (Live Regions)
**Create new file**: `src/components/ui/Announcer.tsx`
```typescript
import { createContext, useContext, useState, useCallback } from 'react';
interface AnnouncerContextValue {
announce: (message: string, politeness?: 'polite' | 'assertive') => void;
}
const AnnouncerContext = createContext<AnnouncerContextValue | null>(null);
export function AnnouncerProvider({ children }: { children: React.ReactNode }) {
const [politeMessage, setPoliteMessage] = useState('');
const [assertiveMessage, setAssertiveMessage] = useState('');
const announce = useCallback((message: string, politeness: 'polite' | 'assertive' = 'polite') => {
if (politeness === 'assertive') {
setAssertiveMessage(message);
setTimeout(() => setAssertiveMessage(''), 1000);
} else {
setPoliteMessage(message);
setTimeout(() => setPoliteMessage(''), 1000);
}
}, []);
return (
<AnnouncerContext.Provider value={{ announce }}>
{children}
<div
aria-live="polite"
aria-atomic="true"
className="sr-only"
>
{politeMessage}
</div>
<div
aria-live="assertive"
aria-atomic="true"
className="sr-only"
>
{assertiveMessage}
</div>
</AnnouncerContext.Provider>
);
}
export function useAnnounce() {
const context = useContext(AnnouncerContext);
if (!context) throw new Error('useAnnounce must be used within AnnouncerProvider');
return context.announce;
}
```
**Integration points**:
- [ ] Wrap app in `AnnouncerProvider`
- [ ] Announce when command executes: "Opening profile viewer"
- [ ] Announce when window closes: "Window closed"
- [ ] Announce loading complete: "Timeline loaded, 50 events"
- [ ] Announce errors: "Error: Failed to load profile"
---
## Phase 2: Form Accessibility (Priority: High)
### 2.1 Form Error Association
**Pattern to implement across all forms**:
```typescript
interface FormFieldProps {
id: string;
label: string;
error?: string;
description?: string;
}
function FormField({ id, label, error, description, children }: FormFieldProps) {
const errorId = `${id}-error`;
const descriptionId = `${id}-description`;
return (
<div>
<Label htmlFor={id}>{label}</Label>
{description && (
<span id={descriptionId} className="text-sm text-muted-foreground">
{description}
</span>
)}
{React.cloneElement(children as React.ReactElement, {
id,
'aria-describedby': [
description ? descriptionId : null,
error ? errorId : null,
].filter(Boolean).join(' ') || undefined,
'aria-invalid': !!error,
})}
{error && (
<span id={errorId} role="alert" className="text-sm text-destructive">
{error}
</span>
)}
</div>
);
}
```
**Files to update**:
- [ ] `SpellDialog.tsx` - Spell creation form
- [ ] `SettingsDialog.tsx` - Settings inputs
- [ ] `WorkspaceSettings.tsx` - Workspace name input
- [ ] `CommandLauncher.tsx` - Command input
### 2.2 Required Field Indicators
```typescript
// Add to Label component
function Label({ required, children, ...props }) {
return (
<label {...props}>
{children}
{required && <span aria-hidden="true" className="text-destructive ml-1">*</span>}
{required && <span className="sr-only"> (required)</span>}
</label>
);
}
```
---
## Phase 3: Component ARIA Improvements (Priority: Medium)
### 3.1 Event Renderers
**Base pattern for all renderers**:
```typescript
// BaseEventRenderer.tsx additions
<article
aria-label={`${kindName} by ${authorName}`}
aria-describedby={`event-${event.id}-content`}
>
<header>
<UserName pubkey={pubkey} aria-label={`Author: ${displayName}`} />
<time dateTime={isoDate} aria-label={`Posted ${relativeTime}`}>
{relativeTime}
</time>
</header>
<div id={`event-${event.id}-content`}>
{children}
</div>
</article>
```
**Tasks**:
- [ ] Add `article` landmark to event containers
- [ ] Add proper `time` elements with dateTime
- [ ] Add aria-labels to interactive elements
- [ ] Ensure all buttons have labels (close, menu, copy, etc.)
### 3.2 Feed/Timeline Components
**Files to update**: `Feed.tsx`, `ReqViewer.tsx`
```typescript
// Add feed landmarks
<section aria-label="Event timeline" role="feed" aria-busy={loading}>
<h2 className="sr-only">Timeline</h2>
{events.map((event, index) => (
<article
key={event.id}
aria-posinset={index + 1}
aria-setsize={events.length}
>
<KindRenderer event={event} />
</article>
))}
</section>
```
**Tasks**:
- [ ] Add `role="feed"` to timeline containers
- [ ] Add `aria-posinset` and `aria-setsize` for virtual lists
- [ ] Add `aria-busy` during loading
- [ ] Announce when new events arrive
### 3.3 Collapsible/Accordion
**Files to update**: `ui/accordion.tsx`, `ui/collapsible.tsx`
```typescript
// Ensure proper ARIA states
<button
aria-expanded={isOpen}
aria-controls={contentId}
>
Toggle
</button>
<div
id={contentId}
aria-hidden={!isOpen}
role="region"
>
{children}
</div>
```
---
## Phase 4: Color & Visual Accessibility (Priority: Medium)
### 4.1 Color Contrast Audit
**Tool**: Use `axe-core` for automated checking
```bash
npm install -D @axe-core/react
```
```typescript
// Development-only accessibility audit
if (process.env.NODE_ENV === 'development') {
import('@axe-core/react').then(axe => {
axe.default(React, ReactDOM, 1000);
});
}
```
**Known issues to check**:
- [ ] Muted foreground text (`hsl(215 20.2% 70%)`)
- [ ] Gradient text (`.text-grimoire-gradient`)
- [ ] Disabled state opacity (50%)
- [ ] Placeholder text color
### 4.2 High Contrast Mode
**Create theme option**:
```css
/* Add to index.css */
@media (prefers-contrast: more) {
:root {
--foreground: 0 0% 0%;
--background: 0 0% 100%;
--muted-foreground: 0 0% 30%;
--border: 0 0% 0%;
}
.dark {
--foreground: 0 0% 100%;
--background: 0 0% 0%;
--muted-foreground: 0 0% 80%;
--border: 0 0% 100%;
}
}
```
**Tasks**:
- [ ] Add system preference detection
- [ ] Create high-contrast theme variables
- [ ] Test with Windows High Contrast Mode
- [ ] Add manual toggle in settings
### 4.3 Reduced Motion
```css
/* Already partially implemented, verify coverage */
@media (prefers-reduced-motion: reduce) {
*,
*::before,
*::after {
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
transition-duration: 0.01ms !important;
}
}
```
**Tasks**:
- [ ] Audit all animations (Framer Motion, CSS transitions)
- [ ] Ensure skeleton pulse respects preference
- [ ] Verify window transitions can be disabled
---
## Phase 5: Testing & Documentation (Priority: High)
### 5.1 Automated Testing
**Add to CI pipeline**:
```yaml
# .github/workflows/accessibility.yml
name: Accessibility Checks
on: [push, pull_request]
jobs:
a11y:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
- run: npm ci
- run: npm run build
- name: Run axe-core
run: npx @axe-core/cli http://localhost:4173
```
### 5.2 Manual Testing Checklist
**Keyboard-only testing**:
- [ ] Can navigate entire app without mouse
- [ ] Focus order is logical
- [ ] All interactive elements are reachable
- [ ] Can dismiss dialogs with Escape
- [ ] Can activate buttons with Enter/Space
**Screen reader testing** (with VoiceOver/NVDA):
- [ ] Page structure is announced correctly
- [ ] Links and buttons describe their purpose
- [ ] Form fields have associated labels
- [ ] Errors are announced when they occur
- [ ] Loading states are announced
**Visual testing**:
- [ ] Content readable at 200% zoom
- [ ] No horizontal scrolling at 320px width (for non-tiling views)
- [ ] Focus indicators visible
- [ ] Color not sole means of conveying info
### 5.3 Accessibility Documentation
**Create `docs/ACCESSIBILITY.md`**:
- Document keyboard shortcuts
- List known limitations
- Provide screen reader recommendations
- Document testing procedures
---
## Implementation Phases
### Phase 1: Foundation (2-3 weeks)
- Live region announcer
- Keyboard shortcuts help
- Focus management fixes
### Phase 2: Forms (1-2 weeks)
- Error association pattern
- Required field indicators
- Form validation feedback
### Phase 3: Components (2-3 weeks)
- Event renderer improvements
- Feed landmarks
- Dialog ARIA fixes
### Phase 4: Visual (1-2 weeks)
- Color contrast audit
- High contrast mode
- Reduced motion support
### Phase 5: Testing (Ongoing)
- Automated CI checks
- Manual testing protocol
- Documentation
---
## Success Metrics
| Metric | Current | Target |
|--------|---------|--------|
| axe-core violations | Unknown | 0 critical, <5 minor |
| ARIA coverage | 16% | 90%+ |
| Keyboard accessibility | Partial | Full |
| Color contrast ratio | Unknown | 4.5:1 minimum |
| WCAG 2.1 Level | Unknown | AA |
---
## Resources
- [WCAG 2.1 Quick Reference](https://www.w3.org/WAI/WCAG21/quickref/)
- [Radix UI Accessibility](https://www.radix-ui.com/docs/primitives/overview/accessibility)
- [React ARIA](https://react-spectrum.adobe.com/react-aria/)
- [axe-core Rules](https://dequeuniversity.com/rules/axe/)
- [WebAIM Contrast Checker](https://webaim.org/resources/contrastchecker/)

178
AGENTS.md
View File

@@ -1,178 +0,0 @@
# AGENTS.md
This file contains guidelines for agentic coding agents working in the Grimoire repository.
## Quick Reference
For detailed project architecture, patterns, and conventions, see **[CLAUDE.md](./CLAUDE.md)**.
## Build Commands
### Development
```bash
npm run dev # Start development server with HMR
npm run build # TypeScript check + production build
npm run preview # Preview production build locally
```
### Code Quality
```bash
npm run lint # Run ESLint (fails on errors)
npm run lint:fix # Auto-fix ESLint issues + formatting
npm run format # Format with Prettier
npm run format:check # Check formatting without changes
```
### Testing
```bash
npm test # Run tests in watch mode
npm run test:ui # Visual test explorer
npm run test:run # Single test run (CI mode)
```
**Single Test**: Use `vitest run` with file pattern:
```bash
npm run test:run src/lib/nostr-utils.test.ts
```
### Verification
Always run before committing:
```bash
npm run lint && npm run test:run && npm run build
```
## Code Style Guidelines
### Imports & Dependencies
- **Path Alias**: Use `@/` for `src/` directory (configured in Vite & Vitest)
- **Import Order**: External libs → Applesauce → Local `@/` modules → Relative imports
- **Applesauce**: Prefer helpers from `applesauce-core/helpers` and `applesauce-common/helpers`
- **No Default Exports**: Use named exports for better tree-shaking
```typescript
// ✅ Correct import order
import { useState } from "react";
import { use$ } from "applesauce-react/hooks";
import { getProfileContent } from "applesauce-core/helpers";
import { cn } from "@/lib/utils";
import { UserName } from "./components/UserName";
```
### TypeScript & Types
- **Strict Mode**: Project uses strict TypeScript configuration
- **No `any`**: ESLint rule disabled, but prefer proper types
- **Applesauce Types**: Use types from `applesauce-core` and `nostr-tools`
- **Local Types**: Extend in `src/types/` when needed
```typescript
// ✅ Use proper types from Applesauce
import type { NostrEvent, ProfileContent } from "applesauce-core/helpers";
import type { EventPointer } from "nostr-tools/nip19";
```
### React Patterns
- **React 19**: Use latest features (hooks, concurrent rendering)
- **No Default Exports**: Components use named exports
- **Props Interface**: Always define props interface with JSDoc comments
- **Destructured Props**: Destructure props in function signature
```typescript
// ✅ Component pattern
interface QuotedEventProps {
/** EventPointer with optional relay hints */
eventPointer?: EventPointer;
/** Depth level for nesting */
depth?: number;
}
export function QuotedEvent({ eventPointer, depth = 0 }: QuotedEventProps) {
// Component logic
}
```
### Performance & Caching
- **Applesauce Helpers**: Cache internally - NO `useMemo` needed
- **Custom Helpers**: Use `useMemo` for complex transformations
- **Stable References**: Use `useStableValue`/`useStableArray` for filters/options
```typescript
// ❌ WRONG - Unnecessary memoization
const title = useMemo(() => getArticleTitle(event), [event]);
// ✅ CORRECT - Helpers cache internally
const title = getArticleTitle(event);
```
### Styling
- **TailwindCSS**: Primary styling approach
- **CSS Variables**: Theme tokens in HSL format (see `index.css`)
- **Utility Function**: Use `cn()` from `@/lib/utils` for class merging
- **Dark Mode**: Default (controlled via HTML class)
```typescript
// ✅ Styling pattern
import { cn } from "@/lib/utils";
const className = cn(
"flex items-center gap-2 p-2 rounded-lg",
isActive && "bg-primary text-primary-foreground",
className
);
```
### File Organization
- **By Domain**: Group files by feature/domain (`nostr/`, `ui/`, `services/`, `hooks/`)
- **Colocated Tests**: Test files next to source files (`*.test.ts`)
- **Barrel Exports**: Use `index.ts` for clean imports
- **Pure Functions**: Business logic in `src/core/logic.ts`
### Naming Conventions
- **Components**: PascalCase with descriptive names (`LiveActivityRenderer`)
- **Hooks**: camelCase with `use` prefix (`useAccount`, `useProfile`)
- **Utilities**: camelCase with descriptive names (`getDisplayName`, `canAccountSign`)
- **Constants**: UPPER_SNAKE_CASE for exports
- **Files**: kebab-case for utilities, PascalCase for components
## Testing Guidelines
### Test Environment
- **Vitest**: Test framework with Node environment
- **Polyfills**: IndexedDB, WebSocket, localStorage pre-configured
- **Setup File**: `src/test/setup.ts` contains browser API polyfills
### What to Test
- **Parsers**: All argument parsing logic and edge cases
- **Pure Functions**: State mutations and business logic
- **Utilities**: Helper functions and data transformations
- **Not UI Components**: React components tested manually
### Test Structure
```typescript
describe("parseReqCommand", () => {
describe("kind flag (-k, --kind)", () => {
it("should parse single kind", () => {
const result = parseReqCommand(["-k", "1"]);
expect(result.filter.kinds).toEqual([1]);
});
});
});
```
## Critical Rules
1. **Singleton Services**: Never create new EventStore, RelayPool, or RelayLiveness instances
2. **Verification**: Always run full verification before committing changes
3. **Applesauce Caching**: Don't use `useMemo` with Applesauce helpers
4. **Account Signing**: Always check `canSign` before signing operations
5. **Error Boundaries**: Wrap event renderers in error boundaries
6. **Path Alias**: Use `@/` for all internal imports
## Project Context
Grimoire is a Nostr protocol explorer with a tiling window manager interface. 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
---
*See [CLAUDE.md](./CLAUDE.md) for detailed architecture, patterns, hooks, and conventions.*

View File

@@ -1,379 +0,0 @@
# Applesauce Helpers Refactoring Plan
## Executive Summary
After investigating applesauce-core helpers and grimoire's codebase, I've identified several opportunities to leverage applesauce's built-in helpers and caching mechanisms. **Key insight**: Applesauce helpers use internal caching via symbols (`getOrComputeCachedValue`), so we don't need `useMemo` when calling them.
## Key Findings
### 1. Applesauce Caching System
Applesauce helpers cache computed values on event objects using symbols:
```typescript
// From applesauce-core/helpers/cache.d.ts
export declare function getOrComputeCachedValue<T>(
event: any,
symbol: symbol,
compute: () => T
): T;
```
**Implication**: When you call helpers like `getArticleTitle(event)`, `getHighlightText(event)`, etc., the result is cached on the event object. Subsequent calls return the cached value instantly. **We don't need useMemo for applesauce helper calls.**
### 2. Already Using Applesauce Helpers
Grimoire already imports and uses many applesauce helpers correctly:
-`getTagValue` - used extensively in nip34-helpers.ts, nip-c0-helpers.ts
-`getNip10References` - used in nostr-utils.ts
-`getCommentReplyPointer` - used in nostr-utils.ts
- ✅ Article helpers - `getArticleTitle`, `getArticleSummary` (ArticleRenderer.tsx)
- ✅ Highlight helpers - all 6+ helpers used (HighlightRenderer.tsx, HighlightDetailRenderer.tsx)
- ✅ Code snippet helpers - imported from nip-c0-helpers.ts
### 3. Applesauce Helpers Available but Not Used
#### Profile Helpers
```typescript
// applesauce-core/helpers/profile
export function getDisplayName(
metadata: ProfileContent | NostrEvent | undefined,
fallback?: string
): string | undefined;
```
**Current grimoire code** (src/lib/nostr-utils.ts:65-76):
```typescript
export function getDisplayName(
pubkey: string,
metadata?: ProfileContent,
): string {
if (metadata?.display_name) return metadata.display_name;
if (metadata?.name) return metadata.name;
return derivePlaceholderName(pubkey);
}
```
**Issue**: Grimoire's version requires both `pubkey` and `metadata`, while applesauce only takes `metadata`. Our version adds fallback logic with `derivePlaceholderName`.
**Recommendation**: Keep grimoire's version - it provides better UX with pubkey-based fallback.
#### Pointer Helpers
```typescript
// applesauce-core/helpers/pointers
export function getEventPointerFromETag(tag: string[]): EventPointer;
export function getEventPointerFromQTag(tag: string[]): EventPointer;
export function getAddressPointerFromATag(tag: string[]): AddressPointer;
export function getProfilePointerFromPTag(tag: string[]): ProfilePointer;
export function parseCoordinate(a: string): AddressPointerWithoutD | null;
```
**Current usage** in ReactionRenderer.tsx:58-66:
```typescript
const addressParts = useMemo(() => {
if (!reactedAddress) return null;
const parts = reactedAddress.split(":");
return {
kind: parseInt(parts[0], 10),
pubkey: parts[1],
dTag: parts[2],
};
}, [reactedAddress]);
```
**Recommendation**: Replace manual parsing with `parseCoordinate` helper.
#### Reaction Pointer Helpers
```typescript
// applesauce-core/helpers/reactions
export function getReactionEventPointer(event: NostrEvent): EventPointer | undefined;
export function getReactionAddressPointer(event: NostrEvent): AddressPointer | undefined;
```
**Current usage** in ReactionRenderer.tsx: Manual tag extraction and parsing.
**Recommendation**: Use built-in reaction pointer helpers.
#### Filter Comparison Helper
```typescript
// applesauce-core/helpers/filter
export function isFilterEqual(
a: FilterWithAnd | FilterWithAnd[],
b: FilterWithAnd | FilterWithAnd[]
): boolean;
```
**Current usage** in useStable.ts:55-58:
```typescript
export function useStableFilters<T>(filters: T): T {
return useMemo(() => filters, [JSON.stringify(filters)]);
}
```
**Recommendation**: Replace JSON.stringify comparison with `isFilterEqual` for more robust filter comparison.
### 4. Custom Helpers We Need to Keep
These are **not** available in applesauce and provide grimoire-specific functionality:
1. **`getTagValues` (plural)** - src/lib/nostr-utils.ts:59-63
```typescript
export function getTagValues(event: NostrEvent, tagName: string): string[] {
return event.tags
.filter((tag) => tag[0] === tagName && tag[1])
.map((tag) => tag[1]);
}
```
**Keep**: Applesauce only has `getTagValue` (singular). We need the plural version.
2. **`resolveFilterAliases`** - src/lib/nostr-utils.ts:85-156
- Resolves `$me` and `$contacts` in filters
- Grimoire-specific feature
**Keep**: No applesauce equivalent.
3. **NIP-34 helpers** - src/lib/nip34-helpers.ts
- Git repository, issue, patch, PR helpers
**Keep**: Grimoire-specific, uses `getTagValue` underneath.
4. **NIP-C0 helpers** - src/lib/nip-c0-helpers.ts
- Code snippet helpers
**Keep**: Uses `getTagValue` underneath, grimoire-specific.
5. **Custom event processing** - src/lib/spell-conversion.ts, spellbook-manager.ts
**Keep**: Grimoire-specific functionality.
## Refactoring Opportunities
### HIGH PRIORITY: Remove Unnecessary useMemo
Since applesauce helpers cache internally, **remove useMemo from all applesauce helper calls**:
#### 1. ArticleRenderer.tsx (lines 17-18)
```typescript
// BEFORE
const title = useMemo(() => getArticleTitle(event), [event]);
const summary = useMemo(() => getArticleSummary(event), [event]);
// AFTER - helpers cache internally
const title = getArticleTitle(event);
const summary = getArticleSummary(event);
```
#### 2. HighlightRenderer.tsx (lines 24-36) + HighlightDetailRenderer.tsx (lines 22-35)
```typescript
// BEFORE
const highlightText = useMemo(() => getHighlightText(event), [event]);
const sourceUrl = useMemo(() => getHighlightSourceUrl(event), [event]);
const comment = useMemo(() => getHighlightComment(event), [event]);
const eventPointer = useMemo(() => getHighlightSourceEventPointer(event), [event]);
const addressPointer = useMemo(() => getHighlightSourceAddressPointer(event), [event]);
const context = useMemo(() => getHighlightContext(event), [event]);
// AFTER - helpers cache internally
const highlightText = getHighlightText(event);
const sourceUrl = getHighlightSourceUrl(event);
const comment = getHighlightComment(event);
const eventPointer = getHighlightSourceEventPointer(event);
const addressPointer = getHighlightSourceAddressPointer(event);
const context = getHighlightContext(event);
```
#### 3. CodeSnippetDetailRenderer.tsx (lines 37-44)
```typescript
// BEFORE
const name = useMemo(() => getCodeName(event), [event]);
const language = useMemo(() => getCodeLanguage(event), [event]);
const extension = useMemo(() => getCodeExtension(event), [event]);
const description = useMemo(() => getCodeDescription(event), [event]);
const runtime = useMemo(() => getCodeRuntime(event), [event]);
const licenses = useMemo(() => getCodeLicenses(event), [event]);
const dependencies = useMemo(() => getCodeDependencies(event), [event]);
const repo = useMemo(() => getCodeRepo(event), [event]);
// AFTER - our custom helpers use getTagValue which caches
const name = getCodeName(event);
const language = getCodeLanguage(event);
const extension = getCodeExtension(event);
const description = getCodeDescription(event);
const runtime = getCodeRuntime(event);
const licenses = getCodeLicenses(event);
const dependencies = getCodeDependencies(event);
const repo = getCodeRepo(event);
```
#### 4. ChatView.tsx (lines 94, 96)
```typescript
// BEFORE
const threadRefs = useMemo(() => getNip10References(event), [event]);
const qTagValue = useMemo(() => getTagValue(event, "q"), [event]);
// AFTER - helpers cache internally
const threadRefs = getNip10References(event);
const qTagValue = getTagValue(event, "q");
```
#### 5. LiveActivityRenderer.tsx (lines 20-22)
```typescript
// BEFORE - if using applesauce helpers
const activity = useMemo(() => parseLiveActivity(event), [event]);
const status = useMemo(() => getLiveStatus(event), [event]);
const hostPubkey = useMemo(() => getLiveHost(event), [event]);
// AFTER - check if these use applesauce helpers internally
// If yes, remove useMemo. If no, keep as is.
```
**Note**: Check if `parseLiveActivity`, `getLiveStatus`, `getLiveHost` use applesauce helpers or implement caching themselves.
### MEDIUM PRIORITY: Use Applesauce Pointer Helpers
#### 1. ReactionRenderer.tsx - Replace manual coordinate parsing
```typescript
// BEFORE (lines 58-66)
const addressParts = useMemo(() => {
if (!reactedAddress) return null;
const parts = reactedAddress.split(":");
return {
kind: parseInt(parts[0], 10),
pubkey: parts[1],
dTag: parts[2],
};
}, [reactedAddress]);
// AFTER - use parseCoordinate helper
import { parseCoordinate } from "applesauce-core/helpers";
const addressPointer = reactedAddress ? parseCoordinate(reactedAddress) : null;
// No useMemo needed - parseCoordinate is a simple function
```
#### 2. ReactionRenderer.tsx - Use reaction pointer helpers
```typescript
// CURRENT: Manual tag extraction
const reactedEventId = event.tags.find((t) => t[0] === "e")?.[1];
const reactedAddress = event.tags.find((t) => t[0] === "a")?.[1];
// POTENTIAL ALTERNATIVE: Use built-in helpers
import { getReactionEventPointer, getReactionAddressPointer } from "applesauce-core/helpers";
const eventPointer = getReactionEventPointer(event);
const addressPointer = getReactionAddressPointer(event);
```
**Trade-off**: Current code gets raw values, helpers return typed pointers. May require component changes. **Evaluate if worth it.**
### MEDIUM PRIORITY: Improve Filter Comparison
#### useStable.ts - Use isFilterEqual instead of JSON.stringify
```typescript
// BEFORE (lines 55-58)
export function useStableFilters<T>(filters: T): T {
return useMemo(() => filters, [JSON.stringify(filters)]);
}
// AFTER - use isFilterEqual for comparison
import { isFilterEqual } from "applesauce-core/helpers";
export function useStableFilters<T>(filters: T): T {
const prevFiltersRef = useRef<T>();
if (!prevFiltersRef.current || !isFilterEqual(prevFiltersRef.current as any, filters as any)) {
prevFiltersRef.current = filters;
}
return prevFiltersRef.current;
}
```
**Benefits**:
- More robust comparison (handles undefined values correctly)
- Avoids JSON serialization overhead
- Supports NIP-ND AND operator (`&` prefix)
**Note**: May need to handle non-filter types (arrays, objects).
### LOW PRIORITY: Code Organization
#### 1. Document applesauce caching in code comments
Add comments to custom helpers that use applesauce helpers:
```typescript
// nip34-helpers.ts
/**
* Get the repository name from a repository event
* Note: Uses applesauce getTagValue which caches internally
* @param event Repository event (kind 30617)
* @returns Repository name or undefined
*/
export function getRepositoryName(event: NostrEvent): string | undefined {
return getTagValue(event, "name");
}
```
#### 2. Consider consolidating tag extraction
Since we use `getTagValue` extensively, ensure all single-tag extractions use it instead of manual `find()`:
```typescript
// PREFER
const value = getTagValue(event, "tagName");
// AVOID
const value = event.tags.find(t => t[0] === "tagName")?.[1];
```
## Testing Recommendations
1. **Test helper caching**: Verify applesauce helpers actually cache (call twice, ensure same reference)
2. **Performance testing**: Measure before/after removing useMemo (expect minimal change due to caching)
3. **Filter comparison**: Test `isFilterEqual` edge cases (undefined, empty arrays, NIP-ND AND operator)
4. **Pointer parsing**: Test `parseCoordinate` with various coordinate formats
## Migration Strategy
### Phase 1: Remove Unnecessary useMemo (Low Risk)
1. Remove useMemo from applesauce helper calls in kind renderers
2. Test rendering performance
3. Verify no issues
4. Commit
### Phase 2: Replace Pointer Parsing (Medium Risk)
1. Replace manual coordinate parsing with `parseCoordinate`
2. Update types if needed
3. Test reaction rendering
4. Commit
### Phase 3: Improve Filter Comparison (Medium Risk)
1. Implement `useStableFilters` with `isFilterEqual`
2. Test filter subscription behavior
3. Verify no unnecessary re-subscriptions
4. Commit
### Phase 4: Documentation (Low Risk)
1. Update CLAUDE.md with applesauce helper guidance
2. Add code comments documenting caching
3. Update skills if needed
## Questions to Investigate
1. **Do all our custom helpers cache?** Check `parseLiveActivity`, `getLiveStatus`, `getEventDisplayTitle`, etc.
2. **Should we create a shared cache util?** For custom helpers that don't use applesauce helpers underneath
3. **Is getTagValues used enough to add to applesauce?** Consider contributing upstream
4. **Filter aliases**: Could `resolveFilterAliases` be contributed to applesauce?
## Summary of Changes
| Category | Impact | Files Affected | Effort |
|----------|--------|----------------|--------|
| Remove useMemo from applesauce helpers | Performance (minor), Code clarity (major) | 8+ renderer files | Low |
| Use pointer helpers | Type safety, Code clarity | ReactionRenderer.tsx | Medium |
| Improve filter comparison | Correctness, Performance | useStable.ts | Medium |
| Documentation | Developer experience | CLAUDE.md, skills | Low |
**Total estimated effort**: 4-6 hours
**Risk level**: Low-Medium
**Expected benefits**: Cleaner code, better alignment with applesauce patterns, easier maintenance
## References
- [Applesauce Documentation](https://hzrd149.github.io/applesauce/)
- [Applesauce GitHub](https://github.com/hzrd149/applesauce)
- [Applesauce Core TypeDoc](https://hzrd149.github.io/applesauce/typedoc/modules/applesauce-core.html)

View File

@@ -1,650 +0,0 @@
# Grimoire Codebase Analysis & S-Tier Quality Plan
**Analysis Date**: December 2025
**Codebase Size**: ~28,500 lines of TypeScript across 240 files
**Stack**: React 19 + TypeScript 5.6 + Vite 6 + TailwindCSS + Jotai + Dexie + Applesauce
---
## Executive Summary
Grimoire is a **well-architected Nostr protocol explorer** with a unique tiling window manager interface. The codebase demonstrates strong engineering fundamentals with thoughtful patterns, comprehensive testing of core logic, and modern React practices. However, several areas require attention to reach S-tier quality.
### Current Quality Assessment
| Category | Grade | Summary |
|----------|-------|---------|
| **Architecture** | A- | Clean separation, singleton patterns, reactive data flow |
| **Code Quality** | B+ | Strong patterns with some duplication and inconsistencies |
| **Performance** | B+ | Good optimizations, but gaps in memoization |
| **Security** | A | Zero vulnerabilities, proper input validation |
| **Testing** | B | Excellent parser coverage, gaps in components/hooks |
| **Accessibility** | C+ | Foundation present, sparse coverage |
| **UX** | B | Desktop-first, keyboard-driven, limited mobile support |
| **Documentation** | B- | Good inline docs, missing API documentation |
---
## Part 1: Architecture Analysis
### Strengths
#### 1. Tri-Partite State Management
The separation of concerns across three state systems is excellent:
```
┌─────────────────────────────────────────────────────────────────┐
│ STATE ARCHITECTURE │
├──────────────────────┬──────────────────┬──────────────────────┤
│ UI State (Jotai) │ Nostr State │ Relay/DB State │
│ ├─ Workspaces │ (EventStore) │ (RelayLiveness) │
│ ├─ Windows │ ├─ Events │ ├─ Connection state │
│ ├─ Layout tree │ ├─ Profiles │ ├─ Auth preferences │
│ └─ Active account │ └─ Replaceables │ └─ Backoff tracking │
│ │ │ │
│ localStorage │ In-memory RxJS │ IndexedDB (Dexie) │
└──────────────────────┴──────────────────┴──────────────────────┘
```
#### 2. Pure Function State Mutations (`src/core/logic.ts`)
All UI state mutations follow a pure function pattern:
```typescript
export const addWindow = (state: GrimoireState, payload: AddWindowPayload): GrimoireState => ({
...state,
windows: { ...state.windows, [window.id]: window },
// ...immutable updates
});
```
**Benefits**: Easily testable, predictable, no side effects
#### 3. Singleton Pattern for Services
Critical services use singletons preventing resource duplication:
- `EventStore` - Single source of truth for Nostr events
- `RelayPool` - Reuses WebSocket connections
- `RelayLiveness` - Centralized health tracking
- `RelayStateManager` - Global connection + auth state
#### 4. Reactive Data Flow
Applesauce + RxJS provides elegant reactive patterns:
```typescript
// Events flow: Relay → EventStore → Observable → Hook → Component
const events = useTimeline(filters, relays); // Auto-updates on new events
```
#### 5. Command System Design
Unix-style man pages with async parsers:
```typescript
manPages: {
req: {
synopsis: "req [options] [relay...]",
argParser: async (args) => parseReqCommand(args),
appId: "req"
}
}
```
### Weaknesses
#### 1. Disconnected State Systems
- UI state (Jotai) doesn't know about relay health
- Manual sync points (`useAccountSync`, `useRelayState`) create coupling
- No unified error aggregation across systems
#### 2. Race Conditions
```typescript
// useProfile.ts - async DB write can outlive component
const sub = profileLoader(...).subscribe({
next: async (event) => {
await db.profiles.put(...); // Component may unmount during await
if (mounted) setProfile(...);
}
});
```
#### 3. Polling Instead of Events
```typescript
// RelayStateManager polls every 1 second
this.pollingIntervalId = setInterval(() => {
pool.relays.forEach(relay => {
if (!this.subscriptions.has(relay.url)) {
this.monitorRelay(relay);
}
});
}, 1000);
```
#### 4. No Memory Bounds on EventStore
EventStore can grow unbounded with continuous streams. No LRU eviction or max size.
---
## Part 2: Code Quality Analysis
### Strengths
#### 1. Kind Renderer Registry Pattern
Scalable dispatch without conditionals:
```typescript
const kindRenderers: Record<number, ComponentType> = {
0: ProfileRenderer,
1: NoteRenderer,
// 40+ kinds...
};
export function KindRenderer({ event }) {
const Renderer = kindRenderers[event.kind] || DefaultKindRenderer;
return <Renderer event={event} />;
}
```
#### 2. Error Boundary Strategy
Three-tier isolation prevents cascading failures:
- **App-level**: Full recovery UI with reload options
- **Window-level**: Close broken window, others continue
- **Event-level**: Single event fails, feed continues
#### 3. Dependency Stabilization Pattern
Prevents infinite render loops in hooks:
```typescript
const stableFilters = useMemo(() => filters, [JSON.stringify(filters)]);
const stableRelays = useMemo(() => relays, [relays.join(",")]);
```
### Weaknesses
#### 1. Code Duplication
**Replaceable Event Constants** (duplicated in 2 files):
```typescript
// Both BaseEventRenderer.tsx and KindRenderer.tsx define:
const REPLACEABLE_START = 10000;
const REPLACEABLE_END = 20000;
const PARAMETERIZED_REPLACEABLE_START = 30000;
```
**Replaceable Detection Logic** (repeated 3+ times):
```typescript
const isAddressable =
(event.kind >= REPLACEABLE_START && event.kind < REPLACEABLE_END) ||
(event.kind >= PARAMETERIZED_REPLACEABLE_START && ...);
```
**Dependency Stabilization** (in 4+ hooks):
```typescript
// Identical pattern in useTimeline, useReqTimeline, useLiveTimeline, useOutboxRelays
const stableFilters = useMemo(() => filters, [JSON.stringify(filters)]);
```
#### 2. Inconsistent Memoization
- Only 14/40+ kind renderers use `useMemo`
- Event handlers rarely wrapped in `useCallback`
- Creates unnecessary re-renders in virtualized lists
#### 3. Type Safety Gaps
```typescript
// Scattered `as any` casts
(args as any) // CommandLauncher.tsx:81
```
#### 4. Dead Code
```typescript
// BaseEventRenderer.tsx has large commented-out blocks
// import { kinds } from "nostr-tools";
// ... commented compact mode code
```
---
## Part 3: Performance Analysis
### Strengths
#### 1. Strategic Code Splitting
```typescript
// vite.config.ts
manualChunks: {
'react-vendor': ['react', 'react-dom'],
'ui': ['@radix-ui/*', 'react-mosaic-component'],
'nostr': ['applesauce-*', 'nostr-tools', 'rxjs', 'dexie'],
'markdown': ['react-markdown', 'remark-gfm']
}
```
#### 2. Virtual Scrolling
- `react-virtuoso` for large event feeds
- Handles 1000+ events efficiently
#### 3. Lazy Loading
```typescript
const ProfileViewer = lazy(() => import("./ProfileViewer"));
// All viewers lazy-loaded with Suspense fallback
```
#### 4. Network Efficiency
- Connection pooling via RelayPool singleton
- Relay liveness prevents dead relay connections
- Aggregator fallback for event discovery
### Weaknesses
#### 1. JSON.stringify in Dependencies
```typescript
// O(n) serialization on every render
useMemo(() => filters, [JSON.stringify(filters)]);
```
#### 2. Missing useMemo in Renderers
Expensive operations computed on every render:
- `formatTimestamp()` called repeatedly
- Event content parsing without memoization
- Profile data extraction
#### 3. No Performance Monitoring
- No web vitals tracking
- No performance budgets in build
- No Lighthouse CI integration
---
## Part 4: Security Analysis
### Strengths (Zero Critical Issues)
| Check | Status | Details |
|-------|--------|---------|
| XSS Prevention | ✅ | No `dangerouslySetInnerHTML`, `skipHtml` enabled in markdown |
| Input Validation | ✅ | Regex patterns on NIP-05, URL normalization, title sanitization |
| Dependency Security | ✅ | `npm audit` returns 0 vulnerabilities |
| Memory Safety | ✅ | Proper subscription cleanup in all hooks |
| Cryptography | ✅ | Delegated to trusted libraries (nostr-tools, applesauce) |
### Minor Concerns
1. **localStorage Usage**: Account metadata stored in world-readable localStorage (by design - no private keys)
2. **No CSP Header**: Consider adding Content-Security-Policy meta tag
---
## Part 5: Testing Analysis
### Current Coverage
| Category | Files | Coverage | Quality |
|----------|-------|----------|---------|
| Parsers | 7 | Excellent | ~95% edge cases |
| State Logic | 1 | Comprehensive | All mutations tested |
| Utilities | 11 | Good | Core paths covered |
| Services | 2 | Moderate | Selection logic tested |
| Components | 0 | None | Manual testing only |
| Hooks | 0 | None | No subscription tests |
### Test Files (18 total)
```
src/lib/req-parser.test.ts # Most comprehensive (600+ lines)
src/lib/command-parser.test.ts # Command parsing
src/lib/global-flags.test.ts # Flag extraction
src/core/logic.test.ts # State mutations
src/lib/migrations.test.ts # Schema migrations
... (13 more utility tests)
```
### Gaps
1. **No component tests** - All React components tested manually
2. **No hook tests** - Subscription cleanup not verified
3. **No integration tests** - End-to-end flows untested
4. **No error boundary tests** - Recovery paths untested
---
## Part 6: Accessibility Analysis
### Strengths
- **Keyboard Navigation**: Cmd+K palette, arrow keys, Enter/Escape
- **ARIA Labels**: 25 files with `aria-*` attributes
- **Focus Management**: Visible focus rings with proper styling
- **Screen Reader Support**: `VisuallyHidden` component, `sr-only` classes
- **Loading States**: Skeletons with `role="status"` and `aria-busy`
### Weaknesses (Grade: C+)
| Issue | Impact | Current State |
|-------|--------|---------------|
| Sparse ARIA coverage | High | Only 16% of components have ARIA |
| No form validation feedback | Medium | Errors not associated with inputs |
| No high contrast mode | Medium | Single theme only |
| Limited mobile support | High | Tiling UI unsuitable for touch |
| No live regions | Medium | Dynamic updates not announced |
| Missing keyboard legend | Low | Advanced shortcuts hidden |
---
## Part 7: UX Analysis
### Strengths
1. **Power User Focus**: Unix-style commands, keyboard-driven
2. **Error Recovery**: Clear error states with retry options
3. **Skeleton Loading**: Context-appropriate loading placeholders
4. **Dark Mode Default**: Respects modern preferences
5. **Workspace System**: Virtual desktops with persistence
### Weaknesses
1. **Desktop Only**: Tiling window manager not suited for mobile
2. **Learning Curve**: No onboarding or tutorials
3. **Discovery**: Advanced features not discoverable
4. **No Undo**: Destructive actions (close window) not undoable
---
## Part 8: S-Tier Improvement Plan
### Phase 1: Critical Fixes (Week 1-2)
#### 1.1 Extract Shared Constants
```typescript
// NEW FILE: src/lib/nostr-constants.ts
export const REPLACEABLE_START = 10000;
export const REPLACEABLE_END = 20000;
export const EPHEMERAL_START = 20000;
export const EPHEMERAL_END = 30000;
export const PARAMETERIZED_REPLACEABLE_START = 30000;
export const PARAMETERIZED_REPLACEABLE_END = 40000;
export function isReplaceableKind(kind: number): boolean {
return (kind >= REPLACEABLE_START && kind < REPLACEABLE_END) ||
(kind >= PARAMETERIZED_REPLACEABLE_START && kind < PARAMETERIZED_REPLACEABLE_END);
}
```
#### 1.2 Create Dependency Stabilization Hook
```typescript
// NEW FILE: src/hooks/useStable.ts
export function useStableValue<T>(value: T, serialize?: (v: T) => string): T {
const serialized = serialize?.(value) ?? JSON.stringify(value);
return useMemo(() => value, [serialized]);
}
export function useStableArray<T>(arr: T[]): T[] {
return useMemo(() => arr, [arr.join(",")]);
}
```
#### 1.3 Fix Race Conditions
```typescript
// useProfile.ts - use AbortController pattern
useEffect(() => {
const controller = new AbortController();
const sub = profileLoader(...).subscribe({
next: async (event) => {
if (controller.signal.aborted) return;
await db.profiles.put(...);
if (!controller.signal.aborted) setProfile(...);
}
});
return () => {
controller.abort();
sub.unsubscribe();
};
}, [pubkey]);
```
#### 1.4 Replace Polling with Events
```typescript
// RelayStateManager - use pool events instead of setInterval
pool.on('relay:add', (relay) => this.monitorRelay(relay));
pool.on('relay:remove', (url) => this.unmonitorRelay(url));
```
### Phase 2: Performance Optimization (Week 3-4)
#### 2.1 Add useMemo to Kind Renderers
Audit all 40+ kind renderers and add memoization for:
- Content parsing
- Tag extraction
- Formatting operations
#### 2.2 Memoize Event Handlers
```typescript
// Wrap handlers passed to memoized children
const handleReplyClick = useCallback(() => {
addWindow("open", { pointer: replyPointer });
}, [replyPointer, addWindow]);
```
#### 2.3 Add EventStore Memory Bounds
```typescript
// Configure max events with LRU eviction
const eventStore = new EventStore({
maxEvents: 10000,
evictionPolicy: 'lru'
});
```
#### 2.4 Implement Performance Monitoring
```bash
npm install web-vitals
```
```typescript
// src/lib/analytics.ts
import { onCLS, onFID, onLCP } from 'web-vitals';
export function initPerformanceMonitoring() {
onCLS(console.log);
onFID(console.log);
onLCP(console.log);
}
```
### Phase 3: Testing Excellence (Week 5-6)
#### 3.1 Component Testing Setup
```bash
npm install -D @testing-library/react @testing-library/jest-dom
```
#### 3.2 Add Hook Tests
```typescript
// src/hooks/useProfile.test.ts
describe('useProfile', () => {
it('should clean up subscription on unmount', async () => {
const { unmount } = renderHook(() => useProfile('pubkey'));
unmount();
// Verify no memory leaks
});
it('should handle race conditions', async () => {
// Rapid mount/unmount should not cause errors
});
});
```
#### 3.3 Error Boundary Tests
```typescript
describe('EventErrorBoundary', () => {
it('should catch render errors', () => {...});
it('should reset on event change', () => {...});
it('should show retry button', () => {...});
});
```
#### 3.4 Integration Tests
```typescript
// src/__tests__/integration/command-flow.test.tsx
describe('Command Flow', () => {
it('should parse command and open window', async () => {
// Type "profile alice" → verify window opens
});
});
```
### Phase 4: Accessibility (Week 7-8)
#### 4.1 Audit Tool Integration
```bash
npm install -D @axe-core/react
```
```typescript
// Development-only accessibility audit
if (process.env.NODE_ENV === 'development') {
import('@axe-core/react').then(axe => {
axe.default(React, ReactDOM, 1000);
});
}
```
#### 4.2 Form Error Pattern
```typescript
// Create consistent error association
<Input
id="relay-url"
aria-describedby={error ? "relay-url-error" : undefined}
aria-invalid={!!error}
/>
{error && (
<span id="relay-url-error" role="alert" className="text-destructive">
{error}
</span>
)}
```
#### 4.3 Live Regions
```typescript
// Announce dynamic updates
<div aria-live="polite" aria-atomic="true" className="sr-only">
{statusMessage}
</div>
```
#### 4.4 Keyboard Shortcut Help
```typescript
// Add discoverable shortcut modal (Cmd+?)
const shortcuts = [
{ keys: ['⌘', 'K'], description: 'Open command palette' },
{ keys: ['⌘', '1-9'], description: 'Switch workspace' },
// ...
];
```
### Phase 5: UX Enhancements (Week 9-10)
#### 5.1 Onboarding Flow
```typescript
// First-time user experience
const GrimoireWelcome = () => (
<Dialog open={isFirstVisit}>
<DialogContent>
<h2>Welcome to Grimoire</h2>
<p>Press K to get started...</p>
<InteractiveDemo />
</DialogContent>
</Dialog>
);
```
#### 5.2 Undo System
```typescript
// Track recent actions for undo
const undoStack = atom<Action[]>([]);
export function addWindow(state, payload) {
pushUndo({ type: 'ADD_WINDOW', windowId: window.id });
return { ...state, ... };
}
export function undo(state) {
const action = popUndo();
// Reverse the action
}
```
#### 5.3 Mobile Detection
```typescript
// Show appropriate message on mobile
const isMobile = /iPhone|iPad|Android/i.test(navigator.userAgent);
if (isMobile) {
return <MobileNotSupported />;
}
```
### Phase 6: Documentation & Polish (Week 11-12)
#### 6.1 API Documentation
```typescript
/**
* Parse a REQ command string into filter and relay configuration
*
* @param args - Tokenized command arguments
* @returns ParsedReqCommand with filter, relays, and resolution metadata
*
* @example
* parseReqCommand(["-k", "1", "-a", "npub1..."]);
* // Returns: { filter: { kinds: [1], authors: ["hex..."] }, ... }
*/
export function parseReqCommand(args: string[]): ParsedReqCommand
```
#### 6.2 Architecture Documentation
Create `docs/ARCHITECTURE.md` with:
- State management diagram
- Data flow documentation
- Service interaction patterns
#### 6.3 Remove Dead Code
- Delete commented code blocks
- Remove unused imports
- Clean up TODO/FIXME comments
#### 6.4 Add CI/CD Quality Gates
```yaml
# .github/workflows/quality.yml
- run: npm run lint
- run: npm run test:run
- run: npm run build
- run: npx lighthouse-ci
```
---
## Priority Matrix
| Priority | Items | Effort | Impact |
|----------|-------|--------|--------|
| **P0 Critical** | Race condition fixes, memory bounds | Medium | High |
| **P1 High** | Code deduplication, memoization | Low | High |
| **P2 Medium** | Testing expansion, accessibility | High | High |
| **P3 Low** | UX polish, documentation | Medium | Medium |
---
## Success Metrics for S-Tier
| Metric | Current | Target |
|--------|---------|--------|
| Lighthouse Performance | ~75 | 95+ |
| Lighthouse Accessibility | ~60 | 95+ |
| Test Coverage | ~40% | 80%+ |
| Code Duplication | ~5% | <2% |
| npm audit vulnerabilities | 0 | 0 |
| Core Web Vitals | Unknown | All "Good" |
| TypeScript Strict | Yes | Yes |
| ARIA Coverage | 16% | 90%+ |
---
## Conclusion
Grimoire is a **solid B+ codebase** with excellent architecture fundamentals and security posture. The path to S-tier requires:
1. **Immediate**: Fix race conditions, extract shared code
2. **Short-term**: Add memoization, expand testing
3. **Medium-term**: Accessibility audit, UX improvements
4. **Long-term**: Documentation, CI/CD quality gates
The codebase is well-positioned for these improvements - the architecture is sound, patterns are consistent, and the team clearly values quality. With focused effort on the gaps identified, Grimoire can reach S-tier status.

View File

@@ -1,93 +0,0 @@
# GEMINI.md
This file provides context and guidance for Gemini (and other AI agents) when working with the Grimoire repository.
## Project Overview
Grimoire is a Nostr protocol explorer and developer tool. It features 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 a `Cmd+K` palette.
**Stack:** React 19 + TypeScript + Vite + TailwindCSS + Jotai + Dexie + Applesauce
## Core Architecture
### 1. Dual State System
* **UI State** (`src/core/state.ts` + `src/core/logic.ts`):
* Managed by **Jotai** atoms, persisted to `localStorage`.
* **Mutations:** strict adherence to pure functions in `src/core/logic.ts` (`(state, payload) => newState`).
* **Scope:** Workspaces, windows, layout tree, active account.
* **Nostr State** (`src/services/event-store.ts`):
* **Singleton `EventStore`** from `applesauce-core`.
* Single source of truth for all Nostr events.
* Reactive: Components subscribe via hooks (`useProfile`, `useTimeline`, `useNostrEvent`).
* **CRITICAL:** Do NOT create new `EventStore` instances. Use the singleton in `src/services/`.
* **Relay State** (`src/services/relay-liveness.ts`):
* Singleton `RelayLiveness` tracks relay health.
* Persisted to Dexie.
### 2. Window System
* **Layout:** Recursive binary split layout via `react-mosaic-component`.
* **Structure:**
* **Leaf:** Window ID (UUID).
* **Branch:** Split space.
* **Constraint:** **Never manipulate the layout tree directly.** Use `updateLayout()` callbacks or `logic.ts` helpers.
* **Window Props:** `id`, `appId` (type identifier), `title`, `props`.
### 3. Command System
* **Definition:** `src/types/man.ts` defines commands as Unix man pages.
* **Flow:** User types command -> `argParser` resolves props -> Helper opens specific `appId` viewer.
* **Global Flags:** Defined in `src/lib/global-flags.ts` (e.g., `--title` overrides window title).
### 4. Reactive Nostr Pattern
* **Flow:** Relays -> EventStore -> Observables -> Component Hooks.
* **Helpers:** Use `applesauce-react` hooks or custom hooks in `src/hooks/`.
* **Replaceable Events:** Handled automatically by EventStore (kinds 0, 3, 10000+, 30000+).
## Key Conventions
* **Path Alias:** `@/` maps to `./src/`.
* **Styling:** TailwindCSS + HSL variables for theming (defined in `index.css`).
* **Organization:** Domain-based (`nostr/`, `ui/`, `services/`, `hooks/`, `lib/`).
* **Types:** Prefer `applesauce-core` types; extend in `src/types/`.
## Important Patterns
### Adding New Commands
1. Add entry to `manPages` in `src/types/man.ts`.
2. Create argument parser in `src/lib/*-parser.ts`.
3. Create viewer component for the `appId`.
4. Register viewer in `WindowTitle.tsx` (or equivalent registry).
### Event Rendering
* **Pattern:** Registry-based rendering.
* **Files:** `src/components/nostr/kinds/index.tsx`.
* **Components:** `KindRenderer` (feed) and `DetailKindRenderer` (detail/full view).
* **Naming:** Use descriptive names (`LiveActivityRenderer`) not numbers (`Kind30311Renderer`).
* **Safety:** All renderers are wrapped in `EventErrorBoundary`.
### Testing
* **Framework:** Vitest.
* **Commands:**
* `npm test`: Watch mode.
* `npm run test:run`: CI mode (single run).
* **Focus:** Test pure functions in `logic.ts`, parsers in `lib/*-parser.ts`, and utilities.
## Critical Rules for Agents
> [!IMPORTANT]
> **Do NOT create new instances of singletons.**
> * `EventStore`, `RelayPool`, `RelayLiveness` are singletons in `src/services/`.
> [!IMPORTANT]
> **Respect the Layout Tree.**
> * Do not manually traverse or modify the Mosaic layout object. Use specific update callbacks.
> [!NOTE]
> **Use the Knowledge Base.**
> * Refer to `CLAUDE.md` for the original documentation source.
> * Check `.claude/skills` for library-specific documentation (Applesauce, Nostr tools).

View File

@@ -1,427 +0,0 @@
# Applesauce v5 Upgrade Plan for Grimoire
## Overview
This plan outlines the migration from applesauce v4 to v5, covering breaking changes, new features to adopt, and documentation updates.
## Phase 1: Package Updates & Import Migration
### 1.1 Update package.json Dependencies
**Current versions:**
```json
"applesauce-accounts": "^4.1.0",
"applesauce-actions": "^4.0.0",
"applesauce-content": "^4.0.0",
"applesauce-core": "latest",
"applesauce-loaders": "^4.2.0",
"applesauce-react": "^4.0.0",
"applesauce-relay": "latest",
"applesauce-signers": "^4.1.0"
```
**Target versions:**
```json
"applesauce-accounts": "^5.0.0",
"applesauce-actions": "^5.0.0",
"applesauce-common": "^5.0.0", // NEW - required for v5
"applesauce-content": "^5.0.0",
"applesauce-core": "^5.0.0",
"applesauce-loaders": "^5.0.0",
"applesauce-react": "^5.0.0",
"applesauce-relay": "^5.0.0",
"applesauce-signers": "^5.0.0"
```
**Remove:**
```json
"applesauce-factory": "..." // Removed in v5 - EventFactory now in applesauce-core
```
### 1.2 EventFactory Import Migration
**File:** `src/services/hub.ts`
```typescript
// Before (v4)
import { EventFactory } from "applesauce-factory";
// After (v5)
import { EventFactory } from "applesauce-core/event-factory";
```
### 1.3 Helper Import Migration
Many helpers moved from `applesauce-core/helpers` to `applesauce-common/helpers`. Need to audit all imports:
**Helpers that moved to applesauce-common/helpers:**
- Profile helpers: `getDisplayName`, `getProfilePicture`
- Social graph: `groupPubkeysByRelay`, `getSeenRelays`
- Threading: `getNip10References`, `interpretThreadTags`
- Zaps: `getZapAmount`, `getZapSender`, `isValidZap`
- Lists: `FAVORITE_RELAYS_KIND`, `getListTags`, `getRelaysFromList`
- Article helpers: `getArticleTitle`, `getArticleSummary`, etc.
- Highlight helpers: all `getHighlight*` functions
- Comment helpers: `getCommentReplyPointer`
**Files to update (from exploration):**
- `src/lib/nostr-utils.ts`
- `src/lib/event-title.ts`
- `src/lib/nip34-helpers.ts`
- `src/lib/nip-c0-helpers.ts`
- `src/services/loaders.ts`
- `src/services/relay-selection.ts`
- `src/hooks/useProfile.ts`
- `src/hooks/useNostrEvent.ts`
- `src/components/nostr/RichText.tsx`
- All kind renderers in `src/components/nostr/kinds/`
**Strategy:** Use grep to find all `applesauce-core/helpers` imports and update to `applesauce-common/helpers` where appropriate. Some low-level helpers remain in `applesauce-core/helpers`.
---
## Phase 2: Loader Migration (Unified Event Loader)
### 2.1 Current Loader Architecture
**File:** `src/services/loaders.ts`
Currently uses:
- `createEventLoader` - for single events
- `createAddressLoader` - for replaceable/addressable events
- `createTimelineLoader` - for timelines
- Custom `eventLoader` wrapper with smart relay hint merging
### 2.2 Unified Loader Setup
Replace separate loaders with unified loader:
```typescript
// Before (v4)
import {
createEventLoader,
createAddressLoader,
createTimelineLoader,
} from "applesauce-loaders/loaders";
const baseEventLoader = createEventLoader(pool, { eventStore, extraRelays });
export const addressLoader = createAddressLoader(pool, { eventStore, extraRelays });
export const profileLoader = createAddressLoader(pool, { eventStore, bufferTime: 200, extraRelays });
// After (v5)
import { createEventLoaderForStore } from "applesauce-loaders/loaders";
// One-time setup - attaches loader to eventStore
createEventLoaderForStore(eventStore, pool, {
bufferTime: 200,
followRelayHints: true,
extraRelays: AGGREGATOR_RELAYS,
lookupRelays: ["wss://purplepag.es/", "wss://index.hzrd149.com/"],
});
// Usage changes - use eventStore methods directly
eventStore.event({ id: "event_id" }).subscribe(...);
eventStore.replaceable({ kind: 0, pubkey: "pubkey" }).subscribe(...);
eventStore.addressable({ kind: 30023, pubkey, identifier }).subscribe(...);
```
### 2.3 Custom eventLoader Wrapper
The current smart relay hint merging in `eventLoader()` needs to be preserved. Options:
**Option A:** Keep custom wrapper, use unified loader internally
```typescript
export function eventLoader(pointer, context) {
// Keep existing relay hint merging logic
const enhancedPointer = mergeRelayHints(pointer, context);
// Use eventStore.event() from unified loader
return eventStore.event(enhancedPointer);
}
```
**Option B:** Move relay hint logic to a cacheRequest/extraRelays callback
```typescript
createEventLoaderForStore(eventStore, pool, {
bufferTime: 200,
followRelayHints: true,
extraRelays: (pointer) => getRelayHintsForPointer(pointer),
});
```
**Recommendation:** Option A - Keep custom wrapper for backward compatibility and control over relay selection.
### 2.4 Hook Updates for Unified Loader
**Files to update:**
- `src/hooks/useNostrEvent.ts` - Use eventStore.event() / eventStore.replaceable()
- `src/hooks/useProfile.ts` - Use eventStore.replaceable() for kind 0
- `src/hooks/useTimeline.ts` - May continue using createTimelineLoader
- `src/hooks/useLiveTimeline.ts` - No change (uses pool.subscription directly)
- `src/hooks/useAccountSync.ts` - Use eventStore.addressable() for kind 10002
---
## Phase 3: Action System Migration (ActionHub → ActionRunner)
### 3.1 Breaking Changes
**Before (v4):** Actions are async generators that `yield` events
```typescript
export function PublishSpellbook(options) {
return async function* ({ factory }: ActionContext): AsyncGenerator<NostrEvent> {
const draft = await factory.build({ kind, content, tags });
const event = await factory.sign(draft);
yield event; // ActionHub handles publishing
};
}
```
**After (v5):** Actions are async functions that call `context.publish()`
```typescript
export function PublishSpellbook(options) {
return async function ({ factory, publish }: ActionContext): Promise<void> {
const draft = await factory.build({ kind, content, tags });
const event = await factory.sign(draft);
await publish(event); // Explicit publish call
};
}
```
### 3.2 Files to Migrate
1. **`src/services/hub.ts`:**
```typescript
// Before
import { ActionHub } from "applesauce-actions";
export const hub = new ActionHub(eventStore, factory, publishEvent);
// After
import { ActionRunner } from "applesauce-actions";
export const hub = new ActionRunner(eventStore, factory, publishEvent);
```
2. **`src/actions/publish-spellbook.ts`:**
- Convert from async generator to async function
- Replace `yield event` with `await publish(event)`
3. **`src/actions/publish-spell.ts`:**
- Same conversion pattern
4. **`src/actions/delete-event.ts`:**
- Already uses direct factory.sign + pool.publish pattern
- Consider migrating to ActionRunner for consistency
### 3.3 Action Context Changes
v5 ActionContext provides additional features:
- `publish(event, relays?)` - Explicit publish with optional relay override
- `cast(event, Cast)` - Cast events to typed classes
- Sub-action support for composing actions
---
## Phase 4: React Hooks Migration (use$ Hook)
### 4.1 New use$ Hook
Replace `useObservableMemo` with `use$`:
```typescript
// Before (v4)
import { useObservableMemo } from "applesauce-react/hooks";
const event = useObservableMemo(() => eventStore.event(eventId), [eventId]);
const activeAccount = useObservableMemo(() => accounts.active$, []);
// After (v5)
import { use$ } from "applesauce-react/hooks";
const event = use$(() => eventStore.event(eventId), [eventId]);
const activeAccount = use$(accounts.active$); // Direct observable, no factory needed
```
### 4.2 Files to Update
Search for all `useObservableMemo` imports and update:
- `src/hooks/useNostrEvent.ts`
- `src/hooks/useTimeline.ts`
- `src/hooks/useLiveTimeline.ts`
- `src/hooks/useAccountSync.ts`
- `src/hooks/useStable.ts`
- Any component using `useObservableMemo` directly
### 4.3 Type Differences
`use$` has better TypeScript inference:
- `BehaviorSubject<T>` → returns `T` (never undefined)
- `Observable<T>` → returns `T | undefined`
---
## Phase 5: Adopt New Features
### 5.1 Casting System
Add casting for improved type safety and reactive patterns:
```typescript
import { castEvent, Note, User, Profile } from "applesauce-common/casts";
// Cast events to typed classes
const note = castEvent(event, Note, eventStore);
// Synchronous properties
console.log(note.id, note.createdAt, note.isReply);
// Reactive observables
const profile = use$(note.author.profile$);
const replies = use$(note.replies$);
```
**Potential use cases in Grimoire:**
- Note renderers - cast kind 1 events for cleaner access
- Profile components - use `User` cast for profile data
- Reply threads - use reactive `replies$` observable
- Zap displays - cast for amount/sender access
### 5.2 Encrypted Content Caching
For DM/encrypted content features:
```typescript
import { persistEncryptedContent } from "applesauce-common/helpers";
// Setup in services/event-store.ts or main.tsx
persistEncryptedContent(eventStore, storage);
// Decrypted content automatically cached
await unlockHiddenBookmarks(bookmarks, signer);
```
### 5.3 Blueprints and Operations
Move event creation to blueprints for cleaner code:
```typescript
// Before - manual event building
const draft = await factory.build({
kind: 30777,
content: JSON.stringify(content),
tags: [["d", slug], ["title", title]]
});
// After - using blueprints (if available for kind 30777)
import { SpellbookBlueprint } from "applesauce-common/blueprints";
const draft = await factory.build(SpellbookBlueprint({ title, content, slug }));
```
Note: Custom kinds like 30777 may not have built-in blueprints, but the pattern can be used for standard kinds.
---
## Phase 6: Documentation Updates
### 6.1 CLAUDE.md Updates
Update the following sections:
1. **Package structure** - Add `applesauce-common` to stack description
2. **Import patterns** - Update helper import paths
3. **Loader documentation** - Document unified loader pattern
4. **Action documentation** - Document ActionRunner pattern
5. **Hook documentation** - Document `use$` hook
6. **New features** - Add casting system documentation
7. **Helper caching note** - Update import paths in examples
### 6.2 Skill Updates
**`.claude/skills/applesauce-core/SKILL.md`:**
- Update import paths for helpers that moved to applesauce-common
- Add section on unified event loader
- Update examples to use `use$` hook
- Add casting system documentation
- Note v5 breaking changes
**New skill: `.claude/skills/applesauce-common/SKILL.md`:**
- Document casting system (Note, User, Profile, etc.)
- Document helpers that moved from applesauce-core
- Document blueprints and operations
- Document encrypted content caching
**`.claude/skills/applesauce-signers/SKILL.md`:**
- Verify no breaking changes (appears stable)
- Update if any signer interface changes
### 6.3 Code Comments
Update inline comments in affected files to reflect v5 patterns.
---
## Phase 7: Testing & Verification
### 7.1 Test Updates
Update test files for new patterns:
- `src/actions/publish-spell.test.ts`
- `src/actions/publish-spellbook.test.ts`
- Any tests using mocked applesauce imports
### 7.2 Verification Steps
1. Run `npm install` after package.json updates
2. Fix all TypeScript compilation errors
3. Run `npm run lint` and fix issues
4. Run `npm run test:run` and fix failing tests
5. Run `npm run build` to verify production build
6. Manual testing of critical flows:
- Profile loading
- Timeline feeds
- Event detail views
- Publishing events
- Account login/logout
---
## Migration Order
Recommended order to minimize breakage:
1. **Phase 1.1-1.3**: Package updates and imports (required first)
2. **Phase 3**: Action system migration (isolated change)
3. **Phase 2**: Loader migration (larger change, test carefully)
4. **Phase 4**: Hook migration (use$ is compatible alongside useObservableMemo)
5. **Phase 5**: New features adoption (optional enhancements)
6. **Phase 6**: Documentation updates
7. **Phase 7**: Final testing and verification
---
## Rollback Plan
If v5 migration encounters blocking issues:
1. Revert package.json to v4 versions
2. Run `npm install` to restore v4 packages
3. Git revert any code changes
4. Document blocking issues for resolution
---
## Estimated Scope
**Files to modify:**
- `package.json` - 1 file
- Service files (`src/services/`) - 3-4 files
- Hook files (`src/hooks/`) - 5-6 files
- Action files (`src/actions/`) - 3 files
- Helper usage across codebase - 20+ files
- Documentation files - 4-5 files
**New files:**
- `.claude/skills/applesauce-common/SKILL.md`
**Risk areas:**
- Loader migration (most complex, affects data loading)
- Helper import paths (many files, easy to miss some)
- Action generator → async function (behavior change)

View File

@@ -1,501 +0,0 @@
# Spell System Implementation Plan
## Executive Summary
Refactor the spell system to support:
1. **Alias** (local-only quick name) + **Name** (published spell name)
2. Non-technical user-friendly spell creator wizard
3. Spell discovery and browsing UI
4. Improved command palette integration
## Data Model Changes
### Current State
```typescript
LocalSpell {
localName?: string; // Local only
description?: string; // Published
command: string;
}
SpellEvent (kind 777) {
tags: [
["cmd", "REQ"],
["client", "grimoire"],
// NO name tag
],
content: description
}
```
### New State
```typescript
LocalSpell {
alias?: string; // NEW: Local-only quick name
name?: string; // NEW: Mirror from published event
description?: string;
command: string;
isPublished: boolean;
eventId?: string;
}
SpellEvent (kind 777) {
tags: [
["cmd", "REQ"],
["client", "grimoire"],
["name", "Bitcoin Feed"], // NEW: Published name
["t", "bitcoin"], // Topic tags
],
content: description // Optional
}
```
### Key Distinction
- **Alias**: Personal shortcut, typed in command palette (e.g., `btc`)
- **Name**: Public spell title, shown in discovery (e.g., "Bitcoin Feed")
- **Description**: Detailed explanation of what the spell does
## Implementation Phases
### Phase 1: Foundation Fixes (Immediate - 2-3 hours)
**Goal:** Fix data model and current UI
**Changes:**
1. Add `name` field to `CreateSpellOptions` and `ParsedSpell` types
2. Add name tag encoding/decoding in `spell-conversion.ts`
3. Rename `localName``alias` in `LocalSpell` interface
4. Add database migration v9→v10
5. Update `SpellDialog`:
- Add alias field (local-only, top)
- Add name field (published)
- Rename "Filter" label to "Command"
- Remove Cancel button
6. Update `spell-storage.ts` for alias field
7. Update all tests
**Files Modified:**
- `src/types/spell.ts`
- `src/lib/spell-conversion.ts`
- `src/services/db.ts`
- `src/services/spell-storage.ts`
- `src/components/nostr/SpellDialog.tsx`
- `src/lib/spell-conversion.test.ts`
**Success Criteria:**
- ✅ Build passes
- ✅ All tests pass
- ✅ Migration preserves existing spells
- ✅ Can create spells with alias + name
- ✅ Published spells include name tag
---
### Phase 2: Spell Browser (2-3 days)
**Goal:** Create spell discovery and management UI
**New Components:**
1. **SpellsViewer** (`src/components/SpellsViewer.tsx`)
- Main window component (appId: "spells")
- Three tabs: My Spells, Discover, Favorites
- Search bar and filters
- "New Spell" button
2. **SpellList** (`src/components/nostr/SpellList.tsx`)
- Virtual scrolling for performance
- Sort by: recent, popular, name
- Filter by: content type, author, tags
3. **SpellCard** (`src/components/nostr/SpellCard.tsx`)
- Compact display with metadata
- Quick actions: Run (▶), Edit (✏), More (⋮)
- Visual distinction: local vs published
4. **SpellDetailModal** (`src/components/nostr/SpellDetailModal.tsx`)
- Expanded spell view
- Friendly metadata display (no technical REQ syntax)
- Stats: reactions, forks, usage
- Actions: Run, Edit, Fork, Share
**Features:**
- Browse local and network spells
- Run spells directly from browser
- Fork published spells
- Search by name, alias, description, tags
- Filter by content type (kinds)
- Sort by popularity or recency
**Command Palette Integration:**
- `spells` → Open spell browser
- `spell create` → Open spell creator
- `<alias>` → Run spell by alias
- Autocomplete shows spell suggestions
**Success Criteria:**
- ✅ Can browse local spells
- ✅ Can discover network spells
- ✅ Can run spells from browser
- ✅ Search and filtering work
- ✅ Command palette integration functional
- ✅ Performance good with 100+ spells
---
### Phase 3: Spell Creator Wizard (3-4 days)
**Goal:** Non-technical friendly spell creation
**Wizard Steps:**
**Step 1: Content Type**
```
What do you want to see?
[📝 Notes & Posts] [📰 Long Articles]
[👤 Profiles] [🎨 Images]
[💬 Replies] [🎵 Audio/Video]
[📚 Custom...]
```
Visual cards with descriptions, most popular types first.
**Step 2: Authors**
```
Who posted this?
○ Everyone
○ People I follow
○ Specific people: [Search...]
```
People picker with:
- Profile pictures and display names
- Search by name, NIP-05, npub
- Multi-select with chips
- Quick "Add from follows" button
**Step 3: Time Range**
```
When?
[⏰ Last Hour] [📅 Today] [🗓️ This Week]
[📆 This Month] [🌐 All Time]
Or custom: From [___] to [___]
```
Visual preset buttons + custom date picker.
**Step 4: Advanced Filters** (collapsible, optional)
```
▼ More Options
Tags: [#bitcoin] [#nostr] [+ Add]
Mentions: [@jack] [+ Add]
Search: [_____________]
Limit: [50 ▼]
```
**Step 5: Preview & Name**
```
Preview
This spell will show:
📝 Notes from @jack, @alice
⏰ From the last 7 days
🏷 Tagged #bitcoin
[Live preview of results...]
---
Quick Name (alias): [btc ]
Spell Name (published): [Bitcoin Feed]
Description (published): [___________]
[< Back] [Save Locally] [Save & Publish]
```
**Templates:**
Provide curated templates for quick start:
- My Network (posts from follows)
- Trending Topics (popular recent posts)
- Bitcoin News (#bitcoin #btc)
- Art Gallery (images from artists)
**Helper Components:**
1. **PeoplePicker** (`src/components/ui/people-picker.tsx`)
- Author/mention selection
- Profile integration
- Multi-select support
2. **TagInput** (`src/components/ui/tag-input.tsx`)
- Hashtag selection
- Autocomplete from popular tags
3. **Wizard Converters** (`src/lib/wizard-converter.ts`)
```typescript
wizardToCommand(state: WizardState): string
commandToWizard(command: string): WizardState
filterToFriendlyDescription(filter: NostrFilter): string
```
**Success Criteria:**
- ✅ Non-technical users can create spells
- ✅ All wizard steps functional
- ✅ Live preview works
- ✅ Templates load correctly
- ✅ Conversion wizard↔command accurate
- ✅ Keyboard navigation works
---
### Phase 4: Additional Features (Future)
**Spell Templates** (`src/lib/spell-templates.ts`)
```typescript
interface SpellTemplate {
id: string;
name: string;
description: string;
icon: string;
category: 'social' | 'media' | 'discovery' | 'monitoring';
requiresAccount: boolean;
wizardDefaults: Partial<WizardState>;
}
```
**Spell Discovery Enhancements:**
- Popularity metrics (reactions, forks)
- Trust indicators (verified creators, from follows)
- Categorization by content type
- Network-wide trending spells
**Command Palette Enhancements:**
- Spell autocomplete with descriptions
- Recent spells quick access
- Fuzzy search for spell names/aliases
**Future Enhancements (Phase 5):**
- Parameterized spells (variables)
- Scheduled spells (hourly, daily)
- Spell playlists/collections
- Spell analytics and stats
- Collaborative spell sharing
- AI-assisted spell creation
## Technical Architecture
### Component Structure
```
src/
├── components/
│ ├── SpellsViewer.tsx # Main spell browser
│ ├── nostr/
│ │ ├── SpellList.tsx # List of spells
│ │ ├── SpellCard.tsx # Spell card
│ │ ├── SpellDetailModal.tsx # Expanded view
│ │ ├── SpellCreatorWizard.tsx # Wizard main
│ │ ├── SpellEditor.tsx # Rename from SpellDialog
│ │ └── wizard/
│ │ ├── ContentTypeStep.tsx
│ │ ├── AuthorStep.tsx
│ │ ├── TimeRangeStep.tsx
│ │ ├── AdvancedStep.tsx
│ │ └── PreviewStep.tsx
│ └── ui/
│ ├── people-picker.tsx
│ └── tag-input.tsx
├── lib/
│ ├── spell-templates.ts # Curated templates
│ ├── spell-metadata.ts # Filter formatting
│ └── wizard-converter.ts # Wizard ↔ command
├── hooks/
│ ├── useSpells.ts # Spell data
│ ├── useSpellDiscovery.ts # Network discovery
│ └── useSpellActions.ts # Actions
└── types/
└── wizard.ts # Wizard state types
```
### State Management
**Option A: Jotai (Current Pattern)**
```typescript
export const localSpellsAtom = atom<LocalSpell[]>([]);
export const publishedSpellsAtom = atom<ParsedSpell[]>([]);
export const spellDiscoveryAtom = atom<ParsedSpell[]>([]);
```
**Option B: React Query (Recommended for Phase 2+)**
```typescript
export function useLocalSpells() {
return useQuery({
queryKey: ['spells', 'local'],
queryFn: () => getAllSpells(),
});
}
export function usePublishedSpells() {
const subscription = useSubscription({
filter: { kinds: [777] },
relays: AGGREGATOR_RELAYS,
});
return useQuery({
queryKey: ['spells', 'published'],
queryFn: () => parsePublishedSpells(subscription.events),
});
}
```
### Discovery Mechanisms
1. **From Follows:** Query kind 777 from contact list
2. **From Aggregators:** Query AGGREGATOR_RELAYS
3. **By Category:** Filter by "k" tags
4. **Search:** Full-text on name, description, tags
5. **Popularity:** Sort by reaction count (kind 7)
### Performance Considerations
- Virtual scrolling for spell lists (react-window)
- Debounced search (300ms)
- Lazy load published spells
- Cache parsed spells in memory
- Background sync when inactive
## Edge Cases & Validation
### Alias Validation
- Alphanumeric + dash + underscore only: `/^[a-zA-Z0-9_-]+$/`
- Max length: 32 characters
- Cannot conflict with built-in commands (req, profile, etc.)
### Name Validation
- Any Unicode characters allowed
- Max length: 64 characters
- Optional (can be empty)
### Description Validation
- Any Unicode characters allowed
- Max length: 500 characters
- Optional (can be empty)
### Empty Spell Handling
- If no name/alias/description: show "(Unnamed Spell)"
- Auto-derive fallback from command: "Kind 1 Notes"
### Conflict Resolution
- Alias conflicts: Show warning, allow override
- Published spell updates: Show "Local changes not published"
- Duplicate aliases: Last one wins, show warning
## Testing Strategy
### Unit Tests
- Spell encoding/decoding with name tag
- Alias validation
- Filter-to-metadata conversion
- Wizard-to-command conversion
- Database migration
### Integration Tests
- Create and save spell
- Publish spell to network
- Fork published spell
- Run spell via alias
- Search and filter spells
### Manual Testing Checklist
- [ ] Create spell from REQ window
- [ ] Create spell via wizard
- [ ] Edit existing spell
- [ ] Delete local spell
- [ ] Publish local spell
- [ ] Fork published spell
- [ ] Run spell via alias
- [ ] Search spells
- [ ] Filter by category
- [ ] Command palette integration
## Migration Strategy
### Database Migration v9 → v10
```typescript
this.version(10)
.stores({
// ... same schema ...
})
.upgrade(async (tx) => {
const spells = await tx.table<any>("spells").toArray();
for (const spell of spells) {
// Rename localName → alias
if (spell.localName) {
spell.alias = spell.localName;
delete spell.localName;
}
// Initialize name field
spell.name = spell.name || undefined;
await tx.table("spells").put(spell);
}
console.log(`[DB Migration v10] Migrated ${spells.length} spells`);
});
```
**Zero Data Loss:** Existing spells preserved with quick names as aliases.
## Implementation Timeline
### Phase 1: Immediate (2-3 hours)
- Foundation fixes
- Data model corrections
- SpellDialog updates
- Tests
### Phase 2: Spell Browser (2-3 days)
- SpellsViewer component
- Discovery and browsing
- Command palette integration
- Basic actions
### Phase 3: Wizard (3-4 days)
- Multi-step wizard
- Visual builders
- Templates
- Live preview
### Total: ~1 week full-time
## Success Metrics
- **User Adoption:** 50%+ of users create at least one spell
- **Non-Technical Success:** 30%+ of spells created via wizard
- **Discovery:** 20%+ of runs are discovered spells (not user-created)
- **Performance:** <100ms to load spell browser
- **Quality:** 0 critical bugs in Phase 1
## Accessibility
- Keyboard navigation for all features
- Screen reader support with ARIA labels
- Focus management in modals
- Clear visual hierarchy
- Empty state guidance
## Conclusion
This plan transforms the spell system from technical CLI-only to user-friendly with visual builders, while maintaining power-user CLI workflows. The phased approach allows incremental delivery and iteration based on feedback.
**Next Steps:**
1. Implement Phase 1 (immediate fixes)
2. Test and validate with users
3. Begin Phase 2 (spell browser)
4. Iterate based on feedback

File diff suppressed because it is too large Load Diff

View File

@@ -1,787 +0,0 @@
# ReqViewer State Machine Analysis
**Date**: 2025-12-22
**Issue**: Disconnected relays are incorrectly shown as "LIVE" and counted as having sent EOSE
## Executive Summary
The ReqViewer state machine has a critical bug where relay disconnections are indistinguishable from EOSE messages, leading to incorrect status indicators. A query using 30 relays where all disconnect will show "LIVE" status with 0/30 relays connected.
## Architecture Overview
### Current Flow
```
User Query → useReqTimeline → pool.subscription → RelayGroup → Individual Relays
↓ ↓
setEoseReceived(true) ←── "EOSE" string ←── catchError → DISCONNECTION
Shows "LIVE" indicator
```
### Key Components
1. **ReqViewer** (`src/components/ReqViewer.tsx`):
- UI component that displays query results and status
- Lines 918-957: Status indicator logic based on `loading`, `eoseReceived`, `stream`
- Lines 735-737: Connected relay count based on `connectionState === "connected"`
2. **useReqTimeline** (`src/hooks/useReqTimeline.ts`):
- Hook that manages REQ subscription
- Line 88: Sets `eoseReceived = true` when response is string "EOSE"
- No awareness of relay disconnection state
3. **RelayPool** (applesauce-relay):
- `pool.subscription()` delegates to RelayGroup
- Uses retry/reconnect logic but doesn't expose per-relay EOSE state
4. **RelayGroup** (applesauce-relay/dist/group.js):
- **CRITICAL BUG HERE**: Line with `catchError(() => of("EOSE"))`
- Treats ANY error (including disconnection) as EOSE
- Aggregates EOSE from all relays before emitting overall EOSE
5. **Relay** (applesauce-relay/dist/relay.js):
- Individual relay connection
- Has 10-second EOSE timeout that emits fake EOSE if none received
- Emits observables: `connected$`, `challenge$`, `authenticated$`, `notice$`
## Critical Bug: Error Handling in RelayGroup
### The Problem
In `node_modules/applesauce-relay/dist/group.js`:
```javascript
const observable = project(relay).pipe(
// Catch connection errors and return EOSE
catchError(() => of("EOSE")), // ← BUG: Disconnections become EOSE!
map((value) => [relay, value])
);
```
**Why this is problematic**:
- A relay that never connected emits "EOSE"
- A relay that disconnects mid-query emits "EOSE"
- A relay with a WebSocket error emits "EOSE"
- These fake EOSE messages are indistinguishable from real ones
### EOSE Aggregation Logic
```javascript
const eose = this.relays$.pipe(
switchMap((relays) =>
main.pipe(
filter(([_, value]) => value === "EOSE"),
scan((received, [relay]) => [...received, relay], []),
// Wait until ALL relays have "sent" EOSE
takeWhile((received) => relays.some((r) => !received.includes(r))),
ignoreElements(),
endWith("EOSE") // ← Emits when all relays done (or errored)
)
)
);
```
**Result**: The overall EOSE is emitted when:
- ✅ All relays sent real EOSE and are streaming
- ✅ All relays sent real EOSE and closed connection
- ❌ All relays disconnected (caught and turned into fake EOSE)
- ❌ Mix of real EOSE and disconnections (can't tell the difference)
## Edge Cases & Failure Scenarios
### Scenario 1: All Relays Disconnect Immediately
**Setup**: Query with 10 relays, all are offline or reject connection
**Current Behavior**:
- Each relay: `catchError` → emits "EOSE"
- useReqTimeline: Sets `eoseReceived = true`
- ReqViewer: Shows "LIVE" indicator (green, pulsing)
- Connection count: 0/10
- User sees: "LIVE" with 0 connected relays
**Expected Behavior**: Show "ERROR" or "NO RELAYS" status
### Scenario 2: Slow Relays with Timeout
**Setup**: Query with relay that takes 15 seconds to respond
**Current Behavior**:
- After 10s: EOSE timeout fires → emits fake "EOSE"
- Relay still connected, might send more events later
- User sees: "LIVE" but relay is counted as "done"
**Expected Behavior**: Continue waiting or show "PARTIAL" status
### Scenario 3: Mixed Success/Failure
**Setup**: 30 relays, 10 succeed with EOSE, 15 disconnect, 5 timeout
**Current Behavior**:
- All 30 eventually emit "EOSE" (real or fake)
- Overall EOSE emitted
- Shows "LIVE" with 10/30 connected
- User can't tell which relays actually completed vs failed
**Expected Behavior**: Show per-relay status and overall "PARTIAL" indicator
### Scenario 4: Mid-Query Disconnection
**Setup**: Relay sends 50 events, then disconnects before EOSE
**Current Behavior**:
- Disconnection → `catchError` → fake "EOSE"
- Events are shown, looks like query completed successfully
- No indication that query was interrupted
**Expected Behavior**: Show warning that relay disconnected mid-query
### Scenario 5: Streaming Mode with Gradual Disconnections
**Setup**: Query in streaming mode, relays disconnect one by one
**Current Behavior**:
- Each disconnection → fake "EOSE"
- Eventually all relays have "EOSE"
- Shows "LIVE" with 0/30 connected (THE REPORTED BUG!)
**Expected Behavior**: Show "OFFLINE" or "NO ACTIVE RELAYS" when all disconnect
### Scenario 6: Single Relay Query
**Setup**: Query with explicit relay that doesn't respond
**Current Behavior**:
- After 10s timeout → fake "EOSE"
- Shows "CLOSED" (not streaming)
- No indication relay never responded
**Expected Behavior**: Show "TIMEOUT" or "NO RESPONSE" status
### Scenario 7: AUTH Required But Not Provided
**Setup**: Relay requires authentication, no account active
**Current Behavior**:
- Relay returns "auth-required" CLOSED message
- Caught and turned into "EOSE"
- Looks like query completed with no results
**Expected Behavior**: Show "AUTH REQUIRED" status
## State Machine Requirements
### Required States
**Query-Level States**:
- `DISCOVERING`: Selecting relays (NIP-65 outbox discovery)
- `CONNECTING`: Waiting for first relay to connect
- `LOADING`: At least one relay connected, waiting for initial EOSE
- `LIVE`: At least one relay streaming after EOSE
- `PARTIAL`: Some relays completed, some failed/disconnected
- `CLOSED`: All relays sent EOSE and closed (non-streaming)
- `FAILED`: All relays failed to connect or errored
- `TIMEOUT`: No relays responded within timeout
- `AUTH_REQUIRED`: Some/all relays require authentication
**Per-Relay States** (tracked separately):
- `PENDING`: Relay in list but not yet connected
- `CONNECTING`: Connection attempt in progress
- `CONNECTED`: WebSocket open, REQ sent
- `RECEIVING`: Events being received
- `EOSE_RECEIVED`: EOSE message received (still connected)
- `CLOSED`: Clean closure after EOSE
- `DISCONNECTED`: Unexpected disconnection
- `ERROR`: Connection error or protocol error
- `TIMEOUT`: No response within timeout
- `AUTH_REQUIRED`: Relay requires authentication
### State Transition Rules
**Query Level**:
```
DISCOVERING → CONNECTING (when relays selected)
CONNECTING → LOADING (when first relay connects)
CONNECTING → FAILED (when all relay connections fail, timeout)
LOADING → LIVE (when EOSE received, stream=true, >0 relays connected)
LOADING → PARTIAL (when some EOSE, some failed, stream=true)
LOADING → CLOSED (when all EOSE received, stream=false)
LOADING → FAILED (when all relays fail before EOSE)
LIVE → PARTIAL (when some relays disconnect)
LIVE → FAILED (when all relays disconnect)
PARTIAL → LIVE (when previously failed relays reconnect)
PARTIAL → FAILED (when remaining relays disconnect)
```
**Per-Relay** (tracked in RelayStateManager):
```
PENDING → CONNECTING (when connection initiated)
CONNECTING → CONNECTED (when WebSocket open, REQ sent)
CONNECTING → ERROR (when connection fails)
CONNECTING → TIMEOUT (when connection takes too long)
CONNECTED → RECEIVING (when first event received)
CONNECTED → EOSE_RECEIVED (when EOSE received, no prior events)
CONNECTED → ERROR (when connection lost)
RECEIVING → EOSE_RECEIVED (when EOSE received)
RECEIVING → DISCONNECTED (when connection lost before EOSE)
RECEIVING → ERROR (when protocol error)
EOSE_RECEIVED → CLOSED (when relay closes connection after EOSE)
EOSE_RECEIVED → DISCONNECTED (when relay keeps connection open in streaming)
```
## Data Requirements
### Information We Need But Don't Have
1. **Per-Relay EOSE Status**:
- Which relays sent real EOSE?
- Which relays disconnected without EOSE?
- Which relays timed out?
- Which relays are still streaming?
2. **Per-Relay Event Counts**:
- How many events did each relay send?
- Useful for showing progress and diagnosing issues
3. **Error Details**:
- Why did relay fail? (connection refused, timeout, protocol error, auth required)
- Currently lost in `catchError(() => of("EOSE"))`
4. **Timing Information**:
- When did relay connect?
- When did first event arrive?
- When did EOSE arrive?
- How long did query take per relay?
5. **Relay Health Context**:
- Is relay in RelayLiveness backoff state?
- Has relay been failing consistently?
- Should we even attempt connection?
### Information We Have But Don't Use
From **RelayStateManager** (`src/services/relay-state-manager.ts`):
-`connectionState`: "connected" | "connecting" | "disconnected" | "error"
-`lastConnected`, `lastDisconnected`: Timestamps
-`errors[]`: Array of error messages with types
-`stats.connectionsCount`: How many times relay connected
From **RelayLiveness** (`src/services/relay-liveness.ts`):
- ✅ Failure counts per relay
- ✅ Backoff states
- ✅ Last success/failure times
- ✅ Should prevent connection attempts to dead relays
**Problem**: useReqTimeline doesn't integrate with either of these!
## Nostr Protocol Semantics
### REQ Lifecycle (NIP-01)
1. Client sends: `["REQ", <subscription_id>, <filter1>, <filter2>, ...]`
2. Relay responds with zero or more: `["EVENT", <subscription_id>, <event>]`
3. Relay sends: `["EOSE", <subscription_id>]` when initial query complete
4. Client can keep subscription open for streaming
5. Client closes: `["CLOSE", <subscription_id>]`
6. Relay can close: `["CLOSED", <subscription_id>, <reason>]`
### EOSE Semantics
**What EOSE means**:
- ✅ "I have sent all stored events matching your filter"
- ✅ "Initial query phase is complete"
- ✅ Connection is still open (unless relay closes immediately after)
**What EOSE does NOT mean**:
- ❌ "No more events will be sent" (streaming continues)
- ❌ "Connection is closing"
- ❌ "Query was successful" (could have returned 0 events)
### CLOSED Semantics
**Why relays send CLOSED**:
- `auth-required`: AUTH event required before query
- `rate-limited`: Too many requests
- `error`: Generic error (parsing, internal, etc.)
- `invalid`: Filter validation failed
**Client should**:
- Distinguish CLOSED from EOSE
- Handle auth-required by prompting user
- Handle rate-limiting with backoff
- Show errors to user
## Applesauce Behavior Analysis
### Retry/Reconnect Logic
**relay.subscription()** options:
- `retries` (deprecated): Number of retry attempts
- `reconnect` (default: true, 10 retries): Retry on connection failures
- `resubscribe` (default: false): Resubscribe if relay sends CLOSED
**Current usage in useReqTimeline.ts**:
```typescript
pool.subscription(relays, filtersWithLimit, {
retries: 5,
reconnect: 5,
resubscribe: true,
eventStore,
});
```
**Behavior**:
- Retries connection failures up to 5 times
- Resubscribes if relay sends CLOSED (like auth-required)
- Uses exponential backoff (see `Relay.createReconnectTimer`)
**Issue**: All this retry logic happens inside applesauce, invisible to useReqTimeline. We can't show "RETRYING" status or retry count to user.
### Group Subscription Behavior
**relay.subscription()** in RelayGroup:
```javascript
subscription(filters, opts) {
return this.internalSubscription(
(relay) => relay.subscription(filters, opts),
opts?.eventStore == null ? identity : filterDuplicateEvents(opts?.eventStore)
);
}
```
**Key behaviors**:
1. Creates observable for each relay
2. Merges all observables
3. Deduplicates events via EventStore
4. Catches errors and converts to "EOSE" (THE BUG)
5. Emits overall "EOSE" when all relays done
**Missing**:
- No per-relay state tracking
- No way to query "which relays have sent EOSE?"
- No way to query "which relays are still connected?"
- Error information is lost
## Technical Constraints
### What We Can't Change
1. **Applesauce-relay library behavior**:
- We can't modify the `catchError(() => of("EOSE"))` in RelayGroup
- This is in node_modules, upstream library
- Would need to fork or submit PR
2. **Observable-based API**:
- pool.subscription returns `Observable<SubscriptionResponse>`
- Response is either `NostrEvent` or string `"EOSE"`
- Can't change this interface without forking
3. **Relay connection pooling**:
- RelayPool manages all relay connections globally
- Multiple components can share same relay connection
- Can't have per-query relay isolation
### What We Can Work With
1. **RelayStateManager**:
- Already tracks per-relay connection state
- Updates in real-time via observables
- Available via `useRelayState()` hook
- CAN BE ENHANCED to track per-query state
2. **EventStore**:
- Already receives all events
- Could be instrumented to track per-relay events
- Has access to relay URL via event metadata
3. **Custom observables**:
- We can tap into the subscription observable
- Track events and EOSE per relay ourselves
- Build parallel state tracking
4. **Relay URL in events**:
- Events marked with relay URL via `markFromRelay()` operator
- Can track which relay sent which events
## Proposed Solutions
### Solution 1: Per-Relay Subscription Tracking (Recommended)
**Approach**: Track individual relay subscriptions in parallel with the group subscription.
**Implementation**:
```typescript
interface RelaySubscriptionState {
url: string;
status: 'pending' | 'connecting' | 'receiving' | 'eose' | 'closed' | 'error';
eventCount: number;
firstEventAt?: number;
eoseAt?: number;
error?: Error;
}
function useReqTimelineEnhanced(id, filters, relays, options) {
const [relayStates, setRelayStates] = useState<Map<string, RelaySubscriptionState>>();
// Subscribe to individual relays
useEffect(() => {
const subs = relays.map(url => {
const relay = pool.relay(url);
return relay.req(filters).subscribe({
next: (response) => {
if (response === 'EOSE') {
setRelayStates(prev => prev.set(url, { ...prev.get(url), status: 'eose', eoseAt: Date.now() }));
} else {
setRelayStates(prev => prev.set(url, {
...prev.get(url),
status: 'receiving',
eventCount: (prev.get(url)?.eventCount ?? 0) + 1
}));
}
},
error: (err) => {
setRelayStates(prev => prev.set(url, { ...prev.get(url), status: 'error', error: err }));
}
});
});
return () => subs.forEach(sub => sub.unsubscribe());
}, [relays, filters]);
// Derive overall state from individual relay states
const overallState = useMemo(() => {
const states = Array.from(relayStates.values());
const connected = states.filter(s => ['receiving', 'eose'].includes(s.status));
const eose = states.filter(s => s.status === 'eose');
const errors = states.filter(s => s.status === 'error');
if (connected.length === 0 && errors.length === states.length) return 'FAILED';
if (eose.length === states.length) return 'CLOSED';
if (eose.length > 0 && connected.length > 0) return 'LIVE';
if (connected.length > 0) return 'LOADING';
return 'CONNECTING';
}, [relayStates]);
return { events, relayStates, overallState };
}
```
**Pros**:
- ✅ Accurate per-relay tracking
- ✅ Can distinguish real EOSE from errors
- ✅ Works around applesauce bug without forking
- ✅ Provides rich debugging information
**Cons**:
- ❌ Duplicate subscriptions (one per relay + one group)
- ❌ More memory usage
- ❌ Potential for state synchronization issues
### Solution 2: Enhanced Group Observable Wrapper
**Approach**: Wrap the group subscription and parse relay URL from event metadata.
**Implementation**:
```typescript
function useReqTimelineWithTracking(id, filters, relays, options) {
const [relayEose, setRelayEose] = useState<Set<string>>(new Set());
const { relays: relayStates } = useRelayState();
useEffect(() => {
const observable = pool.subscription(relays, filters, options).pipe(
tap(response => {
if (typeof response === 'string' && response === 'EOSE') {
// This is the aggregated EOSE, check which relays are still connected
const stillConnected = relays.filter(url =>
relayStates[url]?.connectionState === 'connected'
);
// If no relays connected, treat as failure not EOSE
if (stillConnected.length === 0) {
setError(new Error('All relays disconnected'));
return;
}
} else if (isNostrEvent(response)) {
// Track which relay sent this event
const relayUrl = (response as any)._relay; // Added by markFromRelay()
if (relayUrl && !relayEose.has(relayUrl)) {
// Mark relay as active/receiving
}
}
})
);
return observable.subscribe(/* ... */);
}, [relays, filters]);
}
```
**Pros**:
- ✅ Single subscription (no duplication)
- ✅ Uses existing infrastructure
- ✅ Leverages RelayStateManager
**Cons**:
- ❌ Can't distinguish real EOSE from fake (happens in applesauce)
- ❌ Relies on relay URL being added to events
- ❌ Still shows "EOSE" when all relays disconnect
### Solution 3: Fork Applesauce-Relay (Not Recommended)
**Approach**: Fork applesauce-relay and fix the catchError bug.
**Changes needed**:
```typescript
// In group.js, change:
catchError(() => of("EOSE"))
// To:
catchError((err) => of({ type: 'ERROR', relay, error: err }))
// And update EOSE aggregation to only count real EOSE
```
**Pros**:
- ✅ Fixes root cause
- ✅ Proper error handling
- ✅ Could be upstreamed
**Cons**:
- ❌ Maintenance burden of fork
- ❌ Need to track upstream changes
- ❌ Breaks applesauce API contract
### Solution 4: Hybrid Approach (RECOMMENDED)
**Combine** Solution 1 + Solution 2:
1. Use RelayStateManager to track connection state
2. Subscribe to group observable for events (deduplication)
3. Build per-relay state machine based on:
- Connection state from RelayStateManager
- Events received (tracked by relay URL in metadata)
- Overall EOSE from group subscription
4. Derive accurate overall state
**Implementation** in new file `src/hooks/useReqTimelineEnhanced.ts`:
```typescript
interface ReqRelayState {
url: string;
connectionState: 'pending' | 'connecting' | 'connected' | 'disconnected' | 'error';
subscriptionState: 'waiting' | 'receiving' | 'eose' | 'timeout' | 'error';
eventCount: number;
firstEventAt?: number;
lastEventAt?: number;
errorMessage?: string;
}
interface ReqOverallState {
status: 'discovering' | 'connecting' | 'loading' | 'live' | 'partial' | 'closed' | 'failed';
connectedCount: number;
eoseCount: number;
errorCount: number;
totalRelays: number;
}
export function useReqTimelineEnhanced(
id: string,
filters: Filter | Filter[],
relays: string[],
options: UseReqTimelineOptions = {}
) {
// State
const [relayStates, setRelayStates] = useState<Map<string, ReqRelayState>>(new Map());
const [overallEose, setOverallEose] = useState(false);
// Get relay connection states
const { relays: globalRelayStates } = useRelayState();
// Subscribe to events
const observable = pool.subscription(relays, filters, options);
useEffect(() => {
// Initialize relay states
setRelayStates(new Map(relays.map(url => [
url,
{
url,
connectionState: 'pending',
subscriptionState: 'waiting',
eventCount: 0,
}
])));
const sub = observable.subscribe({
next: (response) => {
if (response === 'EOSE') {
setOverallEose(true);
} else {
const event = response as NostrEvent;
const relayUrl = (event as any)._relay;
setRelayStates(prev => {
const state = prev.get(relayUrl);
if (!state) return prev;
const next = new Map(prev);
next.set(relayUrl, {
...state,
subscriptionState: 'receiving',
eventCount: state.eventCount + 1,
firstEventAt: state.firstEventAt ?? Date.now(),
lastEventAt: Date.now(),
});
return next;
});
}
},
error: (err) => {
// Overall subscription error
},
});
return () => sub.unsubscribe();
}, [relays, filters]);
// Sync connection state from RelayStateManager
useEffect(() => {
setRelayStates(prev => {
const next = new Map(prev);
for (const [url, state] of prev) {
const globalState = globalRelayStates[url];
if (globalState) {
next.set(url, {
...state,
connectionState: globalState.connectionState as any,
});
}
}
return next;
});
}, [globalRelayStates]);
// Derive overall state
const overallState: ReqOverallState = useMemo(() => {
const states = Array.from(relayStates.values());
const connected = states.filter(s => s.connectionState === 'connected');
const receivedData = states.filter(s => s.eventCount > 0);
const errors = states.filter(s => s.connectionState === 'error');
const status = (() => {
if (relays.length === 0) return 'discovering';
if (connected.length === 0 && errors.length === states.length) return 'failed';
if (connected.length === 0 && receivedData.length === 0) return 'connecting';
if (!overallEose) return 'loading';
if (connected.length === 0 && overallEose) return 'closed';
if (connected.length > 0 && overallEose && options.stream) return 'live';
if (connected.length < relays.length && overallEose) return 'partial';
return 'closed';
})();
return {
status,
connectedCount: connected.length,
eoseCount: states.filter(s => s.subscriptionState === 'eose').length,
errorCount: errors.length,
totalRelays: relays.length,
};
}, [relayStates, overallEose, relays.length, options.stream]);
return {
events,
relayStates,
overallState,
loading: !overallEose,
eoseReceived: overallEose,
};
}
```
**Pros**:
- ✅ No duplicate subscriptions
- ✅ Accurate connection tracking
- ✅ Rich per-relay information
- ✅ Works with existing infrastructure
- ✅ Can show "LIVE" only when relays actually connected
**Cons**:
- ❌ Can't distinguish real EOSE from timeout/error (upstream issue)
- ❌ More complex state management
- ❌ Depends on event metadata having relay URL
## Recommendation
**Implement Solution 4 (Hybrid Approach)** as the most pragmatic path forward:
1. Create `useReqTimelineEnhanced` hook with per-relay state tracking
2. Update ReqViewer to use enhanced hook
3. Improve status indicator logic to use overall state
4. Add per-relay status display in relay dropdown
5. Show accurate indicators for edge cases
**Future work**:
- Submit PR to applesauce-relay to fix catchError bug
- Add per-relay EOSE tracking to applesauce (upstream enhancement)
- Implement relay health scoring to avoid dead relays
## Implementation Priority
### Phase 1: Critical Fixes (Immediate)
1. Implement `useReqTimelineEnhanced` hook
2. Update ReqViewer status indicator logic
3. Add per-relay state display
4. Handle "all relays disconnected" case
### Phase 2: Enhanced UX (Next)
5. Add per-relay event counts
6. Show relay timing information
7. Add retry/reconnection indicators
8. Integrate with RelayLiveness for smarter relay selection
### Phase 3: Advanced Features (Future)
9. Partial EOSE indicator (some relays done, some still loading)
10. Relay performance metrics
11. Automatic relay ranking and selection
12. Query optimization suggestions
## Testing Strategy
### Unit Tests
- State machine transitions
- Edge case handling
- EOSE aggregation logic
### Integration Tests
- Real relay connections
- Timeout scenarios
- Mixed success/failure scenarios
### Manual Testing Scenarios
1. Query with all offline relays
2. Query with mixed offline/online
3. Query with slow relay (>10s response)
4. Mid-query disconnections
5. Streaming mode with gradual disconnections
6. Single relay queries
7. AUTH-required relays
8. Rate-limited relays
## Metrics to Track
### User-Visible
- Time to first event
- Time to EOSE per relay
- Events per relay
- Success/failure ratio
### Debug/Observability
- Relay response times
- Failure reasons
- Retry attempts
- Reconnection events
## Related Issues
- RelayLiveness not being checked before connection attempts
- No visual feedback during relay discovery phase
- No indication of AUTH requirements
- No rate limiting awareness
## References
- NIP-01: https://github.com/nostr-protocol/nips/blob/master/01.md
- Applesauce-relay docs: (internal node_modules)
- RelayStateManager: `src/services/relay-state-manager.ts`
- useReqTimeline: `src/hooks/useReqTimeline.ts`
- ReqViewer: `src/components/ReqViewer.tsx`