Files
website/docs/apps.md
highperfocused 89692ad382 feat: Spells app for saved, shareable Nostr queries (grimoire kind 777)
The original feedback mentioned "grimoire spells" — traced to
github.com/purrgrammer/grimoire, a third-party Nostr client with its
own draft NIP for kind 777 "Spell" events: a REQ filter (kinds,
authors, one tag filter, limit, time window) encoded as portable,
shareable tags, with $me/$contacts runtime variables and relative
timestamps ("7d", "now").

- src/hooks/useSpells.ts implements that draft NIP as-is (same tags,
  same variables, same relative-time grammar) rather than a
  reinterpretation, so a spell saved here round-trips with Grimoire.
- src/apps/spells: browse "My Spells" / "Discover", build one with
  NewSpellForm, and Run it on demand against the resolved filter,
  rendering kind-1 results with NoteCard.
- Only the "Spell" half is implemented; "Spellbook" (kind 30777,
  saved window layouts) is left as a documented follow-up.

Closes #24

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BYiUtZMQeA5RHggQw73wto
2026-09-06 16:58:34 +02:00

133 lines
5.5 KiB
Markdown

# Apps
An app is a component that renders inside a window. It receives its window's parameters,
can rename its window and can rewrite its own parameters — and knows nothing else about
the OS.
## The registry
`src/os/registry.ts` is the single source of truth. Adding an entry there is all it takes
for an app to appear on the desktop, in the **Go** menu, in the command palette and in the
About app. There is no router change, no menu update, no icon grid to edit.
```ts
interface AppDefinition {
id: string; // 'feed' — also the ?app= value and the window id prefix
title: string; // default window title
description: string; // one line, shown in the palette and About
icon: LucideIcon;
category: 'social' | 'tools' | 'system';
component: LazyExoticComponent<ComponentType<AppProps>>;
defaultSize: Size; // capped to the viewport by fitSize()
minSize: Size; // enforced while resizing
resizable?: boolean; // default true
singleton?: boolean; // default true — a second open focuses the existing window
showOnDesktop?: boolean; // default true
requiresAuth?: boolean;
}
```
Every `component` is a `React.lazy(() => import('@/apps/<id>'))`, so each app is its own
code-split chunk and the initial bundle contains only the shell. `WindowFrame` supplies
the `<Suspense>` skeleton and an `ErrorBoundary` around it — a crashing app takes down its
own window, not the desktop.
## The contract
```ts
interface AppProps {
windowId: string;
params: AppParams; // Record<string, string>
setTitle: (title: string) => void; // truncated to 48 chars by the reducer
setParams: (params: AppParams) => void;
}
```
**`params` is the app's navigation state, not local state.** Putting the current
selection in `params` rather than `useState` buys three things at once: the URL describes
what is on screen, a reload restores it, and the state survives the remount when the
window switches between the desktop and mobile shells. The Reader does this with the
selected article; the Feed keeps its scope in local state because a tab choice is not
worth a URL.
`setTitle` is normally called from an effect once the content is known:
```tsx
useEffect(() => {
setTitle(name ? `Profile — ${name}` : 'Profile');
}, [name, setTitle]);
```
## Adding an app
1. Create `src/apps/<id>/index.tsx` with a **default export** taking `AppProps`.
2. Build the UI from the `AppChrome` primitives (see [`styleguide.md`](./styleguide.md)) so
it fills its window instead of centring a column like a web page.
3. Add an entry to `APPS` in `src/os/registry.ts`.
4. If the app should be reachable by a NIP-19 identifier, map that identifier to it in
`src/pages/NIP19Page.tsx`.
5. Run `npm run test`.
A minimal app:
```tsx
import { useEffect } from 'react';
import { AppBody, AppLayout, AppToolbar } from '@/components/os/AppChrome';
import type { AppProps } from '@/os/types';
export default function ExampleApp({ setTitle }: AppProps) {
useEffect(() => setTitle('Example'), [setTitle]);
return (
<AppLayout>
<AppToolbar>
<span className="text-[13px] font-medium">Example</span>
</AppToolbar>
<AppBody className="p-4"></AppBody>
</AppLayout>
);
}
```
## The eight apps
| App | `id` | Params | Notes |
|---|---|---|---|
| Feed | `feed` | — | kind 1 timeline, Following/Global, composer (⌘↵ publishes) |
| Profile | `profile` | `pubkey`, `relays?` | kind 0 metadata, the author's notes, follow/unfollow |
| Note | `notes` | `id`, `relays?` | One note and its replies. **Not** a singleton |
| Reader | `articles` | `pubkey?`, `identifier?`, `kind?`, `relays?` | NIP-23 long-form, `react-markdown` |
| Spells | `spells` | `id?` | Saved/shareable REQ filters — kind 777, a third-party draft NIP |
| Relays | `relays` | — | Connection state, subscription count, measured latency |
| Settings | `settings` | — | Theme, relay list, Blossom servers, account, session |
| About | `about` | — | What this is, the app list, the shortcuts |
### Spells are a third-party kind, adopted for interop
Kind `777` ("Spell") isn't in the official nostr-protocol/nips registry — it comes from
[Grimoire](https://github.com/purrgrammer/grimoire), a third-party Nostr client that also
happens to be a tiling-window-manager OS like this one. `src/hooks/useSpells.ts` implements
its draft NIP as-is (same tag names, same `$me`/`$contacts` runtime variables, same relative
timestamp grammar) rather than inventing an incompatible shape, so a spell saved here is
readable by Grimoire and vice versa. Only the "Spell" half (kind `777`, a saved query) is
implemented; "Spellbook" (kind `30777`, a saved window layout) is not — see the app's
tracking issue for that as a possible follow-up.
### Follow lists are a whole-list replacement
kind 3 replaces the entire contact list. The follow button therefore reads the current
list back before writing, or the edit would silently drop everyone else.
### Relay latency is a real round trip
A WebSocket gives the browser no ping, so the Relays app times an actual `REQ`/`EOSE`
cycle (`{ kinds: [1], limit: 1 }`, 5s timeout). It is the only honest number available.
`useRelayStatus` samples socket state once a second — sockets have no change event to
subscribe to — and feeds both the Relays app and the menu bar indicator, so the two can
never disagree.
A **closed** socket is the resting state, not a fault: relays are opened on demand and
dropped after idling. That is why nothing turns red at "0 connected"; only a connection
that keeps trying to establish itself gets an amber dot.