diff --git a/docs/README.md b/docs/README.md new file mode 100644 index 0000000..4332bda --- /dev/null +++ b/docs/README.md @@ -0,0 +1,66 @@ +# Documentation + +This project is a Nostr client built as a **desktop operating system**: there are no +pages, only apps that open as windows you can move, resize, stack and keep side by +side. `PLAN.md` in the repository root records why it was built this way and which +decisions were taken; these documents describe how it actually works. + +| Document | What it covers | +|---|---| +| [`window-manager.md`](./window-manager.md) | The OS core: state, geometry, gestures, persistence, routing | +| [`apps.md`](./apps.md) | The app registry, the contract every app implements, and how to add one | +| [`nostr.md`](./nostr.md) | Data access, relay hints, and the rules for rendering untrusted content | +| [`styleguide.md`](./styleguide.md) | Design tokens, materials, typography, motion, and the layout rules for app content | + +## Layout of the custom code + +``` +src/ + os/ # Window manager — no UI, no Nostr + types.ts # AppDefinition, WindowState, AppProps + registry.ts # The catalogue of apps + windowReducer.ts # All state transitions (pure) + WindowManagerProvider.tsx + WindowManagerContext.ts + useWindowManager.ts + layout.ts # Viewport maths: clamping, cascade, snap targets + useDrag.ts # Pointer-driven moving + useResize.ts # Pointer-driven resizing + useOsKeyboard.ts # System-wide shortcuts + persistence.ts # Session save / restore + + components/os/ # The shell + OsShell.tsx # Entry point: picks desktop or mobile, syncs the URL + MenuBar.tsx # macOS-style bar (app menu, Go, Window, relays, theme, login) + MenuBarClock.tsx + Desktop.tsx # Wallpaper, icon grid, desktop context menu + DesktopIcon.tsx + WindowLayer.tsx # Renders every window, owns the snap preview + WindowFrame.tsx # Window chrome: title bar, traffic lights, resize handles + TrafficLights.tsx + CommandPalette.tsx # ⌘K + MobileAppShell.tsx # Home screen + full-screen app, under 768px + AppChrome.tsx # Layout primitives every app builds on + + components/nostr/ # Shared Nostr UI + NoteCard.tsx, NoteContent.tsx, AuthorLine.tsx, LoginRequired.tsx + + apps//index.tsx # One default-exported component per app + + hooks/ # useRelayStatus, useRelayHints, useFollows (+ template hooks) + lib/nostrUtils.ts # sanitizeUrl, relay hints, tag helpers, time formatting +``` + +The three layers do not reach into each other: `src/os/` knows nothing about Nostr or +about any particular app, `components/os/` knows about windows but not about note +kinds, and an app knows about its own data but never about window geometry. + +## Running it + +```bash +npm run dev # Vite dev server (port 8080 by default — see vite.config.ts) +npm run test # tsc --noEmit + eslint + vitest + production build +``` + +`npm run test` is the gate: it type-checks, lints, runs the unit tests and builds. +Nothing is finished until it passes. diff --git a/docs/apps.md b/docs/apps.md new file mode 100644 index 0000000..6cc09bf --- /dev/null +++ b/docs/apps.md @@ -0,0 +1,120 @@ +# 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>; + 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/'))`, so each app is its own +code-split chunk and the initial bundle contains only the shell. `WindowFrame` supplies +the `` 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 + 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//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 ( + + + Example + + + + ); +} +``` + +## The seven 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` | +| 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 | + +### 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. diff --git a/docs/nostr.md b/docs/nostr.md new file mode 100644 index 0000000..67029a6 --- /dev/null +++ b/docs/nostr.md @@ -0,0 +1,110 @@ +# Nostr data layer + +The template's infrastructure is unchanged: `NostrProvider` owns a single `NPool`, and +data is read through `useNostr()` + TanStack Query. This document covers what was added on +top and the rules that keep relay content safe to render. + +## Reading + +Every query follows the same shape: a `queryKey` that includes **everything** the result +depends on, an abort signal combined with a timeout, and validation before the data +reaches a component. + +```ts +useQuery({ + queryKey: ['nostr', 'feed', scope, authors.length], + queryFn: async ({ signal }) => { + const events = await nostr.query([filter], { + signal: AbortSignal.any([signal, AbortSignal.timeout(6000)]), + relays, // optional hints, see below + }); + return events.filter(isRenderable).sort((a, b) => b.created_at - a.created_at); + }, + staleTime: 30_000, +}); +``` + +**Validate before rendering.** Relays return blanks, oddities and events that do not match +what their kind promises. Each app defines a small predicate — `isRenderableNote` requires +non-empty content on a kind 1; `isRenderableArticle` additionally requires a `d` tag, +without which an addressable event cannot be addressed at all. + +Custom hooks: + +| Hook | Purpose | +|---|---| +| `useFollows(pubkey)` / `useMyFollows()` | The pubkeys in a kind 3 contact list | +| `useRelayStatus()` | Live socket state of every configured relay, sampled each second | +| `useRelayHints()` | The first two read relays, for embedding in identifiers we hand out | + +## Relay hints + +This is the single change that made deep links actually work. + +A `nprofile`, `nevent` or `naddr` can carry relay hints, and the event it points at very +often lives on a relay the reader does not subscribe to. Discarding the hints means a +shared link resolves only for people who happen to read the same relays as the sender — +which, in testing, was most of the time a failure. + +The flow is symmetric: + +- **Incoming** — `NIP19Page` decodes the identifier and passes the hints on as a `relays` + param (comma-separated). Apps read them with `decodeRelayHints(params.relays)` and hand + them to `nostr.query(..., { relays })`. +- **Outgoing** — "Copy link" in `NoteCard` and the Reader embeds `useRelayHints()` into the + identifier it encodes, so links leaving this client carry the same courtesy. + +`encodeRelayHints` / `decodeRelayHints` in `src/lib/nostrUtils.ts` accept only `ws://` and +`wss://` URLs. + +## Publishing + +Through `useNostrPublish()` (kind, content, tags). Two places write events: + +- **`Composer`** — kind 1. Replies carry NIP-10 tags: `['e', id, '', 'root']` plus + `['p', authorPubkey]`. +- **Follow button** — kind 3, the complete list, read back before writing. + +Failures surface as a toast with the underlying message; they are never swallowed. + +## Rendering untrusted content + +Everything below arrived from a stranger's relay. The rules are not optional. + +**URLs.** `sanitizeUrl()` parses the URL and returns it only if the protocol is `https:`, +`http:`, `mailto:` or `nostr:`. `javascript:` and `data:` never survive it. It guards every +`href` and `src` in the app — avatars, banners, article images, links inside note text. + +**Note text.** `NoteContent` tokenises the raw string into text, URLs and NIP-19 +references and renders each as an element. Nothing is ever passed to +`dangerouslySetInnerHTML`. + +**Markdown.** `react-markdown` builds a React tree and never touches `innerHTML`, so raw +HTML inside an article body is inert by construction — that is why it was chosen over +`marked` or `markdown-it`, which return HTML strings you must remember to sanitize. +Additionally: + +- `rehype-sanitize` runs as a second line of defence. Strictly redundant while + `rehype-raw` is absent, but it stops a future change from quietly opening a hole. +- A custom `urlTransform` routes every link and image through `sanitizeUrl`. +- **`rehype-raw` is deliberately not installed.** Adding it means overturning this + decision on purpose. + +**External links** get `target="_blank"` with `rel="noopener noreferrer nofollow"`. + +## Nostr references open windows + +`components.a` in the Markdown renderer and `RefToken` in `NoteContent` intercept +`nostr:` URIs and bare `npub`/`nprofile`/`note`/`nevent`/`naddr` strings, and turn them +into **window openers** rather than navigations: + +``` +npub / nprofile → openApp('profile', { pubkey }) +note / nevent → openApp('notes', { id }) +naddr → openApp('articles', { pubkey, kind, identifier }) +``` + +Clicking a mention raises a Profile window next to the note you were reading instead of +taking the page away from you. This is the point at which the app stops feeling like a +website — and it is only cleanly possible because the Markdown renderer hands us +components instead of an HTML string. diff --git a/docs/styleguide.md b/docs/styleguide.md new file mode 100644 index 0000000..10a6eec --- /dev/null +++ b/docs/styleguide.md @@ -0,0 +1,271 @@ +# Style guide + +The design has two parents. **macOS** supplies the mechanics: a menu bar pinned to the top, +windows with title bars and traffic lights, focus expressed through the stack. +**[PostHog](https://posthog.com)** supplies the look: flat surfaces, warm off-white, hairline +borders, generous but not empty spacing, one confident accent used sparingly. + +What it is *not*: an Apple pastiche. No Apple iconography, no glassmorphism everywhere, no +skeuomorphic textures. The OS is a metaphor for multitasking, not an end in itself. + +--- + +## 1. Colour + +All colour lives in CSS custom properties in `src/index.css`, defined on `:root` and +overridden in `.dark`. Nothing in a component should ever contain a raw hex value — the +one deliberate exception is the traffic lights, whose red/amber/green are a platform +convention rather than part of this palette. + +### Accent + +**Nostr violet.** Chosen over the PostHog orange to have an identity of its own at the +same brightness and saturation. + +| | Light | Dark | +|---|---|---| +| `--primary` | `hsl(265 85% 60%)` | `hsl(265 85% 70%)` | +| `--primary-foreground` | `hsl(0 0% 100%)` | `hsl(265 40% 12%)` | +| `--ring` | `hsl(265 85% 60%)` | `hsl(265 85% 70%)` | + +Green is deliberately **not** an accent — it is reserved for connection status, so a green +dot always means one thing. + +### Surfaces + +| Token | Light | Dark | Used for | +|---|---|---|---| +| `--os-desktop` | `hsl(40 22% 95%)` | `hsl(265 12% 8%)` | The wallpaper ground | +| `--os-desktop-dot` | `hsl(30 12% 82%)` | `hsl(265 8% 20%)` | The dot grid | +| `--background` | `hsl(0 0% 100%)` | `hsl(265 10% 12%)` | Window content | +| `--os-titlebar` | `hsl(40 20% 98%)` | `hsl(265 10% 16%)` | Focused title bar | +| `--os-titlebar-inactive` | `hsl(40 12% 96%)` | `hsl(265 10% 13%)` | Unfocused title bar | +| `--os-window-border` | `hsl(30 12% 86%)` | `hsl(265 8% 24%)` | Window outline | +| `--os-menubar` | `hsl(40 25% 99% / 0.72)` | `hsl(265 12% 12% / 0.72)` | Menu bar, translucent | +| `--sidebar` | `hsl(40 20% 97%)` | `hsl(265 11% 10%)` | In-app sidebars | + +The light surfaces are warm (hue 30–40) rather than neutral grey; the dark ones are tinted +towards the violet accent (hue 265). Both themes are designed, not derived — dark mode is +not an inversion. + +### Text and lines + +| Token | Light | Dark | +|---|---|---| +| `--foreground` | `hsl(20 14% 12%)` | `hsl(40 12% 94%)` | +| `--muted-foreground` | `hsl(25 8% 45%)` | `hsl(265 6% 64%)` | +| `--border` | `hsl(30 12% 88%)` | `hsl(265 8% 22%)` | + +Measured contrast (WCAG 2.1, sRGB), so these are facts rather than intentions: + +| Pair | Light | Dark | +|---|---|---| +| `--foreground` on `--background` | 16.6:1 | 14.9:1 | +| `--muted-foreground` on `--background` | 4.7:1 | 6.4:1 | +| `--primary-foreground` on `--primary` | 5.1:1 | 5.4:1 | +| Desktop icon label (`foreground/80`) on `--os-desktop` | 8.2:1 | 10.7:1 | + +`--muted-foreground` clears 4.5:1 with little to spare, which is the point: it is as light +as it can be while still being safe for real secondary text. Darkening the surface it sits +on, or lightening it further, breaks that — check before you do either. + +One caveat: `--muted-foreground` on `--os-desktop` measures **4.3:1**, just under AA. +Muted text is never placed directly on the wallpaper for that reason; desktop icon labels +use `text-foreground/80` instead. + +### Status + +| Token | Light | Dark | Meaning | +|---|---|---|---| +| `--success` | `hsl(150 60% 38%)` | `hsl(150 55% 50%)` | Relay connected | +| `--warning` | `hsl(38 92% 48%)` | `hsl(38 90% 58%)` | Connecting / closing | +| `--destructive` | `hsl(0 72% 51%)` | `hsl(0 65% 55%)` | Failed action | + +The status dots are 8px graphical indicators, which WCAG holds to 3:1 rather than +4.5:1 — `--success` measures 3.5:1 on white and 7.7:1 on the dark surface. That threshold +is only defensible because **status colour is never the only signal**: every dot sits next +to a text label or an `aria-label` spelling out the state. + +> **Do not paint a resting state red.** Relays are opened on demand and closed after +> idling, so "0 connected" is normal. It renders as muted grey; red would cry wolf and +> teach people to ignore it. + +--- + +## 2. Materials + +**Windows.** `bg-background`, a 1px `border-os-window-border`, `rounded-xl`, and one of two +shadows. Focus is carried by the shadow and the title bar, not by a coloured outline: + +```css +--os-shadow-idle: 0 8px 24px -10px …/0.14; /* unfocused */ +--os-shadow-focused: 0 24px 60px -12px …/0.22, + 0 8px 20px -8px …/0.14; /* focused */ +``` + +The switch is driven by `data-focused="true"` on the window root, so it costs no extra +class churn. A **maximized** window drops its rounding and side borders — rounded corners +would leave slivers of desktop showing. + +**Menu bar.** The only surface in the app with a blur: `backdrop-filter: blur(14px) +saturate(180%)` over a 72% opaque ground. Keeping it to one place makes it feel +deliberate rather than decorative. + +**Wallpaper.** A dot grid drawn with a single `radial-gradient`, 22px spacing, at very low +contrast. No image, so it re-colours with the theme and costs nothing to load: + +```css +.os-desktop-surface { + background-color: var(--os-desktop); + background-image: radial-gradient(var(--os-desktop-dot) 1px, transparent 1px); + background-size: 22px 22px; +} +``` + +**Radius.** `--radius: 0.75rem`. Windows and desktop icon tiles `rounded-xl`, buttons and +inputs `rounded-md`, avatars and dots fully round. + +**Scrollbars.** `.os-scroll` gives a 10px translucent thumb on a transparent track. Applied +to every scrolling region so dense content does not get a heavy platform bar. + +--- + +## 3. Typography + +**Inter Variable**, self-hosted via `@fontsource-variable/inter` — the project's CSP is +`font-src 'self'`, so Google Fonts is not an option. System sans is the fallback stack; +`ui-monospace` is used for keys, event ids and relay URLs. + +| Context | Size | Weight | +|---|---|---| +| Menu bar, title bars, app toolbars | 13px | 400, app menu 500–600 | +| App body text | 14–15px | 400 | +| Note content | 15px | 400, `leading-relaxed` | +| Section labels | 11px uppercase, `tracking-wide` | 600, `muted-foreground` | +| Headings in app content | 18–24px, `tracking-tight` | 600 | +| Article headline | 30px, `leading-tight` | 600 | +| Desktop icon labels | 11px | 500 | + +Chrome (menu bar, title bars, toolbars) sits at 13px and stays quiet. Content is larger and +carries the hierarchy. Numeric columns use `tabular-nums` so they do not jitter as they +update. + +--- + +## 4. Motion + +Short and functional. Nothing bounces, nothing announces itself. + +| Element | Animation | +|---|---| +| Window opening | 160ms `scale(.96) → 1` + fade, `cubic-bezier(.22, 1, .36, 1)` | +| Snap preview | 200ms fade | +| Focus shadow | 160ms ease | +| Hover states | Tailwind default transitions | + +```css +@media (prefers-reduced-motion: reduce) { + .os-window { transition: none; } + .os-window-enter > * { animation: os-fade-in 120ms ease-out; } +} +``` + +Reduced motion drops every transform and keeps only opacity. This is a hard requirement, +not a nicety — a desktop full of scaling windows is exactly the pattern that triggers +vestibular discomfort. + +Dragging and resizing are not animated at all: they follow the pointer directly, because +any easing would feel like lag. + +--- + +## 5. App content: how to not look like a website + +This is the section that does the most work. An app inside a window must read as an +application, not as a page that happens to be in a frame. + +### Compose from the primitives + +`src/components/os/AppChrome.tsx`: + +| Component | Role | +|---|---| +| `AppLayout` | `h-full` flex column — the root of every app | +| `AppToolbar` | Fixed 44px bar under the title bar: search, filters, actions | +| `AppBody` | The **only** scrolling region (`.os-scroll`, `overflow-y-auto`) | +| `AppSidebar` | 224px, `bg-sidebar`, hidden below 640px | +| `AppSplit` | Row wrapper for sidebar + body | +| `AppSectionTitle` | The 11px uppercase label | +| `EmptyState` | Title, optional hint, optional action | + +```tsx + + + + + + + +``` + +### The rules + +- **No page heading.** The window title *is* the heading. A large `

` at the top of the + content repeats it and wastes the first screenful. +- **No centred column.** `max-w-4xl mx-auto` inside a window leaves dead margins. Fill the + window with flex or grid. (The article reader is the single exception: a measure limit is + what long-form prose needs.) +- **Scroll inside, never outside.** `body` has `overflow: hidden`. Only `AppBody` scrolls. +- **Dense over airy.** List rows around 44px, separated by `border-b border-border`. Not + cards with 24px of padding stacked in a column. +- **Empty states are short and actionable.** "You are not following anyone yet" plus a + button that does something about it. No illustrations, no marketing voice. +- **Skeletons for structured content**, spinners only inside buttons or for very short + operations. A skeleton should echo the shape of what is loading. +- **Hover-revealed actions.** Row actions live at `opacity-0`, appearing on + `group-hover` *and* `focus-within` — so keyboard users get them too. + +### Spacing + +Tailwind's 4px scale. Chrome uses `px-3` / `gap-2` / `gap-3`; content uses `p-4` and up. +Avoid one-off arbitrary values; `text-[13px]` for chrome type is the intentional exception, +because 13px is genuinely between Tailwind's `text-xs` and `text-sm`. + +--- + +## 6. Accessibility + +Non-negotiable, and cheap if done from the start. + +- **Real controls.** Traffic lights are `