Files
website/docs/window-manager.md
mroxso 949a551492 feat: New Web OS design (#12)
* new web os frontend

* add docs

* Add CNAME and restore NIP-05 nostr.json for GitHub Pages

The Pages custom domain (layer.systems) is only stored in repo settings;
a CNAME file in the build output makes it survive Pages reconfiguration.
Restore public/.well-known/nostr.json, which this branch had dropped —
removing it would break the existing NIP-05 identifiers on layer.systems.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014YtQoCzkP7Bo8nruhxojPi

* Ignore eslint and tsc build caches

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014YtQoCzkP7Bo8nruhxojPi

* Rebrand page title and metadata to LAYER.systems

The site ships on layer.systems, so the document title, meta and OG
description, and the web manifest now carry that name instead of
"Nostr OS". OsShell sets the title at runtime, so it is updated too —
otherwise the tab would fall back to the old branding after hydration.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014YtQoCzkP7Bo8nruhxojPi

* Rename remaining visible "Nostr OS" strings to LAYER.systems

Covers the About window heading, the mobile shell header and the app
icon's aria-label, so the visible branding matches the page title.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014YtQoCzkP7Bo8nruhxojPi

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-09-06 11:37:43 +02:00

161 lines
7.4 KiB
Markdown

# 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.