Redesign visual identity: layer-stack design system + styleguide

Reworks the site around a signature "layer stack" motif — the relay is
literally one layer between a client and the wider Nostr network, so
the brand mark is that stack rather than a logo standing in for it.

- New color/type/spacing tokens (src/index.css, tailwind.config.ts):
  cool "Paper" light surface, deep "Ink" dark surface, relay-amber
  accent, navy/cyan structural colors; JetBrains Mono as the display
  face for headlines and data, Inter for body copy (both self-hosted
  via fontsource to satisfy the existing font-src 'self' CSP).
- New brand components: LayerStack (hero signature), LayerDivider,
  Wordmark, SiteHeader/SiteFooter, shared across the landing page,
  legal pages, and the new styleguide.
- Reworked the landing page hero, feature grid, and connection steps
  with benefit-first, plain-voice copy.
- Added docs/DESIGN_STYLEGUIDE.md and a live /styleguide route
  documenting the system.
- Fixed structural gaps: /messages had no route, and Explore/Messages
  had no sidebar navigation entry despite being real pages; both now
  sit in the same app shell as Dashboard.

Closes #8

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FvaqCkYtksTvsEKGzjGcde
This commit is contained in:
2026-09-05 21:15:29 +02:00
parent 4acdb31b8a
commit f74f2f58f6
24 changed files with 1003 additions and 397 deletions

View File

@@ -116,6 +116,8 @@ The project includes a **`docs/`** directory containing specialized documentatio
- **`docs/NOSTR_DIRECT_MESSAGES.md`**: Read when implementing direct messaging features, building chat interfaces, or working with encrypted peer-to-peer communication (NIP-04 and NIP-17).
- **`docs/DESIGN_STYLEGUIDE.md`**: Read before making any visual change — color, type, spacing, or component styling. Covers the design tokens in `src/index.css`, the "layer stack" signature motif, and the rationale behind them. The live version is the `/styleguide` route (`src/pages/Styleguide.tsx`).
## System Prompt Management
The AI assistant's behavior and knowledge is defined by the AGENTS.md file, which serves as the system prompt. To modify the assistant's instructions or add new project-specific guidelines:

127
docs/DESIGN_STYLEGUIDE.md Normal file
View File

@@ -0,0 +1,127 @@
# LAYER.systems Design Styleguide
Read this before making visual changes anywhere in the app. The live version of
everything below is at [`/styleguide`](../src/pages/Styleguide.tsx).
## The idea
LAYER.systems is a public Nostr relay: one layer in the path between a
person's client and the rest of the network. The brand doesn't use a logo to
*represent* that — the signature visual **is** the stack (`Client → Relay →
Network`, see `src/components/brand/LayerStack.tsx`), lit up at the relay
layer, because that's literally what this product is.
Everything else in the system follows from taking that stack seriously:
layered, hairline-bordered surfaces instead of soft drop shadows; a
monospace face used for headlines and data, not tucked away as a caption
font; and a dark "Ink" hero that reads like an oscilloscope panel, not a
marketing gradient.
## Color
Named tokens live in `src/index.css` as HSL CSS variables, consumed through
Tailwind (`tailwind.config.ts`) and shadcn/ui components. Don't hardcode hex
values in components — reference the token (`bg-primary`, `text-brand-cyan`,
`hsl(var(--brand-navy))`) so light/dark mode and future palette tweaks stay
centralized.
| Name | Hex | Token | Usage |
|---|---|---|---|
| Paper | `#F5F6F5` | `--background` (light) | Default light-mode surface — cool, not cream |
| Ink | `#0E1116` | `--brand-ink`, `--background` (dark) | Hero panel + dark-mode base surface |
| Relay Amber | `#F5A623` | `--primary` | The signature signal color: primary actions, the "live" layer, focus rings |
| Deep Signal Navy | `#1C2B45` | `--brand-navy` | Structural color for layer bands and dark-mode accents |
| Circuit Cyan | `#3FC1CE` | `--brand-cyan`, `--chart-2` | Secondary data color — charts, hover accents |
| Slate Ink | `#2B3140` | `--foreground` (light) | Body text on light surfaces |
Both light and dark modes share the same amber accent so the brand reads
consistently either way — only the surface (paper vs. ink) flips.
**Why not the obvious defaults:** we deliberately avoided a warm-cream +
serif look (doesn't fit infrastructure) and a pure near-black + single
neon accent (too generic for what should feel like real hardware — a relay
lamp, not a startup gradient). The dark hero pairs a warm accent (amber)
with a cool structural color (navy) and a cool data color (cyan), which is
richer than the single-accent cliché, and only the *hero* is dark — the app
itself is light-first.
## Type
Two faces, doing different jobs:
- **Display / data — JetBrains Mono** (`font-display` / `font-mono`,
`@fontsource-variable/jetbrains-mono`). Used for headlines, the eyebrow
labels (`.eyebrow` utility class), stat numbers, relay URLs, hex ids, and
timestamps. This is a wire-protocol product — a monospace headline is a
deliberate choice that matches the subject, not a caption font promoted
by accident.
- **Body — Inter** (`font-sans`, `@fontsource-variable/inter`). Everything a
person reads at length: paragraphs, descriptions, terms & privacy copy.
Both are self-hosted via `@fontsource-variable/*` and imported once in
`src/main.tsx` — the CSP (`font-src 'self'`) blocks third-party font CDNs,
so don't add a Google Fonts `<link>`.
Eyebrow labels use the `.eyebrow` utility (`src/index.css`): mono, uppercase,
`0.14em` tracking, small size. Use them to label a section's role
("Public Nostr relay", "Getting connected"), not as decoration.
## Spacing & radius
Standard Tailwind 4px scale (`--spacing: 0.25rem`). `--radius` is `0.5rem`
site-wide — soft enough to feel modern, restrained enough to stay
technical. Don't mix radius values ad hoc; use the `rounded-lg` /
`rounded-md` / `rounded-sm` scale derived from the token.
## Signature components
- **`LayerStack`** (`src/components/brand/LayerStack.tsx`) — the hero mark.
Three cascading bands (Client / Relay / Network), the relay band lit
amber and gently pulsing. Respects `prefers-reduced-motion` via Tailwind's
`motion-safe:` variant — the settle-in animation and pulse simply don't
run when reduced motion is requested; the stack still renders in its
final state.
- **`LayerDivider`** — a quiet three-line echo of the stack for breaking up
sections without repeating the hero verbatim.
- **`.layer-card`** utility class — the default card treatment across the
marketing site: two hairline borders offset by 6px instead of a drop
shadow, widening slightly on hover. Use plain `border + shadow-sm` cards
instead inside dense data views (dashboard, explorer) where the offset
border would add noise against a grid of cards.
## Components
Built on shadcn/ui (`src/components/ui/*`) — Button, Card, Badge, Input,
Alert, Sidebar, etc. — restyled entirely through the token layer, not by
forking the components. See `/styleguide` for live specimens of every
variant. When adding new UI, prefer an existing shadcn primitive over a
bespoke one.
## Motion
- Page-load: the `LayerStack` bands settle in with a short staggered
animation (`animate-layer-settle`); the relay band pulses continuously
but subtly (`animate-signal-pulse`).
- Hover: `.layer-card` widens its offset border; buttons and links use the
existing shadcn transition tokens. No scroll-triggered effects — this is
a technical, read-once page, not a scrollytelling site.
- Everything animated is wrapped in Tailwind's `motion-safe:` variant so
`prefers-reduced-motion: reduce` gets the static end state.
## Accessibility
- Color pairs (background/foreground, primary/primary-foreground, etc.)
are chosen for contrast in both themes — check new token pairs with a
contrast checker before shipping.
- Interactive elements rely on shadcn's built-in focus rings (`--ring`,
amber) — don't remove `focus-visible` outlines.
- The `LayerStack` diagram carries a text `aria-label` describing the same
information conveyed visually (client → this relay → the network).
## Voice
Plain, direct, written from the person's side of the screen: "add the
relay," not "leverage relay connectivity." Say what things do, not why
they're impressive. Errors and empty states say what happened and what to
do next — see `NotFound.tsx` for the tone ("There's no layer at
`/whatever`") instead of a generic apology.

30
package-lock.json generated
View File

@@ -9,6 +9,7 @@
"version": "0.0.0",
"dependencies": {
"@fontsource-variable/inter": "^5.2.6",
"@fontsource-variable/jetbrains-mono": "^5.3.0",
"@getalby/sdk": "^5.1.1",
"@hookform/resolvers": "^3.9.0",
"@nostrify/nostrify": "^0.48.2",
@@ -955,6 +956,15 @@
"url": "https://github.com/sponsors/ayuhito"
}
},
"node_modules/@fontsource-variable/jetbrains-mono": {
"version": "5.3.0",
"resolved": "https://registry.npmjs.org/@fontsource-variable/jetbrains-mono/-/jetbrains-mono-5.3.0.tgz",
"integrity": "sha512-F32xpS2NsGYoQi2ADSkKTgpJj7ozajsGgDJ8woTnqjmIB+dxDIqImjl4pXZVEExu8UFZ2ndhmX18EBS/hdz3Lw==",
"license": "OFL-1.1",
"funding": {
"url": "https://github.com/sponsors/ayuhito"
}
},
"node_modules/@getalby/lightning-tools": {
"version": "5.2.0",
"resolved": "https://registry.npmjs.org/@getalby/lightning-tools/-/lightning-tools-5.2.0.tgz",
@@ -3260,7 +3270,6 @@
"cpu": [
"arm"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -3274,7 +3283,6 @@
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -3288,7 +3296,6 @@
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -3302,7 +3309,6 @@
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -3316,7 +3322,6 @@
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -3330,7 +3335,6 @@
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -3344,7 +3348,6 @@
"cpu": [
"arm"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -3358,7 +3361,6 @@
"cpu": [
"arm"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -3372,7 +3374,6 @@
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -3386,7 +3387,6 @@
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -3400,7 +3400,6 @@
"cpu": [
"loong64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -3414,7 +3413,6 @@
"cpu": [
"ppc64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -3428,7 +3426,6 @@
"cpu": [
"riscv64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -3442,7 +3439,6 @@
"cpu": [
"riscv64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -3456,7 +3452,6 @@
"cpu": [
"s390x"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -3470,7 +3465,6 @@
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -3484,7 +3478,6 @@
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -3498,7 +3491,6 @@
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -3512,7 +3504,6 @@
"cpu": [
"ia32"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -3526,7 +3517,6 @@
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [

View File

@@ -11,6 +11,7 @@
},
"dependencies": {
"@fontsource-variable/inter": "^5.2.6",
"@fontsource-variable/jetbrains-mono": "^5.3.0",
"@getalby/sdk": "^5.1.1",
"@hookform/resolvers": "^3.9.0",
"@nostrify/nostrify": "^0.48.2",

View File

@@ -6,6 +6,8 @@ import { Explore } from "./pages/Explore";
import { Dashboard } from "./pages/Dashboard";
import { DashboardEvents } from "./pages/DashboardEvents";
import { DashboardExport } from "./pages/DashboardExport";
import Messages from "./pages/Messages";
import { Styleguide } from "./pages/Styleguide";
import { NIP19Page } from "./pages/NIP19Page";
import { Terms } from "./pages/Terms";
import { Privacy } from "./pages/Privacy";
@@ -21,6 +23,8 @@ export function AppRouter() {
<Route path="/dashboard" element={<Dashboard />} />
<Route path="/dashboard/events" element={<DashboardEvents />} />
<Route path="/dashboard/export" element={<DashboardExport />} />
<Route path="/messages" element={<Messages />} />
<Route path="/styleguide" element={<Styleguide />} />
<Route path="/terms" element={<Terms />} />
<Route path="/privacy" element={<Privacy />} />
{/* NIP-19 route for npub1, note1, naddr1, nevent1, nprofile1 */}

View File

@@ -0,0 +1,16 @@
import { cn } from '@/lib/utils';
/**
* A quiet echo of the layer-stack device used to break up sections —
* three hairlines of shrinking width, same cascade logic as the hero mark,
* without repeating it verbatim.
*/
export function LayerDivider({ className }: { className?: string }) {
return (
<div className={cn('flex flex-col items-center gap-1.5', className)} aria-hidden="true">
<div className="h-px w-16 bg-border" />
<div className="h-px w-10 bg-border" />
<div className="h-px w-4 bg-primary" />
</div>
);
}

View File

@@ -0,0 +1,93 @@
import { Laptop, Radio, Globe2 } from 'lucide-react';
import { cn } from '@/lib/utils';
interface Layer {
eyebrow: string;
name: string;
detail: string;
icon: typeof Laptop;
emphasis?: boolean;
}
const layers: Layer[] = [
{
eyebrow: '01 · You',
name: 'Client',
detail: 'Damus, Amethyst, Snort, or any Nostr app',
icon: Laptop,
},
{
eyebrow: '02 · This relay',
name: 'LAYER.systems',
detail: 'Receives, stores, and serves signed events',
icon: Radio,
emphasis: true,
},
{
eyebrow: '03 · Everyone else',
name: 'Nostr network',
detail: 'Every other relay and client your notes reach',
icon: Globe2,
},
];
/**
* The site's signature device: LAYER.systems is literally one layer in a
* relay stack, so the brand mark is that stack, not a logo standing in
* for it. Each band cascades down and to the right — data moving outward
* from the reader's client, through this relay, into the wider network.
*/
export function LayerStack({ className }: { className?: string }) {
return (
<div className={cn('w-full max-w-md', className)} role="img" aria-label="Diagram: your client connects through the LAYER.systems relay to reach the wider Nostr network">
{layers.map((layer, i) => (
<div
key={layer.name}
className={cn(
'motion-safe:animate-layer-settle',
i > 0 && '-mt-px'
)}
style={{
marginLeft: `${i * 28}px`,
marginRight: `${(layers.length - 1 - i) * 28}px`,
animationDelay: `${i * 140}ms`,
}}
>
<div
className={cn(
'flex items-center gap-4 rounded-lg border px-5 py-4 backdrop-blur-sm transition-transform',
layer.emphasis
? 'border-brand-amber/50 bg-brand-amber/10 shadow-lg shadow-brand-amber/10 motion-safe:animate-signal-pulse'
: 'border-white/10 bg-white/[0.03]'
)}
>
<div
className={cn(
'flex h-10 w-10 shrink-0 items-center justify-center rounded-md border',
layer.emphasis
? 'border-brand-amber/40 bg-brand-amber/15 text-brand-amber'
: 'border-white/10 bg-white/5 text-white/60'
)}
>
<layer.icon className="h-5 w-5" />
</div>
<div className="min-w-0">
<p
className={cn(
'eyebrow',
layer.emphasis ? 'text-brand-amber' : 'text-white/40'
)}
>
{layer.eyebrow}
</p>
<p className={cn('font-display text-base font-semibold', layer.emphasis ? 'text-white' : 'text-white/85')}>
{layer.name}
</p>
<p className="truncate text-sm text-white/45">{layer.detail}</p>
</div>
</div>
</div>
))}
</div>
);
}

View File

@@ -0,0 +1,36 @@
import { Link } from 'react-router-dom';
import { Wordmark } from '@/components/brand/Wordmark';
const links = [
{ to: '/styleguide', label: 'Styleguide' },
{ to: '/terms', label: 'Terms' },
{ to: '/privacy', label: 'Privacy' },
];
export function SiteFooter() {
return (
<footer className="border-t border-border bg-muted/30">
<div className="mx-auto flex max-w-6xl flex-col items-center gap-4 px-6 py-8 sm:flex-row sm:justify-between">
<div className="flex items-center gap-3 text-sm text-muted-foreground">
<Wordmark className="text-sm" />
<span aria-hidden="true">·</span>
<span className="flex items-center gap-1.5">
<span className="h-1.5 w-1.5 rounded-full bg-primary motion-safe:animate-signal-pulse" />
relay online
</span>
</div>
<nav className="flex items-center gap-6 text-sm">
{links.map((link) => (
<Link
key={link.to}
to={link.to}
className="text-muted-foreground transition-colors hover:text-foreground"
>
{link.label}
</Link>
))}
</nav>
</div>
</footer>
);
}

View File

@@ -0,0 +1,30 @@
import { Link } from 'react-router-dom';
import { LoginArea } from '@/components/auth/LoginArea';
import { Wordmark } from '@/components/brand/Wordmark';
import { cn } from '@/lib/utils';
interface SiteHeaderProps {
/** Transparent, for placement over a dark hero. */
transparent?: boolean;
className?: string;
}
export function SiteHeader({ transparent = false, className }: SiteHeaderProps) {
return (
<header
className={cn(
transparent
? 'absolute top-0 left-0 right-0 z-50'
: 'sticky top-0 z-50 border-b border-border bg-background/90 backdrop-blur supports-[backdrop-filter]:bg-background/70',
className
)}
>
<div className="mx-auto flex max-w-6xl items-center justify-between px-6 py-5">
<Link to="/" className="transition-opacity hover:opacity-80">
<Wordmark inverted={transparent} className="text-lg" />
</Link>
<LoginArea className="max-w-60" />
</div>
</header>
);
}

View File

@@ -0,0 +1,26 @@
import { cn } from '@/lib/utils';
interface WordmarkProps {
className?: string;
/** Renders on a dark surface (hero, footer) instead of the default app surface. */
inverted?: boolean;
}
/**
* The LAYER.systems wordmark, set in the display mono face. ".systems" is
* de-emphasized so "LAYER" reads first — the word the brand is built on.
*/
export function Wordmark({ className, inverted = false }: WordmarkProps) {
return (
<span
className={cn(
'font-display font-bold tracking-tight',
inverted ? 'text-white' : 'text-foreground',
className
)}
>
LAYER
<span className={inverted ? 'text-white/45' : 'text-muted-foreground'}>.systems</span>
</span>
);
}

View File

@@ -1,4 +1,4 @@
import { Home, LayoutDashboard, FileText, Download } from 'lucide-react';
import { Home, LayoutDashboard, FileText, Download, Compass, MessageCircle } from 'lucide-react';
import { Link, useLocation } from 'react-router-dom';
import {
Sidebar,
@@ -7,11 +7,13 @@ import {
SidebarGroup,
SidebarGroupContent,
SidebarGroupLabel,
SidebarHeader,
SidebarMenu,
SidebarMenuButton,
SidebarMenuItem,
} from '@/components/ui/sidebar';
import { LoginArea } from '@/components/auth/LoginArea';
import { Wordmark } from '@/components/brand/Wordmark';
const navigationItems = [
{
@@ -19,6 +21,11 @@ const navigationItems = [
url: '/',
icon: Home,
},
{
title: 'Explore',
url: '/explore',
icon: Compass,
},
{
title: 'Dashboard',
url: '/dashboard',
@@ -34,6 +41,11 @@ const navigationItems = [
url: '/dashboard/export',
icon: Download,
},
{
title: 'Messages',
url: '/messages',
icon: MessageCircle,
},
];
export function AppSidebar() {
@@ -41,9 +53,14 @@ export function AppSidebar() {
return (
<Sidebar>
<SidebarHeader>
<Link to="/" className="flex items-center px-2 py-1 transition-opacity hover:opacity-80">
<Wordmark className="text-base" />
</Link>
</SidebarHeader>
<SidebarContent>
<SidebarGroup>
<SidebarGroupLabel>Navigation</SidebarGroupLabel>
<SidebarGroupLabel className="eyebrow">Navigation</SidebarGroupLabel>
<SidebarGroupContent>
<SidebarMenu>
{navigationItems.map((item) => {

View File

@@ -2,113 +2,135 @@
@tailwind components;
@tailwind utilities;
/*
* LAYER.systems design tokens.
* See docs/DESIGN_STYLEGUIDE.md for the full rationale — palette names,
* type scale, spacing, and the "layer stack" signature motif.
*/
@layer base {
:root {
--background: 0 0% 100%;
--foreground: 0 0% 14.9020%;
--card: 0 0% 100%;
--card-foreground: 0 0% 14.9020%;
--popover: 0 0% 100%;
--popover-foreground: 0 0% 14.9020%;
--primary: 37.6923 92.1260% 50.1961%;
--primary-foreground: 0 0% 0%;
--secondary: 220.0000 14.2857% 95.8824%;
--secondary-foreground: 215 13.7931% 34.1176%;
--muted: 210 20.0000% 98.0392%;
/* Paper — cool, slightly green-gray light surface (not cream) */
--background: 120 5.2632% 96.2745%;
--foreground: 222.8571 19.6262% 20.9804%;
--card: 60 9.0909% 97.8431%;
--card-foreground: 222.8571 19.6262% 20.9804%;
--popover: 60 9.0909% 97.8431%;
--popover-foreground: 222.8571 19.6262% 20.9804%;
/* Relay Amber — the signature signal color */
--primary: 37.4286 91.3043% 54.9020%;
--primary-foreground: 217.5000 22.2222% 7.0588%;
--secondary: 220 23.0769% 92.3529%;
--secondary-foreground: 222.8571 19.6262% 20.9804%;
--muted: 216 16.1290% 93.9216%;
--muted-foreground: 220 8.9362% 46.0784%;
--accent: 48.0000 100.0000% 96.0784%;
--accent-foreground: 22.7273 82.5000% 31.3725%;
--destructive: 0 84.2365% 60.1961%;
/* Circuit Cyan tint, used for hover/accent surfaces */
--accent: 183.3333 47.3684% 92.5490%;
--accent-foreground: 187.1429 70.0000% 23.5294%;
--destructive: 358.0892 75.1196% 59.0196%;
--destructive-foreground: 0 0% 100%;
--border: 220 13.0435% 90.9804%;
--input: 220 13.0435% 90.9804%;
--ring: 37.6923 92.1260% 50.1961%;
--chart-1: 37.6923 92.1260% 50.1961%;
--chart-2: 32.1327 94.6188% 43.7255%;
--chart-3: 25.9649 90.4762% 37.0588%;
--chart-4: 22.7273 82.5000% 31.3725%;
--chart-5: 21.7143 77.7778% 26.4706%;
--sidebar: 210 20.0000% 98.0392%;
--sidebar-foreground: 0 0% 14.9020%;
--sidebar-primary: 37.6923 92.1260% 50.1961%;
--sidebar-primary-foreground: 0 0% 100%;
--sidebar-accent: 48.0000 100.0000% 96.0784%;
--sidebar-accent-foreground: 22.7273 82.5000% 31.3725%;
--sidebar-border: 220 13.0435% 90.9804%;
--sidebar-ring: 37.6923 92.1260% 50.1961%;
--font-sans: Inter, sans-serif;
--font-serif: Source Serif 4, serif;
--font-mono: JetBrains Mono, monospace;
--radius: 0.375rem;
--border: 213.3333 15.2542% 88.4314%;
--input: 213.3333 15.2542% 88.4314%;
--ring: 37.4286 91.3043% 54.9020%;
--chart-1: 37.4286 91.3043% 54.9020%;
--chart-2: 185.4545 59.3361% 52.7451%;
--chart-3: 219.0909 41.7722% 30.9804%;
--chart-4: 36.7839 84.6809% 46.0784%;
--chart-5: 185.6934 61.9910% 43.3333%;
--sidebar: 216 18.5185% 94.7059%;
--sidebar-foreground: 222.8571 19.6262% 20.9804%;
--sidebar-primary: 37.4286 91.3043% 54.9020%;
--sidebar-primary-foreground: 217.5000 22.2222% 7.0588%;
--sidebar-accent: 183.3333 47.3684% 92.5490%;
--sidebar-accent-foreground: 187.1429 70.0000% 23.5294%;
--sidebar-border: 213.3333 15.2542% 88.4314%;
--sidebar-ring: 37.4286 91.3043% 54.9020%;
/* Brand-specific values used outside the shadcn token set
(the layer-stack device, marketing surfaces) */
--brand-ink: 217.5000 22.2222% 7.0588%;
--brand-ink-raised: 216.9231 23.6364% 10.7843%;
--brand-navy: 218.0488 42.2680% 19.0196%;
--brand-navy-light: 219.0909 41.7722% 30.9804%;
--brand-cyan: 185.4545 59.3361% 52.7451%;
--brand-amber: 37.4286 91.3043% 54.9020%;
--font-sans: 'Inter Variable', Inter, sans-serif;
--font-display: 'JetBrains Mono Variable', 'JetBrains Mono', ui-monospace, monospace;
--font-mono: 'JetBrains Mono Variable', 'JetBrains Mono', ui-monospace, monospace;
--radius: 0.5rem;
--shadow-x: 0px;
--shadow-y: 4px;
--shadow-blur: 8px;
--shadow-spread: -1px;
--shadow-opacity: 0.1;
--shadow-color: hsl(0 0% 0%);
--shadow-2xs: 0px 4px 8px -1px hsl(0 0% 0% / 0.05);
--shadow-xs: 0px 4px 8px -1px hsl(0 0% 0% / 0.05);
--shadow-sm: 0px 4px 8px -1px hsl(0 0% 0% / 0.10), 0px 1px 2px -2px hsl(0 0% 0% / 0.10);
--shadow: 0px 4px 8px -1px hsl(0 0% 0% / 0.10), 0px 1px 2px -2px hsl(0 0% 0% / 0.10);
--shadow-md: 0px 4px 8px -1px hsl(0 0% 0% / 0.10), 0px 2px 4px -2px hsl(0 0% 0% / 0.10);
--shadow-lg: 0px 4px 8px -1px hsl(0 0% 0% / 0.10), 0px 4px 6px -2px hsl(0 0% 0% / 0.10);
--shadow-xl: 0px 4px 8px -1px hsl(0 0% 0% / 0.10), 0px 8px 10px -2px hsl(0 0% 0% / 0.10);
--shadow-2xl: 0px 4px 8px -1px hsl(0 0% 0% / 0.25);
--shadow-color: hsl(217.5 22% 7% / 1);
--shadow-2xs: 0px 2px 4px -1px hsl(217.5 22% 7% / 0.04);
--shadow-xs: 0px 2px 4px -1px hsl(217.5 22% 7% / 0.06);
--shadow-sm: 0px 4px 8px -2px hsl(217.5 22% 7% / 0.08), 0px 1px 2px -2px hsl(217.5 22% 7% / 0.06);
--shadow: 0px 4px 8px -2px hsl(217.5 22% 7% / 0.08), 0px 1px 2px -2px hsl(217.5 22% 7% / 0.06);
--shadow-md: 0px 6px 12px -2px hsl(217.5 22% 7% / 0.10), 0px 2px 4px -2px hsl(217.5 22% 7% / 0.08);
--shadow-lg: 0px 10px 20px -4px hsl(217.5 22% 7% / 0.12), 0px 4px 6px -2px hsl(217.5 22% 7% / 0.08);
--shadow-xl: 0px 16px 28px -6px hsl(217.5 22% 7% / 0.16), 0px 8px 10px -4px hsl(217.5 22% 7% / 0.10);
--shadow-2xl: 0px 24px 40px -8px hsl(217.5 22% 7% / 0.24);
--tracking-normal: 0em;
--spacing: 0.25rem;
}
.dark {
--background: 0 0% 9.0196%;
--foreground: 0 0% 89.8039%;
--card: 0 0% 14.9020%;
--card-foreground: 0 0% 89.8039%;
--popover: 0 0% 14.9020%;
--popover-foreground: 0 0% 89.8039%;
--primary: 37.6923 92.1260% 50.1961%;
--primary-foreground: 0 0% 0%;
--secondary: 0 0% 14.9020%;
--secondary-foreground: 0 0% 89.8039%;
--muted: 0 0% 12.1569%;
--muted-foreground: 0 0% 63.9216%;
--accent: 22.7273 82.5000% 31.3725%;
--accent-foreground: 48 96.6387% 76.6667%;
--destructive: 0 84.2365% 60.1961%;
/* Ink — deep blue-graphite, not pure black */
--background: 217.5000 22.2222% 7.0588%;
--foreground: 215 17.6471% 86.6667%;
--card: 216.9231 23.6364% 10.7843%;
--card-foreground: 215 17.6471% 86.6667%;
--popover: 216.9231 23.6364% 10.7843%;
--popover-foreground: 215 17.6471% 86.6667%;
--primary: 37.4286 91.3043% 54.9020%;
--primary-foreground: 217.5000 22.2222% 7.0588%;
--secondary: 216 22.3881% 13.1373%;
--secondary-foreground: 215 17.6471% 86.6667%;
--muted: 216.9231 22.0339% 11.5686%;
--muted-foreground: 218.4000 11.9617% 59.0196%;
--accent: 219.0909 41.7722% 30.9804%;
--accent-foreground: 185.1064 58.0247% 84.1176%;
--destructive: 358.0892 75.1196% 59.0196%;
--destructive-foreground: 0 0% 100%;
--border: 0 0% 25.0980%;
--input: 0 0% 25.0980%;
--ring: 37.6923 92.1260% 50.1961%;
--chart-1: 43.2558 96.4126% 56.2745%;
--chart-2: 32.1327 94.6188% 43.7255%;
--chart-3: 22.7273 82.5000% 31.3725%;
--chart-4: 25.9649 90.4762% 37.0588%;
--chart-5: 22.7273 82.5000% 31.3725%;
--sidebar: 0 0% 5.8824%;
--sidebar-foreground: 0 0% 89.8039%;
--sidebar-primary: 37.6923 92.1260% 50.1961%;
--sidebar-primary-foreground: 0 0% 100%;
--sidebar-accent: 22.7273 82.5000% 31.3725%;
--sidebar-accent-foreground: 48 96.6387% 76.6667%;
--sidebar-border: 0 0% 25.0980%;
--sidebar-ring: 37.6923 92.1260% 50.1961%;
--font-sans: Inter, sans-serif;
--font-serif: Source Serif 4, serif;
--font-mono: JetBrains Mono, monospace;
--radius: 0.375rem;
--shadow-x: 0px;
--shadow-y: 4px;
--shadow-blur: 8px;
--shadow-spread: -1px;
--shadow-opacity: 0.1;
--shadow-color: hsl(0 0% 0%);
--shadow-2xs: 0px 4px 8px -1px hsl(0 0% 0% / 0.05);
--shadow-xs: 0px 4px 8px -1px hsl(0 0% 0% / 0.05);
--shadow-sm: 0px 4px 8px -1px hsl(0 0% 0% / 0.10), 0px 1px 2px -2px hsl(0 0% 0% / 0.10);
--shadow: 0px 4px 8px -1px hsl(0 0% 0% / 0.10), 0px 1px 2px -2px hsl(0 0% 0% / 0.10);
--shadow-md: 0px 4px 8px -1px hsl(0 0% 0% / 0.10), 0px 2px 4px -2px hsl(0 0% 0% / 0.10);
--shadow-lg: 0px 4px 8px -1px hsl(0 0% 0% / 0.10), 0px 4px 6px -2px hsl(0 0% 0% / 0.10);
--shadow-xl: 0px 4px 8px -1px hsl(0 0% 0% / 0.10), 0px 8px 10px -2px hsl(0 0% 0% / 0.10);
--shadow-2xl: 0px 4px 8px -1px hsl(0 0% 0% / 0.25);
--border: 217.8947 21.3483% 17.4510%;
--input: 217.8947 21.3483% 17.4510%;
--ring: 37.4286 91.3043% 54.9020%;
--chart-1: 37.4286 91.3043% 54.9020%;
--chart-2: 185.4545 59.3361% 52.7451%;
--chart-3: 219.7674 40.9524% 41.1765%;
--chart-4: 36.7839 84.6809% 46.0784%;
--chart-5: 185.6934 61.9910% 43.3333%;
--sidebar: 217.5000 26.6667% 5.8824%;
--sidebar-foreground: 215 17.6471% 86.6667%;
--sidebar-primary: 37.4286 91.3043% 54.9020%;
--sidebar-primary-foreground: 217.5000 22.2222% 7.0588%;
--sidebar-accent: 219.0909 41.7722% 30.9804%;
--sidebar-accent-foreground: 185.1064 58.0247% 84.1176%;
--sidebar-border: 217.8947 21.3483% 17.4510%;
--sidebar-ring: 37.4286 91.3043% 54.9020%;
--brand-ink: 217.5000 22.2222% 7.0588%;
--brand-ink-raised: 216.9231 23.6364% 10.7843%;
--brand-navy: 218.0488 42.2680% 19.0196%;
--brand-navy-light: 219.7674 40.9524% 41.1765%;
--brand-cyan: 185.4545 59.3361% 52.7451%;
--brand-amber: 37.4286 91.3043% 54.9020%;
--font-sans: 'Inter Variable', Inter, sans-serif;
--font-display: 'JetBrains Mono Variable', 'JetBrains Mono', ui-monospace, monospace;
--font-mono: 'JetBrains Mono Variable', 'JetBrains Mono', ui-monospace, monospace;
--radius: 0.5rem;
--shadow-color: hsl(217.5 22% 7% / 1);
--shadow-2xs: 0px 2px 4px -1px hsl(217.5 22% 3% / 0.20);
--shadow-xs: 0px 2px 4px -1px hsl(217.5 22% 3% / 0.24);
--shadow-sm: 0px 4px 8px -2px hsl(217.5 22% 3% / 0.32), 0px 1px 2px -2px hsl(217.5 22% 3% / 0.24);
--shadow: 0px 4px 8px -2px hsl(217.5 22% 3% / 0.32), 0px 1px 2px -2px hsl(217.5 22% 3% / 0.24);
--shadow-md: 0px 6px 12px -2px hsl(217.5 22% 3% / 0.36), 0px 2px 4px -2px hsl(217.5 22% 3% / 0.28);
--shadow-lg: 0px 10px 20px -4px hsl(217.5 22% 3% / 0.40), 0px 4px 6px -2px hsl(217.5 22% 3% / 0.28);
--shadow-xl: 0px 16px 28px -6px hsl(217.5 22% 3% / 0.48), 0px 8px 10px -4px hsl(217.5 22% 3% / 0.32);
--shadow-2xl: 0px 24px 40px -8px hsl(217.5 22% 3% / 0.6);
}
}
@@ -119,5 +141,47 @@
body {
@apply bg-background text-foreground;
font-feature-settings: 'cv11', 'ss01';
}
::selection {
background: hsl(var(--brand-amber) / 0.28);
}
}
/* Signature "layer stack" treatment: two offset hairline borders instead of
a soft drop shadow, evoking stacked strata / relay layers. Used on cards
that should read as part of the brand rather than generic chrome. */
@layer components {
.layer-card {
position: relative;
isolation: isolate;
}
.layer-card::before {
content: '';
position: absolute;
inset: 0;
border-radius: inherit;
border: 1px solid hsl(var(--border));
transform: translate(6px, 6px);
z-index: -1;
transition: transform 200ms ease, opacity 200ms ease;
opacity: 0.7;
}
@media (hover: hover) {
.layer-card:hover::before {
transform: translate(9px, 9px);
opacity: 1;
}
}
.eyebrow {
font-family: var(--font-display);
font-size: 0.75rem;
font-weight: 500;
letter-spacing: 0.14em;
text-transform: uppercase;
}
}

View File

@@ -3,6 +3,10 @@ import { createRoot } from 'react-dom/client';
// Import polyfills first
import './lib/polyfills.ts';
// Brand fonts: Inter for body copy, JetBrains Mono as the display/data face
import '@fontsource-variable/inter';
import '@fontsource-variable/jetbrains-mono';
import { ErrorBoundary } from '@/components/ErrorBoundary';
import App from './App.tsx';
import './index.css';

View File

@@ -26,7 +26,7 @@ export function Dashboard() {
<main className="flex-1 min-w-0">
<div className="sticky top-0 z-10 flex h-14 items-center gap-4 border-b bg-background px-4 lg:h-[60px] lg:px-6">
<SidebarTrigger />
<h1 className="text-lg font-semibold md:text-xl truncate">Dashboard</h1>
<h1 className="font-display text-lg font-semibold md:text-xl truncate">Dashboard</h1>
</div>
<div className="flex-1 space-y-6 p-4 md:p-6 lg:p-8 overflow-x-hidden">

View File

@@ -16,7 +16,7 @@ export function DashboardEvents() {
<main className="flex-1 min-w-0">
<div className="sticky top-0 z-10 flex h-14 items-center gap-4 border-b bg-background px-4 lg:h-[60px] lg:px-6">
<SidebarTrigger />
<h1 className="text-lg font-semibold md:text-xl truncate">My Events</h1>
<h1 className="font-display text-lg font-semibold md:text-xl truncate">My Events</h1>
</div>
<div className="flex-1 space-y-6 p-4 md:p-6 lg:p-8 overflow-x-hidden">

View File

@@ -94,7 +94,7 @@ export function DashboardExport() {
<main className="flex-1 min-w-0">
<div className="sticky top-0 z-10 flex h-14 items-center gap-4 border-b bg-background px-4 lg:h-[60px] lg:px-6">
<SidebarTrigger />
<h1 className="text-lg font-semibold md:text-xl truncate">Export Following List</h1>
<h1 className="font-display text-lg font-semibold md:text-xl truncate">Export Following List</h1>
</div>
<div className="flex-1 space-y-6 p-4 md:p-6 lg:p-8 overflow-x-hidden">

View File

@@ -1,4 +1,5 @@
import { useState } from 'react';
import { useSeoMeta } from '@unhead/react';
import type { NostrEvent, NostrMetadata } from '@nostrify/nostrify';
import { useExploreEvents } from '@/hooks/useExploreEvents';
import { useAuthor } from '@/hooks/useAuthor';
@@ -9,6 +10,8 @@ import { Badge } from '@/components/ui/badge';
import { Skeleton } from '@/components/ui/skeleton';
import { NoteContent } from '@/components/NoteContent';
import { genUserName } from '@/lib/genUserName';
import { SidebarProvider, SidebarTrigger } from '@/components/ui/sidebar';
import { AppSidebar } from '@/components/navigation/AppSidebar';
function TextNoteCard({ event }: { event: NostrEvent }) {
const author = useAuthor(event.pubkey);
@@ -160,18 +163,25 @@ export function Explore() {
const [activeTab, setActiveTab] = useState('notes');
const { data, isLoading, isError } = useExploreEvents();
useSeoMeta({
title: 'Explore — LAYER.systems',
description: 'The latest text notes and profiles passing through the LAYER.systems relay.',
});
return (
<div className="min-h-screen bg-gradient-to-b from-background via-background to-muted/20">
<div className="container max-w-4xl mx-auto px-4 py-8 space-y-6">
{/* Header */}
<div className="space-y-2">
<h1 className="text-4xl sm:text-5xl font-bold bg-gradient-to-r from-primary to-primary/60 bg-clip-text text-transparent">
Explore
</h1>
<p className="text-muted-foreground text-sm sm:text-base">
Discover the latest text notes and profiles from the Nostr
</p>
</div>
<SidebarProvider>
<div className="flex min-h-screen w-full overflow-x-hidden">
<AppSidebar />
<main className="flex-1 min-w-0">
<div className="sticky top-0 z-10 flex h-14 items-center gap-4 border-b bg-background px-4 lg:h-[60px] lg:px-6">
<SidebarTrigger />
<h1 className="font-display text-lg font-semibold md:text-xl truncate">Explore</h1>
</div>
<div className="max-w-4xl mx-auto px-4 py-8 space-y-6">
<p className="text-muted-foreground text-sm sm:text-base">
The latest text notes and profiles passing through this relay.
</p>
{/* Tabs */}
<Tabs value={activeTab} onValueChange={setActiveTab} className="w-full">
@@ -264,7 +274,9 @@ export function Explore() {
</div>
</TabsContent>
</Tabs>
</div>
</main>
</div>
</div>
</SidebarProvider>
);
}

View File

@@ -1,240 +1,181 @@
import { useSeoMeta } from '@unhead/react';
import { useState } from 'react';
import { Button } from '@/components/ui/button';
import { Card, CardContent } from '@/components/ui/card';
import { CheckCircle2, Copy, Server, Gift, Users, Globe } from 'lucide-react';
import { CheckCircle2, Copy, Gift, Users, Globe, ShieldCheck } from 'lucide-react';
import { useToast } from '@/hooks/useToast';
import { LoginArea } from '@/components/auth/LoginArea';
import { SiteHeader } from '@/components/brand/SiteHeader';
import { SiteFooter } from '@/components/brand/SiteFooter';
import { LayerStack } from '@/components/brand/LayerStack';
import { LayerDivider } from '@/components/brand/LayerDivider';
const relayUrl = 'wss://relay.layer.systems';
const features = [
{
icon: Gift,
title: 'No cost, no catch',
description: 'Publishing and reading are free. No paywalled feeds, no premium relay tier.',
},
{
icon: Users,
title: 'Run for the community',
description: 'Kept online by people who use Nostr daily, not a company monetizing your notes.',
},
{
icon: Globe,
title: 'Speaks to every client',
description: 'Standard NIP-01 relay behavior — works with Damus, Amethyst, Snort, and the rest.',
},
{
icon: ShieldCheck,
title: 'Yours to inspect',
description: 'Your events, your keys. Browse everything youve published from your own dashboard.',
},
];
const steps = [
{
step: '01',
title: 'Choose your client',
body: 'Pick a Nostr client — Damus, Amethyst, Snort, or any other app that speaks the protocol.',
},
{
step: '02',
title: 'Add the relay',
body: 'In your clients relay settings, add the URL below to your read/write relay list.',
},
{
step: '03',
title: 'Post through it',
body: 'Thats it. Notes you publish now pass through LAYER.systems on their way to the network.',
},
];
const Index = () => {
const [copied, setCopied] = useState(false);
const { toast } = useToast();
const relayUrl = 'wss://relay.layer.systems';
useSeoMeta({
title: 'LAYER.systems - Public Nostr Relay',
description: 'A fast, reliable, and open Nostr relay serving the decentralized social network.',
title: 'LAYER.systems Public Nostr Relay',
description: 'A free, community-run Nostr relay. Add wss://relay.layer.systems to any client to connect to the decentralized social network.',
});
const copyToClipboard = async () => {
try {
await navigator.clipboard.writeText(relayUrl);
setCopied(true);
toast({
title: 'Copied!',
description: 'Relay URL copied to clipboard',
});
toast({ title: 'Copied', description: 'Relay URL copied to clipboard' });
setTimeout(() => setCopied(false), 2000);
} catch {
toast({
title: 'Failed to copy',
description: 'Please copy the URL manually',
title: 'Couldnt copy automatically',
description: 'Select and copy the URL manually.',
variant: 'destructive',
});
}
};
const features = [
{
icon: Gift,
title: 'Free',
description: 'No cost to use - accessible for everyone',
},
{
icon: Users,
title: 'Community driven',
description: 'Built and maintained by the Nostr community',
},
{
icon: Globe,
title: 'Global Network',
description: 'Part of the decentralized Nostr ecosystem',
},
{
icon: Server,
title: 'Open Access',
description: 'Free to use for all Nostr clients',
},
];
return (
<div className="min-h-screen bg-gradient-to-br from-background via-background to-muted/20">
{/* Header */}
<header className="absolute top-0 left-0 right-0 z-50">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-4">
<div className="flex justify-end">
<LoginArea className="max-w-60" />
</div>
</div>
</header>
<div className="min-h-screen bg-background">
{/* Hero — the layer stack, lit on dark ink */}
<div className="relative overflow-hidden bg-brand-ink">
<div
className="pointer-events-none absolute inset-0 opacity-40"
style={{
backgroundImage:
'radial-gradient(600px circle at 15% 20%, hsl(var(--brand-amber) / 0.12), transparent 60%), radial-gradient(500px circle at 85% 60%, hsl(var(--brand-cyan) / 0.10), transparent 60%)',
}}
aria-hidden="true"
/>
<SiteHeader transparent />
{/* Hero Section */}
<div className="relative overflow-hidden">
{/* Animated Background Elements */}
<div className="absolute inset-0 overflow-hidden pointer-events-none">
<div className="absolute top-1/4 -left-20 w-96 h-96 bg-primary/5 rounded-full blur-3xl animate-pulse" />
<div className="absolute bottom-1/4 -right-20 w-96 h-96 bg-primary/5 rounded-full blur-3xl animate-pulse delay-1000" />
</div>
<div className="relative max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 pt-20 pb-16 sm:pt-32 sm:pb-24">
{/* Status Badge */}
{/* <div className="flex justify-center mb-8">
<Badge variant="outline" className="px-4 py-2 text-sm font-medium gap-2 border-primary/20 bg-primary/5">
<div className="w-2 h-2 bg-green-500 rounded-full animate-pulse" />
Relay Online
</Badge>
</div> */}
{/* Main Heading */}
<div className="text-center space-y-6 mb-12">
<h1 className="text-5xl sm:text-7xl font-bold tracking-tight">
<span className="bg-gradient-to-r from-primary via-primary to-primary/60 bg-clip-text text-transparent">
LAYER.systems
</span>
<div className="relative mx-auto grid max-w-6xl gap-16 px-6 pb-24 pt-28 sm:pb-32 sm:pt-36 lg:grid-cols-[1.1fr_0.9fr] lg:items-center">
<div className="motion-safe:animate-fade-up">
<p className="eyebrow text-brand-amber">Public Nostr relay</p>
<h1 className="mt-4 text-balance font-display text-4xl font-bold leading-[1.05] tracking-tight text-white sm:text-5xl lg:text-6xl">
One relay in the stack between you and the network.
</h1>
<p className="text-xl sm:text-2xl text-muted-foreground max-w-2xl mx-auto font-light">
Your gateway to the decentralized social network
<p className="mt-6 max-w-lg text-lg text-white/60">
LAYER.systems stores and forwards the events your client sends it free, open,
and built for the people actually using Nostr.
</p>
<p className="text-base sm:text-lg text-muted-foreground/80 max-w-xl mx-auto">
A fast, reliable, and open Nostr relay connecting you to the future of social media
</p>
</div>
{/* Relay URL Card */}
<div className="max-w-2xl mx-auto mb-16">
<Card className="border-2 border-primary/20 shadow-2xl shadow-primary/5 backdrop-blur-sm bg-card/95">
<CardContent className="p-8">
<div className="space-y-4">
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
<div className="sm:flex-1 min-w-0">
<p className="text-sm text-muted-foreground mb-2">Relay URL</p>
<code className="text-lg sm:text-xl font-mono text-primary break-all">
{relayUrl}
</code>
</div>
<Button
size="lg"
onClick={copyToClipboard}
className="w-full sm:w-auto sm:shrink-0 gap-2 hover:scale-105 transition-transform"
>
{copied ? (
<>
<CheckCircle2 className="w-5 h-5" />
Copied
</>
) : (
<>
<Copy className="w-5 h-5" />
Copy
</>
)}
</Button>
</div>
<p className="text-sm text-muted-foreground">
Add this URL to your Nostr client to connect to LAYER.systems
</p>
</div>
</CardContent>
</Card>
</div>
{/* Features Grid */}
<div className="max-w-5xl mx-auto">
<h2 className="text-3xl font-bold text-center mb-12">Why Choose LAYER.systems?</h2>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-6">
{features.map((feature, index) => (
<Card
key={index}
className="group hover:border-primary/40 transition-all duration-300 hover:shadow-lg hover:shadow-primary/5 hover:-translate-y-1"
>
<CardContent className="p-6 space-y-4">
<div className="w-12 h-12 rounded-lg bg-primary/10 flex items-center justify-center group-hover:bg-primary/20 transition-colors">
<feature.icon className="w-6 h-6 text-primary" />
</div>
<div>
<h3 className="font-semibold text-lg mb-2">{feature.title}</h3>
<p className="text-sm text-muted-foreground">{feature.description}</p>
</div>
</CardContent>
</Card>
))}
<div className="mt-10 max-w-lg rounded-lg border border-white/10 bg-white/[0.04] p-5 backdrop-blur-sm">
<p className="eyebrow text-white/40">Relay URL</p>
<div className="mt-2 flex flex-col gap-3 sm:flex-row sm:items-center">
<code className="min-w-0 flex-1 truncate font-display text-base text-brand-amber sm:text-lg">
{relayUrl}
</code>
<Button onClick={copyToClipboard} className="shrink-0 gap-2">
{copied ? (
<>
<CheckCircle2 className="h-4 w-4" /> Copied
</>
) : (
<>
<Copy className="h-4 w-4" /> Copy
</>
)}
</Button>
</div>
</div>
</div>
<div className="flex justify-center lg:justify-end">
<LayerStack />
</div>
</div>
</div>
{/* How to Connect Section */}
<div className="border-t border-border/40">
<div className="max-w-4xl mx-auto px-4 sm:px-6 lg:px-8 py-16 sm:py-24">
<h2 className="text-3xl font-bold text-center mb-12">Getting Started</h2>
<div className="space-y-8">
<Card>
<CardContent className="p-8">
<div className="space-y-6">
<div className="flex gap-4">
<div className="shrink-0 w-8 h-8 rounded-full bg-primary text-primary-foreground flex items-center justify-center font-semibold">
1
</div>
<div>
<h3 className="font-semibold text-lg mb-2">Choose Your Client</h3>
<p className="text-muted-foreground">
Pick a Nostr client like Damus, Amethyst, Snort, or any other compatible application
</p>
</div>
</div>
<div className="flex gap-4">
<div className="shrink-0 w-8 h-8 rounded-full bg-primary text-primary-foreground flex items-center justify-center font-semibold">
2
</div>
<div>
<h3 className="font-semibold text-lg mb-2">Add the Relay</h3>
<p className="text-muted-foreground">
In your client settings, add <code className="px-2 py-1 bg-muted rounded text-sm font-mono">{relayUrl}</code> to your relay list
</p>
</div>
</div>
<div className="flex gap-4">
<div className="shrink-0 w-8 h-8 rounded-full bg-primary text-primary-foreground flex items-center justify-center font-semibold">
3
</div>
<div>
<h3 className="font-semibold text-lg mb-2">Start Connecting</h3>
<p className="text-muted-foreground">
You're all set! Start posting, following, and connecting with the Nostr network
</p>
</div>
</div>
</div>
</CardContent>
</Card>
</div>
{/* Why this relay */}
<section className="mx-auto max-w-6xl px-6 py-20 sm:py-28">
<div className="mx-auto max-w-xl text-center">
<p className="eyebrow text-primary">Why this relay</p>
<h2 className="mt-3 text-3xl font-bold tracking-tight sm:text-4xl">
Infrastructure, not a product to sell you
</h2>
</div>
<div className="mt-14 grid grid-cols-1 gap-x-8 gap-y-12 sm:grid-cols-2 lg:grid-cols-4">
{features.map((feature) => (
<div key={feature.title} className="layer-card rounded-lg border border-border bg-card p-6">
<div className="flex h-11 w-11 items-center justify-center rounded-md bg-primary/10">
<feature.icon className="h-5 w-5 text-primary" />
</div>
<h3 className="mt-4 font-display text-base font-semibold">{feature.title}</h3>
<p className="mt-2 text-sm leading-relaxed text-muted-foreground">{feature.description}</p>
</div>
))}
</div>
</section>
<div className="flex justify-center py-4">
<LayerDivider />
</div>
{/* Footer */}
<footer className="border-t border-border/40 bg-muted/20">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
<div className="flex flex-col sm:flex-row justify-between items-center gap-4">
<p className="text-sm text-muted-foreground">
© {new Date().getFullYear()} LAYER.systems. Powered by Nostr.
</p>
<div className="flex items-center gap-4">
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<Server className="w-4 h-4" />
<span>Open and free for all</span>
</div>
<div className="flex items-center gap-4 text-sm">
<a href="/terms" className="text-muted-foreground hover:text-foreground transition-colors">
Terms
</a>
<a href="/privacy" className="text-muted-foreground hover:text-foreground transition-colors">
Privacy
</a>
</div>
</div>
{/* How to connect — a genuine sequence, numbered accordingly */}
<section className="border-t border-border bg-muted/20">
<div className="mx-auto max-w-3xl px-6 py-20 sm:py-28">
<div className="mx-auto max-w-xl text-center">
<p className="eyebrow text-primary">Getting connected</p>
<h2 className="mt-3 text-3xl font-bold tracking-tight sm:text-4xl">Three steps, in order</h2>
</div>
<ol className="mt-14 space-y-10">
{steps.map((item) => (
<li key={item.step} className="flex gap-5">
<span className="font-display text-sm font-semibold text-primary/70">{item.step}</span>
<div>
<h3 className="font-display text-base font-semibold">{item.title}</h3>
<p className="mt-1.5 text-muted-foreground">{item.body}</p>
</div>
</li>
))}
</ol>
</div>
</footer>
</section>
<SiteFooter />
</div>
);
};

View File

@@ -1,23 +1,30 @@
import { useSeoMeta } from '@unhead/react';
import { DMMessagingInterface } from '@/components/dm/DMMessagingInterface';
import { SidebarProvider, SidebarTrigger } from '@/components/ui/sidebar';
import { AppSidebar } from '@/components/navigation/AppSidebar';
const Messages = () => {
useSeoMeta({
title: 'Messages',
description: 'Private encrypted messaging on Nostr',
title: 'Messages — LAYER.systems',
description: 'Private, end-to-end encrypted messaging on Nostr.',
});
return (
<div className="min-h-screen bg-background">
<div className="container mx-auto p-4 h-screen flex flex-col">
{/* Header */}
<div className="flex items-center justify-between mb-4">
<h1 className="text-2xl font-semibold">Messages</h1>
</div>
<SidebarProvider>
<div className="flex h-screen w-full overflow-hidden">
<AppSidebar />
<main className="flex-1 min-w-0 flex flex-col">
<div className="shrink-0 flex h-14 items-center gap-4 border-b bg-background px-4 lg:h-[60px] lg:px-6">
<SidebarTrigger />
<h1 className="font-display text-lg font-semibold md:text-xl truncate">Messages</h1>
</div>
<DMMessagingInterface className="flex-1" />
<div className="flex-1 min-h-0 p-4">
<DMMessagingInterface className="h-full" />
</div>
</main>
</div>
</div>
</SidebarProvider>
);
};

View File

@@ -1,13 +1,15 @@
import { useSeoMeta } from "@unhead/react";
import { useLocation } from "react-router-dom";
import { useLocation, Link } from "react-router-dom";
import { useEffect } from "react";
import { Button } from "@/components/ui/button";
import { Wordmark } from "@/components/brand/Wordmark";
const NotFound = () => {
const location = useLocation();
useSeoMeta({
title: "404 - Page Not Found",
description: "The page you are looking for could not be found. Return to the home page to continue browsing.",
title: "404 — LAYER.systems",
description: "This page doesn't exist. Return to the home page to keep browsing.",
});
useEffect(() => {
@@ -18,13 +20,19 @@ const NotFound = () => {
}, [location.pathname]);
return (
<div className="min-h-screen flex items-center justify-center bg-gray-100 dark:bg-gray-900">
<div className="text-center">
<h1 className="text-4xl font-bold mb-4 text-gray-900 dark:text-gray-100">404</h1>
<p className="text-xl text-gray-600 dark:text-gray-400 mb-4">Oops! Page not found</p>
<a href="/" className="text-blue-500 hover:text-blue-700 dark:text-blue-400 dark:hover:text-blue-300 underline">
Return to Home
</a>
<div className="flex min-h-screen items-center justify-center bg-background px-6">
<div className="max-w-sm text-center">
<p className="eyebrow text-primary">No signal</p>
<p className="mt-3 font-display text-6xl font-bold tracking-tight">404</p>
<p className="mt-3 text-muted-foreground">
There's no layer at <code className="rounded bg-muted px-1.5 py-0.5 font-mono text-sm">{location.pathname}</code>.
</p>
<div className="mt-8 flex flex-col items-center gap-6">
<Button asChild>
<Link to="/">Back to LAYER.systems</Link>
</Button>
<Wordmark className="text-sm opacity-60" />
</div>
</div>
</div>
);

View File

@@ -1,25 +1,18 @@
import { useSeoMeta } from '@unhead/react';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Shield } from 'lucide-react';
import { Link } from 'react-router-dom';
import { SiteHeader } from '@/components/brand/SiteHeader';
import { SiteFooter } from '@/components/brand/SiteFooter';
export function Privacy() {
useSeoMeta({
title: 'Privacy Policy - LAYER.systems',
description: 'Privacy Policy for LAYER.systems Nostr relay',
title: 'Privacy Policy LAYER.systems',
description: 'Privacy Policy for the LAYER.systems Nostr relay.',
});
return (
<div className="min-h-screen bg-gradient-to-br from-background via-background to-muted/20">
{/* Header */}
<header className="border-b bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/60">
<div className="container mx-auto px-4 py-4">
<Link to="/" className="inline-flex items-center gap-2 text-xl font-bold hover:opacity-80 transition-opacity">
<Shield className="h-6 w-6" />
<span>LAYER.systems</span>
</Link>
</div>
</header>
<div className="min-h-screen bg-background">
<SiteHeader />
{/* Main Content */}
<main className="container mx-auto px-4 py-12 max-w-4xl">
@@ -29,6 +22,7 @@ export function Privacy() {
<div className="inline-flex items-center justify-center w-16 h-16 rounded-full bg-primary/10 mb-4">
<Shield className="h-8 w-8 text-primary" />
</div>
<p className="eyebrow text-primary">Legal</p>
<h1 className="text-4xl font-bold tracking-tight">Privacy Policy</h1>
<p className="text-muted-foreground text-lg">
Last updated: December 27, 2025
@@ -258,19 +252,9 @@ export function Privacy() {
</CardContent>
</Card>
</div>
{/* Footer Navigation */}
<div className="mt-12 pt-8 border-t text-center space-y-4">
<div className="flex justify-center gap-6 text-sm">
<Link to="/" className="text-muted-foreground hover:text-foreground transition-colors">
Home
</Link>
<Link to="/terms" className="text-muted-foreground hover:text-foreground transition-colors">
Terms of Service
</Link>
</div>
</div>
</main>
<SiteFooter />
</div>
);
}

215
src/pages/Styleguide.tsx Normal file
View File

@@ -0,0 +1,215 @@
import { useSeoMeta } from '@unhead/react';
import { SiteHeader } from '@/components/brand/SiteHeader';
import { SiteFooter } from '@/components/brand/SiteFooter';
import { LayerStack } from '@/components/brand/LayerStack';
import { LayerDivider } from '@/components/brand/LayerDivider';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { Input } from '@/components/ui/input';
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert';
import { InfoIcon } from 'lucide-react';
import { cn } from '@/lib/utils';
interface Swatch {
name: string;
token: string;
hex: string;
usage: string;
dark?: boolean;
}
const colors: Swatch[] = [
{ name: 'Paper', token: 'background', hex: '#F5F6F5', usage: 'Default light-mode surface' },
{ name: 'Ink', token: 'brand-ink', hex: '#0E1116', usage: 'Hero panel + dark-mode surface', dark: true },
{ name: 'Relay Amber', token: 'primary', hex: '#F5A623', usage: 'Signature accent, primary actions, the "signal"' },
{ name: 'Deep Signal Navy', token: 'brand-navy', hex: '#1C2B45', usage: 'Structural color — layer bands, dark accents', dark: true },
{ name: 'Circuit Cyan', token: 'brand-cyan', hex: '#3FC1CE', usage: 'Secondary data color for charts + hover accents' },
{ name: 'Slate Ink', token: 'foreground', hex: '#2B3140', usage: 'Body text on light surfaces' },
];
const spacingScale = [1, 2, 3, 4, 6, 8, 12, 16, 24];
function Section({
eyebrow,
title,
children,
className,
}: {
eyebrow: string;
title: string;
children: React.ReactNode;
className?: string;
}) {
return (
<section className={cn('py-14 sm:py-16', className)}>
<p className="eyebrow text-primary">{eyebrow}</p>
<h2 className="mt-2 text-2xl font-bold tracking-tight sm:text-3xl">{title}</h2>
<div className="mt-8">{children}</div>
</section>
);
}
export function Styleguide() {
useSeoMeta({
title: 'Styleguide — LAYER.systems',
description: 'The LAYER.systems design system: color, type, spacing, and components.',
});
return (
<div className="min-h-screen bg-background">
<SiteHeader />
<main className="mx-auto max-w-5xl px-6">
<div className="border-b border-border py-16 sm:py-20">
<p className="eyebrow text-primary">Design system</p>
<h1 className="mt-3 text-4xl font-bold tracking-tight sm:text-5xl">Styleguide</h1>
<p className="mt-4 max-w-2xl text-lg text-muted-foreground">
The visual language behind LAYER.systems: a relay is one layer between a client and
the network, so the whole system is built around that stack literally.
The full rationale lives in{' '}
<code className="rounded bg-muted px-1.5 py-0.5 font-mono text-sm">docs/DESIGN_STYLEGUIDE.md</code>.
</p>
</div>
{/* Signature */}
<Section eyebrow="Signature" title="The layer stack">
<p className="max-w-2xl text-muted-foreground">
Every note travels Client Relay Network. Instead of a logo standing in for that,
the brand mark is the stack itself this relay lit up as the middle layer.
</p>
<div className="mt-8 rounded-xl border border-border bg-brand-ink p-10">
<LayerStack className="mx-auto" />
</div>
<div className="mt-10 flex items-center gap-6">
<LayerDivider />
<p className="text-sm text-muted-foreground">
A quiet echo of the stack, used to break up sections without repeating the hero mark.
</p>
</div>
</Section>
<div className="h-px bg-border" />
{/* Color */}
<Section eyebrow="Color" title="Palette">
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3">
{colors.map((c) => (
<div key={c.name} className="overflow-hidden rounded-lg border border-border">
<div
className="h-20"
style={{ backgroundColor: `hsl(var(--${c.token}))` }}
/>
<div className="space-y-1 p-4">
<div className="flex items-center justify-between">
<p className="font-display text-sm font-semibold">{c.name}</p>
<span className="font-mono text-xs text-muted-foreground">{c.hex}</span>
</div>
<p className="text-xs text-muted-foreground">{c.usage}</p>
</div>
</div>
))}
</div>
</Section>
<div className="h-px bg-border" />
{/* Type */}
<Section eyebrow="Type" title="Display, body, data">
<div className="space-y-8">
<div>
<p className="eyebrow text-muted-foreground">Display JetBrains Mono, for headlines &amp; data</p>
<p className="mt-2 font-display text-5xl font-bold tracking-tight">Signal in, signal out.</p>
</div>
<div>
<p className="eyebrow text-muted-foreground">Body Inter, for everything you read</p>
<p className="mt-2 max-w-xl text-lg text-muted-foreground">
Body copy stays in a humanist sans so long-form reading terms, docs, dashboard
explanations never feels like staring at a terminal.
</p>
</div>
<div>
<p className="eyebrow text-muted-foreground">Mono in use labels &amp; identifiers</p>
<p className="mt-2 font-mono text-sm text-foreground">
wss://relay.layer.systems · kind:1 · npub1q4w0…f8x2
</p>
</div>
</div>
</Section>
<div className="h-px bg-border" />
{/* Spacing */}
<Section eyebrow="Spacing" title="4px base scale">
<div className="space-y-2">
{spacingScale.map((n) => (
<div key={n} className="flex items-center gap-4">
<span className="w-10 shrink-0 font-mono text-xs text-muted-foreground">{n * 4}px</span>
<div className="h-3 rounded-sm bg-primary/70" style={{ width: `${n * 4}px` }} />
</div>
))}
</div>
</Section>
<div className="h-px bg-border" />
{/* Components */}
<Section eyebrow="Components" title="Buttons">
<div className="flex flex-wrap items-center gap-3">
<Button>Primary</Button>
<Button variant="secondary">Secondary</Button>
<Button variant="outline">Outline</Button>
<Button variant="ghost">Ghost</Button>
<Button variant="destructive">Destructive</Button>
<Button size="sm">Small</Button>
<Button size="lg">Large</Button>
</div>
</Section>
<Section eyebrow="Components" title="Badges">
<div className="flex flex-wrap items-center gap-3">
<Badge>Default</Badge>
<Badge variant="secondary">Secondary</Badge>
<Badge variant="outline">Outline</Badge>
<Badge variant="destructive">Destructive</Badge>
</div>
</Section>
<Section eyebrow="Components" title="Cards">
<div className="grid gap-8 pb-4 sm:grid-cols-2">
<div className="layer-card rounded-lg border border-border bg-card p-6">
<p className="font-display text-sm font-semibold">Layer card</p>
<p className="mt-2 text-sm text-muted-foreground">
Two offset hairline borders instead of a soft shadow the site's default card
treatment, echoing the stack motif.
</p>
</div>
<div className="rounded-lg border border-border bg-card p-6 shadow-sm">
<p className="font-display text-sm font-semibold">Plain card</p>
<p className="mt-2 text-sm text-muted-foreground">
Used inside dense data views (dashboard, explorer) where the layered border would
add visual noise.
</p>
</div>
</div>
</Section>
<Section eyebrow="Components" title="Form &amp; feedback">
<div className="max-w-md space-y-4">
<Input placeholder="npub1…" />
<Alert>
<InfoIcon className="h-4 w-4" />
<AlertTitle>Heads up</AlertTitle>
<AlertDescription>
Alerts explain what happened and what to do next never vague, never apologetic.
</AlertDescription>
</Alert>
</div>
</Section>
</main>
<SiteFooter />
</div>
);
}
export default Styleguide;

View File

@@ -2,24 +2,18 @@ import { useSeoMeta } from '@unhead/react';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { ScrollText } from 'lucide-react';
import { Link } from 'react-router-dom';
import { SiteHeader } from '@/components/brand/SiteHeader';
import { SiteFooter } from '@/components/brand/SiteFooter';
export function Terms() {
useSeoMeta({
title: 'Terms of Service - LAYER.systems',
description: 'Terms of Service for LAYER.systems Nostr relay',
title: 'Terms of Service LAYER.systems',
description: 'Terms of Service for the LAYER.systems Nostr relay.',
});
return (
<div className="min-h-screen bg-gradient-to-br from-background via-background to-muted/20">
{/* Header */}
<header className="border-b bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/60">
<div className="container mx-auto px-4 py-4">
<Link to="/" className="inline-flex items-center gap-2 text-xl font-bold hover:opacity-80 transition-opacity">
<ScrollText className="h-6 w-6" />
<span>LAYER.systems</span>
</Link>
</div>
</header>
<div className="min-h-screen bg-background">
<SiteHeader />
{/* Main Content */}
<main className="container mx-auto px-4 py-12 max-w-4xl">
@@ -29,6 +23,7 @@ export function Terms() {
<div className="inline-flex items-center justify-center w-16 h-16 rounded-full bg-primary/10 mb-4">
<ScrollText className="h-8 w-8 text-primary" />
</div>
<p className="eyebrow text-primary">Legal</p>
<h1 className="text-4xl font-bold tracking-tight">Terms of Service</h1>
<p className="text-muted-foreground text-lg">
Last updated: December 27, 2025
@@ -194,19 +189,9 @@ export function Terms() {
</CardContent>
</Card>
</div>
{/* Footer Navigation */}
<div className="mt-12 pt-8 border-t text-center space-y-4">
<div className="flex justify-center gap-6 text-sm">
<Link to="/" className="text-muted-foreground hover:text-foreground transition-colors">
Home
</Link>
<Link to="/privacy" className="text-muted-foreground hover:text-foreground transition-colors">
Privacy Policy
</Link>
</div>
</div>
</main>
<SiteFooter />
</div>
);
}

View File

@@ -19,6 +19,11 @@ export default {
}
},
extend: {
fontFamily: {
sans: ['var(--font-sans)'],
display: ['var(--font-display)'],
mono: ['var(--font-mono)'],
},
colors: {
border: 'hsl(var(--border))',
input: 'hsl(var(--input))',
@@ -53,6 +58,14 @@ export default {
DEFAULT: 'hsl(var(--card))',
foreground: 'hsl(var(--card-foreground))'
},
brand: {
ink: 'hsl(var(--brand-ink))',
'ink-raised': 'hsl(var(--brand-ink-raised))',
navy: 'hsl(var(--brand-navy))',
'navy-light': 'hsl(var(--brand-navy-light))',
cyan: 'hsl(var(--brand-cyan))',
amber: 'hsl(var(--brand-amber))'
},
sidebar: {
DEFAULT: 'hsl(var(--sidebar))',
foreground: 'hsl(var(--sidebar-foreground))',
@@ -85,11 +98,42 @@ export default {
to: {
height: '0'
}
},
'layer-settle': {
from: {
transform: 'translateY(var(--settle-offset, -14px))',
opacity: '0'
},
to: {
transform: 'translateY(0)',
opacity: '1'
}
},
'signal-pulse': {
'0%, 100%': {
opacity: '0.5'
},
'50%': {
opacity: '1'
}
},
'fade-up': {
from: {
transform: 'translateY(10px)',
opacity: '0'
},
to: {
transform: 'translateY(0)',
opacity: '1'
}
}
},
animation: {
'accordion-down': 'accordion-down 0.2s ease-out',
'accordion-up': 'accordion-up 0.2s ease-out'
'accordion-up': 'accordion-up 0.2s ease-out',
'layer-settle': 'layer-settle 0.7s cubic-bezier(0.16, 1, 0.3, 1) both',
'signal-pulse': 'signal-pulse 2.4s ease-in-out infinite',
'fade-up': 'fade-up 0.6s cubic-bezier(0.16, 1, 0.3, 1) both'
}
}
},