* 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
* fix: address review feedback on the Spells app
Per review:
- Scope is now derived (forced to "discover" when signed out) rather
than stored as the requested value directly, matching the Feed
app's pattern — signing out mid-session can no longer leave "My
Spells" selected.
- resolveSpellFilter() now validates a spell's tag-filter letter
(single a-zA-Z char) before using it as a "#<letter>" filter key,
and clamps limit to [1, 500] instead of trusting a relay-sourced
spell's number outright — a malformed or hostile spell can no
longer produce a "#undefined" filter key or an enormous/NaN/zero
limit. Added regression tests for all of these.
- NewSpellForm's Field now renders a real <label htmlFor> connected
to each input's id (via useId()), and the Authors button group
moved to a <fieldset>/<legend> instead of a label sitting over
unrelated buttons — screen readers can now name every control.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BYiUtZMQeA5RHggQw73wto
* fix: validate spell tag filter/limit at write time, and hide malformed badges
Per review:
- encodeSpellTags() now validates the tag filter's letter and the
limit before writing them, instead of only resolveSpellFilter()
catching bad values on Run — a spell authored through this app can
no longer save a filter it will silently fail to apply later.
Exported isValidTagLetter() so both sides share one definition of
"valid."
- The spell detail view's tag-filter badge now hides itself for a
malformed tag filter (e.g. from a relay-sourced spell this app
didn't author) instead of rendering "#undefined:" or similar.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BYiUtZMQeA5RHggQw73wto
* fix: reject non-integer/negative spell kinds, de-flake a timing test
Per remaining "previously missed" findings:
- parseSpell() now requires k tags to be non-negative integers
(Number.isInteger && >= 0), not just finite — a relay-sourced spell
claiming kind "1.5" or "-1" no longer passes through into a
malformed filter.
- The resolveTimestamp wall-clock tests asserted toBeCloseTo a single
captured `now`, which a slow runner or timing skew between the two
Date.now() calls could flake. Replaced with a before/after range
assertion.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BYiUtZMQeA5RHggQw73wto
---------
Co-authored-by: highperfocused <highperfocused@pm.me>
Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
7.5 KiB
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.
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
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:
useEffect(() => {
setTitle(name ? `Profile — ${name}` : 'Profile');
}, [name, setTitle]);
Adding an app
- Create
src/apps/<id>/index.tsxwith a default export takingAppProps. - Build the UI from the
AppChromeprimitives (seestyleguide.md) so it fills its window instead of centring a column like a web page. - Add an entry to
APPSinsrc/os/registry.ts. - If the app should be reachable by a NIP-19 identifier, map that identifier to it in
src/pages/NIP19Page.tsx. - Run
npm run test.
A minimal app:
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 eleven 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, or a blank local draft when id is absent. Not a singleton |
| Reader | articles |
pubkey?, identifier?, kind?, relays? |
NIP-23 long-form, react-markdown, NIP-84 highlights |
| Bookmarks | bookmarks |
— | NIP-51 kind 10003 list — bookmarked notes and articles |
| Web Bookmarks | web-bookmarks |
— | NIP-B0 kind 39701 — one addressable event per saved URL |
| Live | live |
pubkey?, identifier? |
NIP-53 kind 30311 live events + kind 1311 chat |
| 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, 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.
Live only links out to playback, it doesn't embed a player
NIP-53's streaming tag is typically an HLS (.m3u8) URL, which no browser plays natively
without a library like hls.js. Rather than pull that dependency in for a first cut, the Live
app (src/apps/live) shows the stream's metadata and chat and opens streaming (or
recording, once status is ended) in a new tab. Spaces/interactive rooms (kind
30312/30313) are a separate, larger effort — see the tracking issue.
Web bookmarks are one event per URL, not a list
Unlike a NIP-51 list, each NIP-B0 web bookmark (kind 39701) is its own addressable event —
the d tag is the URL itself (scheme stripped for https, see bookmarkDTag in
src/hooks/useWebBookmarks.ts). Removing one publishes a NIP-09 kind 5 deletion request,
which relays are free to ignore, so the client also drops it from its own query cache
rather than trusting a refetch to reflect it.
Highlighting selects against the DOM, not the markdown source
HighlightLayer (src/apps/articles/HighlightLayer.tsx) tracks window.getSelection()
against the rendered article, not the raw markdown — the highlighted text saved to a kind
9802 event is whatever that Selection's .toString() returns, i.e. the plain-text content
the reader actually saw, not markdown syntax.
Bookmarks are one whole-list replacement, like follow lists
kind 10003 is a replaceable event: publishing it replaces the entire list. useToggleBookmark
(src/hooks/useBookmarks.ts) therefore reads the current list back before publishing an
update, the same trap follow lists have.
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.