Introduces a macOS-like desktop metaphor at /os: a wallpaper, menu bar (wordmark/window-switcher, live relay-status indicator, clock, login), and a dock. Explore, Dashboard, My Events, Export Following and Messages are ported into windowed apps sharing one window-manager (drag/resize via Pointer Events, focus/z-order, minimize/maximize/ close, layout persisted to localStorage). Legacy routes now redirect into the shell with the matching app opened, so existing links keep working. On screens under 768px the same window-manager state renders as a phone-OS-style shell instead: a home-screen app grid, one full-screen app at a time, and the dock as a bottom tab bar — "going home" just minimizes the active app, so switching apps never loses state. Also: a Nostr-key lock screen gates the shell for logged-out users (with a guest bypass), Cmd/Ctrl+` cycles window focus, windows are role="dialog" with managed focus, and the previously-unrouted Messages app is wired up (it was missing its DMProvider, so it crashed on mount — fixed by scoping DMProvider to the Messages app). See docs/DESKTOP_OS.md for the architecture. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BYiUtZMQeA5RHggQw73wto
6.2 KiB
Desktop-OS shell
The site's tools (Explore, Dashboard, My Events, Export Following, Messages) are presented as "apps" you open into windows on a desktop, with a menu bar and a dock — see issue #10 for the original design brief. This document explains how it's implemented, for anyone extending it later.
Where things live
src/os/
Desktop.tsx Entry point mounted at /os/* — wires everything together
window-manager/
context.ts WindowManagerApi type + the React context object
WindowManagerContext.tsx Reducer + provider (state, persistence)
useWindowManager.ts Hook to read/dispatch window state
Window.tsx Window chrome: drag, resize, focus, traffic-light buttons
types.ts WindowBounds / WindowState / WindowManagerSnapshot
apps/
registry.tsx The list of apps (id, title, icon, component, default size)
shell/
Wallpaper.tsx Layered-strata background
MenuBar.tsx Wordmark, window switcher, relay status, clock, login
Dock.tsx App launcher (bottom dock on desktop, tab bar on mobile)
LockScreen.tsx Nostr-key "unlock" screen shown while logged out
MobileHome.tsx Small-screen home screen (app icon grid)
MobileAppView.tsx Small-screen full-screen app view
The apps themselves are not duplicated — apps/registry.tsx wraps the
existing page components (src/pages/Dashboard.tsx, Explore.tsx, etc.) with
an embedded prop that strips their standalone-page chrome (sidebar, sticky
header) so the same component works both inside a window and, if linked to
directly, as a full page.
Window manager
WindowManagerContext holds one WindowState per open app (position, size,
minimized/maximized, previous bounds for restore) plus a zOrder array of app
ids — the last entry is always the focused/topmost window. All mutations go
through a reducer (OPEN, CLOSE, FOCUS, MINIMIZE, TOGGLE_MAXIMIZE,
MOVE, RESIZE), so window behavior is easy to reason about and test in
isolation.
Layout is single-instance per app (opening an already-open app focuses it
rather than spawning a second window) and is persisted to
localStorage["nostr:os-window-layout"], debounced by 200ms, and restored on
mount — so a reload (or the next visit) comes back with the same windows open
in the same place.
Window.tsx implements dragging and resizing with native Pointer Events
(setPointerCapture), which works identically for mouse, trackpad, and touch
input — no separate touch handling was needed. Z-index is derived from
zOrder, and clicking anywhere in a window (or via the menu bar's "Windows"
switcher) calls focusApp, which moves it to the end of zOrder.
Mobile fallback
Desktop-style overlapping, draggable windows don't make sense on a phone.
Desktop.tsx checks useIsMobile() (the existing 768px breakpoint hook) and
swaps the whole window-manager rendering — not its state — for a mobile
shell:
MobileHome: an icon-grid "home screen" shown when no app is active.MobileAppView: whichever app is topmost and not minimized renders full-screen, with a "Home" button that callsminimizeApp(so switching apps doesn't lose their state — same idea as backgrounding an app on iOS).Dockrenders as acompactbottom tab bar instead of a floating dock.
Because both layouts share the same WindowManagerContext, "minimize" on
mobile is exactly "go home while keeping the app running in the background",
and re-opening it from the dock or home screen resumes it where it left off.
Desktop-only affordances (drag, resize, maximize) simply aren't rendered on
mobile — there's no separate mobile state machine to keep in sync.
Deep links
Legacy routes (/dashboard, /dashboard/events, /dashboard/export,
/explore, /messages) redirect to /os/<app-path>. Desktop.tsx's
DeepLinkHandler matches that path against the app registry, opens the
corresponding app if it isn't already open, and then normalizes the URL back
to /os (the window manager's own state is the source of truth for what's
open from then on, not the URL). The public marketing landing page at /
stays a normal page outside the shell, with a "Launch the App" /
"Open the Desktop" call to action linking into /os.
Accessibility
- Each
Windowisrole="dialog"witharia-labelset to the app title, and receives DOM focus (tabIndex={-1}+.focus()) whenever it becomes the focused window, so keyboard/screen-reader users always land somewhere sensible after switching apps. - Cmd/Ctrl+` cycles focus through open windows (mirrors macOS's "cycle through windows of the front app" shortcut; Cmd/Ctrl+Tab is reserved by the OS/browser).
- The menu bar's "Windows" menu is a fully keyboard-navigable dropdown listing every open app, so window switching never requires a mouse.
- Traffic-light buttons (close/minimize/maximize) all have explicit
aria-labels (e.g. "Close Messages") rather than relying on color alone. - Dock buttons expose
aria-pressedfor their running/focused state. - The relay-status indicator is in an
aria-live="polite"region so connect/disconnect changes are announced. - The wallpaper's drift animation is disabled under
prefers-reduced-motion.
Nostr touches
- Lock screen: while logged out,
LockScreencovers the desktop like a macOS login screen, offering the existingLoginArea(Nostr key / extension / bunker login) to "unlock", or "Continue without an account" to browse read-only apps as a guest. The guest choice is remembered inlocalStorage["nostr:os-guest-mode"]. - Relay status:
useRelayStatus(src/hooks/useRelayStatus.ts) opens a small dedicated WebSocket to the configured relay purely to reflect connecting/online/offline state in the menu bar (it doesn't participate in the app's actual Nostr queries, which go throughNPool/NostrProvideras before).
Known v1 simplifications
- Windows resize from the bottom-right corner only (no edge handles).
- Each app is single-instance (no "open two Explore windows").
- The menu bar's relay indicator shows connection state only, not a live event count.