Files
website/docs/styleguide.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

11 KiB
Raw Permalink Blame History

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 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 3040) 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:

--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:

.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 500600
App body text 1415px 400
Note content 15px 400, leading-relaxed
Section labels 11px uppercase, tracking-wide 600, muted-foreground
Headings in app content 1824px, 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
@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
<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 divs. 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.
  • 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 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.