mirror of
https://github.com/layer-systems/website.git
synced 2026-09-12 21:53:40 +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>
350 lines
18 KiB
Markdown
350 lines
18 KiB
Markdown
# Project Overview
|
||
|
||
This project is a Nostr client application built with React 19.x, TailwindCSS 4.x, Vite, shadcn/ui, and Nostrify.
|
||
|
||
## Technology Stack
|
||
|
||
- **React 19.x**: hooks, concurrent rendering, ref-as-prop
|
||
- **TailwindCSS 4.x**: utility-first styling
|
||
- **Vite**: dev server and production bundler
|
||
- **shadcn/ui**: unstyled accessible components on Radix UI + Tailwind (48+ components in `@/components/ui`)
|
||
- **Nostrify** (`@nostrify/react`): Nostr protocol framework
|
||
- **React Router**: client-side routing with `BrowserRouter` and automatic scroll-to-top
|
||
- **TanStack Query**: data fetching, caching, state
|
||
- **TypeScript**: type-safe JS. **Never use the `any` type.**
|
||
|
||
## Project Structure
|
||
|
||
- `/src/components/` — UI components. `ui/` holds shadcn/ui primitives; `auth/` holds login components (`LoginArea`, `AuthDialog`, `AccountSwitcher`).
|
||
- `/src/hooks/` — custom hooks. Discover the full set with `ls src/hooks/`. Key ones: `useNostr`, `useAuthor`, `useCurrentUser`, `useNostrPublish`, `useUploadFile`, `useAppContext`, `useTheme`, `useToast`, `useLoggedInAccounts`, `useLoginActions`, `useIsMobile`.
|
||
- `/src/pages/` — page components wired into React Router (`Index`, `NotFound`, `NIP19Page`).
|
||
- `/src/lib/` — utility functions and shared logic.
|
||
- `/src/contexts/` — React context providers (`AppContext`).
|
||
- `/src/test/` — testing utilities including the `TestApp` wrapper.
|
||
- `/public/` — static assets.
|
||
- `App.tsx` — **already configured** with `QueryClientProvider`, `NostrProvider`, `UnheadProvider`, `AppProvider`, `NostrLoginProvider`. **Read before editing**; changes are rarely needed.
|
||
- `AppRouter.tsx` — React Router configuration. The catch-all `/:nip19` route handles all NIP-19 identifiers (see the `nip19-routing` skill).
|
||
|
||
**Always read an existing file before modifying it.** Never write over `App.tsx`, `AppRouter.tsx`, or `NostrProvider` without first reading their contents.
|
||
|
||
## UI Components
|
||
|
||
Components in `@/components/ui` are unstyled, accessible primitives styled with Tailwind. They follow a consistent React 19 pattern: plain function components that type props via `React.ComponentProps<...>` and forward `ref` as a normal prop (no `React.forwardRef`), tag their root with a `data-slot` attribute, merge classes with the `cn()` utility, and define variants with `class-variance-authority`. Components built on Radix import from the unified `radix-ui` package (e.g. `import { Dialog as DialogPrimitive } from "radix-ui"`), not individual `@radix-ui/react-*` packages. When you need a specific component, list the directory (`ls src/components/ui/`) or import from `@/components/ui/<name>` — all common primitives are present (buttons, inputs, dialogs, dropdowns, forms, tables, etc.).
|
||
|
||
## System Prompt Management
|
||
|
||
The assistant's behavior is defined by this file (`AGENTS.md`). Edit it directly to change guidelines — updates take effect the next session. Specialized workflows live in `/.agents/skills/` as loadable skills, discoverable through the `skill` tool.
|
||
|
||
## Nostr Protocol Integration
|
||
|
||
### When to reuse an existing NIP vs. create a new kind
|
||
|
||
1. **Always review existing NIPs first.** Use the NIP index tool, then read candidate NIPs in detail. The goal is to find the closest existing solution.
|
||
2. **Prefer extending existing NIPs** over creating custom kinds, even if it requires minor schema compromises. Custom kinds fragment the ecosystem.
|
||
3. **When existing NIPs are close but not perfect**, use the existing kind as the base and add domain-specific tags. Document extensions in `NIP.md`.
|
||
4. **Only generate a new kind** when no existing NIP covers the core functionality, the data structure is fundamentally different, or the use case needs different storage characteristics (regular/replaceable/addressable).
|
||
5. **If a tool to generate a new kind number is available, you MUST use it** — don't pick an arbitrary number.
|
||
6. **Custom kinds MUST include a NIP-31 `alt` tag** with a human-readable description.
|
||
|
||
### Kind Ranges
|
||
|
||
- **Regular** (1000 ≤ kind < 10000): stored permanently by relays. Notes, articles, etc.
|
||
- **Replaceable** (10000 ≤ kind < 20000): only the latest event per `pubkey+kind` is stored. Profile metadata, contact lists.
|
||
- **Addressable** (30000 ≤ kind < 40000): identified by `pubkey+kind+d-tag`; only the latest per combo is stored. Articles, long-form content.
|
||
|
||
Kinds below 1000 are "legacy"; their storage behavior is per-kind (e.g. kind 1 is regular, kind 3 is replaceable).
|
||
|
||
### Tag Design Principles
|
||
|
||
- **Kind = schema, tags = semantics.** Don't create new kinds just to represent a different category of the same data.
|
||
- **Relays only index single-letter tags.** Use `t` for categories so filters like `'#t': ['electronics']` work at the relay level. Multi-letter tags (`product_type`, etc.) force inefficient client-side filtering.
|
||
- **Filter at the relay.** Pass tag filters in the query rather than fetching everything and filtering in JS.
|
||
- **For community/niche apps**, tag events with a `t` and query by it: `createEvent({ kind: 1, content, tags: [['t', 'farming']] })`, then `nostr.query([{ kinds: [1], '#t': ['farming'] }])`. Don't do this for generic platforms.
|
||
|
||
### Content Field Design
|
||
|
||
- **Use `content` for** large freeform text or existing industry-standard JSON formats (GeoJSON, FHIR, Tiled). Kind 0 is the one exception where structured JSON goes in `content`.
|
||
- **Use tags for** queryable metadata and structured data — anything you might filter on.
|
||
- **Empty content is fine.** `content: ""` is idiomatic for tag-only events.
|
||
- If you need to filter by a field, it **must** be a tag — relays don't index content.
|
||
|
||
### NIP.md
|
||
|
||
`NIP.md` documents any custom kinds/schemas this project defines. If the file doesn't exist, this project has no custom kinds. **Whenever you generate a new kind or change a custom schema, create or update `NIP.md`.**
|
||
|
||
### Nostr Security Model
|
||
|
||
**CRITICAL:** Nostr private keys (`nsec`) are stored **in plaintext in `localStorage`**. Any JavaScript running on the origin can steal them. A single XSS = permanent, unrecoverable key theft across every Nostr client the user ever touches. **Treat XSS mitigation as the top-priority security concern.**
|
||
|
||
- **Never** use `dangerouslySetInnerHTML`, `innerHTML`, or `document.write` with event data, URL params, or other untrusted strings.
|
||
- **CSP is defense-in-depth**, not primary defense. `index.html` ships a restrictive CSP (`script-src 'self'`, `default-src 'none'`). Never relax it with `'unsafe-eval'`, `'unsafe-inline'` on `script-src`, or wildcard sources.
|
||
- **Sanitize every event-sourced URL** (`sanitizeUrl()` — https-only allowlist) before using it as `href`, `src`, iframe `src`, or CSS `url()`.
|
||
- **Sanitize every event-sourced string interpolated into CSS**. A malicious `font-family` or `url()` value can break out of the CSS context and inject rules.
|
||
|
||
Beyond XSS, Nostr is permissionless — signatures prove authorship, not trustworthiness. Filter by `authors` whenever trust is implied:
|
||
|
||
- **Admin/moderator/owner queries** — filter by trusted pubkeys.
|
||
- **Addressable events (kinds 30000–39999)** and **user-owned replaceable events** — filter by `authors`; the `d` tag alone is not a trust boundary.
|
||
- **Routes for addressable/replaceable events** — include the author in the URL (e.g. `/article/:npub/:slug`) so the filter can constrain on author.
|
||
- **Public UGC** (kind 1 notes, reactions, public feeds, discovery) — author filtering NOT required.
|
||
|
||
```ts
|
||
// ❌ Anyone can spoof this event
|
||
nostr.query([{ kinds: [30078], '#d': ['app-organizers'], limit: 1 }]);
|
||
// ✅ Only trust admin authors
|
||
nostr.query([{ kinds: [30078], authors: ADMIN_PUBKEYS, '#d': ['app-organizers'], limit: 1 }]);
|
||
```
|
||
|
||
For the full threat model — CSP walkthrough, `sanitizeUrl` / `sanitizeCssString` implementations, NIP-72 community moderation, and the pre-merge checklist — load the **`nostr-security`** skill.
|
||
|
||
### The `useNostr` Hook
|
||
|
||
```ts
|
||
import { useNostr } from '@nostrify/react';
|
||
|
||
function useCustomHook() {
|
||
const { nostr } = useNostr();
|
||
// nostr.query(filters) / nostr.event(event) / nostr.req(filters)
|
||
}
|
||
```
|
||
|
||
By default `nostr` uses the app's connection pool (reads from one relay, publishes to all configured). For targeted single-relay or relay-group calls, load the **`nostr-relay-pools`** skill.
|
||
|
||
### Querying with TanStack Query
|
||
|
||
Combine `useNostr` with `useQuery` in custom hooks:
|
||
|
||
```ts
|
||
function usePosts() {
|
||
const { nostr } = useNostr();
|
||
return useQuery({
|
||
queryKey: ['posts'],
|
||
queryFn: async (c) => nostr.query([{ kinds: [1], limit: 15 }], { signal: c.signal }),
|
||
});
|
||
}
|
||
```
|
||
|
||
**Efficient query design** — minimize round-trips:
|
||
|
||
- **Combine kinds** in one filter: `{ kinds: [1, 6, 16], '#e': [eventId] }` and split by kind in JS. Don't run three parallel queries for repost variants.
|
||
- **Use multiple filter objects** in one query when different tag filters are needed.
|
||
- **Raise `limit`** when combining so you still get enough of each kind.
|
||
- Each query costs relay capacity and may count against rate limits.
|
||
|
||
**Event validation** — for kinds with required tags or strict schemas, filter query results through a validator:
|
||
|
||
```ts
|
||
function isValidCalendarEvent(event: NostrEvent): boolean {
|
||
if (![31922, 31923].includes(event.kind)) return false;
|
||
const d = event.tags.find(([n]) => n === 'd')?.[1];
|
||
const title = event.tags.find(([n]) => n === 'title')?.[1];
|
||
const start = event.tags.find(([n]) => n === 'start')?.[1];
|
||
return Boolean(d && title && start);
|
||
}
|
||
|
||
const events = (await nostr.query([{ kinds: [31922, 31923], limit: 15 }]))
|
||
.filter(isValidCalendarEvent);
|
||
```
|
||
|
||
Validation is optional for loose kinds (kind 1), but strongly recommended for custom kinds and kinds with required tags.
|
||
|
||
### The `useAuthor` Hook
|
||
|
||
Fetch profile metadata (kind 0) for a pubkey:
|
||
|
||
```tsx
|
||
import type { NostrEvent, NostrMetadata } from '@nostrify/nostrify';
|
||
import { useAuthor } from '@/hooks/useAuthor';
|
||
|
||
function Post({ event }: { event: NostrEvent }) {
|
||
const author = useAuthor(event.pubkey);
|
||
const metadata: NostrMetadata | undefined = author.data?.metadata;
|
||
|
||
const displayName = metadata?.name ?? 'Anonymous';
|
||
const profileImage = metadata?.picture;
|
||
// ...
|
||
}
|
||
```
|
||
|
||
The `NostrMetadata` type (from `@nostrify/nostrify`) covers the standard kind-0 fields: `name`, `display_name`, `about`, `picture`, `banner`, `website`, `nip05`, `lud06`, `lud16`, `bot`. Read the type definition from the package if you need the exact field list.
|
||
|
||
### The `useNostrPublish` Hook
|
||
|
||
Publishes events (auto-adds a `client` tag). Always guard with `useCurrentUser`:
|
||
|
||
```tsx
|
||
import { useCurrentUser } from '@/hooks/useCurrentUser';
|
||
import { useNostrPublish } from '@/hooks/useNostrPublish';
|
||
|
||
export function MyComponent() {
|
||
const { user } = useCurrentUser();
|
||
const { mutate: createEvent } = useNostrPublish();
|
||
|
||
if (!user) return <span>You must be logged in.</span>;
|
||
|
||
return (
|
||
<button onClick={() => createEvent({ kind: 1, content: 'hello' })}>
|
||
Publish
|
||
</button>
|
||
);
|
||
}
|
||
```
|
||
|
||
### Nostr Login
|
||
|
||
Use the `LoginArea` component (already in the project). It renders a single "Join" button when logged out (opens an `AuthDialog` supporting signup, extension, nsec, and remote signer) and becomes an account switcher when logged in. **Do not wrap it in conditional logic.**
|
||
|
||
```tsx
|
||
import { LoginArea } from '@/components/auth/LoginArea';
|
||
|
||
<LoginArea className="max-w-60" />
|
||
```
|
||
|
||
`LoginArea` is inline-flex by default. Pass `flex` or `w-full` to expand it; otherwise set a sensible `max-w-*`.
|
||
|
||
**Social apps should include a profile/account menu in the main navigation** for access to settings, profile editing, and logout — don't only show `LoginArea` in logged-out states.
|
||
|
||
### NIP-19 Identifiers
|
||
|
||
Nostr uses bech32-encoded identifiers (`npub1`, `nprofile1`, `note1`, `nevent1`, `naddr1`, `nsec1`). **All NIP-19 identifiers are routed at the URL root (`/:nip19`)**, handled by `src/pages/NIP19Page.tsx` — never nest them under `/note/`, `/profile/`, etc.
|
||
|
||
**Filters only accept hex.** Always decode before querying:
|
||
|
||
```ts
|
||
import { nip19 } from 'nostr-tools';
|
||
|
||
const decoded = nip19.decode(value);
|
||
if (decoded.type !== 'naddr') throw new Error('Unsupported identifier');
|
||
const { kind, pubkey, identifier } = decoded.data;
|
||
|
||
nostr.query([{
|
||
kinds: [kind],
|
||
authors: [pubkey], // critical for addressable events
|
||
'#d': [identifier],
|
||
}]);
|
||
```
|
||
|
||
Never treat `nsec1` or unknown prefixes as anything but a 404.
|
||
|
||
**For full details** (identifier-type comparison, populating `NIP19Page`, building NIP-19 links, security patterns), load the **`nip19-routing`** skill.
|
||
|
||
### File Uploads, Encryption, Multi-Relay
|
||
|
||
These are specialized workflows — load the matching skill when needed:
|
||
|
||
- **`file-uploads`** — `useUploadFile` + Blossom + NIP-94 `imeta` tags.
|
||
- **`nostr-encryption`** — NIP-44 / NIP-04 via the user's signer (DMs, gift wraps, private content).
|
||
- **`nostr-relay-pools`** — `nostr.relay(url)` / `nostr.group([urls])` for targeted queries.
|
||
|
||
## App Configuration
|
||
|
||
The `AppProvider` manages global state (theme + NIP-65 relay list), persisted to local storage.
|
||
|
||
```ts
|
||
const defaultConfig: AppConfig = {
|
||
theme: 'light',
|
||
relayMetadata: {
|
||
relays: [
|
||
{ url: 'wss://relay.ditto.pub', read: true, write: true },
|
||
{ url: 'wss://relay.dreamith.to', read: true, write: true },
|
||
{ url: 'wss://relay.primal.net', read: false, write: true },
|
||
{ url: 'wss://nos.lol', read: false, write: true },
|
||
],
|
||
updatedAt: 0,
|
||
},
|
||
};
|
||
```
|
||
|
||
### Relay Management
|
||
|
||
- **`NostrSync`** auto-loads the user's NIP-65 relay list on login and writes it into `AppContext`.
|
||
- **Automatic publishing** — updating the relay config publishes a new kind 10002 event when the user is logged in.
|
||
- A drop-in settings UI (`RelayListManager`) is available as the **`relay-management`** skill.
|
||
|
||
## Routing
|
||
|
||
Routes live in `AppRouter.tsx`. To add one:
|
||
|
||
1. Create the page component in `src/pages/`.
|
||
2. Import it in `AppRouter.tsx`.
|
||
3. Add the route **above** the catch-all `*` route:
|
||
|
||
```tsx
|
||
<Route path="/your-path" element={<YourComponent />} />
|
||
```
|
||
|
||
The router provides automatic scroll-to-top on navigation and a 404 `NotFound` page. The `/:nip19` route is already wired (see the `nip19-routing` skill).
|
||
|
||
## Design Standards
|
||
|
||
Designs should be polished and production-ready. Concrete rules:
|
||
|
||
- **Responsive** down to ~360px; test mobile, tablet, desktop.
|
||
- **WCAG 2.1 AA**: ≥ 4.5:1 contrast for body text, ≥ 3:1 for large text and UI elements. Full keyboard nav, ARIA labels, visible `focus-visible` rings.
|
||
- **8px grid** for spacing (Tailwind's 4-based scale). Don't sprinkle `p-[13px]`-style one-offs.
|
||
- **Typography hierarchy**: ≥ 18px body, ≥ 40px primary headlines. Prefer a modern sans (e.g. Inter) for UI and pair a display/serif for headings when personality is needed.
|
||
- **Depth**: soft shadows, gentle gradients, rounded corners (`rounded-lg` / `rounded-xl`). Avoid heavy drop shadows.
|
||
- **Motion**: lightweight, purposeful (hover, scroll reveals, transitions). Respect `prefers-reduced-motion` with Tailwind's `motion-safe:` / `motion-reduce:` variants.
|
||
- **Reusable components**: consistent variants and feedback states (`hover`, `focus-visible`, `active`, `disabled`, `aria-invalid`). Use `cn()` for conditional classes and `class-variance-authority` for variants (copy an existing `ui/` component as a template).
|
||
- **Custom over generic**: avoid template-looking headers — combine layered visuals, subtle motion, and brand colors. Generate custom images with available tools before reaching for stock.
|
||
|
||
### Loading and Empty States
|
||
|
||
**Use skeletons** for structured content (feeds, profiles, forms). **Use spinners** only for buttons or short operations.
|
||
|
||
```tsx
|
||
<Card>
|
||
<CardHeader>
|
||
<div className="flex items-center space-x-3">
|
||
<Skeleton className="h-10 w-10 rounded-full" />
|
||
<div className="space-y-1">
|
||
<Skeleton className="h-4 w-24" />
|
||
<Skeleton className="h-3 w-16" />
|
||
</div>
|
||
</div>
|
||
</CardHeader>
|
||
<CardContent>
|
||
<div className="space-y-2">
|
||
<Skeleton className="h-4 w-full" />
|
||
<Skeleton className="h-4 w-4/5" />
|
||
</div>
|
||
</CardContent>
|
||
</Card>
|
||
```
|
||
|
||
For empty results, show a minimalist empty state in a `border-dashed` card:
|
||
|
||
```tsx
|
||
<Card className="border-dashed">
|
||
<CardContent className="py-12 px-8 text-center">
|
||
<p className="text-muted-foreground max-w-sm mx-auto">
|
||
No results found. Try checking your relay connections or wait a moment for content to load.
|
||
</p>
|
||
</CardContent>
|
||
</Card>
|
||
```
|
||
|
||
For font installation, color-scheme changes, light/dark theming, or the `isolate` + negative-z-index gotcha, load the **`theming`** skill.
|
||
|
||
## Writing Tests vs. Running Tests
|
||
|
||
**Running the existing test script — always do it.** After any code change, run the project's test/validation script. **Your task is not complete until it passes.** The script typically covers TypeScript compilation, ESLint, and existing tests.
|
||
|
||
**Writing new test files — don't, unless the user asks.** If the user explicitly requests tests, describes a bug to diagnose with a test, or reports that a problem persists after a fix, load the **`testing`** skill for the project's Vitest + `TestApp` setup and policy.
|
||
|
||
## Validating Your Changes
|
||
|
||
**Your task is not finished until the code type-checks and builds without errors.** In priority order:
|
||
|
||
1. **Type check** (required)
|
||
2. **Build/compile** (required)
|
||
3. **Lint** (recommended; fix anything critical)
|
||
4. **Run tests** (if available)
|
||
5. **Git commit** (required)
|
||
|
||
### Using Git
|
||
|
||
Use `git status` / `git diff` to review changes and `git log` to learn project conventions. If you make a mistake, `git checkout` restores files.
|
||
|
||
**Always commit when you are finished.** Non-negotiable — every completed task ends with a commit. Don't wait for the user to ask.
|