mirror of
https://github.com/layer-systems/website.git
synced 2026-09-13 22:41:46 +02:00
feat: rework the app as a windowed desktop-OS shell (#10)
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
This commit is contained in:
124
docs/DESKTOP_OS.md
Normal file
124
docs/DESKTOP_OS.md
Normal file
@@ -0,0 +1,124 @@
|
||||
# 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](https://github.com/layer-systems/website/issues/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 calls `minimizeApp` (so switching
|
||||
apps doesn't lose their state — same idea as backgrounding an app on iOS).
|
||||
- **`Dock`** renders as a `compact` bottom 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 `Window` is `role="dialog"` with `aria-label` set 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-label`s (e.g. "Close Messages") rather than relying on color alone.
|
||||
- Dock buttons expose `aria-pressed` for 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, `LockScreen` covers the desktop like a
|
||||
macOS login screen, offering the existing `LoginArea` (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 in
|
||||
`localStorage["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 through `NPool`/`NostrProvider` as
|
||||
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.
|
||||
Reference in New Issue
Block a user