Files
website/AGENTS.md
mroxso 949a551492 feat: New Web OS design (#12)
* new web os frontend

* add docs

* Add CNAME and restore NIP-05 nostr.json for GitHub Pages

The Pages custom domain (layer.systems) is only stored in repo settings;
a CNAME file in the build output makes it survive Pages reconfiguration.
Restore public/.well-known/nostr.json, which this branch had dropped —
removing it would break the existing NIP-05 identifiers on layer.systems.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014YtQoCzkP7Bo8nruhxojPi

* Ignore eslint and tsc build caches

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014YtQoCzkP7Bo8nruhxojPi

* Rebrand page title and metadata to LAYER.systems

The site ships on layer.systems, so the document title, meta and OG
description, and the web manifest now carry that name instead of
"Nostr OS". OsShell sets the title at runtime, so it is updated too —
otherwise the tab would fall back to the old branding after hydration.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014YtQoCzkP7Bo8nruhxojPi

* Rename remaining visible "Nostr OS" strings to LAYER.systems

Covers the About window heading, the mobile shell header and the app
icon's aria-label, so the visible branding matches the page title.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014YtQoCzkP7Bo8nruhxojPi

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-09-06 11:37:43 +02:00

18 KiB
Raw Blame History

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.tsxalready 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 3000039999) 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.
// ❌ 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

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:

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:

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:

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:

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.

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:

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-uploadsuseUploadFile + Blossom + NIP-94 imeta tags.
  • nostr-encryption — NIP-44 / NIP-04 via the user's signer (DMs, gift wraps, private content).
  • nostr-relay-poolsnostr.relay(url) / nostr.group([urls]) for targeted queries.

App Configuration

The AppProvider manages global state (theme + NIP-65 relay list), persisted to local storage.

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

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

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