mirror of
https://github.com/layer-systems/website.git
synced 2026-09-12 05:33:12 +02:00
* 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>
272 lines
11 KiB
Markdown
272 lines
11 KiB
Markdown
# 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](https://posthog.com)** 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 30–40) 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:
|
||
|
||
```css
|
||
--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:
|
||
|
||
```css
|
||
.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 500–600 |
|
||
| App body text | 14–15px | 400 |
|
||
| Note content | 15px | 400, `leading-relaxed` |
|
||
| Section labels | 11px uppercase, `tracking-wide` | 600, `muted-foreground` |
|
||
| Headings in app content | 18–24px, `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 |
|
||
|
||
```css
|
||
@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 |
|
||
|
||
```tsx
|
||
<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 `div`s. 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`](./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`](./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.
|