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

7.4 KiB

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

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 [].

The router itself is untouched: /, /:nip19 and the catch-all still exist. The OS state lives in the query string.

  • /?app=feedOsShell 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 — 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.