mirror of
https://github.com/layer-systems/website.git
synced 2026-09-12 05:33:12 +02:00
add docs
This commit is contained in:
66
docs/README.md
Normal file
66
docs/README.md
Normal file
@@ -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/<id>/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.
|
||||
120
docs/apps.md
Normal file
120
docs/apps.md
Normal file
@@ -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<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 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.
|
||||
110
docs/nostr.md
Normal file
110
docs/nostr.md
Normal file
@@ -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<NostrEvent[]>({
|
||||
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.
|
||||
271
docs/styleguide.md
Normal file
271
docs/styleguide.md
Normal file
@@ -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
|
||||
<AppLayout>
|
||||
<AppToolbar>…</AppToolbar>
|
||||
<AppSplit>
|
||||
<AppSidebar>…</AppSidebar>
|
||||
<AppBody>…</AppBody>
|
||||
</AppSplit>
|
||||
</AppLayout>
|
||||
```
|
||||
|
||||
### The rules
|
||||
|
||||
- **No page heading.** The window title *is* the heading. A large `<h1>` 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 `<button>` elements with `aria-label`
|
||||
("Close window", "Minimize window", "Toggle full size") — not coloured `div`s. Every
|
||||
clickable thing is a button or a link.
|
||||
- **Windows** are `role="dialog"` with `aria-label={title}` and `aria-modal={false}` —
|
||||
they are not modal and must not trap focus.
|
||||
- **Focus is always visible.** `:focus-visible` paints a 2px `--ring` outline with 2px
|
||||
offset, globally. Nothing removes it.
|
||||
- **Full keyboard operation.** Desktop icons respond to Enter and Space; menus and the
|
||||
command palette come from Radix and cmdk with their keyboard handling intact; the system
|
||||
shortcuts are listed in [`window-manager.md`](./window-manager.md).
|
||||
- **Shortcuts yield to text entry.** `⌘W` and `⌘M` do nothing while an input, textarea,
|
||||
select or `contenteditable` has focus.
|
||||
- **Hiding without unmounting** uses an inline `display: none` rather than the `hidden`
|
||||
attribute — `hidden` loses to the `flex` utility class, and a "hidden" window that is
|
||||
still in the accessibility tree is worse than useless.
|
||||
- **Responsive to 360px** through the mobile shell.
|
||||
|
||||
---
|
||||
|
||||
## 7. Adding to the system
|
||||
|
||||
**A new colour** goes in `:root` *and* `.dark` in `src/index.css`, and gets a
|
||||
`--color-*` alias in the `@theme inline` block if components need it as a Tailwind
|
||||
utility. Never a bare hex in a component.
|
||||
|
||||
**A new UI component** starts as a copy of an existing `src/components/ui/` component, uses
|
||||
`cn()` for conditional classes and `class-variance-authority` for variants, and covers
|
||||
`hover`, `focus-visible`, `active` and `disabled`.
|
||||
|
||||
**A new app** follows [`apps.md`](./apps.md) and composes from `AppChrome`. If you find
|
||||
yourself reaching past the primitives for layout, that is the signal the app is drifting
|
||||
back towards being a web page.
|
||||
160
docs/window-manager.md
Normal file
160
docs/window-manager.md
Normal file
@@ -0,0 +1,160 @@
|
||||
# Window manager
|
||||
|
||||
Everything in `src/os/`. It has no UI and no knowledge of Nostr — it manages rectangles,
|
||||
stacking order and focus, and nothing else. The shell in `src/components/os/` renders
|
||||
what it decides.
|
||||
|
||||
## State
|
||||
|
||||
```ts
|
||||
interface WindowState {
|
||||
id: string; // `${appId}-${counter}`, unique for the session
|
||||
appId: string; // key into the registry
|
||||
title: string; // shown in the title bar, Window menu and browser tab
|
||||
x, y: number; // position, relative to the desktop (not the viewport)
|
||||
width, height: number;
|
||||
z: number; // stacking order
|
||||
minimized: boolean;
|
||||
maximized: boolean;
|
||||
prevRect?: Rect; // geometry before maximizing, restored on un-maximize
|
||||
params: AppParams; // Record<string, string> — the app's own state
|
||||
}
|
||||
|
||||
interface WindowManagerState {
|
||||
windows: WindowState[];
|
||||
focusedId: string | null; // null means the desktop itself has focus
|
||||
counter: number; // mints window ids
|
||||
maxZ: number; // highest z currently in use
|
||||
}
|
||||
```
|
||||
|
||||
Window coordinates are **relative to the desktop surface**, which starts below the menu
|
||||
bar. `getViewport()` in `layout.ts` already subtracts `MENUBAR_HEIGHT` (28px), so a
|
||||
window at `y: 0` sits directly under the bar rather than behind it.
|
||||
|
||||
## The reducer
|
||||
|
||||
`windowReducer.ts` holds every transition and is pure — it is the one place to look when
|
||||
window behaviour is wrong. Actions:
|
||||
|
||||
`OPEN_APP` · `CLOSE_WINDOW` · `FOCUS_WINDOW` · `MOVE_WINDOW` · `RESIZE_WINDOW` ·
|
||||
`MINIMIZE` · `RESTORE` · `TOGGLE_MAXIMIZE` · `SET_TITLE` · `SET_PARAMS` ·
|
||||
`MINIMIZE_ALL` · `CLOSE_ALL` · `VIEWPORT_CHANGED` · `HYDRATE`
|
||||
|
||||
Four rules are worth knowing because they are not obvious from the action names:
|
||||
|
||||
**Singletons.** `OPEN_APP` on an app with `singleton !== false` does not create a second
|
||||
window: it focuses the existing one, un-minimizes it, and replaces its params if any were
|
||||
passed. Only `notes` opts out (`singleton: false`), because comparing two threads
|
||||
side by side is the point.
|
||||
|
||||
**Z-index normalisation.** Focusing sets `z = maxZ + 1`. Once `maxZ` would pass 9000
|
||||
(`Z_NORMALIZE_THRESHOLD`), every window is renumbered from 1 in its current order. Without
|
||||
this a long session drifts upwards forever.
|
||||
|
||||
**Title truncation.** `SET_TITLE` collapses whitespace and cuts at 48 characters
|
||||
(`MAX_TITLE_LENGTH`). Apps title themselves after content they loaded from a relay, and
|
||||
relay content has no length limit — an article headline would otherwise blow out the title
|
||||
bar, the Window menu and the browser tab.
|
||||
|
||||
**Focus after hiding.** Closing or minimizing the focused window moves focus to the
|
||||
topmost window that is still visible, not to nothing.
|
||||
|
||||
## Geometry (`layout.ts`)
|
||||
|
||||
| Export | Purpose |
|
||||
|---|---|
|
||||
| `MENUBAR_HEIGHT` | 28px. The desktop starts here. |
|
||||
| `getViewport()` | Desktop size — viewport minus the menu bar |
|
||||
| `maximizedRect(vp)` | Full desktop area |
|
||||
| `halfRect(side, vp)` | Left or right half, for edge snapping |
|
||||
| `clampPosition(x, y, size, vp)` | Keeps ≥ 80px (`KEEP_VISIBLE`) of the window reachable and never lets it go above the desktop origin |
|
||||
| `clampRect(rect, minSize, vp)` | Shrinks a window that no longer fits, then re-clamps its position |
|
||||
| `cascadePosition(size, openCount, vp)` | Centre, offset by 28px per open window, wrapping every 6 |
|
||||
| `fitSize(defaultSize, minSize, vp)` | A new window never exceeds the viewport it opens into |
|
||||
|
||||
`VIEWPORT_CHANGED` runs `clampRect` over every window when the browser is resized, so
|
||||
nothing ends up stranded off-screen. Maximized windows simply take the new full rect.
|
||||
|
||||
## Gestures
|
||||
|
||||
`useDrag.ts` and `useResize.ts` are pointer-event based. Both follow the same pattern,
|
||||
and the reason for it matters:
|
||||
|
||||
> During a gesture the geometry is written **straight to the DOM node** via
|
||||
> `style.transform` / `style.width` / `style.height`, and the reducer is dispatched
|
||||
> **once, on `pointerup`**.
|
||||
|
||||
Routing every `pointermove` through React state would re-render the whole window stack on
|
||||
each frame. `WindowFrame` is additionally wrapped in `React.memo`, and an effect
|
||||
re-synchronises the inline styles from state once the gesture commits, so React is the
|
||||
single source of truth again between gestures.
|
||||
|
||||
While a gesture is live, `document.body` gets the class `os-dragging`, which disables
|
||||
pointer events inside window content — otherwise dragging across a window would select
|
||||
text or trigger hovers.
|
||||
|
||||
**Resize handles.** Eight of them (`RESIZE_HANDLES`): 6px along each edge, 12px in each
|
||||
corner, each with the cursor from `HANDLE_CURSOR`. Dragging a north or west edge moves the
|
||||
origin as well as the size; when the minimum size is reached the moving edge pins so the
|
||||
window stops sliding instead of drifting.
|
||||
|
||||
**Snapping.** Within 12px (`SNAP_THRESHOLD`) of an edge the drag reports a `SnapZone`
|
||||
(`'left' | 'right' | 'maximize'`). `WindowLayer` draws a ghost rectangle for it, and on
|
||||
release the window takes that geometry. A maximized window cannot be dragged at all —
|
||||
un-maximize it first.
|
||||
|
||||
## Keyboard (`useOsKeyboard.ts`)
|
||||
|
||||
| Shortcut | Action |
|
||||
|---|---|
|
||||
| `⌘/Ctrl + K` | Command palette |
|
||||
| `⌘/Ctrl + ,` | Settings |
|
||||
| `⌘/Ctrl + \`` | Cycle focus through visible windows in z-order |
|
||||
| `⌘/Ctrl + W` | Close focused window |
|
||||
| `⌘/Ctrl + M` | Minimize focused window |
|
||||
|
||||
`⌘W` and `⌘M` are suppressed while the target is an input, textarea, select or
|
||||
`contenteditable`, so typing never destroys a window.
|
||||
|
||||
## Persistence (`persistence.ts`)
|
||||
|
||||
Stored in `localStorage` under `nostr:os-session`, versioned (`version: 1`), debounced by
|
||||
300ms so dragging does not thrash storage. On load:
|
||||
|
||||
- The payload is validated with a Zod schema; anything malformed or from another version
|
||||
is discarded and the desktop boots empty.
|
||||
- Windows whose `appId` is no longer in the registry are dropped.
|
||||
- Every window is re-clamped against the *current* viewport, so a session saved on a large
|
||||
screen still opens correctly on a small one.
|
||||
- Every read and write is wrapped in `try`/`catch`: private mode or a full quota costs you
|
||||
the restore, never the app.
|
||||
|
||||
An empty window list removes the key rather than storing `[]`.
|
||||
|
||||
## Routing and deep links
|
||||
|
||||
The router itself is untouched: `/`, `/:nip19` and the catch-all still exist. The OS state
|
||||
lives in the query string.
|
||||
|
||||
- **`/?app=feed`** — `OsShell` reads `?app=` once on mount and opens that window. Every
|
||||
other query parameter becomes an app param.
|
||||
- **Focus sync** — with `syncUrl` (the `/` route only), the URL is rewritten via
|
||||
`replaceState` whenever focus changes, so the address bar always describes the window
|
||||
you are looking at. Moving and resizing never touch the URL; there is no history spam.
|
||||
- **`/npub1…`, `/note1…`, `/nevent1…`, `/naddr1…`** — `NIP19Page` decodes the identifier,
|
||||
boots the desktop and opens the matching app. It does **not** sync the URL, because the
|
||||
path is already the deep link.
|
||||
- **Relay hints** carried by `nprofile`, `nevent` and `naddr` are passed through as a
|
||||
`relays` param and used in the query. See [`nostr.md`](./nostr.md) — deep links fail
|
||||
surprisingly often without them.
|
||||
|
||||
Booting is guarded by a ref so it happens exactly once: a later render must never reopen a
|
||||
window the user has closed.
|
||||
|
||||
## Mobile
|
||||
|
||||
Under 768px (`useIsMobile`) `OsShell` renders `MobileAppShell` instead of the desktop:
|
||||
the same registry and the same window state, presented as a home screen with one
|
||||
full-screen app at a time plus an app switcher. There is no dragging, no resizing and no
|
||||
geometry to persist.
|
||||
Reference in New Issue
Block a user